# Architecture Two halves and one route. `lib/index.js` runs on the host and owns everything that costs money; `plugin.client.js` is the console and owns nothing. They talk over `/rollout-scout` — `GET` returns the whole state, `POST` takes an action and returns the whole state back. The console polls; there is no incremental protocol and no client-side model of the run. `lib/client.js` is generated from `plugin.client.js` by `_wrap-client.mjs` and is committed, because `dsh plugin add` does not run lifecycle scripts. Edit the source, run `npm run build`, commit both. ## The probe lifecycle `pump` keeps `concurrency` probes in flight, launching a fresh session per probe through `ctx.agents.create` and subscribing to that one agent's `session/event`. `reasoning-delta` chunks accumulate into `attempt.reasoning` and re-run the classifier on every chunk. An attempt stores independent execution, verdict, discard, retention and deletion axes. `status` is a read-only UI projection of those facts. The interesting projections are: | status | meaning | | --- | --- | | `streaming` | undecided, still thinking | | `kept-streaming` | reads as rollout, still thinking, still revisable | | `pending-discard` | judged old, within its recoverable fade grace | | `discarding` | cancelled, waiting on `turn/end` | | `stopping` | stop requested, agent creation/teardown still draining | | `kept` / `discarded` | closed | `pending-discard` exists so a wrong verdict is recoverable. Its first old verdict creates one absolute `FADE_MS` deadline; repeated reasoning chunks and final evaluation may update evidence but cannot move that deadline. Hovering suspends the timer and saves only its unspent remainder. The last explicit release resumes that remainder, while a retained card resets the grace entirely. The host exposes the active deadline as `discardAt`, allowing the client's line animation to use the same remaining duration without polling restarting it. `discardAt` is the visual deadline `D`; the irreversible host commit is bounded at `D + 250ms`. Pointer entry sends a claim containing the renderer's entry time and the exact `discardAt` it observed. Hold/release use a dedicated priority route lane, so neither a manifest load nor an unrelated serialized action can consume that local transit allowance. Every response carries an exact `{ action, id, lease, accepted, reason }` review ACK. The client starts heartbeats only after the initial ACK, and any rejection, malformed ACK or timeout closes that immutable lease epoch, clears its visual anchor, and sends a release tombstone. An expired or released token can never be resurrected by a late heartbeat. Clicking the card transfers that same temporary review epoch across the console-to-conversation transition; the next real pointer movement releases it. The hold is transient — a probe survives cleanup only through `protect`. ## Ownership, stopping, and deletion are separate One predicate cannot safely answer all lifecycle questions. The host uses three independent facts: ```js settled(attempt) // classifier may no longer revise this conversation hasOwnedResources(attempt) // create/handle/dispose can still touch the session deletable(attempt) // no durable Keep promise protects the log ``` **Force stop** and plugin unload cancel every owned live turn, including a protected catch. Protection preserves data, not token spending. Pause is more selective: it cancels already-decided old probes and lets undecided probes reach a verdict. Clear and Delete act only on `deletable` conversations, and they do not unlink a log until `hasOwnedResources` is false. Each attempt owns its config and folder snapshot. Protection sets are cached per normalized folder and persisted atomically, in mutation order, to `/.rollout-scout.json`. This prevents an old turn ending after a folder change from writing its promise into the new folder. ## Pausing Pause stops launching and cancels the probes already judged old; undecided ones run on to their own verdict. That is the whole distinction from Force stop, which cancels every live probe while preserving protected logs. ## Cleanup Every natural `turn/end` releases its agent handle. Cancellation paths swap the long watchdog for a short reaper, and destructive actions wait for both agent creation and disposal to drain before unlinking a log. A failed deletion keeps its card, so retry remains possible instead of producing an invisible orphan. `probeSessionIds` is the single answer to "what probe conversations exist", unioning three sources — workspace slots, the live session store, and the persistence listing — because each survives a different failure. See [dsh-host-notes.md](dsh-host-notes.md) for why sessions are matched on `cwd` and why the listing alone is not enough. `orphans` is that set minus what the console is tracking: conversations no card can reach, which is what a plugin reload or a half-finished delete leaves behind. `countOrphans` is rate-limited and runs off the request path — the console shows a banner and offers a sweep; `GET` never waits on a filesystem walk. ## The corpus `lib/fixtures.js` holds thirteen hand-labelled chains-of-thought. It ships with the plugin rather than living in the test, because the `self-check` action runs them through `classify` and `wantsDiscard` — the same functions a live probe goes through — and reports what the user's current thresholds would decide. The test imports the same module, so the samples asserted on in CI are the ones the console demonstrates. ## Screenshots `node scripts/shoot.mjs` regenerates `assets/console*.png` by rendering the real `lib/client.js` in headless Chrome against a stubbed route, so the README images track the shipped UI. The probe cards are staged; the self-check numbers come from `selfCheck`, so an image cannot advertise a detection rate the classifier does not actually produce. Animations are disabled in the harness — without that, the entry animation and button transitions land mid-flight and the two locales come out looking different from identical state. ## Tests `npm test` runs framework-free scripts that print `PASS`/`FAIL` lines and exits non-zero: - `classifier.test.mjs` — the classifier and the config guards, against the labelled corpus in `lib/fixtures.js` - `route.test.mjs` — HTTP surface and the same-origin/content-type guards - `pump.test.mjs` — the launch-failure breaker - `delete.test.mjs` — deleting never runs against a live log - `lifecycle.test.mjs` — pause culls settled probes, a keep survives every destructive path, sweeps find conversations by `cwd`, and the self-check reports the corpus honestly under an unreachable threshold - `ownership.test.mjs` / `state-machine.test.mjs` — stop-during-create, watchdog convergence, priority review traffic, exact fade/claim boundaries, deletion interleavings, and lease tombstones across `turn/end` - `client-review.test.mjs` — executable exact-ACK, timeout, heartbeat, L1-to-L2 generation, and carried-review protocol tests - `client-anchor.test.mjs` — deterministic viewport anchoring geometry - `client-css.test.mjs` — source-to-artifact checks for shell geometry The host is driven through the real route handler with a mock `ctx`, so tests exercise dispatch, not internals.