feat(coil): loops — a durable supervisor for unattended threads - #133
Merged
Conversation
…sumptions Issue #125 listed twelve findings against the Loops design package, all of which had to land before Phase 2 is built from the plan. This resolves every one and re-verifies the assumptions the build depends on against the tree. Four of the fixes are latent bugs rather than contradictions. `deadlineAtMs` is now mandatory and non-nullable (the old null branch in guard 10b meant *no deference at all*, so a deadline-less loop fired on top of a healthy self-pacing thread). Stop conditions move from guard 13 to guard 4b, ahead of every non-consuming skip, because they were only evaluated on a tick that had already decided the thread was idle — so a busy self-paced run walked past its own deadline. Guard 5 is retired: upstream pingdotgg#8600 made settlement a server-side sweep with no provenance marker, the same correction autoResume/guards.ts already made after a timer destroyed an armed week-long resume. And `thread.pin` turns out to emit companion unsettled/unsnoozed events, so arming a snoozed thread would have cancelled the snooze. Costs are re-measured against merge-base 941acb4, and the result inverts the plan's intuition: Phase 5 falls from "0-3 rows [A]" to one row at risk 6, while Phase 4's settings entry rises to ~350 of the feature's ~411 total risk. The master toggle gained a data model and routes and moved from Phase 4 to Phase 2, since Phases 2 and 3 were otherwise unswitchable as ordered. build-report.mjs now asserts the prototype count in both directions. The old guard only fired at zero matches, so one drifted marker emitted a report missing a frame — invisible in the browser and in a 15k-line diff. Reproduced and fixed: a `>` in a hint now throws "inlined 7 prototypes but prototypes/ holds 8". TESTS.md goes 162 -> 183 cases, each addition marked [#125]. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1 foundation) Three new fork-owned files under apps/server/src/coil/loop/, mirroring the proven autoResume module. Nothing acts yet: this is the state everything else reads. state.ts — LoopRecord, LoopGlobalSettings and the durable LoopStore (SynchronizedRef + atomic write + the four-way rehydrate, unreadable meaning persistence OFF). Every field carries a decoding default and every default is the fail-closed reading, because a missing required key fails the whole-file decode and the boot path turns that into EMPTY_STATE — which would silently disarm every loop on the machine. A schema-reflective test drops each field in turn, so adding one without a default fails the suite. config.ts — resolveConfig(env) over COIL_LOOP_*, the derived wake grace, and check-in prompt composition. Prompt and sentinel roots resolve worktreePath ?? workspaceRoot, worktree FIRST: autoResume has this inverted, and copying it would put the supervisor and the agent in different directories on every worktree-backed thread. cron/parse.ts — a ~250-line parser for exactly the producer's 5-field grammar, no dependency. Wall-clock evaluation makes it DST-correct (the spring-forward gap is skipped, the fall-back repeat resolves to its first occurrence), and anything outside the grammar yields null, which means no deference from that entry rather than deferring forever. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g questions (Phase 1)
`cron_durable` is false, so the provider's cron table is in-process: a wake lost
to a restart leaves no trace anywhere. T3's store is the trace. The `Stop` /
`SubagentStop` hooks read `session_crons`, the fork parses each 5-field schedule
into a `nextFireAtMs` at the moment it is observed (a one-shot re-parsed after it
fires resolves to next year), and the snapshot is persisted. `[]` clears the
record — the agent stopped self-pacing — while an absent field leaves it
untouched; getting that backwards would silently retire deference. A
`PostToolUse` probe on `ScheduleWakeup` records `gate_off` as a substring, never
a parse, so a probe that finds nothing behaves exactly like no probe.
A Stop hook can halt a turn, so "observability only" is a property the code
holds: every callback catches every failure and defect, logs at debug, and
always resolves to `{ continue: true }`, with a timeout on each matcher.
Also records `user-input.requested` fork-side, because upstream pingdotgg#5127 settles
pending inputs as an empty answer during teardown — a question nobody saw is
otherwise indistinguishable from an answered one. Empty resolutions are recorded
as `voided`, and the pingdotgg#8144 resume dialog is recognised through the shared
predicate the web client already uses, so the console can name what it is
waiting on.
The one upstream edit is 3 additive lines in `ClaudeAdapter.ts`. `loopHooksFor`
reads `LoopStore` through `Effect.serviceOption`, so the adapter's layer
requirements do not widen and no second seam edit appears in `server.ts`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The whole loop decision table, with no server, clock or provider anywhere in it: `decide(input)` returns one action, `evaluateGuards(input)` returns the first thing that blocks, and both read plain values. Two orderings are the product and both are asserted. Guard 4b (the stop sweep) runs before every non-consuming skip, so a self-paced run stops at its deadline while the thread is busy and a done-file written mid-work is honoured immediately; it still runs after guard 4, and guard 2 still runs before both, so a deleted thread disarms and the master toggle never manufactures a terminal state. Guard 5 is retired and the module says why. Deference is bounded by the run's own deadline, its grace is derived from the recorded entry rather than a constant, and both boundaries are inclusive. `session.status` and `backgroundLiveness` only lengthen the fuse; neither can veto a fire. 223 tests across the loop module, covering TESTS.md cases 1-46 by number plus the table-driven invariant that no stand-down touches the budget. Branch coverage on both new files is 100%, measured with a V8 block coverage harness (@vitest/coverage-v8 is not installed in this worktree). Model: Claude Fable 5.1 via Claude Code.
`sentinel.ts` stats `<root>/.coil/loop-done` across `worktreePath ?? workspaceRoot`, worktree first, newest mtime wins, honoured only when it is newer than `armedAtMs`. Read-only by construction, and every filesystem failure resolves to "no sentinel" rather than a defect in the tick fiber. `http.ts` ships the loop API: the per-thread read, arm/rearm/edit/disarm, the loops list and the master-toggle settings pair. Nothing is clamped — every out-of-range bound is a 400 with its own code, and no refusal touches the durable record. Arming pins only when the thread was not already pinned, and `pinnedByLoop` gates the unpin so disarming never removes a pin the user set themselves; arming a snoozed thread is `400 thread_snoozed` rather than a silently cancelled snooze. `POST /api/coil/loop/answer` is left as a documented TODO for phase 3, where the console can supply the question ids the native resolve path needs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opening a loop thread in the morning answered nothing: the state lived in coil-loop.json and the only way to read it was the HTTP route. This adds the console — the standing answer to "what do you need from me?" — as an overlay above the composer, plus the one route it needs to answer a deferred blocker. The seam cost is zero. `_chat.$environmentId.$threadId.tsx` swaps one JSX element and one import for `<ThreadCoilOverlay>`, a fork-owned aggregator, so the row is still +10/-6 against the merge-base and every future per-thread fork surface is now free. The console shows what stopped the loop, what it worked around, what was never answered, the bounds, and the check-in ledger — and, when there is nothing waiting, still says what happened, what it consumed and when it last moved. `spent` is never rendered as success, and that distinction is pinned by tests rather than by styling. With agent browser access off, the deferred section renders a named degraded state instead of an empty list, because a missing question channel that looks like "no questions" is the exact failure the channel exists to prevent. Two deliberate refusals worth stating. The blocking section does not mount the native `AskUserQuestion` card: it is already rendered live by `ComposerPendingUserInputPanel` fifty pixels below, so a second instance would duplicate the card and register a second document-level 1-9 keydown handler. The console names it and points there, leaving upstream's answer path with one instance of itself on the page. `POST /api/coil/loop/answer` therefore ships the blocker half only, with an explicit `blockerId` rather than §9's polymorphic `id`. `useComposerAnchor` moves out of `AutoResumeOverlay.tsx` into `coil/composerAnchor.ts` unchanged, so the two overlays share one measurement instead of two copies of it. No spinners and no per-second repaints: deadlines are absolute, ages are computed once per poll, and the panel states when it last heard from the server. Model: Claude Opus 5. Harness: T3 Code.
The master toggle shipped in phase 2 with no way to flip it. This adds the Settings section that owns it, the defaults a newly armed loop starts from, and the roster answering "did any of my runs give up overnight?" from one page rather than three threads. The help text is the point of the section. The toggle is a **guard, not a lifecycle**: with it off nothing fires, every armed loop stands down keeping its budget, its deadline and its arm, and nothing is disarmed or stopped. The same rule covers the ceiling — lowering it below the number armed stands the excess down rather than disarming them. Wording it as an on/off for loops themselves would teach people that flipping it cancels their overnight runs, which is exactly what it does not do. The panel is fork-owned and reads and writes `/api/coil/loop/settings`, so neither `SettingsPanels.tsx` (churn 43) nor `packages/contracts/src/settings.ts` (churn 38, persisted) is touched. Loop state stays in coil-loop.json. Two new seam rows, both fully additive and both type-forced rather than stylistic — `SETTINGS_SECTION_LABELS` and `SETTINGS_SECTION_ICONS` are both `Readonly<Record<SettingsPath, …>>`, so a union member without both entries is a type error. The search catalog also buys the command palette for free, since `CommandPalette.tsx` builds its settings results from `searchSettings` over the same catalog; no palette or keybinding row is needed. The ledger is brought back in step with the tree it now describes: 53 files +2609/-981 → 57 files +2649/-981. That is `settingsSearch.ts` +14, the sidebar nav +2, the generated `routeTree.gen.ts` +21, and `ClaudeAdapter.ts` +3, whose row landed in phase 1 without its header totals. Every one is +N/-0, so the additive-seam invariant holds and the deletion count is untouched. Model: Claude Opus 5. Harness: T3 Code.
What a loop is, how to arm one from a thread, the bounds it runs under, how to read the console, and what the master switch in Settings → Loops does and does not do. Also states why the "answer when you can" channel needs agent browser access on, and why an empty list there is not evidence the agent had nothing to ask. The loop vocabulary goes into the fork's own plan rather than `docs/internals/glossary.md`. That file is upstream-owned with no fork edit today, so four terms of prose would open a new seam row against the tripwire in SEAMS.md. `docs/README.md` is left alone for the same reason, which means the new page is not yet linked from the docs index — a maintainer call, recorded in the ledger rather than taken here. Model: Claude Opus 5. Harness: T3 Code.
`AskUserQuestion` parks the turn on a `Deferred`, so an agent that hits a real fork in the road at 01:00 and asks about it correctly stops working until morning — and guard 8 then correctly refuses to nudge it. No tuning resolves that; a second, non-blocking channel does. `raise_blocker` records a question against the calling thread and returns. `loop_status` reports the run's remaining budget. `loop_done` sets exactly what `guards.ts` `doneSignal` reads — a `loopDoneAtMs` newer than `armedAtMs` — so the reactor's stop sweep ends the run through the same path as the done-file, including the terminal work a directly-written `stopped` record would skip. Ungated (Path B): no `"loop"` `McpCapability`, no `packages/contracts` edit. Nothing at registration or dispatch consults `capabilities`, and the real gate is `global.enabled` plus the armed record, checked in the handlers. One seam row, `McpHttpServer.ts` +5/-1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The supervisor that acts. Three scoped fibers self-start at layer construction, mirroring `autoResume/Reactor.ts`: a tick that lists the armed loops and executes whatever the pure `decide` returns, a rate-limit tap that holds a loop out of a live usage limit (auto-resume only arms when its own per-thread switch is on, so a thread with it off would otherwise be nudged straight into the wall), and the user-input recorder from `userInputs.ts`. Two lessons copied from the neighbour rather than its bugs: the tick uses `getThreadShellById` / `getProjectShellById` instead of `getSnapshot()`, and issues zero queries of any kind when nothing is armed; the check-in prompt and the done-file resolve `worktreePath ?? workspaceRoot`, worktree first, so the agent and the supervisor agree on a directory. The three firing disciplines are the whole of the fire path: reserve the check-in before dispatching, so a provider that cannot spawn burns budget instead of tight-looping; re-read the shell and re-decide against the pre-reserve record before dispatching, so a thread that wakes inside the tick is not nudged; and restore the keep-active pin with `thread.unsettle` only when the pre-dispatch shell already carried it, so the repair can never create one. Wired into `coil/index.ts` with the store defined once at module scope and provided to both the reactor and the routes, the same shared-identity rule the auto-resume and Web Push stores already rely on. `loop/sharing.test.ts` pins it, because two copies of this record over one file would show an armed loop in the console that never checks in. Tests: `Reactor.test.ts` (TESTS.md 87–107) and `integration.test.ts` (128–137), on TestClock with condition-based settling and no sleeps. Claude Opus 4.6 via Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Row 44 (`ClaudeAdapter.ts` +3/-0) was appended when the Claude hooks landed, but the header still read the pre-loops totals. Regenerated with the command in the file's own Regenerating section against merge-base `941acb4f9`: 53 files / +2609 on `origin/main`, 54 / +2612 with this branch, the difference being exactly that one row. PLAN.md's status line said "proposed — nothing built", which stopped being true three commits ago. Phases 0-2 are on this branch; 3-5 are still proposed. Claude Opus 4.6 via Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # docs/coil/SEAMS.md
# Conflicts: # docs/coil/SEAMS.md
Three phases landed on three branches, and each needed the same record. The reactor and the routes were reading a `LoopStoreLive` declared in `coil/index.ts`; the MCP toolkit registers off `mcp/McpHttpServer.ts`, which cannot import from the coil aggregator, so it brought its own `loop/layer.ts`. Two layer values over one JSON file, and Effect memoises by layer identity, so the toolkit would have been writing to a store neither the supervisor nor the console ever read: `loop_done` would report success and the loop would keep checking in. `coil/index.ts` now imports `layer.ts`'s value; the declaration it replaces was identical in substance (same `coil-loop.json`, same `stateDir`), so nothing was reconciled. `loop_done` also had no reader. The reactor was passing a hard-coded `loopDoneAtMs: null` into the decision input with a comment saying the tool had not shipped yet; it has, so the record's field is passed through and `doneSignal` takes the newer of the two done channels. The reason string the agent gives is stitched onto the terminal in the reactor rather than the decision table, which is pure and takes a timestamp. Ledger: the Phase 5 row for `McpHttpServer.ts` was lost resolving the console/MCP conflict. Re-added as row 48 and the header regenerated — 58 files, +2654/-982. That `-1` is the fork's only displaced upstream line: registering a second toolkit rewrites the terminal `export const layer` rather than adding beside it, because a second export would shadow the first. It is now called out in the header note as a displacement the additive-seam invariant cannot avoid, with the instruction to re-read that expression every sync. Claude Opus 4.6 via Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ffef26c1b` edited three prototypes and rebuilt `report.html` from them, but skipped the `vp fmt` half of the documented build→fmt pipeline, so `vp fmt --check` over the branch was red on files that are correctly formatted on `main`. Formatted the three sources, re-ran `build-report.mjs` because the report inlines them, then formatted the report. No content change. Claude Opus 4.6 via Claude Code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t the decision table loopHooksFor read LoopStore through Effect.serviceOption, which never resolves in production: the adapter's fiber runs in upstream's layer graph, where CoilLayerLive has already discharged the store with Layer.provide. session_crons was therefore recorded on no real machine; only the tests, which provide the store themselves, saw hooks at all. The supervisor now publishes its store in a fork-owned process-level holder for the life of its scope and loopHooksFor falls back to it, context first. Also in the decision table: the stop sweep reads the done signal before the bounds (a run that finished on its last check-in reported 'spent' and had its session killed), guard 14 counts the OTHER armed loops (at exactly maxArmedThreads every loop stood down), resolveWake skips wakes that already landed (one dropped Stop hook and a still-pending wake was masked), tookOver treats an empty createdAtIso as no baseline, an ambiguous fall-back wall clock resolves to the later instant, guard 6 reports 'held' like every other bounded hold, and the pre-dispatch re-read distinguishes a projection failure from a missing thread and acts on a takeover it observes rather than leaving it for the next tick. The Stop/SubagentStop hooks now record only for armed threads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it holes The routes: a re-arm no longer disowns a pin the loop itself created, disarm and edit are refused with 409 unless the loop is armed (a stale tab could rewrite how a night ended), a body that is not JSON gets the invalid_body code rather than a bare empty 400, a disarm that leaves the agent's own wakes pending banks a session stop for the supervisor to service, and 'clear' is the way to dismiss a finished run — the reverse state the stopped pill was missing. The toolkit: loop_done records the signal regardless of the master toggle (the toggle gates firing, not recording, and discarding it meant re-enabling loops resumed check-ins on a finished run), and raise_blocker on a thread with no armed loop reports 'unavailable' instead of promising a delivery that cannot happen. The store: blockers and recorded questions are capped at fifty per thread, and records nothing will read again — a stopped run past its retention window, or one carrying nothing at all — are pruned inside the same critical section that persists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The settings panel said 'Loading…' forever with every control disabled on any non-200 read; it now names the failure and offers a retry. The console's empty state disappeared when a run's only questions were already answered, taking with it the one card that explains a finished run; the browser-access warning rendered on every thread in the app rather than on threads with a loop; a snoozed thread was told to answer something in the composer; a finished run could not be dismissed; and the deadline printed as a time of day, so a run armed at 23:00 read as having ended eight hours ago. The console also no longer renders on a thread belonging to a non-primary environment, where every fork route would have answered for a thread id that server has never heard of. docs/user/loops.md states the limitation, which auto-resume shares. SEAMS row 543 now describes the chat route as mounting <ThreadCoilOverlay>, at the same +10/-6. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner
Author
|
Adversarial review round (4 lenses, 25 findings verified against the code, all confirmed and fixed):
Server: 826 tests pass; web: 250 tests pass; both typechecks and lint clean; the six upstream files are byte-identical to the previous push. |
…for the answer CI failed 136c with 'timed out waiting for the covered wake'. It was not the landed-wake fix: a probe of resolveWake with 136c's exact inputs resolves the wake at 26, 27 and 46 minutes with landed=false, because that shell's updatedAt (5 min) precedes the wake (25 min), so the skip never fires there. It was the stop-request sweep. serviceStopRequests read the store a second time on every tick, and that extra trip through the synchronized ref costs a scheduler turn per tick — enough to halve 136c's margin, which is the tightest in the suite (a 20-poll budget meeting the heaviest tick). Measured by binary-searching the harness's per-poll pump budget: pre-fix 136c survives 10, 5 and 3; with the sweep added it failed at 5; removing just the sweep restored it, while removing the prune's clock read did not. Both work lists now come from the tick's single read. The harness was the other half. Its per-poll settle is a fixed ten scheduler turns — a guess about how much real progress a turn buys — so on a loaded runner the simulated clock outruns a tick that is still in flight and the wait dies while the answer is one turn away. advanceUntil now drains on the condition once the caller's poll budget is spent, bounded by iterations and never by wall-clock time. At a budget of 3 that takes the file from 3/5 green to 5/5. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`advanceUntil` decided how far to move the simulated clock partly from how busy the machine was: it settled each poll with a fixed handful of scheduler turns, so on a loaded runner "the condition is still false" meant "the tick's write has not landed yet" rather than "nothing has happened", and it advanced anyway. Every borrowed minute landed in the assertions — CI reported one wake covered twice, because the extra minutes carried the following advancePolls(10) over the loop's 15-minute check-in floor, and a check-in five simulated minutes late. Both are assertion failures about the product, produced entirely by the harness. Each poll now settles until the condition holds, bounded by scheduler turns rather than by time, so the clock stops at the instant that satisfied the condition and nowhere else. Simulated time moves only where a test says it moves. reactorHarness.test.ts pins that contract, including on the give-up path, which is where a clock-nudging drain shows itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Retention pruning ran inside the critical section of every store write, including the reactor's per-poll bookkeeping. Stopped records only appear when a run ends, so that is where the sweep belongs: stop, disarm, clear, and the global settings write. The hot path goes back to a thin modify. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every milestone the tests had to infer — a tick finishing, a check-in reserved, dispatched or aborted, a stop, a disarm, a banked stop serviced, blockers delivered, a rate-limit hold, a recorded question — is now published on a receipt stream. `tick.completed` is last in every tick, including the early exit when nothing is armed, so "one poll happened and the reactor is done" is an exact fact rather than something a test guesses at by spinning the scheduler. The service is optional. No production layer provides it, so `receiptEmitter` resolves to a constant no-op and `enabled` is false — which is also what keeps the empty-armed tick from reading a clock for a receipt nobody will read. The buffer is bounded and dropping: a subscriber that stops draining loses receipts rather than stalling the supervisor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The harness waited by spinning both schedulers a fixed number of turns and looking again, which is a bet on how fast the machine is: the store persists through real filesystem I/O, so on a two-core runner a turn buys less progress and a budget that is generous on a laptop runs out. It cost this branch three CI failures naming three different sets of tests, and because the harness kept advancing the simulated clock while it guessed, they arrived as assertions about the product — a wake covered twice, a check-in five simulated minutes late, an answer not yet marked delivered — rather than as anything that looked like a timing bug. `advancePolls(n)` is now n × (advance one poll, await that tick's `tick.completed`), so it is exactly n whole ticks on any machine, and `advanceUntil` evaluates its condition once per completed tick and rests on the poll where the answer arrived. The two stream-driven waits await the tap's own receipt and spend no simulated time at all. Two `settleQuiet` calls are gone because a completed tick already implies the writes inside it; the one that remains asserts an absence, which is the only thing a bounded spin can honestly do. `PER_POLL_TURNS` and the turn budgets are deleted. `reactorHarness.test.ts` pins the contract that is left: a poll waits for the whole tick however slow the machine is, and simulated time moves only where a scenario says it moves — including on the give-up path, which is where a harness that buys time on its own shows itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the loops design (PR #120,
docs/coil/loops-v2/) — phases 1 through 5 — and closes the review findings in #125. Phase 0 landed separately as #131.Problem. A thread left to work overnight stalls silently: a turn ends while background work is still going, a scheduled wake armed by the agent is lost to a server restart, or one correctly-asked question at 01:00 blocks the turn until someone answers at 09:00. Nothing durable restarts the run, bounds what it may spend, or collects what the human needs to decide.
What this adds (fork-owned,
apps/server/src/coil/loop/,apps/server/src/mcp/toolkits/loop/,apps/web/src/coil/):coil-loop.json, atomic writes, every field with a fail-closed decoding default) and the ClaudeStop/SubagentStophooks that record the agent's ownsession_crons, with a fork-owned 5-field cron parser and a hook wrapper that can never break the turn.updatedAtstaleness, neversession.status; defer to a recorded wake while it is at or before the loop's deadline (grace inclusive); stop conditions (deadline, budget, done signal, strikes, takeover) evaluated before the idle guards so a busy self-paced run still stops at its deadline;spentis never reported asdone; the master toggle is a guard, not a lifecycle.user-input.requestedso a question voided by session teardown stays visible. Three firing disciplines: reserve the check-in before dispatch, re-read the shell before dispatch, repair the keep-active pin only when it was already there. Bounds stop a self-paced run viastopSession.raise_blocker(returns immediately, per-window cap reported back rather than dropped),loop_status(never errors),loop_done(ends the loop asdone). Attribution fromMcpInvocationContext, never an argument.Seams. Six upstream-owned files, five new ledger rows, +47/−3 in total:
ClaudeAdapter.ts+3 (the hooks spread),settingsSearch.ts+14,SettingsSidebarNav.tsx+2,routeTree.gen.ts+21 (generated),McpHttpServer.ts+5/−1 (the one displacing line: a secondexport const layerwould shadow the first), and the thread route's overlay element swapped for a fork aggregator at an unchanged +10/−6.docs/coil/SEAMS.mdre-measured: 58 files, +2654/−982. Zeropackages/contractsedits.Docs.
docs/user/loops.md(unlinked from the docs index on purpose — linking it would open a new prose seam row); the design docs resolved per #125 with every[A]claim the build depended on flipped to[V].Verified in a worktree: server
vp test run src/coil src/mcp src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts→ 802 passed; webvp test run src/coil src/components/settings→ 234 passed;tsgo --noEmitclean in both; lint clean on every touched directory;vp fmt --checkclean. Not done: a pass in a real client — the console pill and the auto-resume capsule should be eyeballed together on a narrow window. Mobile surfacing is deferred per the plan (no mobile seam row taken).Closes #125. Refs #42, #38.
Work done by Claude Fable 5.1 orchestrating Claude Opus 5 subagents in Claude Code.