The host derives liveness by scanning (procscan + DetectSpec), which needs something recognizable on disk — an install directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one, or a library plugin that records no install directory has none of that, and its launch is a playnite:// hand-off, so the host holds no process either. The lease went Untracked: the exit was never noticed, session_on_game_exit (default true) could not fire, and POST /game/end had nothing to aim at.
Playnite knew the whole time. It starts the game, tracks it in the mode the person configured, and fires an event on both edges carrying the pid. That was being thrown away.
What this adds
PUT /api/v1/library/provider/{provider}/running — body {"running":[{"external_id":"…","pid":1234}]}. The live counterpart to the static detect hints a reconcile carries: that one says how to recognize a title's process, this says it is running now.
Declarative and idempotent, like the reconcile beside it: the body is the provider's complete running set, so a missed event, a plugin restart or an install mid-game all self-correct on the next report instead of drifting. Absent from the set = stopped.
Reports expire (runstate::REPORT_TTL, 90 s; the answer carries ttl_s). This is what makes it safe for a live provider to hold a streaming session open for a game the host cannot see: a plugin that dies with a game running stops counting shortly after, and the host falls back to scanning exactly as today.
Route authority: the plugin lane, like the reconcile. No new authority — the host maps external_id through the catalog, so a provider can only ever speak about entries it published. An unknown id is counted, not refused, because a report legitimately races its own reconcile and 400-ing the batch would throw away the liveness of every other running title (the lesson of sanitize_launcher_entries).
gamelease::LeaseKind::Reported — a lease with no process signal of its own, tracked by what its provider says.
open() reaches it when the spec is empty and a provider speaks for the id.
All three shim-reclassification paths now fall back to it where they fell to Untracked. This is the load-bearing part on Windows, where every launch recipe is a hand-off by construction (explorer.exe "playnite://…"), so a hint-less title always arrives there.
Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit. Unlike procscan::running_hint — which may only ever delay an exit, because Steam's registry flag survives an unclean one — a fresh provider report is decisive in both directions. The TTL is what buys that.
A reported pid joins both termination ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use, so a stale or recycled pid contributes nothing.
@punktfunk/plugin-kit: ProviderClient.reportRunning, exported from the package root, version → 0.4.4 (unpublished; plugin-kit-v0.4.4 owed).
Verification
Run on the Windows CI runner, which compiles the cfg(windows) half this Mac cannot:
Gate
Result
cargo check --all-targets -p punktfunk-host
exit 0
cargo clippy --all-targets -- -D warnings
exit 0
runstate::
3/3
gamelease::
13/13
mgmt::tests::
48/49
openapi_document_is_complete_and_checked_in
passes after regen
The one mgmt failure is display_monitors_answers_even_with_no_compositor, and it is A/B-proven environmental: the base commit fails it identically on the same headless box (0 passed / 1 failed, same panic).
api/openapi.json + the docs-site copy are regenerated; the delta is exactly one path and three schemas, nothing else moved.
⚠ Linux was never gated — OrbStack stayed wedged behind another session's long-running container all evening (docker ps fine, docker run --rm alpine echo hung). The only Linux-exclusive code is the 6-line targets closure in unix_term_ladder; everything else in watch() is shared with the Windows path that did compile and run.
Note
A test caught a real bug on its first run in an environment that executes tests: the three runstate tests shared a provider id and cleared the whole process-global table between cases, so under parallel scheduling they wiped each other — the same shape as mgmt's local_summary race. Each now uses ids only it touches and forgets only its own row.
The other half
punktfunk-plugin-playnite (plugin 0.4.5, exporter 0.4.0) hooks Playnite's events and reports through this route. It calls it through the kit's untyped host seam rather than reportRunning, deliberately — depending on the typed method makes that repo un-installable until the kit publishes, for the identical request. An older host 404s and the plugin carries on, so it ships independently of this.
The host derives liveness by scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install directory, an executable, a Steam reaper. A Playnite-launched **emulated** game, a **manually added** one, or a library plugin that records no install directory has none of that, and its launch is a `playnite://` hand-off, so the host holds no process either. The lease went `Untracked`: the exit was never noticed, `session_on_game_exit` (default **true**) could not fire, and `POST /game/end` had nothing to aim at.
Playnite knew the whole time. It starts the game, tracks it in the mode the person configured, and fires an event on both edges carrying the pid. That was being thrown away.
## What this adds
**`PUT /api/v1/library/provider/{provider}/running`** — body `{"running":[{"external_id":"…","pid":1234}]}`. The **live** counterpart to the static `detect` hints a reconcile carries: that one says *how to recognize* a title's process, this says *it is running now*.
- **Declarative and idempotent**, like the reconcile beside it: the body is the provider's **complete** running set, so a missed event, a plugin restart or an install mid-game all self-correct on the next report instead of drifting. Absent from the set = stopped.
- **Reports expire** (`runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what makes it safe for a live provider to hold a streaming session open for a game the host cannot see: a plugin that dies with a game running stops counting shortly after, and the host falls back to scanning exactly as today.
- **Route authority: the plugin lane**, like the reconcile. No new authority — the host maps `external_id` through the catalog, so a provider can only ever speak about entries it published. An unknown id is *counted*, not refused, because a report legitimately races its own reconcile and 400-ing the batch would throw away the liveness of every other running title (the lesson of `sanitize_launcher_entries`).
**`gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by what its provider says.
- `open()` reaches it when the spec is empty and a provider speaks for the id.
- **All three shim-reclassification paths now fall back to it** where they fell to `Untracked`. This is the load-bearing part on Windows, where every launch recipe is a hand-off by construction (`explorer.exe "playnite://…"`), so a hint-less title *always* arrives there.
- Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit. Unlike `procscan::running_hint` — which may only ever *delay* an exit, because Steam's registry flag survives an unclean one — a **fresh** provider report is decisive in both directions. The TTL is what buys that.
- A reported pid joins both termination ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use, so a stale or recycled pid contributes nothing.
**`@punktfunk/plugin-kit`**: `ProviderClient.reportRunning`, exported from the package root, version → **0.4.4** (unpublished; `plugin-kit-v0.4.4` owed).
## Verification
Run on the Windows CI runner, which compiles the `cfg(windows)` half this Mac cannot:
| Gate | Result |
|---|---|
| `cargo check --all-targets -p punktfunk-host` | exit 0 |
| `cargo clippy --all-targets -- -D warnings` | exit 0 |
| `runstate::` | 3/3 |
| `gamelease::` | 13/13 |
| `mgmt::tests::` | 48/49 |
| `openapi_document_is_complete_and_checked_in` | passes after regen |
The one `mgmt` failure is `display_monitors_answers_even_with_no_compositor`, and it is **A/B-proven environmental**: the base commit fails it identically on the same headless box (0 passed / 1 failed, same panic).
`api/openapi.json` + the docs-site copy are regenerated; the delta is exactly one path and three schemas, nothing else moved.
⚠ **Linux was never gated** — OrbStack stayed wedged behind another session's long-running container all evening (`docker ps` fine, `docker run --rm alpine echo` hung). The only Linux-exclusive code is the 6-line `targets` closure in `unix_term_ladder`; everything else in `watch()` is shared with the Windows path that did compile and run.
## Note
A test caught a real bug on its first run in an environment that executes tests: the three `runstate` tests shared a provider id and cleared the whole process-global table between cases, so under parallel scheduling they wiped each other — the same shape as `mgmt`'s `local_summary` race. Each now uses ids only it touches and forgets only its own row.
## The other half
`punktfunk-plugin-playnite` (plugin **0.4.5**, exporter **0.4.0**) hooks Playnite's events and reports through this route. It calls it through the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the typed method makes that repo un-installable until the kit publishes, for the identical request. An older host 404s and the plugin carries on, so it ships independently of this.
The host derives liveness by scanning, which needs something recognizable on
disk. A Playnite-launched emulated game, a manually added one, or a library
plugin that records no install directory has none — and its launch is a
`playnite://` hand-off, so the host holds no process either. The lease went
`Untracked`: the exit was never noticed, `session_on_game_exit` could not fire,
and `POST /game/end` had nothing to aim at. Playnite knew the whole time.
New `PUT /library/provider/{provider}/running` takes a provider's complete
running set (with the pid where it knows one) — declarative and idempotent like
the reconcile beside it, so a missed event or a plugin restart self-corrects
rather than drifting. `crate::runstate` holds it and expires it after 90s
unless restated, which is what makes it safe for a live provider to hold a
session open for a game the host cannot see: a plugin that dies stops counting
and the host falls back to scanning, exactly as today.
`LeaseKind::Reported` is the lease that follows from it. `open` reaches it when
the spec is empty and a provider speaks for the id, and — the load-bearing part
on Windows, where every launch is a hand-off by construction — the three shim
reclassification paths now fall back to it where they fell to `Untracked`.
Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the
exit. Unlike `procscan::running_hint`, which may only ever delay an exit because
Steam's registry flag survives an unclean one, a fresh report is decisive in
both directions. A reported pid joins the termination ladders on the same terms
as a spawned one: re-resolved and start-time-pinned at the moment of use.
The route is the plugin lane's, like the reconcile. No new authority — the host
maps `external_id` through the catalog, so a provider can only speak about
entries it published; an unknown id is counted, not refused, because a report
legitimately races its own reconcile and 400-ing the batch would throw away the
liveness of every other running title.
plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means
"this host tracks games by scanning".
`reportRunning` and its two types were reachable only through the deep
`./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so
no plugin could import them from the package root the way it imports every
other provider symbol.
Version bumped because it is a published package and the addition is what a
consumer would depend on; the playnite plugin deliberately does NOT, calling the
route through the untyped host seam instead so it is not gated on this publish.
`api/openapi.json` (and its docs-site copy) gain exactly the new route and its
three schemas — nothing else moved, which is the check worth doing on a
regenerated spec.
The test fix is one the tests found themselves, on the first run in an
environment that actually executes them: all three shared the provider id
`playnite` and cleared the whole process-global table between cases, so under
parallel scheduling they flipped each other's answers — `omitted_is_not_running`
read `None` for a title another test had just wiped. Each now takes ids only it
uses and forgets only its own row, which also retires the blunt `reset()` that
made the collision possible.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The host derives liveness by scanning (
procscan+DetectSpec), which needs something recognizable on disk — an install directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one, or a library plugin that records no install directory has none of that, and its launch is aplaynite://hand-off, so the host holds no process either. The lease wentUntracked: the exit was never noticed,session_on_game_exit(default true) could not fire, andPOST /game/endhad nothing to aim at.Playnite knew the whole time. It starts the game, tracks it in the mode the person configured, and fires an event on both edges carrying the pid. That was being thrown away.
What this adds
PUT /api/v1/library/provider/{provider}/running— body{"running":[{"external_id":"…","pid":1234}]}. The live counterpart to the staticdetecthints a reconcile carries: that one says how to recognize a title's process, this says it is running now.runstate::REPORT_TTL, 90 s; the answer carriesttl_s). This is what makes it safe for a live provider to hold a streaming session open for a game the host cannot see: a plugin that dies with a game running stops counting shortly after, and the host falls back to scanning exactly as today.external_idthrough the catalog, so a provider can only ever speak about entries it published. An unknown id is counted, not refused, because a report legitimately races its own reconcile and 400-ing the batch would throw away the liveness of every other running title (the lesson ofsanitize_launcher_entries).gamelease::LeaseKind::Reported— a lease with no process signal of its own, tracked by what its provider says.open()reaches it when the spec is empty and a provider speaks for the id.Untracked. This is the load-bearing part on Windows, where every launch recipe is a hand-off by construction (explorer.exe "playnite://…"), so a hint-less title always arrives there.procscan::running_hint— which may only ever delay an exit, because Steam's registry flag survives an unclean one — a fresh provider report is decisive in both directions. The TTL is what buys that.@punktfunk/plugin-kit:ProviderClient.reportRunning, exported from the package root, version → 0.4.4 (unpublished;plugin-kit-v0.4.4owed).Verification
Run on the Windows CI runner, which compiles the
cfg(windows)half this Mac cannot:cargo check --all-targets -p punktfunk-hostcargo clippy --all-targets -- -D warningsrunstate::gamelease::mgmt::tests::openapi_document_is_complete_and_checked_inThe one
mgmtfailure isdisplay_monitors_answers_even_with_no_compositor, and it is A/B-proven environmental: the base commit fails it identically on the same headless box (0 passed / 1 failed, same panic).api/openapi.json+ the docs-site copy are regenerated; the delta is exactly one path and three schemas, nothing else moved.⚠ Linux was never gated — OrbStack stayed wedged behind another session's long-running container all evening (
docker psfine,docker run --rm alpine echohung). The only Linux-exclusive code is the 6-linetargetsclosure inunix_term_ladder; everything else inwatch()is shared with the Windows path that did compile and run.Note
A test caught a real bug on its first run in an environment that executes tests: the three
runstatetests shared a provider id and cleared the whole process-global table between cases, so under parallel scheduling they wiped each other — the same shape asmgmt'slocal_summaryrace. Each now uses ids only it touches and forgets only its own row.The other half
punktfunk-plugin-playnite(plugin 0.4.5, exporter 0.4.0) hooks Playnite's events and reports through this route. It calls it through the kit's untyped host seam rather thanreportRunning, deliberately — depending on the typed method makes that repo un-installable until the kit publishes, for the identical request. An older host 404s and the plugin carries on, so it ships independently of this.The host derives liveness by scanning, which needs something recognizable on disk. A Playnite-launched emulated game, a manually added one, or a library plugin that records no install directory has none — and its launch is a `playnite://` hand-off, so the host holds no process either. The lease went `Untracked`: the exit was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim at. Playnite knew the whole time. New `PUT /library/provider/{provider}/running` takes a provider's complete running set (with the pid where it knows one) — declarative and idempotent like the reconcile beside it, so a missed event or a plugin restart self-corrects rather than drifting. `crate::runstate` holds it and expires it after 90s unless restated, which is what makes it safe for a live provider to hold a session open for a game the host cannot see: a plugin that dies stops counting and the host falls back to scanning, exactly as today. `LeaseKind::Reported` is the lease that follows from it. `open` reaches it when the spec is empty and a provider speaks for the id, and — the load-bearing part on Windows, where every launch is a hand-off by construction — the three shim reclassification paths now fall back to it where they fell to `Untracked`. Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit. Unlike `procscan::running_hint`, which may only ever delay an exit because Steam's registry flag survives an unclean one, a fresh report is decisive in both directions. A reported pid joins the termination ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use. The route is the plugin lane's, like the reconcile. No new authority — the host maps `external_id` through the catalog, so a provider can only speak about entries it published; an unknown id is counted, not refused, because a report legitimately races its own reconcile and 400-ing the batch would throw away the liveness of every other running title. plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means "this host tracks games by scanning".