viewable-one 0.0.0-stage → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +767 -0
  20. package/lib/codexApps.mjs +344 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +629 -0
  28. package/lib/devserver.mjs +670 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
package/SPAWN.md ADDED
@@ -0,0 +1,2108 @@
1
+ # The spawn contract - running a Claude Code session from the board
2
+
3
+ This is the runner's contract, written for whoever maintains it: every row it
4
+ reads and writes, every bound it respects, and what happens at each edge. It
5
+ is not a guide to using Viewable - [README.md](./README.md) is that.
6
+
7
+ The rows contract below is what runs today.
8
+
9
+ **Words, checked 2026-10-04 against `INBOX_OPS.md` and `src/types.ts`.** The
10
+ board has one kind that carries work: the **idea**. Its title and body are the
11
+ prompt a run is given, its run record sits under `run`, and its questions and
12
+ answers are **question notes** on it, not circles of their own. Everywhere
13
+ below, "the idea" is the thing a run belongs to (what `fromIdOf(request)`
14
+ names), and `ask_user` lands a question note on that idea. Older dated
15
+ paragraphs keep their dates; where one names a prompt or a question as a
16
+ circle, read it as the idea or a question note on it.
17
+
18
+ ## Coding checks (#2193)
19
+
20
+ A successful coding session (not a draft plan or chat turn) runs the folder's
21
+ `package.json` scripts `test` and `build`, sequentially, with `CI=true`.
22
+ No dependency installation is attempted. Missing scripts, dependencies, or
23
+ tools are skipped with a plain reason. Scripts receive the existing dev-server
24
+ environment filter, not the agent's credentials. npm is spawned without a shell;
25
+ npm itself runs the folder's scripts. Checks have their own process group on
26
+ POSIX so Stop and timeout also stop their child processes.
27
+
28
+ A failed check triggers one agent turn in the same copy, with its diagnostics
29
+ and an instruction to reproduce and fix the failure without weakening checks.
30
+ Both scripts are checked again afterwards. A failed check that becomes unavailable
31
+ stays failed. The original session, checks, and repair share one deadline. Running
32
+ out of time fails the available checks with a plain reason; it does not turn a
33
+ completed coding session into a crash. Stop keeps the run's stopped state.
34
+
35
+ The closing `addComment` carries `result`, also retained in `sidecar.result`:
36
+
37
+ ```json
38
+ {"checks":[{"kind":"tests","status":"failed","failed":1,"reason":"Tests did not pass."},{"kind":"build","status":"passed","failed":0}],"fixAttempted":true}
39
+ ```
40
+
41
+ `kind` is `tests` or `build`; `status` is `passed`, `failed`, or `skipped`.
42
+ `failed` counts failed checks (0 or 1), not individual test assertions, whose
43
+ output formats differ. `reason` explains failure or skipping. A repair starts
44
+ with a streamed step and the closing reply says “Coding tried one fix.”
45
+ The result-card consumer is delivered separately in #2023; it must preserve this
46
+ payload on the reply. Installed bridges need a release through #584/#640.
47
+
48
+ ## Before and After pictures (#2194)
49
+
50
+ Checked against the runner code on 2026-10-24.
51
+
52
+ `lib/pagePictures.mjs` is the one module behind this (with
53
+ `lib/pageRoutes.mjs`, which only it uses); it adds no dependency. It runs only
54
+ for a coding run that made a copy and changed a screen file that is still
55
+ there (`.tsx .jsx .vue .svelte .astro .html .css .scss .sass .less`, or a
56
+ routed page file such as `page.ts` or an `.mdx` page; not tests or stories).
57
+ A deleted page is not opened. A run with no copy has no list of what it
58
+ changed, so no pictures.
59
+
60
+ - The browser is one already installed: Chrome, then Edge, then Chromium, in
61
+ the usual install places (PATH on Linux). Nothing is downloaded.
62
+ `VIEWABLE_BROWSER` (or its `GLYTCH_BROWSER` fallback) names one, or `none` turns pictures off. It runs headless
63
+ over its DevTools pipe with a fresh profile that is deleted afterwards, and
64
+ without the runner's secrets.
65
+ - The app is the folder's `dev` script, else `start`, through npm without a
66
+ shell, with the dev-server environment filter, `PORT` set to a free port and
67
+ `BROWSER=none`. Its address is read from its output, else that port. It must
68
+ answer within 60 s. Its whole process group stops after each picture. One
69
+ app runs at a time: the copy, then the folder.
70
+ - The page is the one the session names in `.viewable-page.json` at the copy's
71
+ root (`{"path":"/settings","name":"settings page"}`, never committed), else
72
+ the file route of a changed page file (Next, SvelteKit, Astro, Remix), else
73
+ `/`. Its name comes from the record, else from its address.
74
+ - The page check (`kind: "page"`, with `page` set to its name) is part of the
75
+ check pass, so its failure gets the one fix turn and the same deadline and
76
+ Stop. After is taken then. Before is taken from the folder at the end; the
77
+ folder is still the "before" state because the run worked in its copy. The
78
+ changed area is the box around the pixels that differ, found in the same
79
+ browser, as fractions of the picture. Pictures that do not differ are
80
+ dropped: the wrong page was opened.
81
+ - The closing reply carries `result.before`, `result.after` (JPEG data URLs)
82
+ and `result.changed` (`{x,y,w,h}`). `sidecar.result` keeps the checks but
83
+ not the pictures, so lists of runs stay light.
84
+
85
+ Nothing here fails a run. No browser, or one that does not answer when it
86
+ starts (Stop and the time limit end that wait too): the check is skipped and the reply ends with "No pictures: no browser
87
+ found." A browser that closes while it opens the page: skipped, "No pictures:
88
+ the browser closed." No `dev` or `start` script: skipped,
89
+ "No pictures: this folder has no app to start." An app that starts in neither
90
+ place: skipped, "No pictures: the app did not start." An app that starts in
91
+ the folder but not in the copy, or a page that does not load or answers 400
92
+ or more: the check fails, with what to fix. Stop and the time limit are
93
+ looked at while the page loads, not only between steps. Stopping the app
94
+ kills its whole process group after the grace, so a server that outlives npm
95
+ does not stay up. Installed bridges need a release through #584/#640.
96
+
97
+ ## The runner talks to the board over rows, not files
98
+
99
+ Every request the runner picks up is a row in the hosted database, and every
100
+ answer it gives back is an update to that row. Nothing passes through a
101
+ request folder on disk. Terminal retry receipts are stored locally until the
102
+ server acknowledges them. The tables and the Edge Function ops that own them are
103
+ specified elsewhere (brief 30, "Move 2 contract"); this file owns the
104
+ engine side - worktree, wall clock, resume and retry, money - which the move
105
+ to rows did not change.
106
+
107
+ `bridge/lib/runnerApi.mjs` is the whole of the wire: one function per op (`register`, `heartbeat`, `claim`,
108
+ `update`, `events`, `opTake`, `opResult`, `runs`, `resume`), bearer resolved
109
+ the same way every other hosted call in this package resolves one, POST to
110
+ `supabase/functions/board-runner/`.
111
+
112
+ `bridge/lib/runner.mjs` reads and writes rows, not files, for every request
113
+ kind, chat turns included. What replaces each piece that used to be a file
114
+ under `.glytch/inbox/spawn/`:
115
+
116
+ | was a file | is now |
117
+ | --- | --- |
118
+ | `<id>.json` (the request), `<id>.claim` | `claim {projectIds}` - one atomic server-side update, no exclusive-create race |
119
+ | `<id>.result.txt` | `update {runId, patch:{status, result, finished_at}}` - `result` still write-once |
120
+ | `<id>.log`, streamed | `events {runId, events:[{seq,kind,text}]}` - chunks every `PM_LOG_FLUSH_MS` (default 3000ms), `seq` monotonic; see rowsEvents.mjs for how the head/cut/tail cap is honoured at settle time |
121
+ | `<id>.session.json` | `sidecar` on the run row, written by `update` - for EVERY request kind, chat turns included (a first chat message is not `isPersistent` and mints no worktree either, so without this exception it would write no sidecar at all - see `runnerCompletion.mjs`) |
122
+ | `.runner.json` | `register`+`heartbeat`, on start and every beat (`RUNNER_BEAT_MS`). `viewable doctor`'s liveness row used to read the file and froze at whatever the last file-based daemon wrote; it asks the board directly now, through the read-only `presence` op |
123
+ | `<id>.stop` | `heartbeat`'s `stops` array |
124
+ | `<id>.merge` / `<id>.delete` / `<id>.relaunch` | `heartbeat`'s `ops`, `opTake`, `opResult` - relaunch's record is the run row's own `server` column (or the worktree's live `.viewable-server.json` when the row has none yet), never a `.server` mailbox file |
125
+ | the shared-preview request/receipt pair (`.glytch/inbox/preview/`) | a `run_ops` row of `kind:"preview"` - claimed once (`opTake`), its proofs read from the claimed run row (`runnerApi.runs`) and from `get_node` over `hosted.callTool`, never from `canvas/`; `opResult` ticks through starting/ready/offline/error on the SAME op row, since preview (unlike merge/delete/relaunch) is not one-shot - `bridge/lib/sharedPreview.mjs`'s `createSharedPreviewService` tracks it across beats |
126
+ | `<id>.reply-*.json` promoted to a new request | the `resume` op - one call, no file, no `createdBy:"runner"` request to write. Carries `images` (a late message's own, same shape a reply file's `images` array used) when the message had any - `runnerApi.mjs`'s own header says why the OP does not declare the field yet on its side |
127
+ | the `startSession`/`endSession` ops files, and the wait for their receipt | nothing - `claim` moving a row to `status:'running'` IS the start; there is no more window to guard |
128
+ | a chat-turn's request FILE, `<id>.stop`, its per-room lock (`activeChatTurnForRoom`, disk-scanned) | the same row contract as an ordinary session, plus `activeChatTurnForRoomRows` (`runner.mjs`) in place of the per-idea lock - a chat turn has no idea behind it, so its lock is per `roomId` instead |
129
+
130
+ **Admission, leases and completion recovery (2026-09-26).** Admission refuses
131
+ unavailable concurrency or spending reads. Each claim carries an instance UUID
132
+ and five-minute lease, renewed every 30 seconds; local execution stops before
133
+ lease expiry if renewal fails. Expired claims become errors, never automatic
134
+ re-runs. A later original completion may reconcile an expired claim while its
135
+ result is still unwritten. The one exception is the restart pass (see "The
136
+ conversation on an idea"): it continues a session a person asked for, in its
137
+ own worktree, once.
138
+
139
+ Terminal patches are saved under `VIEWABLE_HOME/run-completions` before delivery.
140
+ The runner retries exact receipts after restart and blocks new admission while
141
+ any receipt remains undelivered. A failed disk write retains the patch in memory
142
+ and attempts delivery; simultaneous disk/network failure cannot survive process
143
+ loss. The server accepts identical retries and rejects conflicting results.
144
+
145
+ **How often the runner calls the board (ticket e8, approved 2026-10-03; #616).**
146
+ `lib/runnerPace.mjs` decides every wait for both loops of a folder:
147
+
148
+ | loop | healthy | queue empty | 5xx, 429 or network failure |
149
+ | --- | --- | --- | --- |
150
+ | claim (`claim`, plus `runs` when `PM_SPAWN_MAX_CONCURRENT` is set) | `PM_SPAWN_POLL_MS`, 5 s | no call while the beat's `pending` leaves this folder out; against an older function, grows by 2.5 s per empty claim to 30 s (6x) | 5, 10, 20, 40, then 60 s (12x), jitter down to 20%; reset by the first success |
151
+ | beat (`register` + `heartbeat`) | `RUNNER_BEAT_MS`, 10 s, one heartbeat for every folder of the process; `register` only every 5 min or when the folder list changes | - | 10, 20, 40, then 60 s (6x); reset by the first success |
152
+
153
+ A 429 or 503 with `Retry-After` waits at least that long, up to an hour, and
154
+ the folders share that failure instead of each asking again. The error line
155
+ names Cloudflare's Ray ID (`cf-ray`, or the Ray ID on its HTML page) and the
156
+ function's request id, and a Cloudflare HTML page is summed up in one line
157
+ instead of 400 characters of markup.
158
+
159
+ A healthy beat stays at 10 s, a third of the 30 s window the app
160
+ (`RUNNER_PROJECT_STALE_MS`) and doctor (`RUNNER_STALE_MS`) read as online.
161
+ A 401 and every refusal keep the base interval: a dead credential already
162
+ stops reaching the network (`auth.mjs`). `runnerApi.mjs` marks the failures
163
+ to back off on with `transient: true`. This is polling backoff only; a failed
164
+ run is still never re-run.
165
+
166
+ The daemon (`multiwatch.mjs`) gives every folder one shared registrar and one
167
+ heartbeat hub. The hub sends one `heartbeat` for every folder that beat
168
+ lately and hands each folder its own stops and ops (`op.project_id`). The
169
+ answer's `pending` lists the folders with an approved run nobody has claimed;
170
+ only those folders call `claim`, each with its own admission lock, its own
171
+ concurrency count and its own `runnerInstanceId`, so no process-level claim
172
+ dispatcher is needed. A function without `pending` (deployed before #616)
173
+ turns the hub off and each folder beats and polls `claim` alone, as before.
174
+ A folder the server refuses inside a batch beats alone so it cannot refuse
175
+ the others. Lease renewal for an active run is still its own heartbeat
176
+ (`runnerLease.mjs`). With five idle folders this goes from about 45 calls a
177
+ minute to about 6 (one heartbeat every 10 s, `register` every 5 minutes,
178
+ no claims).
179
+
180
+ **One casualty confirmed to have no rows equivalent.** `askedAndNeverReported`
181
+ (the "asked N questions and reported nothing" line on a run's outcome
182
+ comment) read `inbox/ops/*.json` and `inbox/tell/*.json` to reconstruct what
183
+ a session had put in front of the human. There is no rows op that answers
184
+ "what did this run itself write to the board" - `collect_session` answers
185
+ "what came back", the opposite question - so this line is dropped rather
186
+ than approximated; a rows-based equivalent would need its own op (e.g. a
187
+ `session_asks` view) and is not in the Move 2 contract. `ops.mjs`,
188
+ `tell.mjs`, `questions.mjs` and `sidecar.mjs` are DELETED (2026-09-11, Move
189
+ 2b) rather than kept as dead code: their only caller, the local stdio server,
190
+ was deleted in Move 1 W3, and nothing else in this package called them
191
+ after Move 2's own rewrite of `runner.mjs`. `lateReplies.mjs` is deleted the
192
+ same pass for the same reason - its rows-era replacement, `resume`, settles
193
+ a late answer or message through a call to `runnerApi.resume`, not a file
194
+ write. `bridge/hooks/stop-report.mjs` was the ONE EXCEPTION - a Claude Code
195
+ Stop hook, not the spawn runner, writing into `inbox/tell/` as a session's own
196
+ fallback report. It is DELETED (2026-09-13): the app-side reader of that
197
+ mailbox went with the rest of the `.glytch/` contract in Move 2, so the write
198
+ had no reader, and `paths.mjs`'s `OPS_DIR` and `TELL_DIR` constants are gone
199
+ with their last user.
200
+
201
+ ## Reference image delivery - 2026-09-10
202
+
203
+ Image-bearing requests must preserve every selected attachment. The runner
204
+ validates the complete `images` array before launching a coding process and
205
+ reports malformed or unsupported data instead of filtering it out. Supported
206
+ transport formats are PNG, JPEG, GIF and WebP. File-based engines receive
207
+ validated temporary files; failure to write any file aborts the run. Claude
208
+ streaming, Codex app-server and ACP receive native image content. ACP must
209
+ advertise image prompt support before an image-bearing model turn is sent.
210
+
211
+ Both draft and implementation sessions are instructed to inspect references,
212
+ identify their sources and record concrete observations before planning or
213
+ editing. Visual implementations must be compared against those references.
214
+ The board MCP `get_node` returns native image blocks with source labels; missing
215
+ stored images are reported explicitly. Instructions inside attachments remain
216
+ reference content, not authority to override the user's request.
217
+
218
+ Transport tests prove byte delivery and refusal behavior using fake processes.
219
+ They do not prove model comprehension. Do not describe these safeguards as a
220
+ guarantee that a model interpreted a reference correctly. Existing runner and
221
+ MCP processes must reload the updated code to pick up the changes.
222
+
223
+
224
+ Amended 2026-09-02 (third pass), ON A BRANCH and not in main yet, against
225
+ `src/agents/`, `src/store/actions/folder.ts` and
226
+ `scripts/check-spawn-callers.mjs`. NO FILE UNDER `bridge/` CHANGED, and the
227
+ request, claim and receipt shapes are all unchanged - what changed is who may
228
+ file a request.
229
+
230
+ - **A request can now be filed without a human click**, by an agent whose
231
+ `AgentCaps` allow it (brief 15, decision 5): auto-run on, under its sessions
232
+ per round. No dollar cap is checked (#75). `src/agents/autoRunSpawn.ts` is the
233
+ only such caller, and it asks `decideAutoRun` on every path before it files.
234
+ An agent whose caps refuse files nothing and spends nothing; the prompt
235
+ waits for the click, exactly as before.
236
+ - **Per-agent caps are a second way a request gets filed, not only a human
237
+ click.** The section at the foot of this file describes filing by a human
238
+ click, which is still true of the human path. Still no confirm dialog
239
+ anywhere - ruled against twice (`q-mt9q8qrg`), and this change does not
240
+ reopen it.
241
+ - **The app now reads `costUsd` out of the session sidecar.** The runner has
242
+ written it since the 2026-09-01 pass ("Verdict and cost"); nothing about the
243
+ sidecar changes, but the app records that figure on the run's receipt,
244
+ and nothing reads it to refuse a run (#75, #364). A runner that omits
245
+ `costUsd` records nothing.
246
+
247
+ Checked and amended 2026-09-02 (second pass) against `bridge/lib/runner.mjs`,
248
+ `bridge/lib/exitClass.mjs`, `bridge/lib/worktree.mjs`: the stall watchdog
249
+ re-arms on tool results, which arrive as USER events (the first pass waited
250
+ for a top-level `tool_result` event that never comes, so it never armed after
251
+ the first tool call); the crash retry budget counts from the spawn, not the
252
+ filing; a crash AFTER any assistant event is not retried. Tests cover stall,
253
+ tool-left-open, crash-before-output, crash-after-output and lock ordering.
254
+
255
+ Checked and amended 2026-09-01 against `bridge/lib/runner.mjs`,
256
+ `bridge/lib/exitClass.mjs`, `bridge/lib/worktree.mjs`: verdict and cost land on
257
+ the frame and in the receipt, stall watchdog and crash retry, per-repo lock for
258
+ git mutations. See new sections "Verdict and cost", "Stall watchdog", and
259
+ "Per-repo worktree lock" below. No file under `src/` changed.
260
+
261
+ Written 2026-08-26, against `bridge/lib/`, `src/folder/folder.ts`, `src/folder/inboxOps.ts`,
262
+ `src/folder/mirror.ts`, `src/types.ts` at `aa0a0eb`. No file under `src/` was changed
263
+ to make the runner work.
264
+
265
+ Checked and amended 2026-08-30 (third pass) against `bridge/lib/runner.mjs`,
266
+ `bridge/lib/repomap.mjs`: a FRESH session (not a resume) gets a repo primer
267
+ after the preamble - the file tree plus exported symbols per source file,
268
+ cached keyed to HEAD sha, deterministic (no model call, no tree-sitter). Built
269
+ to reduce cache re-reads: the 3-day spend audit showed 69% of Opus cost was
270
+ cache re-reads across 100+ turn sessions, and a big share of those turns is
271
+ rediscovering the tree. Primer + attempts section together capped at ~16KB,
272
+ primer trimmed first. See "Repository primer" below. No file under `src/`
273
+ changed.
274
+
275
+ Checked and amended 2026-08-30 (second pass) against `bridge/lib/runner.mjs`,
276
+ `bridge/lib/spawn.mjs` and the receipts in `.glytch/inbox/spawn/`: a persistent
277
+ streaming session that emitted its final result envelope is FINISHED - the
278
+ wall clock closes the idle stream and the receipt settles as success, while
279
+ staying the money bound for a child that never emits one - and the request
280
+ scan skips the runner's own bookkeeping (`*.session.json` sidecars, dotfiles)
281
+ the way it already skips reply files. See "A finished stream is finished" and
282
+ "The scan reads requests and nothing else" below. No file under `src/`
283
+ changed.
284
+
285
+ Checked and amended 2026-08-30 against `bridge/lib/runner.mjs`,
286
+ `bridge/lib/spawn.mjs`, `bridge/lib/worktree.mjs`, `src/store.ts`,
287
+ `src/nodes/SessionTerminal.tsx`, `src/store/runs/prompt.ts`, `src/types.ts`:
288
+ signal files (stop, merge, delete), per-idea lock with resume exemption,
289
+ separate worktree op receipts, worktree summary, merge safety checks, stall
290
+ verdicts, and re-run into a live session. See the new sections "Signal files",
291
+ "Per-idea lock", and "Worktree op receipts" below.
292
+
293
+ Checked and amended 2026-08-29 against `bridge/lib/runner.mjs`,
294
+ `bridge/lib/spawn.mjs` and `bridge/lib/questions.mjs`: reply files are no
295
+ longer read as spawn requests, the live log is a ring rather than a head, and
296
+ an answer given after `ask_user` timed out is pushed into a running streaming
297
+ session. See the amendment under "What is NOT possible without app work". No
298
+ file under `src/` changed.
299
+
300
+ Checked and amended 2026-08-28 against `bridge/lib/runner.mjs` and
301
+ `bridge/lib/spawn.mjs`: a watcher runs as many sessions at once as are filed,
302
+ any number of sessions run at once by default, the liveness mark beats on its own timer,
303
+ and a session that cannot get a worktree while another is running is refused
304
+ before it spawns. See the two new rows in the money table and the note
305
+ under it. No file under `src/` changed.
306
+
307
+ Checked and amended 2026-08-27 against `src/folder/spawnRequest.ts` and `src/store.ts`
308
+ (`spawnPayloadFor`, `fileSpawnRequest`): a request's `prompt` now carries the
309
+ ideas' wired-in context as well as their own words, there is a `contextIds`
310
+ field beside it, the board's controls show the size before the click, and a
311
+ request already in flight refuses a second one. No file under `bridge/`
312
+ changed for any of it - see "The three files" and the new money row.
313
+
314
+ Checked and amended 2026-08-26 against `src/folder/spawnRequest.ts`, `src/store.ts`
315
+ (`fileSpawnRequest`), the idea card and
316
+ `src/components/SelectionBar.tsx`: the board-side button exists now, it files
317
+ on ONE click per ruling `q-mt9q8qrg`, and the last section says so. The
318
+ confirm dialog this file described earlier the same day is gone; see the
319
+ note in the money table. Nothing in the runner changed.
320
+
321
+ ## What this is for
322
+
323
+ The user's overnight goal, verbatim: "be able to spawn sessions of claude code
324
+ here", "be able to see the output in predictable place (currently is all over
325
+ the place)", "be able to use claude code from here to get better analysis on my
326
+ board", "be able to track things between claude code and the area of its
327
+ operation here (when its done - propose to delete)".
328
+
329
+ ## Why it looks like this and not like anything else
330
+
331
+ The repo's one hard architectural rule (`CLAUDE.md`): nothing under `src/` may
332
+ import from `bridge/`, and the app must build and behave identically with
333
+ `bridge/` and `.mcp.json` deleted. A browser also cannot spawn a process. So
334
+ the app can only ever STATE an intent, through the folder contract it already
335
+ offers any outside actor, and something outside must act on it.
336
+
337
+ That gives the whole shape, and there is no second option:
338
+
339
+ ```
340
+ the app / a session the folder the runner
341
+ -------------------- -------------------------- -------------------
342
+ states an intent -> inbox/spawn/<id>.json
343
+ inbox/spawn/<id>.claim <- claims it, once
344
+ ingests, once per inbox/ops/*.json <- startSession
345
+ file version inbox/ops/*.result.txt -> ...checked, THEN spawn
346
+ canvas/<board>.json -> answers, comments
347
+ inbox/ops/*.json <- endSession
348
+ inbox/spawn/<id>.result.txt <- the receipt
349
+ ```
350
+
351
+ `bridge/README.md:22` already anticipated exactly this: "A future hosted MCP
352
+ endpoint or spawn runner replaces this process by speaking the same files."
353
+
354
+ **A third folder, not a third meaning for `.glytch/inbox/ops/`.** The app lists
355
+ `inbox/*.md` (top level, markdown only) and `inbox/ops/*.json` (json only) -
356
+ `src/folder/folder.ts:189` and `:207`. It never walks deeper. So `.glytch/inbox/spawn/` is
357
+ invisible to the app: a request file there can never be parsed as a Board API
358
+ ops file and can never put a red panel on his screen. Putting requests in
359
+ `.glytch/inbox/ops/` would have done exactly that.
360
+
361
+ **The file shapes are copied from the ops channel on purpose.** A request file
362
+ plus a `.result.txt` receipt written back beside it is the idiom this repo
363
+ already has, the one `bridge/lib/ops.mjs` already reads, and the one a session
364
+ already knows how to look for. Inventing a second shape for the same job would
365
+ be the drift the sidecar exists to avoid.
366
+
367
+ ## Two states, and no progress protocol
368
+
369
+ The ruling, 2026-08-26 (`q-mt9as5a0`): a run is **running**, then **done**.
370
+ There is no third board state, no heartbeat, no liveness channel, and nothing
371
+ anywhere infers a session's state from its silence. The record rides the
372
+ idea, and its card draws these same two states.
373
+
374
+ That ruling deletes a lot, and the deletions are the good part. There is no
375
+ `.status.json`, no `STALE_MS`, no reaper, no "waiting on you" state, and no
376
+ timeout that guesses whether a quiet session is thinking or dead. A session
377
+ that dies without ending leaves its card reading `running`, and he sees that
378
+ and presses Rerun or clears the idea. A wrong-looking card he can act on beats a
379
+ right-looking state machine he has to trust.
380
+
381
+ Since 2026-09-07 (the QA sweep of the runner) the end op says which of the two
382
+ endings it was: `endSession` carries `stopped: true` when the run ended because
383
+ a stop was asked for, and a `reason` line when it ended without ever starting.
384
+ That is not a third state - the board still reads running, then done - it is
385
+ the difference between "finished" and "you stopped it", which the card could
386
+ not otherwise tell.
387
+
388
+ What is NOT a progress protocol, and stays:
389
+
390
+ - the **claim file**, which records who owns a request so two runners cannot
391
+ both run it. It is written once and never updated, so it says nothing about
392
+ liveness and cannot go stale in a way anyone reads.
393
+ - the **wall clock**, which kills a child that has run past `maxMs`. That is a
394
+ money bound on a process, not a state on the board. When it fires the runner
395
+ still calls `endSession`, so the board says `done` like any other ending, and
396
+ the reason lives in `inbox/spawn/<id>.result.txt` where a person reads it.
397
+ Amended 2026-08-30: for a streaming child whose final result envelope stands,
398
+ the firing is the reply window closing, not a timeout - see "A finished
399
+ stream is finished" below. The bound itself is unchanged. Amended again
400
+ 2026-09-06: there is no reply window any more, so an idle child does not
401
+ reach the wall clock at all - the envelope closes its stdin and it exits.
402
+ The bound is still unchanged, and it is what a child mid-turn meets.
403
+
404
+ The board carries two states. The disk carries the detail. Neither pretends to
405
+ carry the other.
406
+
407
+ ## A finished stream is finished
408
+
409
+ Added 2026-08-30. A persistent session runs streaming (`--input-format
410
+ stream-json`) so the human can reply into it, and a streaming child NEVER
411
+ exits on its own: it ends each turn with a result envelope (`{"type":
412
+ "result", ..., "total_cost_usd", "session_id"}`) and then sits on stdin
413
+ holding the reply window open. The runner read that ending like a one-shot
414
+ child's: the wall clock fired, killed it, and wrote an ERROR receipt saying
415
+ the work is unfinished. By 2026-08-30, 26 of the 28 sessions killed at the
416
+ 90-minute wall had their final envelope in the log - work done, billed, and
417
+ receipted as failed. spawn-mtfx5hex-bb857e is the type specimen: its log ends
418
+ "tsc clean, build clean, 1865 tests pass" with `total_cost_usd` 7.52, and its
419
+ receipt says the session ran out of time.
420
+
421
+ So the runner scans a streaming child's stdout for result envelopes. An
422
+ envelope means the child is IDLE - the turn is over and nothing is billing.
423
+ Sending it a new user message (a reply, a pushed answer) starts a new turn
424
+ and clears the flag. When the child ends while the flag stands - the wall
425
+ clock closing the reply window, the human clicking Stop, or its own exit -
426
+ the outcome is `finished`: the receipt settles as success, the session's one
427
+ reply lands on the run's idea (`reportOutcomeOnBoard` writes it through
428
+ `commentOnAnchor`, see "The conversation on an idea"), and the sidecar stays resumable with the worktree
429
+ kept per the persistent-session rules. An envelope carrying
430
+ `is_error: true` ends the session without calling it a success.
431
+
432
+ What does NOT change: the wall clock is still the money bound, and for a
433
+ child that never emitted an envelope - hung mid-work, looping, never got
434
+ started - its firing is still a timeout and still an ERROR receipt. The two
435
+ states on the board also do not change: running, then done, with the detail
436
+ on disk (q-mt9as5a0). This is a receipt telling the truth, not a third state.
437
+
438
+ ### The reply window closes with the envelope
439
+
440
+ Everything above still holds about what an envelope MEANS and how the exit is
441
+ read. The runner closes the child's stdin the moment the envelope lands, so
442
+ the child exits on its own and the session ends when its work does.
443
+
444
+ The wall clock is untouched for a child MID-TURN (no envelope since the last
445
+ message it was sent), and the SIGTERM-then-SIGKILL path stays for a child that
446
+ ignores a closed stdin.
447
+
448
+ A reply that arrives for a child whose stdin is closed, or that has already
449
+ exited, is NOT lost and is never written to a dead pipe: the runner turns it
450
+ into a new spawn request in the same folder - `createdBy: "runner"`, approved
451
+ exactly as the original was, the reply as `prompt` and `title`, the same
452
+ `nodeIds` and `agent`, `resumeSessionId` from the sidecar's `claudeSessionId`
453
+ and its `worktreePath` - and the ordinary watch runs it, resuming the session
454
+ in the tree it lived in. See `promoteReplyToRequest` and `promoteLateReplies`
455
+ in `lib/runner.mjs`. A reply arriving MID-TURN still goes straight down stdin,
456
+ which is where the live reply channel described below still applies.
457
+
458
+ Answers are unchanged: a session blocked inside `ask_user` is mid-turn, so its
459
+ stdin is open and `answerPoll` still pushes the ruling in live.
460
+
461
+ ## Resume - the worktree IS the session's life
462
+
463
+ *Added 2026-09-07. Checked against `bridge/lib/runner.mjs` and the four tests
464
+ named at the bottom of this section.*
465
+
466
+ A request carrying `resumeSessionId` continues a conversation Claude Code
467
+ already has. **Claude Code keeps conversations per working directory**, so
468
+ `--resume <id>` only means anything in the directory that conversation ran in.
469
+ That is the whole rule, and everything below follows from it.
470
+
471
+ **The recorded worktree runs the session.** A request that carries
472
+ `resumeSessionId` AND a `worktreePath` that exists on disk and is a git
473
+ worktree inside `<root>/.claude/worktrees/` runs IN that worktree, on its
474
+ existing branch. No new worktree, no new branch, no repo primer, no attempts
475
+ section - a resume already has all of that in its history. The sidecar and the
476
+ receipt both name that tree ("Reused existing worktree at ..."). The per-prompt
477
+ lock is exempted for it: a resume into the prompt's OWN tree is not a second
478
+ session, it IS the session.
479
+
480
+ **The command is now built AFTER the tree is chosen, and that is the fix.**
481
+ Until 2026-09-07 `--resume` was decided at the top of `runRequest` and the
482
+ working directory a hundred lines further down, so a resume whose recorded tree
483
+ had gone got the fallback's FRESH tree with the resume flag still on it. What
484
+ that produces is not an error the runner can read as one:
485
+
486
+ ```
487
+ {"type":"result","subtype":"error_during_execution","num_turns":0,
488
+ "total_cost_usd":0,"errors":["No conversation found with session ID: ..."]}
489
+ ```
490
+
491
+ spawn-mtqay776-f76882 (2026-09-06 21:06 UTC) is that envelope. Its receipt said
492
+ "ended with an error result; $0.00, 0 turns" and its frame read DONE with
493
+ nothing in it. The question it produced - "why did it fail, lack of context?" -
494
+ has an honest answer: no. The resume ran in the wrong directory and never
495
+ started.
496
+
497
+ **When the recorded worktree is gone** (merged and deleted, swept, pruned,
498
+ manually removed), the runner does NOT run `--resume` in a fresh tree. It
499
+ starts a **fresh session** - no `--resume`, a new session id minted by the
500
+ engine - in a fresh worktree, and:
501
+
502
+ - the prompt carries one extra line saying the earlier conversation could not
503
+ be resumed because its worktree is gone, so the session knows its history is
504
+ not there rather than acting as though it is;
505
+ - the fresh session gets what a fresh session gets: the repo primer and the
506
+ attempts log;
507
+ - the receipt says so in plain words, starting `THE EARLIER CONVERSATION COULD
508
+ NOT BE RESUMED: ITS WORKTREE IS GONE`;
509
+ - the sidecar records the NEW `claudeSessionId`, so the app's next "run again"
510
+ on that idea resumes the session that exists instead of the one that does
511
+ not.
512
+
513
+ A request that names its own `cwd` and resumes there is untouched by all of
514
+ this: it never asked for a recorded tree, so it has nothing to have lost.
515
+
516
+ **The tree is there and the conversation still is not.** Same envelope, from a
517
+ pruned session file or a tree restored from elsewhere. `isMissingConversation`
518
+ in `lib/runner.mjs` recognises it - `is_error`, `num_turns: 0`,
519
+ `total_cost_usd: 0`, and `No conversation found` in `errors[]`, all four - and
520
+ the runner retries **exactly once**, as a fresh session, in that SAME worktree,
521
+ logging one line and telling the session the same thing in one line of prompt.
522
+ The receipt says `THE EARLIER CONVERSATION COULD NOT BE RESUMED: CLAUDE CODE
523
+ HAD NO RECORD OF IT`.
524
+
525
+ **Why that retry does not break "Exactly once".** The rule below exists because
526
+ a re-run spends money that is already spent. This envelope says the opposite on
527
+ its face: **0 turns and $0.00**. Nothing was billed, nothing was done, and there
528
+ is no partial work to duplicate - so the retry is not paying twice, it is the
529
+ first attempt actually happening. The turn count and the cost are checked, not
530
+ assumed: a run that reached one turn or one cent is not this case whatever its
531
+ errors say, and a second failure is terminal like any other.
532
+
533
+ Tested in `bridge/test/runner.test.mjs`: a resume with a live `worktreePath`
534
+ runs there and makes no second tree; a resume whose tree is gone starts fresh,
535
+ is told so, and gets the attempts section; the 0-turn envelope retries once in
536
+ the same tree and the sidecar names the session that lives; a second failure is
537
+ terminal. The `no-conversation` mode of `test/fixtures/fake-claude.mjs` plays
538
+ the envelope.
539
+
540
+ ## The scan reads requests and nothing else
541
+
542
+ Added 2026-08-30. The request scan filtered on `*.json` minus the reply
543
+ marker, and everything else the folder legitimately holds in `.json` was
544
+ claimed as a request, found to have no `prompt` and no `approved`, and
545
+ refused. Two victims:
546
+
547
+ - **`<id>.session.json`**, the runner's own sidecar for persistent sessions
548
+ (`claudeSessionId`, `worktreePath`, `expired`). 47 "Refused: Pass prompt"
549
+ receipts had accumulated by 2026-08-30 - one per sidecar - and each
550
+ refusal's claim also counted in `spendSince` as though a session had been
551
+ started, the same quiet half the reply-file bug had.
552
+ - **`.runner.json`**, the watcher's own liveness mark. `.runner.result.txt`
553
+ in the real inbox is the proof the runner refused its own heartbeat.
554
+
555
+ `listRequests` now skips reply files, `*.session.json` (the marker lives in
556
+ `spawn.mjs` as `SESSION_SIDECAR_SUFFIX`, and the runner builds the sidecar
557
+ path from it rather than spelling it twice), and dotfiles. Nothing hidden is
558
+ input, and nothing the runner writes for itself can come back to it as a
559
+ request.
560
+
561
+ ## The three files
562
+
563
+ `bridge/lib/runner.mjs` reads and writes none of this - see the table near
564
+ the top of this file. Kept below verbatim: the SHAPE of a request (what
565
+ fields it carries, what `approved` and `maxMs` mean) is exactly the shape
566
+ `run.request` carries in a row now, and `bridge/spawn-runner.mjs`'s deferred
567
+ CLI subcommands still speak this file contract directly.
568
+
569
+ All under `.glytch/inbox/spawn/`, all keyed by one request id (`spawn-<base36>-<hex>`,
570
+ the same `newOpId` shape the ops channel uses).
571
+
572
+ ### `<id>.json` - the request
573
+
574
+ ```json
575
+ {
576
+ "version": 1,
577
+ "id": "spawn-mt8n-ab12cd",
578
+ "createdAt": "2026-08-26T02:10:00.000Z",
579
+ "createdBy": "board",
580
+ "approved": true,
581
+ "approvedBy": "nicolas",
582
+ "prompt": "Read the board and say which of the four features is closest to shipping.",
583
+ "title": "Which feature is closest",
584
+ "cwd": "/Users/nicolasjamal/Projects/glytch",
585
+ "maxMs": 1800000
586
+ }
587
+ ```
588
+
589
+ - `approved` is the money gate and it is checked, not decorated. **A request
590
+ without `approved: true` is never run** - it is finalised as `refused` with
591
+ the reason, and the runner moves on. See "Money" below.
592
+ - `prompt` is what the session is told to do. Required, non-empty. Since
593
+ 2026-08-27 it carries the ASK followed by everything wired into those
594
+ ideas, as numbered `[Block N]` sections under one heading - ruling
595
+ `q-mt9ssgrs`, "Should spawning a prompt send everything wired into it, the
596
+ way a model run does?" / "Yes, but show me the size before it spends".
597
+ **Nothing in the runner changed for it, and that is the point**: the context
598
+ is inside the field the runner already passes to the engine, because a
599
+ `context` field beside it would be context the engine never sees.
600
+ - `contextIds` (optional, board-side) names the ideas that fed it, in the
601
+ order the session was shown them. Provenance only - the runner ignores it;
602
+ a session calling `get_node` uses it to walk back to the board.
603
+ - `title` names the run on the board - `RunRecord.title`, what the card's
604
+ heading reads (see "The run record" below). Optional;
605
+ the prompt's first line is used when it is absent.
606
+ - `cwd` is where `claude` runs. Optional; defaults to `PM_BOARD_ROOT`.
607
+ - `maxMs` is this request's wall clock. Clamped to `MAX_WALL_MS`.
608
+ - `webSearch` (optional boolean, #478) lets the run search and read the web
609
+ without asking first: Claude Code gets `WebSearch` and `WebFetch` on its
610
+ list, Codex gets live web search (not on a Codex plan draft). Without it a
611
+ Codex thread is started with web search disabled, whatever the computer's
612
+ own Codex config says. It is the agent's Web search setting in Manage
613
+ agent. A value that is not a boolean is refused.
614
+
615
+ ### `<id>.claim` - who owns it
616
+
617
+ Created with `flag: "wx"` - an exclusive create, which is atomic on a local
618
+ filesystem. **That single call is the whole of "exactly once" across two
619
+ runners**: the loser gets `EEXIST`, logs it, and never touches the request
620
+ again. No lockfile protocol, no lease negotiation, nothing to get wrong.
621
+
622
+ ```json
623
+ { "runnerId": "r-mt8n-9f31", "pid": 40122, "host": "mac", "claimedAt": 1756..., "sessionKey": "spawn-mt8n-ab12cd" }
624
+ ```
625
+
626
+ Written once, never updated, never removed - not even on completion. It is the
627
+ permanent record that this request has been taken.
628
+
629
+ ### `<id>.result.txt` - the receipt
630
+
631
+ Terminal, written once, in the ops channel's own voice (`OK` / `ERROR` on line
632
+ one, then the file and a timestamp, then the detail). It is what a human or a
633
+ later session reads to find out what happened without parsing JSON, and it is
634
+ where a timeout, a non-zero exit and a refusal all say what they were.
635
+
636
+ ## The detached dev server - the run row's `server` column
637
+
638
+ A prompt with the Browser switch on tells the session to start the project's
639
+ dev server and file a browser frame pointing at its URL. A finished session is
640
+ closed on its result envelope, so a server started as an ordinary background
641
+ job dies with the child and the frame points at a dead port, which is what the
642
+ 2026-09-06 test pass caught (`npx serve -l 8765`, the frame filed, the session
643
+ done, curl getting nothing). So the session is told to start it DETACHED, and
644
+ the server outlives the session on purpose. **The words differ per platform
645
+ and the record does not.**
646
+
647
+ | the runner reports | what the session is told to run |
648
+ | --- | --- |
649
+ | `darwin`, `linux`, `wsl`, anything else, or nothing at all | `setsid <cmd> >/dev/null 2>&1 &` where setsid exists, `nohup <cmd> >/dev/null 2>&1 & disown` on macOS |
650
+ | `win32` | PowerShell: `$p = Start-Process -FilePath <exe> -ArgumentList <args> -WindowStyle Hidden -PassThru`, and the pid is `$p.Id` |
651
+
652
+ None of `setsid`, `nohup`, `disown`, `&` backgrounding or `/dev/null` exists in
653
+ cmd or PowerShell, so a Windows session handed the first row either fails the
654
+ command or starts a server that dies with it - the frame pointing at a dead
655
+ port, which is the failure this whole section exists to stop. `start /b` was
656
+ option B of the same ruling and was rejected: it does not reliably leave the
657
+ console, so the child still dies with it. `-PassThru` is how a Windows session
658
+ learns its own child's pid at all - there is no `$!` there - and WSL takes the
659
+ POSIX row because it is Linux and has `setsid`.
660
+
661
+ **The words are the app's, and the platform is the runner's.** The instruction
662
+ lives in `src/store/spawnPayload.ts` (shared with the Preview button), and the
663
+ app chooses between the two rows from the `platform` the runner reports when
664
+ it registers its projects: `process.platform`, or `wsl` when `/proc/version`
665
+ names Microsoft (`bridge/lib/platform.mjs`). Never from `navigator`: the board
666
+ may be open in a browser on a different machine from the one the session runs
667
+ on, and a runner that reports no platform means the POSIX row.
668
+
669
+ **The record.** The SESSION writes `<worktree>/.glytch-server.json`,
670
+ `{pid, port, cmd, startedAt}`, in the root of its working directory: the only
671
+ thing that knows what it started. App instructions keep this legacy name for
672
+ older installed runners, whose version the app cannot identify. Updated runners
673
+ read either server-record name and write `.viewable-server.json` on relaunch.
674
+ Keep excludes both filenames from Git. When the child ends, the runner copies that
675
+ record plus `worktreePath` onto the run row's own `server` column
676
+ (`bridge/lib/runnerCompletion.mjs`), so it outlives a tree that is merged,
677
+ deleted or cleaned. Nothing is written beside the request; the row is the
678
+ record.
679
+
680
+ **Who stops it.** `sweepDetachedServers` (`bridge/lib/serverSweep.mjs`), once
681
+ a day from the runner's own beat, per registered folder: it lists the folder's
682
+ finished run rows that still carry a `server`, stops the server when the run
683
+ finished more than 14 days ago or as soon as the worktree it was serving is
684
+ gone, and clears the column. A running or pending row is never touched.
685
+ Nothing else kills it - not the child's close, not `killProcessesIn`, whose
686
+ parent-chain guard spares a process that reparented to init.
687
+ `bridge/test/serverSweep.test.mjs` pins the age, the missing worktree and the
688
+ untouched running row.
689
+
690
+ **And it never kills on a bare pid.** A record can outlive its process by days
691
+ and pids are reused, so `verifyRecordedServer` (`lib/devserver.mjs`) requires
692
+ positive evidence: the pid must be listening on the recorded port, or carry a
693
+ command line that agrees with the recorded one, and its start time must sit
694
+ within ten minutes of the record's `startedAt`. Anything short of that is one
695
+ log line and the process is left alone. `bridge/test/detachedserver.test.mjs`
696
+ pins both halves - a detached grandchild is still alive after the envelope
697
+ closes its parent, and an unverifiable pid is never signalled.
698
+
699
+ **Who starts it again.** Added 2026-09-06, the ask: "When a dev server gets
700
+ detached, add a button in terminal to relaunch dev server in this branch.
701
+ Allows me to test browser." The sweep above is what makes the button necessary
702
+ - the frame outlives the server - and nothing in a browser can start a process,
703
+ so the runner does it. The app files a `relaunch` op on the run (see "Signal
704
+ files" below); on its next beat the runner re-runs the record's `cmd`,
705
+ detached, in the recorded worktree, writes a fresh record to the row, and
706
+ answers through the op's result.
707
+
708
+ **No session, and no command.** A relaunch costs nothing: it is one process
709
+ started from a command that is already written down, so there is no LLM turn to
710
+ pay for and no session to file. The `cmd` comes from the record and only from
711
+ the record - the op may carry `{ worktreePath }` as a hint for finding one,
712
+ and nothing else. An op that could name its own command would be a way to run
713
+ arbitrary shell out of the canvas, which is a different feature and not one
714
+ anybody asked for. So a missing record is a refusal, never a prompt for one.
715
+
716
+ It refuses, and says which, when:
717
+
718
+ | refusal | why |
719
+ | --- | --- |
720
+ | no record | the session started no server, or the sweep cleared the record when the worktree went |
721
+ | the record has no `cmd` | there is nothing to re-run, and nothing may be guessed |
722
+ | the worktree is gone and so is the linked folder | `cmd` has nowhere to run. While the folder is there (Keep changes deleted the copy), the app starts again from the folder and writes no record file into it (#485) |
723
+ | the recorded port is held | something that is not this record is on it, and a second server on a taken port is worse than none |
724
+ | the command exits at once | it died inside `RELAUNCH_SETTLE_MS` (700ms); a detached server has no terminal, so its output went nowhere |
725
+
726
+ A server that is STILL RUNNING is not a refusal: the answer is the pid and port
727
+ it is already on - verified by the same evidence the sweep demands before it
728
+ kills anything - and nothing is started twice.
729
+ `bridge/test/relaunchserver.test.mjs` pins the lot.
730
+
731
+ **How it is stopped, per platform.** The stop is two passes with a grace
732
+ between them, and the words for those two passes differ (brief 21, ruling
733
+ 4A of 2026-09-07). On macOS and Linux it is `SIGTERM` and then `SIGKILL`; on
734
+ Windows, where Node maps both signals to TerminateProcess and reaches only the
735
+ one pid while the real server usually runs as a grandchild of a `.cmd` shim, it
736
+ is `taskkill /pid <pid> /t` and then `taskkill /pid <pid> /t /f`. One seam,
737
+ `killTree` in `lib/kill.mjs`, holds both, and `lib/child.mjs`'s kill closure and
738
+ `lib/procs.mjs` go through the same seam. THE GUARD IS UNCHANGED AND COMES
739
+ FIRST either way: `killTree` decides nothing about whether a pid may be killed.
740
+ The Windows evidence for that guard is `netstat -ano` for the pid listening on
741
+ the recorded port and `Get-CimInstance Win32_Process` (through
742
+ `ConvertTo-Json`) for the start time and the command line, injected exactly
743
+ where `ps` and `lsof` are; `bridge/test/windowsevidence.test.mjs` pins the
744
+ parsers against sample output, and `bridge/test/kill.test.mjs` the two argv
745
+ sequences.
746
+
747
+ ## Windows
748
+
749
+ *Brief 21, the rulings of 2026-09-07. Written against sessions 1-5; session 6
750
+ is the end-to-end on a real machine and nothing here has met one yet.*
751
+
752
+ **What is Windows and what is not.** Ruling 6C: native Windows and WSL both
753
+ count, and `viewable doctor` names which one it is running in. WSL is Linux for
754
+ everything in this file - it has `setsid`, it has real npm shims, it gets the
755
+ POSIX spelling of every command below - so the runner reports it as its own
756
+ name, `wsl`, rather than as either `linux` or `win32`
757
+ (`bridge/lib/platform.mjs`).
758
+
759
+ **How the runner says which.** Every beat of the liveness mark
760
+ (`.glytch/inbox/spawn/.runner.json`) carries a `platform` key beside `at`,
761
+ `version`, `updateAvailable` and `agents`: `process.platform`, or `wsl` when
762
+ `/proc/version` names Microsoft. It is optional and stays optional - a runner
763
+ from before this brief writes none, and the app reads that as "nothing has
764
+ said" and falls back to the POSIX shape, exactly as it reads a missing
765
+ `agents` block. THE APP MUST NOT USE `navigator` FOR THIS: the board runs in
766
+ a browser that need not be on the machine the session runs on.
767
+
768
+ **`.mcp.json` names the board server differently.** Ruling 2A: a writer on
769
+ Windows writes `{"command":"cmd","args":["/c","glytch","mcp"]}`, and every
770
+ other platform keeps `{"command":"glytch","args":["mcp"]}`. npm installs the
771
+ bin as `glytch.cmd` there, and an MCP client spawning `glytch` with no shell
772
+ hits the refusal Node has enforced since 18.20.2 / 20.12.2. `bridge/lib/mcpjson.mjs` branches on `process.platform`, because `viewable init`
773
+ IS the machine, and `bridge/test/fixtures/mcp-json-cases.json` carries the
774
+ Windows cases. (The app's second writer went with the old Help, #494.) **A file
775
+ that already carries EITHER shape is left exactly as it is**, which is the same
776
+ presence rule as always and is what stops a folder shared between two machines
777
+ being rewritten forever.
778
+
779
+ **Starting and stopping processes.** `runnableCommand`
780
+ (`bridge/lib/windows-command.mjs`) resolves `claude`, `opencode` and `npm`
781
+ through `PATHEXT` before spawning them, and `childEnv`'s allowlist matches
782
+ `Path` case-insensitively and passes `SystemRoot` (without which a child cannot
783
+ resolve DNS). `killTree` (`bridge/lib/kill.mjs`) is `taskkill /pid <pid> /t`
784
+ then `/t /f`, behind the same verification guard. See the two sections above.
785
+
786
+ **Git for Windows is a warning, not a refusal** (ruling 8B). Without it there
787
+ is no worktree isolation and no Bash tool, so `viewable init` and `viewable doctor`
788
+ say so in a row that is a WARNING - and a session then runs unisolated in the
789
+ linked folder, which is what `makeWorktreeImpl` already does anywhere git is
790
+ missing. Option A, a FAIL row and `exit 1`, was rejected: an unisolated session
791
+ is worth more than no session.
792
+
793
+ **CI gates it** (ruling 7A). `.github/workflows/bridge-tests.yml` runs the
794
+ bridge suite on `windows-latest` as well as `ubuntu-latest`, and the Windows
795
+ job BLOCKS.
796
+
797
+ ## Exactly once, and the one place this deliberately differs from ops
798
+
799
+ The ops channel keys on `(path, lastModified)`: editing a file makes a new
800
+ version and the new version runs. **The spawn channel does not do that.** A
801
+ request runs AT MOST ONCE, EVER, per request id, no matter how many times the
802
+ file is edited or how the run ended. The claim file is the record of that.
803
+
804
+ The reason is money and nothing else. Re-running an ops file costs a proposal
805
+ the user can reject in one click. Re-running a spawn costs credits that are
806
+ already spent by the time he sees it. Symmetry with the ops channel is worth
807
+ less than that, so it goes. To run something again, write a new request.
808
+
809
+ Consequence, stated so nobody rediscovers it: a request that fails is **not
810
+ retried**, by the runner or by anything else. Failure is terminal. That is also
811
+ why there is no backoff, no retry count and no dead-letter queue - each of
812
+ those is a mechanism for spending money again without being asked.
813
+
814
+ **The two exceptions, and what they have in common** (crash retry, 2026-09-01;
815
+ the No-conversation retry, 2026-09-07). Both re-run the child once, and both
816
+ are allowed for the same reason: the first attempt provably spent nothing and
817
+ produced nothing. A crash before any assistant event streamed has no output to
818
+ duplicate; a `No conversation found` envelope says `num_turns: 0` and
819
+ `total_cost_usd: 0` on its own face. Neither is a retry of work - each is the
820
+ first attempt actually happening. A third exception is a money decision and
821
+ needs a ruling, not a patch.
822
+
823
+ **A runner that dies mid-session leaks its concurrency slot**, because its
824
+ claim has no receipt and nothing infers death from silence. That is the ruling's
825
+ direct cost and it is accepted rather than worked around. `spawn-runner release
826
+ <id>` is the explicit human way to free it - a person deciding a session is
827
+ gone, which is the only actor allowed to decide that.
828
+
829
+ **Caught failures (2026-09-08):** a runner that catches an exception after
830
+ claiming a request writes an ERROR receipt whenever the spawn directory is
831
+ still writable, and attempts to close its board run with the failure reason.
832
+ The receipt distinguishes failure before invoking a child from failure after
833
+ invocation; neither path retries the request. Its live-log timer and temporary
834
+ images are cleaned up even when finalization fails. This does not release an
835
+ unobserved crash's request claim.
836
+
837
+ **Admission (2026-09-08):** direct runs and polling share an atomic mutex at
838
+ `inbox/spawn/.admission.lock` around room checks and the request
839
+ claim. The claim is the durable reservation; the mutex is released before the
840
+ board start and child execution. A busy mutex leaves the request unclaimed for
841
+ the next poll. Concurrent runners therefore share the configured concurrent
842
+ limit, and cannot claim two live turns in the same room.
843
+
844
+ The mutex publishes a complete owner atomically. A verified-dead PID on the
845
+ same host can be recovered automatically; elapsed time alone never permits
846
+ stealing it. Recovery keeps a tiny `.admission-recovered-<token>` marker so a
847
+ delayed competing cleaner cannot remove a newer owner's lock. A remote or
848
+ unreadable owner, or a cleaner that itself crashed during recovery, requires
849
+ manual removal of `.admission.lock` after confirming its owner is stopped.
850
+ This short admission mutex is separate from durable request claims, whose
851
+ manual `release` contract above remains unchanged.
852
+
853
+ ## The run record - the "predictable place"
854
+
855
+ The run ROW is the record: it exists the moment `run_request` inserted it,
856
+ `claim` moving it to `status:'running'` IS the start (no wait, because
857
+ there is no window between "claimed" and "on the board" left to guard),
858
+ and `update`'s `status:'done'|'error'|'stopped'` is the end. `frameId` -
859
+ still the idea's own id, unrenamed - is `fromIdOf(request)`, read
860
+ straight off the row.
861
+
862
+ **Built in the app, not here.** `boardApi.startSession({key, title, brief,
863
+ fromId})` and `boardApi.endSession({key})` land on the app side; this runner
864
+ is a caller of them and defines none of it. What they do, as of brief 26:
865
+
866
+ - `startSession` finds the IDEA named by `fromId` (the idea whose run
867
+ this is - `fromIdOf(request)`, the first id on the request) and writes a
868
+ `RunRecord` onto it: `key`, `title`, `brief`, `status: "running"`,
869
+ `startedAt`, `openedBy`, replacing any earlier record that idea carried.
870
+ It lands live, not as a proposal. With no `fromId`, or one that no longer
871
+ names an idea, the op returns **OK with no id** - the run happens and its
872
+ report reaches him through the Board Agent when `report` is called, and
873
+ nothing is drawn. Refusing would mean a stale idea id costs a session,
874
+ which is the worse trade.
875
+ - The `key` is the same key `report()` takes. That is the whole trick: the
876
+ session's report reaches the Board Agent, which places it beside the
877
+ ideas it is about, and its questions land as question notes on the idea
878
+ that asked. Nothing is wired. No nesting logic anywhere in this runner.
879
+ - `endSession` flips `status` to `done`, stamps `endedAt`, and **proposes
880
+ nothing** - there is no node to propose deleting, because starting the run
881
+ created none.
882
+
883
+ The runner's whole part is: call one, spawn, call the other, with the request id
884
+ as the `key`.
885
+
886
+ ## Answers and comments coming back
887
+
888
+ This is item 2's tail: "as soon as I finish answering questions or commenting,
889
+ to be able to send those to agent directly from this board."
890
+
891
+ A runner-spawned child reaches the board through the HOSTED endpoint, under
892
+ its own run key (`X-Viewable-Session`, `session.key` - the same key `report()`'s
893
+ `PM_SESSION_KEY` already uses). The runner reaches for `collect_session`,
894
+ exactly the tool a session would call by hand - `bridge/lib/rowsAnswers.mjs`
895
+ is the whole of it. Only a runner-spawned session ever has an idea behind it
896
+ at all - a CLI request or a conversational session has nothing to collect, and
897
+ `collect_session` says so plainly rather than erroring.
898
+
899
+ - **While the child runs**, the runner polls `collect_session` through
900
+ `hosted.callTool`, passing the child's OWN run key as `X-Viewable-Session`
901
+ and a `since` watermark it advances from each reply's `next_since`. A
902
+ matching reply hint on the runner topic wakes collection immediately. While
903
+ joined, a one-minute safety read catches missed hints; without push,
904
+ `PM_ROWS_POLL_MS` (default 5000ms, `settingsFromEnv`) remains the fallback.
905
+ Rejoining also wakes collection. `collect_session`
906
+ already matches "this run's own rows" server-side; what the runner adds
907
+ is remembering which ANSWERED QUESTION it has already delivered (the
908
+ tool hands back every one of this key's answered questions on every
909
+ call, with no time filter). A MESSAGE is stamped `delivered_at` only once
910
+ the child TOOK it (round 4 item 75): the poll passes `delivered`, the ids
911
+ the child accepted since its last call, and the endpoint hands a message
912
+ back unstamped until a call names it; the post-exit sweep sends the last
913
+ acks, and what still comes back is carried on as a resume or, when no run
914
+ can carry it, recorded on the sidecar as `undelivered` for the card. Each new
915
+ row becomes a stdin message through the exact door a typed reply already
916
+ uses (`sendToChild` in `child.mjs`'s `runStreamingChild`) - a message as
917
+ `[message from <from>] <text>`, matching a reply file's own attribution.
918
+ Wired only when the run has a bearer to send at all (`viewable login` or
919
+ `VIEWABLE_BOARD_TOKEN`); with neither, there is nothing on the hosted
920
+ endpoint for the child to have asked in the first place.
921
+ - **After the child exits**, ONE more `collect_session` call
922
+ (`promoteLateRows`) catches whatever arrived in the gap between the last
923
+ live poll and the exit, called from `completeRequest` right after the
924
+ receipt is written. `promoteLateRows`'s `promote` callback is
925
+ `runnerApi.resume`, for every request kind, chat turns included: the
926
+ runner sends one `resume {runId, message}` call and the board-runner
927
+ function inserts the approved child run row server-side.
928
+ `acceptsMessages`' one-shot-vs-persistent distinction decides "logged and
929
+ dropped" vs "resumed" the same way it always has. A content-addressed id
930
+ (`lateRowRequestId`) keeps a duplicate promotion attempt from filing the
931
+ same resume twice.
932
+ - **The one known gap**, spelled out rather than hidden: a MESSAGE that
933
+ arrives in the same poll tick the child happens to exit on is marked
934
+ `delivered_at` by that fetch whether or not `sendToChild` actually got to
935
+ write it before the pipe closed - `watchAnswers` breaks its delivery loop
936
+ the moment the child is settled, before calling `onDelivered`. Such a
937
+ message is not "late" (it was already fetched) and will not come back on
938
+ a later poll either (the server will not hand it back). An ANSWERED
939
+ QUESTION has no such gap - it carries no `delivered_at`, so it is simply
940
+ asked for again, here or on the next live poll, until this run's own
941
+ delivered-set has actually recorded it.
942
+
943
+ The file-based reply path (`<id>.reply-*.json`) stays working alongside this:
944
+ a human typing into a running session still goes straight down the same
945
+ stdin door, file or row.
946
+
947
+ A session collects with the `collect_session` tool, passing `since`. The
948
+ watermark is the caller's, not the daemon's, so it survives a restart, and
949
+ `since: 0` reads everything. An EXITED session cannot be woken - that is a
950
+ new session, which is a spend, which is a click.
951
+
952
+ **Images travel the same doors.** A message's `images` travel into `resume`
953
+ alongside `message` when the poll has any (`rowsAnswers.mjs`'s
954
+ `createRowsAnswerPoll`/`promoteLateRows`), and a LIVE answer's images reach a
955
+ still-running child through `child.mjs`'s `userMessage`, which turns them into
956
+ native image blocks. `collect_session` writes a message's pictures as a
957
+ `{"images":[...]}` line right under it; the poll reads that line per message,
958
+ or, when a batch is one message, the block the tool wrote. A picture that
959
+ cannot be read (bad data URL, too large) never drops its message: the text
960
+ goes through with a short line saying a picture came and could not be read.
961
+ `bridge/test/rowsAnswers.test.mjs` runs this through the fake binary.
962
+
963
+ ## Money - the constraint that outranks the feature
964
+
965
+ A spawned session bills the user's subscription. A spawned session that loops
966
+ bills it repeatedly and does so overnight. Every one of these is a bound, and
967
+ each is enforced in code, not in a comment:
968
+
969
+ | bound | value | what it stops |
970
+ | --- | --- | --- |
971
+ | runner off by default | `PM_SPAWN_RUNNER=on` AND an explicit `watch` command | a runner that starts itself because something imported it |
972
+ | per-request approval | `approved: true`, or the request is refused | a row nobody clicked "run" on ever running |
973
+ | `claim` is the start | `claim` moving a row to `status:'running'` IS the start - the row exists under RLS the moment `run_request` inserted it, so nothing waits for a receipt | a run that is on the board but not yet accounted for |
974
+ | concurrency | `PM_SPAWN_MAX_CONCURRENT`, **any number by default** since 2026-08-28, counted from `runs {status:'running'}` plus the runner's own in-flight set | ten requests becoming ten sessions |
975
+ | no shared working tree | a session that wanted isolation, could not get a worktree, and finds another session running is refused before it spawns (2026-08-28) | two agents editing one checkout with no branch between them |
976
+ | wall clock | `maxMs`, default 90min (`DEFAULT_WALL_MS`), at most 2h (`MAX_WALL_MS`), both in `spawn.mjs` | a session that hangs billing all night |
977
+ | no retry, ever | a failed request is terminal | paying twice for the same failure |
978
+ | no re-run on edit | one request id, one run | a saved file re-billing |
979
+ | the fake binary | `PM_CLAUDE_BIN` | every test in this suite costing money |
980
+ | the board's controls say the price | two callers, both a human click, both labelled with the spend AND with the size the payload weighs (`spawnPayloadFor`, 2026-08-27) | a control that spends without saying so |
981
+ | one request per idea in flight | an idea whose request is filed and unclaimed reads "Waiting" and files nothing (`fileSpawnRequest`, 2026-08-27) | a double press billing twice for one intent |
982
+
983
+ **The default of one session at once is gone, since 2026-08-28**, on the ruling
984
+ "No cap, my click is the cap", asked for out loud: *"I would like to operate
985
+ multiple sessions at once on the board."*
986
+
987
+ Worth recording because the number was never the thing that stopped a second
988
+ session, and reading the table would have told you it was. `pollOnce` awaited
989
+ each child inside its own loop, so ONE WATCHER RAN ONE SESSION AT A TIME
990
+ whatever the cap said - the cap was decoration, and being set to 1 is what
991
+ made the serial behaviour look deliberate. Worse, the same await starved the
992
+ runner's own liveness mark, which was refreshed once per pass: with a session
993
+ running the mark aged past the app's 30s window, the board said "A runner was
994
+ watching but has stopped" over a session that was running fine, and the gate
995
+ that message drives refused to file anything else. One running session made a
996
+ second one impossible, and nothing in the money table was doing it.
997
+
998
+ What replaces the number: `startPending` starts runs without awaiting them,
999
+ slots are counted as the union of the claims on disk and the runner's own
1000
+ in-flight set, and the mark beats on its own timer (`RUNNER_BEAT_MS`, a third
1001
+ of the staleness window). A clean stop now drains its children before clearing
1002
+ the mark, so Ctrl-C still writes every receipt.
1003
+
1004
+ **"One click files a session" is gone, 2026-08-26 (later the same day)**, and
1005
+ the halo wedge that did it. He ruled the confirm dialog off that morning
1006
+ (`q-mt9q8qrg`, "One click is what I said, drop it"), then reversed the rule
1007
+ himself once the shape of the feature was clear: *"Yes reverse the one click
1008
+ rule.. Already we're making things efficient enough."*
1009
+
1010
+ What replaces it is not a confirm step - it is the idea becoming the
1011
+ one place a run is configured. An idea carries a TARGET (an OpenRouter model,
1012
+ or a Claude Code session, the latter unselectable while the setup is
1013
+ incomplete) alongside the run settings it already has, and Run means run,
1014
+ whichever target is set. So a session costs the same one gesture a model run
1015
+ costs, and the second "click" is a property you set once rather than a
1016
+ question asked every time.
1017
+
1018
+ Recorded because the two rulings read as contradictory out of context - the
1019
+ first removed a step from a control that was about to be deleted, the second
1020
+ deleted the control. Neither is a reversal of the money principle: every
1021
+ session still needs a deliberate human action, and `approved: true` is still
1022
+ set by that action alone.
1023
+
1024
+ **"The board's own button confirms - one dialog, then the click that
1025
+ approves" is gone from this table, since 2026-08-26.** It shipped and he ruled it out the same
1026
+ day (`q-mt9q8qrg`, "One click is what I said, drop it"), consistent with the
1027
+ brief that asked for the work. Recorded rather than deleted because a later
1028
+ session reading the money section would otherwise reinvent it as an obvious
1029
+ improvement. It is not an improvement he wants: the click IS the approval, and
1030
+ a dialog in front of it is a machine being careful with his money on his
1031
+ behalf, which is the thing this product exists to stop. The bound that
1032
+ replaces it is the row above - a control that spends has to say so on its own
1033
+ label, and `src/store.spawn.test.ts` pins the caller list so a third caller
1034
+ cannot appear quietly.
1035
+
1036
+ **No run has a spend or token limit (#364).** The wall clock is the one
1037
+ bound on a single run; the runner still counts cost and tokens off the
1038
+ child's own usage events, for the receipt, and nothing it counts stops a run.
1039
+ The free AI that comes with a new account is the only limit, and it is the
1040
+ model proxy's, not the runner's.
1041
+
1042
+ **No autonomous spawning.** Nothing in this repo files a request on its own.
1043
+ The runner runs what it is given and refuses what is not approved; the deciding
1044
+ is the human's, exactly as `ask_user` and proposals already are. That is not a
1045
+ policy bolted on top, it is the rule the rest of the bridge already keeps:
1046
+ `runPrompt`, `generateMore`, `composeDoc` and `composeBrief` are all refused by
1047
+ the ops vocabulary for the same reason - "they spend the user's model credits"
1048
+ (`INBOX_OPS.md`).
1049
+
1050
+ ## The one thing that needed `src/` - BUILT 2026-08-26
1051
+
1052
+ This section records the ruling that closed a real gap: the runner half was
1053
+ complete before the app could FILE a request, so spawning still needed a
1054
+ terminal. The ruling on `q-mt9kh6xc` - "Yes, build the button next" - closed
1055
+ it, and it is built.
1056
+
1057
+ The app's half is `src/folder/spawnRequest.ts` (the request shape, REIMPLEMENTED
1058
+ rather than imported - nothing under `src/` may import from `bridge/`), plus
1059
+ `fileSpawnRequest` in `src/store.ts`, which is the one call this section
1060
+ predicted:
1061
+
1062
+ ```ts
1063
+ await writeFolderFile(handle, `inbox/spawn/${id}.json`, JSON.stringify(request));
1064
+ ```
1065
+
1066
+ Two ways in, and each is ONE click: the Go button on an idea
1067
+ (`src/nodes/IdeaCard.tsx`), and a row in the selection bar's overflow menu
1068
+ for anything else (`src/components/SelectionBar.tsx`). That click writes the
1069
+ file, and that click IS the human gesture that sets `approved: true`.
1070
+
1071
+ The ruling, twice. The brief that asked for this work: "Filing and approving
1072
+ may be one gesture here BECAUSE it is a human click; the CLI keeps them
1073
+ separate only because a script can call the CLI." Then `q-mt9q8qrg`, after it
1074
+ shipped with a confirm dialog in front of it and he was asked whether to keep
1075
+ the second click: "One click is what I said, drop it." The fork this section
1076
+ used to say was "not guessed at here" was closed by him, not by a session.
1077
+
1078
+ So the gate is the CALLER LIST, and it is two controls, both a human click,
1079
+ both labelled with what they cost. Nothing inside `fileSpawnRequest` checks
1080
+ that a person asked - it cannot, and it is not supposed to. That safety
1081
+ property lives in the call graph and in no type, so it has a test:
1082
+ `src/store.spawn.test.ts` reads `src/` and fails if a third caller appears.
1083
+
1084
+ Filing does not start anything, and the app says so without a dialog to say it
1085
+ in. Nothing in the app starts a runner or could - a browser cannot spawn a
1086
+ process - so both controls carry the caveat on their own label, and the
1087
+ toolbar's receipt pill says "Needs the runner" rather than reporting a
1088
+ session.
1089
+
1090
+ `node bridge/spawn-runner.mjs request ...` still works and is still the only
1091
+ way for a SCRIPT to file one, unapproved, with `approve` as a separate command.
1092
+ The app collapses the two because there is a person at the other end of the
1093
+ click; the CLI cannot, because a script can call the CLI.
1094
+
1095
+ **This is a RULING, not a session's invention, and it is worth saying so.**
1096
+ It was set directly in the brief that opened the session which built the
1097
+ button, 2026-08-26: filing and approving may be one gesture here BECAUSE it
1098
+ is a human click, and the CLI keeps them separate only because a script can
1099
+ call the CLI. He confirmed it again the same day when asked to choose between
1100
+ one confirm, splitting filing from approving, and forcing the full prompt
1101
+ into the dialog: "One confirm is enough."
1102
+
1103
+ It is recorded here because until now it was not written down anywhere a
1104
+ reader could check. This file previously said the fork was "a UI decision on
1105
+ contested files, so it is not guessed at here", and the paragraph above
1106
+ replaced that with the rule but not with its source - so a later reader would
1107
+ have found an unattributed rule about money and no way to tell whose it was.
1108
+ The shipped design is in fact STRICTER than he allowed: he permitted one
1109
+ click, the button uses two, and the first writes nothing.
1110
+
1111
+ **He ruled against this route, 2026-08-26.** Asked on `q-mt9kh6xb`
1112
+ whether answering a question should be able to file a spawn request, he
1113
+ answered "No, keep spending on its own gesture". Do not build it. The
1114
+ reasoning below is kept because it explains what was rejected and why the
1115
+ option is tempting; it is not a design still on the table.
1116
+
1117
+ ~~There is a second route that needs no app UI at all~~, and it was worth knowing
1118
+ before the button was designed: a question note IS a human approval gate.
1119
+ `addQuestion` lands live, only the human can answer it, the answer reaches the
1120
+ mirror, and a dismissal means no. A session could propose "Spawn a Claude Code
1121
+ session to do X?" with options, and the runner could treat the answered question
1122
+ as the approval and file the request itself. It needs no app change and it works
1123
+ today. It is deliberately NOT built, because it turns every question note into
1124
+ a potential spend, and that is a bigger decision than a night's work should make
1125
+ on its own.
1126
+
1127
+ ## Signal files - stop, merge, delete, relaunch
1128
+
1129
+ `stop`, `merge`, `delete` and `relaunch` are all rows - `heartbeat`'s `stops`
1130
+ array and its `ops` + `opTake`/`opResult` (`bridge/lib/runner.mjs`'s
1131
+ `serviceRowOps`) - for every request kind, chat turns included. `relaunch`'s
1132
+ record is the claimed run row's own `server` column (mirrored there by
1133
+ `runnerCompletion.mjs` when the child exits), falling back to the op's
1134
+ `worktreePath` hint plus the worktree's own live `.viewable-server.json` when
1135
+ the row has none yet. The pure decision core, `relaunchServer`
1136
+ (`bridge/lib/devserver.mjs`), decides exactly what is below - only where the
1137
+ record comes from and where the answer goes is rows rather than files.
1138
+
1139
+ Kept below: the SHAPE of each signal, named for the file it once travelled as
1140
+ - what it decides, not how it travels.
1141
+
1142
+ | signal | shape | what the runner does |
1143
+ | --- | --- | --- |
1144
+ | stop | `{ worktreePath }` | sends SIGTERM to the child, writes receipt |
1145
+ | merge | `{ worktreePath }` | Keep changes: commits the copy's leftovers, merges into the default branch around the person's uncommitted changes, deletes the copy and its branch |
1146
+ | delete | `{ worktreePath }` | Throw away: removes the worktree and deletes the branch without merging |
1147
+ | relaunch | `{ worktreePath }` (optional, a hint) | re-runs the RECORDED dev server command, detached, in the recorded worktree, or in the linked folder once that worktree is gone |
1148
+
1149
+ Each of the first three carries at least `{ worktreePath }`. The runner
1150
+ consumes the op after acting on it (on success only for merge, so a failed
1151
+ merge can be retried). A merge that clashes writes `conflict: true` and
1152
+ `files` (the clashing paths) into its result; a merge that lands writes
1153
+ `files` as the count changed. No question is filed: the app sends Coding back
1154
+ to fit its changes around the person's (#483).
1155
+
1156
+ `relaunch` is the odd one: its body is optional, nothing more than a hint for
1157
+ finding the record, it is consumed on refusal as well as on success - the
1158
+ receipt is the answer, and a signal left behind would relaunch on every pass
1159
+ - and the command it runs is never in the op. See "Who starts it again" above
1160
+ for what it refuses and why.
1161
+
1162
+ **Keep safety (#483).** `mergeWorktree` in `bridge/lib/worktree.mjs` first
1163
+ commits what the agent left uncommitted in the copy, as the person or as
1164
+ Viewable when git has no name, never its `node_modules` link (#482), then tries
1165
+ the merge with `git merge-tree --write-tree`, which touches no file. A clash,
1166
+ including one with the person's uncommitted changes or a file of theirs git
1167
+ does not track or ignores where the merge adds, renames or copies a file
1168
+ (read from the disk, since a fast-forward overwrites an ignored file
1169
+ without a word), stops with nothing in the folder
1170
+ changed and names the files. When the folder is on the default branch, the
1171
+ person's changes are set aside with a named stash, the default branch
1172
+ fast-forwards to the merge commit, and the stash is applied with `--index`
1173
+ and dropped by its sha. When the folder is on another branch, only the
1174
+ default branch's ref moves (compare-and-swap) and the folder's files stay
1175
+ put. Then the copy and its branch are deleted. Nothing is pushed.
1176
+
1177
+ ## Per-idea lock
1178
+
1179
+ Added 2026-08-30. The ruling on `q-mtf2x89x`: "Only one session per idea,
1180
+ unless the old session lost its worktree."
1181
+
1182
+ `activeWorktreeForPromptRows` in `bridge/lib/runner.mjs` reads this project's
1183
+ run rows through `runnerApi.runs`, then checks whether a worktree for the
1184
+ given idea still exists on disk AND its branch has not been merged into
1185
+ main. Both halves matter: without the merged check, a finished session
1186
+ blocks its own idea forever. The former file-scanning
1187
+ `activeWorktreeForPrompt` helper in `worktree.mjs` was deleted on
1188
+ 2026-09-14; admission has used the rows version since 2026-09-11.
1189
+
1190
+ **Own-tree exemption.** A request whose `worktreePath` is one of the same
1191
+ idea's own kept worktrees bypasses the lock and runs IN that tree, on its
1192
+ branch: a resume (Claude, a Codex thread, and opencode, which tries to resume
1193
+ and falls back to a fresh session; D8), and since 2026-10-04 (brief 52
1194
+ review, D7) a new session naming the tree without `resumeSessionId` (a note
1195
+ after a done session that recorded no id). The lock prevents a SECOND tree,
1196
+ not the idea from working on its own branch. A request naming no tree (Rerun)
1197
+ is still refused, and a named tree that is gone gets a new one
1198
+ (`runnerWorkspace.mjs`). A request naming a tree no run of ITS idea made
1199
+ (another idea's tree, or a path no row holds) is treated as tree gone (round
1200
+ 3, item 67). The lock's exemption and the own-tree match compare paths through
1201
+ `realpathSync.native` (items 69, 70), and the tree is reused under its own
1202
+ recorded spelling, so an 8.3 short name or a `/var` spelling of a
1203
+ `/private/var` tree still reuses it.
1204
+
1205
+ ## Chat turns
1206
+
1207
+ Added 2026-09-07, brief 25 session 1, on the rulings of the same day. Ruling
1208
+ 1A: **a coding agent's chat turn runs through the daemon**, as a persistent
1209
+ Claude Code conversation, one per DM, on his subscription, with Claude Code's
1210
+ own tools plus the board tools. Until this, a DM with an agent whose engine
1211
+ said `claude-code` was an OpenRouter API call like every other chat turn -
1212
+ so "Coder, engine claude-code" had never once run on Claude Code when you
1213
+ talked to it.
1214
+
1215
+ **DONE OVER ROWS, 2026-09-11 (brief 30 Move 2b).** Move 2 (the same day,
1216
+ earlier) shipped the ordinary session path over rows and refused a
1217
+ chat-turn row outright, naming the per-room lock and the chat worktree mint
1218
+ as file-shaped work not migrated in that pass. Both are rows now: the
1219
+ per-room lock is `activeChatTurnForRoomRows` (`bridge/lib/runner.mjs`,
1220
+ `runnerApi.runs({..., status:'running'})` filtered to `isChatTurn(request)`
1221
+ and a matching `roomId`) in place of a disk scan for an unreceipted claim;
1222
+ the chat worktree mint (`makeChatWorktree`/`resolveChatCwd`,
1223
+ `bridge/lib/worktree.mjs`) is UNCHANGED - it was never file-shaped, only
1224
+ called from the file-based runner, and is called from the rows one now the
1225
+ same way. Every difference below is unchanged in what it means; only the
1226
+ DOOR each one reaches through moved (a run row and `runnerApi` calls, not a
1227
+ signal file) - marked `chat turn:` at each site in `runner.mjs` exactly as
1228
+ they are marked here.
1229
+
1230
+ **The request is the same shape, riding a row's `request` column, with one
1231
+ new field.**
1232
+
1233
+ ```json
1234
+ {
1235
+ "version": 1,
1236
+ "id": "spawn-mtr...",
1237
+ "kind": "chat-turn",
1238
+ "roomId": "room-dm-coder",
1239
+ "cwd": ".claude/worktrees/chat-a-coder",
1240
+ "mintIfMissing": true,
1241
+ "agent": "claude",
1242
+ "model": "",
1243
+ "prompt": "<the room's composed message>",
1244
+ "approved": true,
1245
+ "resumeSessionId": "…",
1246
+ "worktreePath": "/Users/n/proj/.claude/worktrees/chat-a-coder"
1247
+ }
1248
+ ```
1249
+
1250
+ | field | required | what it is |
1251
+ | --- | --- | --- |
1252
+ | `kind` | no | `"chat-turn"`. **Absent means `"session"`**, so every request written before today runs unchanged. An unknown value is refused rather than guessed at - there is no code path to fall back to, and guessing "session" would put a frame on his board. |
1253
+ | `roomId` | for a chat turn | Which DM this turn belongs to. It is what the per-room lock locks. |
1254
+ | `cwd` | for a chat turn | The directory the conversation lives in. **RELATIVE to the folder root when the app writes it** (a browser directory handle has a name, not a path), absolute when the CLI does, and absolute again from the second turn on because the app hands the runner's own `worktreePath` back. All three are resolved by `resolveChatCwd` (`lib/worktree.mjs`); anything that escapes the watched folder - `..`, an absolute path outside it - is refused. Claude Code keys conversations to a working directory, so the directory is which conversation this is. |
1255
+ | `mintIfMissing` | no | `true` asks the runner to CUT the chat worktree if `cwd` is not there. The app cannot make a worktree, and ruling 1's directory has to exist before the first message can run in it. Absent (or `false`) and a missing `cwd` is refused, unchanged. Anything but a boolean is refused rather than read as true. |
1256
+ | `boardFolder` | no | One folder name (2026-09-30). The turn runs in that subfolder of the chat worktree, made if missing, so each board has its own place in the Viewable folder. The app sends it when the chat's folder is the Viewable folder (`~/Viewable`, made and registered by the runner when it starts watching; `GLYTCH_FOLDER=off` turns it off, a scratch `GLYTCH_HOME` makes none, and a `GLYTCH_FOLDER` path is for tests: the app knows the Viewable folder only in a home folder). Refused: separators, a leading dot, a trailing dot or space, Windows device names. The sidecar's `worktreePath` is then the subfolder, and a later turn whose `cwd` is that subfolder stays in it whatever `boardFolder` says, so a renamed board's chat keeps its files. |
1257
+ | `agent` | no | Must name a coding CLI this bridge has (`isCodingCli`, `lib/agents.mjs`). A SESSION falls back to the default on an unknown name; a chat turn refuses, because answering as `claude` would tell him he is talking to the agent he named when he is not. |
1258
+ | `resumeSessionId` | no | Absent on the first message of a conversation, present on every one after it. |
1259
+ | `worktreePath` | no | What the sidecar recorded, handed back. For a chat turn it equals `cwd`. |
1260
+
1261
+ **The five differences, and no others.**
1262
+
1263
+ 1. **No frame on the board.** No `startSession`, no outcome written into a
1264
+ frame, no `endSession` - a chat turn writes NOTHING into `inbox/ops/`. It
1265
+ therefore does not wait for the app's receipt either, so it answers with
1266
+ the app closed. Ruling 3: chat bubble only. A streaming terminal on the
1267
+ canvas for every message he types is the noise the board is not for.
1268
+ 2. **No worktree is minted PER MESSAGE.** It runs in `cwd` and refuses if that
1269
+ is not a directory - it never falls back to the linked checkout, which is
1270
+ his own working tree with an agent holding Write, Edit and Bash. The one
1271
+ exception is asked for by name, `mintIfMissing`, and it makes the agent's
1272
+ ONE long-lived tree (below). The spawn-time orphan sweep is skipped either
1273
+ way: that sweep exists to clear the way for a tree about to be made per
1274
+ run, and `ps -eo pid,args` over every process on the machine is not a thing
1275
+ to do on the path a person waits on for every message they type.
1276
+ 3. **No repo primer and no attempts log**, and no runner preamble. All three
1277
+ exist to save a FRESH session from rediscovery, and every sentence of the
1278
+ preamble is about a frame ("call `report` before you finish...") that a
1279
+ chat turn does not have. What a chat turn is told is the app's to compose:
1280
+ persona, the room's context blocks, his message.
1281
+ 4. **A per-ROOM lock instead of the per-prompt one.** OVER ROWS (brief 30
1282
+ Move 2b): `activeChatTurnForRoomRows` (`lib/runner.mjs`) asks
1283
+ `runnerApi.runs({..., status:'running'})` and filters to
1284
+ `isChatTurn(request)` with a matching `roomId`, so two runners sharing a
1285
+ project share one answer the same way the file version's disk scan did.
1286
+ One live turn per room: both would otherwise resume the same conversation
1287
+ in the same directory at once, and a conversation is one thread. A chat
1288
+ turn has no idea behind it, so the per-idea lock above never applies to
1289
+ it, and it never takes one.
1290
+ 5. **No `chat` word needed to tell traffic apart.** The file version's
1291
+ receipt needed one on its own first line so a person reading
1292
+ `.glytch/inbox/spawn/` - and `readSpawnReceipt`/`store/actions/folder.ts`,
1293
+ which both parsed a receipt by `startsWith("OK"/"ERROR")` - could tell the
1294
+ traffic apart without a second meaning colliding with the first. A row
1295
+ has no such ambiguity: `run.request.kind` says which it is, structurally,
1296
+ on the SAME row a session's `status`/`result` already live on - there is
1297
+ nothing left for the result TEXT to also have to say.
1298
+
1299
+ **Everything else is deliberately the same.** Same money gate
1300
+ (`approved: true`), same claim-exactly-once (server-side now, `claim`),
1301
+ same live log (`run_events` now, `<id>.log` then), same stop signal
1302
+ (`heartbeat`'s `stops` now, `<id>.stop` then),
1303
+ same wall clock, same stall watchdog, same sidecar, same tool fence
1304
+ (`CLAUDE_TOOLS`) plus the board through the cwd's own `.mcp.json`.
1305
+
1306
+ **Where the conversation lives, and who makes it** (added 2026-09-07, session
1307
+ 1b, ruling 1: "one long-lived chat worktree per agent per folder, made
1308
+ once and kept").
1309
+
1310
+ 1. **The cwd is resolved, once, against the folder being watched.** A relative
1311
+ `cwd` is joined onto the folder root; an absolute one is used as it stands.
1312
+ The result is what the child runs in AND what the sidecar records as
1313
+ `worktreePath`, so the app's next turn sends an absolute path back and the
1314
+ conversation stays in one directory.
1315
+ 2. **A cwd that escapes the folder is refused**, in a receipt that names the
1316
+ resolved path. A chat turn holds Write, Edit and Bash; the linked folder is
1317
+ the boundary the person chose. The folder root ITSELF is allowed - a caller
1318
+ naming it said so - and nothing ever falls back to it.
1319
+ 3. **`mintIfMissing: true` cuts the tree, once.** `git worktree add -b
1320
+ glytch/chat-<agentId> <root>/.claude/worktrees/chat-<agentId> HEAD`, off the
1321
+ checked-out commit for the same reason `makeWorktree` picks it (whatever he
1322
+ is looking at is what the agent starts from, and naming a branch breaks on a
1323
+ detached head), with `node_modules` symlinked the way a session's tree gets
1324
+ it. A branch that already exists is checked out into the new tree rather
1325
+ than re-created, so a hand-removed tree comes back. The branch name is read
1326
+ off the directory name, which is the only thing both sides have to agree on.
1327
+ 4. **Only there.** The mint refuses any path that is not
1328
+ `<root>/.claude/worktrees/chat-*` (`isChatWorktreePath`), so a request file -
1329
+ an injection surface - cannot turn "mint if missing" into "make a directory
1330
+ anywhere in my folder". It also refuses a folder that is not a git
1331
+ repository, in words that say a chat needs one for its own worktree; there
1332
+ is no unisolated fallback to offer.
1333
+ 5. **Nothing removes it.** `cleanWorktree` refuses a chat path (and the runner
1334
+ never calls it for a chat turn), the sweep has never removed a worktree and
1335
+ now says so in its never-touch list, and the orphan reaper steps over a
1336
+ `chat-` name. A chat worktree is removed when the AGENT is deleted, which is
1337
+ not built yet: today the way out is `git worktree remove` by hand, and the
1338
+ next message mints it again.
1339
+
1340
+ **The sidecar is written for EVERY chat turn**, not only a persistent one. A
1341
+ first message carries no session id to resume, so `isPersistentRequest` is
1342
+ false for it and no tree is kept - and without this it would have written no
1343
+ sidecar at all. That file is how the room learns which conversation it just
1344
+ opened (`claudeSessionId`) and where it lives (`worktreePath`, equal to the
1345
+ cwd). No sidecar, no second turn.
1346
+
1347
+ **Stop keeps the conversation** (ruling 5). `<id>.stop` kills the child
1348
+ and the receipt says `Stopped by user request`, and the sidecar still carries
1349
+ the conversation id with no `expired` mark - so the next message resumes where
1350
+ it left off. Stop cancels the turn, not the thread.
1351
+
1352
+ **No limits are checked here** (#364). The `approved: true` gate and the wall
1353
+ clock are what bound a chat turn. `PM_SPAWN_MAX_CONCURRENT` counts a chat turn
1354
+ like anything else; it is a machine bound, `Infinity` by default.
1355
+
1356
+ **An empty `model` passes no `--model`** (ruling 7): an empty field means
1357
+ "you choose", and the daemon is what is running it. This needs no code - it is
1358
+ what `commandFor` already does for an unset model.
1359
+
1360
+ **What is NOT here.** The app half: the room's routing, the streamed bubble,
1361
+ Stop from the room, and the ledger are sessions 2, 3 and 4 of the brief.
1362
+ Channels are phase 2 (ruling 2A). See `brief 25`.
1363
+
1364
+ ## Worktree op receipts
1365
+
1366
+ `opResult`'s `result` on the `run_ops` row is the merge and delete receipt,
1367
+ in the same `{ok, detail}` shape this section describes below. `relaunch`'s
1368
+ receipt works the same way (see "Signal files" above).
1369
+
1370
+ Kept below: the SHAPE of each op's outcome, named for the file it once
1371
+ travelled as - separate from the session's own result, so a merge outcome
1372
+ never overwrites what the session did:
1373
+
1374
+ | op | what it records |
1375
+ | --- | --- |
1376
+ | merge | outcome of a merge operation |
1377
+ | delete | outcome of a delete operation |
1378
+ | relaunch | outcome of a dev server relaunch: the pid and port, or the refusal |
1379
+
1380
+ The app reads the op's result to know when it is done and whether it
1381
+ succeeded. `ok: true` means success; `ok: false` means failure, and `detail`
1382
+ carries why.
1383
+
1384
+ ## Worktree summary
1385
+
1386
+ Added 2026-08-30. The ruling on `q-mtf66we4`: "One summary line: branch,
1387
+ commits, files touched."
1388
+
1389
+ `worktreeSummary` in `bridge/lib/worktree.mjs` produces
1390
+ `{ branch, commits, files, uncommitted, line }`. `files` counts every file the
1391
+ branch touched, committed or not; `uncommitted` is how many are not committed
1392
+ yet (#482). The runner writes it into the session
1393
+ sidecar (`.session.json`) for every kept worktree, not just persistent ones.
1394
+ The app shows the summary line in the session frame before the merge/delete
1395
+ buttons.
1396
+
1397
+ ### The sidecar is written TWICE, 2026-09-08
1398
+
1399
+ The write above happens after the child exits, because the summary counts
1400
+ commits. That made `worktreePath` - the only thing that tells the app where a
1401
+ session works - arrive only once the run was over, so "Where it is working"
1402
+ was empty for the whole life of every run. Evidence: `spawn-mtrrmcux-513704`,
1403
+ running for hours in
1404
+ `.claude/worktrees/glytch-investigate-...-mtrrmcux-513704`, with no
1405
+ `.session.json` in `inbox/spawn/` at all.
1406
+
1407
+ So the runner now writes a FIRST sidecar as soon as the worktree is minted or
1408
+ reused, before the child spawns, carrying `worktreePath` and `fromId` and
1409
+ nothing else. The end-of-run write replaces it whole; nothing is carried
1410
+ forward. It is written **only for a tree that really is one** (`isolated` and
1411
+ a `path`): a run that fell back to the linked checkout has no worktree, and
1412
+ recording the checkout under `worktreePath` would put Merge and Delete over
1413
+ the linked folder itself.
1414
+
1415
+ The app's side of the same gap is in `src/store/actions/folder.ts`: the
1416
+ running branch of `refreshSessionPersistence` pushed a patch only when the
1417
+ sidecar carried a `worktreeSummary`, so a path-only sidecar was read and
1418
+ discarded on every tick.
1419
+
1420
+ ## Orphan reaper
1421
+
1422
+ Added 2026-08-30. `reapOrphanedWorktreeProcesses` in `bridge/lib/worktree.mjs`
1423
+ finds processes whose command line references a `.claude/worktrees/<name>` path
1424
+ that no longer exists on disk and kills them. Called at spawn/teardown time,
1425
+ never as a daemon (ruling `q-mt9as5a0`: no reaper daemon).
1426
+
1427
+ **Corrected 2026-09-02: it WAS a daemon, and this paragraph was the only place
1428
+ that said otherwise.** `watch`'s loop called both the reaper and the sidecar
1429
+ backfill on every five-second pass, for the life of the runner - `ps -eo
1430
+ pid,args` over every process on the machine plus two `git` processes per stale
1431
+ sidecar, twelve times a minute, learning the same nothing each time. A doc and
1432
+ a loop disagreeing about whether something is a daemon is settled by the loop,
1433
+ so the loop moved.
1434
+
1435
+ `housekeeping(root, log, { reap, backfill })` in `bridge/lib/runner.mjs` now
1436
+ holds both, and it runs at the three moments a sweep can find anything:
1437
+
1438
+ | when | reap | backfill | why |
1439
+ | --- | --- | --- | --- |
1440
+ | the watcher starts | yes | yes | whatever a previous runner left behind |
1441
+ | a session is about to spawn | yes | no | a leftover process may hold a port or a lock the new tree wants; nothing has ended, so no sidecar can be newly stale, and a person is waiting on this path |
1442
+ | a session tears down | yes | yes | its tree may have just gone |
1443
+
1444
+ The backfill also **remembers what it tried**. A sidecar whose worktree is
1445
+ missing is skipped on a `stat` rather than on two `git` processes, and one
1446
+ whose git call produced no summary is recorded and never asked again for the
1447
+ life of the process. In memory rather than on disk: the fact is about this
1448
+ process's attempts, not about the session, and writing "could not summarise"
1449
+ into the sidecar would put a second meaning into a file the app reads.
1450
+ `bridge/test/housekeeping.test.mjs` asserts a poll pass does not sweep and
1451
+ that `housekeeping` called directly still does.
1452
+
1453
+ ## Attempts log
1454
+
1455
+ Added 2026-08-30. When the runner composes a FRESH child's prompt (new session,
1456
+ new worktree - NOT a resume, which carries its own conversation memory), it
1457
+ looks up previous requests with the same `fromId` (the idea) whose
1458
+ receipts were errors, and appends a 'Previous attempts' section to the prompt.
1459
+
1460
+ The section includes:
1461
+
1462
+ - Attempt number (chronological, oldest first when displaying)
1463
+ - Request ID
1464
+ - What it tried: closing summary from the log tail, or the receipt reason when
1465
+ the log is unavailable
1466
+
1467
+ **Bounds** (borrowed deliberately from tpm-flow's `attempts.ts`):
1468
+
1469
+ - ~700 characters per attempt
1470
+ - ~6000 characters for the whole section
1471
+ - Oldest attempts dropped first when the cap is reached
1472
+ - When something is dropped, the section SAYS so with a count
1473
+
1474
+ A silently truncated history reads as a complete one, which is why the omission
1475
+ notice is mandatory. Summaries only, never a previous prompt: feeding attempt
1476
+ N-1's prompt into attempt N doubles the prompt every re-run.
1477
+
1478
+ **What stays unchanged:**
1479
+
1480
+ - Read-only over other sessions' files: never edit a receipt or log
1481
+ - Exactly-once stays untouched: this changes what a NEW request is told, never
1482
+ retries anything
1483
+ - Composed at spawn time from disk, no model call
1484
+ - Resume requests see no section: they carry their own conversation memory
1485
+
1486
+ The history a re-run needs is already on disk in `.glytch/inbox/spawn/`: earlier
1487
+ requests carry `fromId`, their `.result.txt` receipts carry the failure reason,
1488
+ and their `.log` tails carry the session's own closing summary.
1489
+
1490
+ ## Repository primer
1491
+
1492
+ Added 2026-08-30. When the runner composes a FRESH child's prompt (new session,
1493
+ new worktree - NOT a resume), it prepends a compact repo map showing the file
1494
+ tree plus top-level exported symbols per source file. Built to reduce cache
1495
+ re-reads: the 3-day spend audit showed 69% of Opus cost was cache re-reads
1496
+ across 100+ turn sessions, and a big share of those turns is rediscovering the
1497
+ tree through `grep` and `find`.
1498
+
1499
+ **Implementation** (`bridge/lib/repomap.mjs`):
1500
+
1501
+ - Walks the repo from its root, skipping `node_modules`, `.git`, `dist`,
1502
+ `build`, `coverage`, `canvas`, `inbox`, and dotdirs
1503
+ - Emits the file tree (always included)
1504
+ - Extracts top-level exported symbols (functions, classes, consts) from source
1505
+ files (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.jsx`) using regex
1506
+ - Deliberately regex-based (not tree-sitter): fast, deterministic, dependency-free,
1507
+ good enough. Reference: tpm-flow's `server/src/repomap.ts` (idea, bounds,
1508
+ regex on purpose); reimplemented here, license boundary matters
1509
+ - Caps the whole map at ~10KB; past the cap, keeps the tree and drops symbol
1510
+ detail deepest-first (files furthest from root lose detail first)
1511
+ - When detail is dropped, the map SAYS so with a count
1512
+
1513
+ **Caching:**
1514
+
1515
+ - Cached keyed to HEAD sha in `inbox/spawn/.repomap-{sha}.txt`
1516
+ - Regenerate when HEAD moved, reuse otherwise
1517
+ - Cache miss rebuilds, cache write is best-effort (doesn't fail the spawn)
1518
+
1519
+ **Bounds:**
1520
+
1521
+ - Primer + attempts section together must not exceed ~16KB
1522
+ - Primer is trimmed first when the combined size exceeds the bound
1523
+ - If less than 500 chars remain for the primer after accounting for attempts,
1524
+ no primer is included
1525
+ - Truncation includes a notice so a truncated primer doesn't read as complete
1526
+
1527
+ **What stays unchanged:**
1528
+
1529
+ - Fresh sessions only: resume requests get nothing (their context already knows
1530
+ the tree through conversation memory)
1531
+ - Composed at spawn time from disk, no model call
1532
+ - Appended after the preamble, before the user's prompt and attempts section
1533
+ - The primer says it may be stale versus the session's working branch (HEAD
1534
+ points to main, session works in a worktree on a branch)
1535
+
1536
+ ## The conversation on an idea
1537
+
1538
+ Brief 52, 2026-10-04. A note a person writes on an idea starts a coding
1539
+ session; the session answers on that same idea; the computer restarting does
1540
+ not lose it. Four pieces, all in the bridge.
1541
+
1542
+ **The request's `note` and `agentId` are not the runner's to touch.** The app
1543
+ puts `note: {noteId, text}` and `agentId` on the request. The runner copies
1544
+ neither, strips neither and never repeats the text: the child gets the prompt
1545
+ once, ahead of which the output contract (`preambleFor` in `lib/agents.mjs`)
1546
+ sits. `bridge/test/runner.test.mjs` has the test.
1547
+
1548
+ **Who is speaking.** A message that reaches a live session arrives as
1549
+ `[message from <name>] <text>`, so the session can tell the person from a
1550
+ teammate. The contract tells the session to answer through `propose` with an
1551
+ `addComment` op and no `id` (it lands as its reply on its own idea), to attach
1552
+ pictures with `images` (up to 6, data URLs) and to choose between looks with
1553
+ `ask_user` and `images`. It also tells the session not to file a closing reply
1554
+ of its own, because the runner files one.
1555
+
1556
+ **One reply at the end, for a session that finished.** When a session
1557
+ finishes (the verdict is `finished`, or a Codex run exits 0),
1558
+ `reportOutcomeOnBoard` posts exactly one `addComment` on the idea, with
1559
+ `agentId`, `runId` (the session's own key), `summary` (first sentence of the
1560
+ closing text, at most 200 characters), `images` (at most 6), and
1561
+ `branch`, `files`, `preview`, `report` for the links. Its text is the closing
1562
+ text of the session (the tail, at most 2000 characters), then a single figures
1563
+ line led by "Finished": "Finished; 12 turns, 4m, branch x, 3 files". It
1564
+ never carries a cost (#281). A stop, an error, a stall, a timeout and a binary that would not start
1565
+ write NO comment from the bridge: the app writes those endings itself from the
1566
+ row (`src/store/runs/sessionReplies.ts`), so a second one would double it.
1567
+ Comments the bridge used to write on those paths are gone.
1568
+
1569
+ **Carry on after a restart.** `lib/restartPass.mjs`. When the runner starts,
1570
+ and once more seven minutes later, it lists this folder's rows that the last
1571
+ process left behind and carries each on once:
1572
+
1573
+ - Which rows: idea-bound, not a chat, not a plan, not already flagged
1574
+ `sidecar.restarted`, not run by this process, and either `running` with a
1575
+ lease that was not renewed in the last four minutes, or `error` with a
1576
+ `recovery_reason` and no result when a person's note started it, whatever
1577
+ its age (other reaped rows stay the app's). The second look
1578
+ exists because a lease only runs out five minutes after the process died.
1579
+ - A Claude Code session with its worktree kept and a conversation: the row is
1580
+ settled as an error that says why (a `resume` needs a finished parent), then
1581
+ ONE `resume` (with `unlessStopped`, so a Stop that landed after the listing
1582
+ wins) files a child that continues the same conversation in the same
1583
+ worktree with "Your computer restarted while you were working. Carry on from
1584
+ where you were." The child keeps `agentId` and `note`, so its closing reply is
1585
+ signed and keyed like the first. The `resume` op carries the parent's
1586
+ `sidecar.worktreePath` into the child's request; without it the per-idea lock
1587
+ would refuse the child as a stranger.
1588
+ - A session that cannot resume (Codex, opencode, or a failed `resume`): the row
1589
+ is settled the same way and ONE reply lands on the idea, "Your computer
1590
+ restarted during this session, so it stopped.", with the
1591
+ branch and file count. The branch is kept.
1592
+ - A Claude Code session whose worktree is gone is left alone for the reaper.
1593
+ - ONE ATTEMPT. The settle writes `sidecar.restarted: true` first and `result`
1594
+ is write-once, so of two runners racing for a row exactly one wins and goes
1595
+ on. The winner records `sidecar.restartedTo`. A flagged row is never a
1596
+ candidate again. The app must not write its own ending reply for a row with
1597
+ `sidecar.restarted`.
1598
+
1599
+ `bridge/test/restartPass.test.mjs` runs the pass against the fake binary.
1600
+
1601
+ ## Verdict and cost
1602
+
1603
+ Added 2026-09-01. The runner now tracks the full result envelope (not just two
1604
+ booleans) and surfaces turn count, time and context fill in the receipt. The
1605
+ cost is kept in the sidecar for the run's record, and no receipt or reply
1606
+ shows it (#281).
1607
+
1608
+ **Envelope tracking:** `runChild` and `runStreamingChild` scan stdout for
1609
+ result envelopes (`{"type": "result", ...}`) and keep the LAST one parsed as
1610
+ `outcome.envelope`. Context fill is tracked separately: the last assistant
1611
+ event's `usage` (ignoring events with `parent_tool_use_id`, so subagent turns
1612
+ cannot overwrite the lead's fill). Fill = `input_tokens` +
1613
+ `cache_read_input_tokens` + `cache_creation_input_tokens`, exposed as
1614
+ `outcome.contextFill`.
1615
+
1616
+ **Verdict line:** one-line summary computed from the envelope and outcome.
1617
+ Format: `finished` / `ended with an error result` / `stopped responding` /
1618
+ `killed at wall clock` / `exited N`, followed by figures from the envelope when
1619
+ present: `41 turns, 38m, context 212k`.
1620
+
1621
+ **Where it lands:**
1622
+
1623
+ - `reportOutcomeOnBoard` writes the one closing reply on the run's idea, and
1624
+ only for a session that finished (see "The conversation on an idea"). Its
1625
+ figures line reads "Finished;" then turns and time, without context or
1626
+ cost. The words above stay exactly as they are in the row's
1627
+ `result`: the app parses them (`verdictOf`, `resultTail`).
1628
+ - `describeOutcome` puts the verdict line at the start of the receipt text.
1629
+ - `writeSessionSidecar` gains `costUsd`, `numTurns`, `contextFill` fields so
1630
+ `spendSince` and other readers can use them when present.
1631
+
1632
+ No new model calls, no new file shapes beyond sidecar fields.
1633
+
1634
+ ## Stall watchdog
1635
+
1636
+ Added 2026-09-01. A streaming session (`runStreamingChild` only) that goes
1637
+ silent for `stallMs` (default 15 minutes) with a turn open is killed with
1638
+ SIGTERM then SIGKILL. The watchdog is armed only when the MODEL should be
1639
+ producing output: a turn is open (no result envelope since the last user
1640
+ message) AND no `tool_use` is awaiting its `tool_result` (a running tool is
1641
+ allowed to be silent; the wall clock covers a hung tool).
1642
+
1643
+ **How it works:** The stream-json scanner tracks `tool_use` blocks (by id) and
1644
+ their `tool_result` events. A `tool_use` opens, a `tool_result` closes. Tool
1645
+ results arrive as USER events (`{"type":"user","message":{"content":[{"type":"tool_result",...}]}}`),
1646
+ and that is what re-arms the watchdog. The stall timer checks every `stallMs / 4`
1647
+ whether it is armed (turn open, no open tools) and whether `Date.now() - lastStdoutAt >= stallMs`.
1648
+ If both, it kills the child. Sending a new user message resets `lastStdoutAt` (a new turn starts).
1649
+
1650
+ **Outcome:** `stalled: true` and `stallMs` when it fires. Receipt and frame say
1651
+ "stopped responding" with the silence duration. It is not a success and not a
1652
+ timeout.
1653
+
1654
+ **Settings:** `PM_SPAWN_STALL_MS` sets the timeout (default 900000, 15 minutes).
1655
+
1656
+ ## Crash retry
1657
+
1658
+ Added 2026-09-01. A crash that happens BEFORE any assistant event streamed is
1659
+ retried exactly once, inside the same wall-clock budget. SIGKILL is never
1660
+ retried (the runner itself killed it). A crash after output streamed is not
1661
+ retried (partial work would be duplicated).
1662
+
1663
+ **Exit code classification** (`bridge/lib/exitClass.mjs`): `classifyExit(code,
1664
+ signal)` distinguishes crashes (SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE,
1665
+ SIGTRAP on Unix; 0xC0000005 and 0xC00000FD on Windows) from clean exits and
1666
+ other failures. SIGKILL is classified as `isKill: true`, not a crash. Returns
1667
+ `{ isCrash, isKill, description }`.
1668
+
1669
+ **Retry logic in runRequest:** After the first run, if `exitClass.isCrash &&
1670
+ !outcome.envelope && !outcome.finished`, the runner logs "retrying once" and
1671
+ runs the child again with `maxMs` set to the remaining wall-clock budget
1672
+ (original `maxMs` minus the first run's duration). Both logs are kept:
1673
+ `outcome.log` becomes `=== First attempt (crash class) ===\n...\n\n=== Retry
1674
+ ===\n...`. The outcome carries `retried: true` and `firstCrashClass`.
1675
+
1676
+ **Receipt:** When retried, the receipt says "Retried once after [class] before
1677
+ any output" in the `where` footer.
1678
+
1679
+ ## What the tool fence stops, and what it does not
1680
+
1681
+ Added 2026-09-02, correcting a claim that stood in `lib/agents.mjs` and
1682
+ `bridge/README.md`: that a Claude session "cannot open a shell". It can.
1683
+ `CLAUDE_TOOLS` carries `Bash(git:*)`, `Bash(npm:*)`, `Bash(npx:*)` and
1684
+ `Bash(node:*)`, and `Bash(node:*)` alone is `node -e '<anything>'` - an
1685
+ arbitrary program with the session's own file access. `Write` and `Edit` are
1686
+ unscoped, so the session may write any path the process can reach.
1687
+
1688
+ | | stopped | not stopped |
1689
+ | --- | --- | --- |
1690
+ | tools | anything not on `CLAUDE_TOOLS` | everything on it |
1691
+ | interactive prompts | `EnterPlanMode`, `AskUserQuestion` | - |
1692
+ | arbitrary code | - | `node -e`, `npx <anything>` |
1693
+ | writes | the checkout you are looking at (the **worktree** does this, not the list) | any other path the process can reach |
1694
+ | money | wall clock, no retry | - |
1695
+
1696
+ **The specific hole this opens.** A child gets `PM_BOARD_ROOT` (`childEnv`)
1697
+ and has `Write`, so it can file its own `.glytch/inbox/spawn/<id>.json`
1698
+ carrying `approved: true` and have the runner that spawned it pick it up.
1699
+ `PM_SPAWN_DEPTH` refuses a session that starts its OWN runner; it does nothing
1700
+ about a session asking the runner above it.
1701
+
1702
+ **There is no cheap discriminator, and one was looked for.** Every field the
1703
+ app writes - `createdBy: "board"`, `approvedBy: "board"`, `version: 1`,
1704
+ `nodeIds` (`src/folder/spawnRequest.ts` `buildSpawnRequest`) - is a field a child with
1705
+ Write can spell exactly. Refusing requests filed *while a session is running*
1706
+ would be a real narrowing but it would break a shipped ruling: "I would like
1707
+ to operate multiple sessions at once on the board" (2026-08-28), which is
1708
+ precisely a person clicking Go on a second idea while the first is going.
1709
+ Anything stronger (an HMAC over the request, a nonce the app and the runner
1710
+ share) means app work and a shared secret in a folder both sides can read, and
1711
+ that is a bigger change than this class of risk earns while the runner is off
1712
+ by default and every real request starts with a click.
1713
+
1714
+ So it is **recorded rather than guarded**. Narrowing `CLAUDE_TOOLS` is the
1715
+ lever that actually closes it, and that is the user's ruling to make: it changes
1716
+ what a brief can do.
1717
+
1718
+ ## Asks in the idea (#478)
1719
+
1720
+ A coding run that wants something outside its list (`CLAUDE_TOOLS`, or
1721
+ what Codex runs without approval) asks the person instead of being refused
1722
+ silently. `lib/permissionAsk.mjs` owns it; `child.mjs` answers Claude's
1723
+ `control_request` and `codex.mjs` answers Codex's approval requests with it.
1724
+
1725
+ - One `ask_user` question on the run's idea: "Can I run pip install requests
1726
+ in my-app?", with Allow (recommended), Not this and Always allow in
1727
+ <folder>, and a line on what it is for.
1728
+ - The asker claims its question on the answer poll and reads the answer with
1729
+ `check_answer` itself: an answer resolves the pending request, it is never
1730
+ typed into the conversation.
1731
+ - No answer means keep waiting. Stop and the wall clock still end the run;
1732
+ the stall watchdog does not count time spent waiting for a person.
1733
+ - Always allow is remembered per folder in
1734
+ `<glytch home>/permissions/<sha256 of the folder path>.json`: a shell
1735
+ command by its first word (`Bash(pip:*)`), anything else by its tool name.
1736
+ A remembered rule is answered without asking. A chained, piped or
1737
+ redirected command is asked every time.
1738
+ - A plan draft is read-only and is never given the asker.
1739
+
1740
+ ## What a spawned session inherits
1741
+
1742
+ Added 2026-09-02. `childEnv` in `bridge/lib/runner.mjs` used to be
1743
+ `{ ...process.env }`. A spawned session is an autonomous agent with Write,
1744
+ Edit and Bash in a worktree on this machine, so that handed it every secret
1745
+ the shell that started the runner happened to be carrying - a deploy token, a
1746
+ database URL, a payment key, whatever `direnv` loaded for the project. A leak
1747
+ through that door is invisible: no error, no log line, nothing on the board.
1748
+
1749
+ It is an ALLOWLIST now, and everything on it is there for one of two stated
1750
+ reasons: a process has to be able to BE a process, and the two engines this
1751
+ runner knows have to be able to AUTHENTICATE. Anything else is dropped.
1752
+
1753
+ **Exact names.** `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`, `TMP`,
1754
+ `TEMP`, `LANG`, `TERM`, `TZ`; `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and
1755
+ their lowercase spellings, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`,
1756
+ `SSL_CERT_DIR`; `CLOUD_ML_REGION`; `CLAUDECODE`.
1757
+
1758
+ **Prefixes.** `LC_`, `PM_`, `ANTHROPIC_`, `CLAUDE_`, `AWS_`, `GOOGLE_`,
1759
+ `XDG_`, `OPENCODE_`, `OPENAI_`, `OPENROUTER_`, `FAKE_CLAUDE_`.
1760
+
1761
+ **Authentication is mostly `HOME`, and that is worth saying out loud.** Claude
1762
+ Code's subscription credentials live in `~/.claude/.credentials.json` (or the
1763
+ macOS keychain); opencode's live under `~/.local/share/opencode/auth.json`.
1764
+ Neither needs a key in the environment to work. The variables above are for
1765
+ the people who set one instead: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`,
1766
+ `CLAUDE_CODE_OAUTH_TOKEN`, `AWS_*` for Bedrock, `GOOGLE_APPLICATION_CREDENTIALS`
1767
+ plus `CLOUD_ML_REGION` for Vertex, `OPENAI_API_KEY` / `OPENROUTER_API_KEY` for
1768
+ opencode. Every one of those names was read out of the shipped binaries on
1769
+ 2026-09-02 (`strings` on `claude` 2.1.x and on `opencode`), not assumed.
1770
+
1771
+ `FAKE_CLAUDE_` is on the list because the fake binary is a shipped seam, not a
1772
+ test detail: `PM_CLAUDE_BIN` is a row in the money table above, and
1773
+ `test/fixtures/fake-claude.mjs` is driven entirely by `FAKE_CLAUDE_*`. Drop
1774
+ them and the crash, timeout and runaway paths lose their only tests.
1775
+
1776
+ **`PM_SPAWN_ENV_ALLOW` is the escape hatch**, because opencode ships around
1777
+ two hundred `*_API_KEY` provider names and enumerating them here would make
1778
+ this list a lie. It is a comma-separated list of variable names, and a
1779
+ trailing `*` makes one a prefix:
1780
+
1781
+ ```bash
1782
+ PM_SPAWN_ENV_ALLOW='CEREBRAS_API_KEY,MYCO_*' PM_SPAWN_RUNNER=on node bridge/spawn-runner.mjs watch
1783
+ ```
1784
+
1785
+ The three doors `childEnv` closes on purpose are closed AFTER the allowlist
1786
+ runs, so the `PM_` prefix does not quietly re-open them: `PM_SPAWN_RUNNER` is
1787
+ deleted (a spawned session must not be able to switch a runner on),
1788
+ `PM_SPAWN_DEPTH` is set rather than inherited, and `PM_SESSION_FROM` is
1789
+ dropped unless this run has an idea of its own (a stale idea is worse than
1790
+ no idea). `bridge/test/childenv.test.mjs` asserts both ends of the list.
1791
+
1792
+ ## Per-repo worktree lock
1793
+
1794
+ Added 2026-09-01. Git mutations on the same repo root run one at a time,
1795
+ serialized by an in-process lock. Two sessions finishing concurrently can no
1796
+ longer both `git checkout main`, pull, and merge at the same time.
1797
+
1798
+ **How it works:** A promise chain keyed by repo root (normalized with
1799
+ `resolve()`). Acquiring the lock means waiting for the existing chain and
1800
+ setting a new promise; releasing resolves that promise so the next waiter can
1801
+ proceed.
1802
+
1803
+ **Amended 2026-09-02: it is two locks, because there are two ways to collide.**
1804
+ This section said "one runner process owns a root (claim files keep two runners
1805
+ apart), so an in-process lock is enough", and the code beside it disagreed:
1806
+ `spawn-runner.mjs merge <path>` and `spawn-runner.mjs delete-worktree <path>`
1807
+ are a SECOND process, typed by a person, calling straight into `mergeWorktree`
1808
+ while a watcher is running. Claim files keep two runners off the same REQUEST;
1809
+ they say nothing about two processes running `git checkout main` in the same
1810
+ second.
1811
+
1812
+ So `withRepoLock` also takes a **lockdir**, `mkdir` being the one atomic
1813
+ exclusive create every filesystem agrees on - the same reasoning the claim file
1814
+ uses `flag: "wx"`. It lives at `<root>/.git/pm-repo.lock`, and that location is
1815
+ load-bearing rather than tidy: a lock `git status` can see would read as the
1816
+ person's own change, and Keep sets those aside and puts them back.
1817
+ `.git/` is the one directory git never reports, it is exactly per-repo, and it
1818
+ is already where git keeps its own locks.
1819
+
1820
+ An `owner.json` inside it records pid, host and time, which is how the lock
1821
+ survives its holder being killed: a pid that is gone **on this host** frees it
1822
+ at once, a lock from another host frees on age (`LOCK_STALE_MS`, 10 minutes).
1823
+ Waiting is bounded (`LOCK_WAIT_MS`, 60s) and losing the wait is not fatal - it
1824
+ logs loudly and goes ahead, because refusing to clean up a worktree is worse
1825
+ than the race that was there all along. A `.git` that is a file (the root is
1826
+ itself a linked worktree) or a read-only checkout takes the same path.
1827
+
1828
+ **No `git pull`.** `mergeWorktreeImpl` used to run `git pull --ff-only` on the
1829
+ user's main before merging, best-effort and unexplained. Removed 2026-09-02: it
1830
+ is a network operation on his main branch that nobody asked for. It moves main
1831
+ under him, brings down commits he has not looked at, can prompt for credentials
1832
+ with nobody watching, and hangs on a slow or absent remote while a button on
1833
+ his board spins. Merging is local work on local branches - the same reasoning
1834
+ the code already gave for not pushing.
1835
+
1836
+ **What is locked:** `makeWorktree`, `cleanWorktree`, `reuseWorktree`,
1837
+ `mergeWorktree`, `deleteWorktree`. Each wraps its implementation (`*Impl`) with
1838
+ `withRepoLock(root, fn)`.
1839
+
1840
+ **What stays unlocked:** Reads (`branchOfWorktree`, `worktreeSummary`) and
1841
+ the runner's `activeWorktreeForPromptRows` admission check do not mutate git
1842
+ state and run without the lock.
1843
+
1844
+ **Test:** `bridge/test/worktree.test.mjs` proves two concurrent `mergeWorktree`
1845
+ calls on the same root serialize (order preserved, second starts after first
1846
+ completes, both commits land in main).
1847
+
1848
+ ## The ACP engine
1849
+
1850
+ Added 2026-09-02 (brief 15 decision 8, on the amendment written by the spike
1851
+ in `spikes/acp-2026-09-02/`). Checked against the code in this commit.
1852
+
1853
+ `acp-claude` is a third engine in `bridge/lib/agents.mjs`, labelled "Claude
1854
+ Code via ACP". It runs `@agentclientprotocol/claude-agent-acp` 0.73.0 over
1855
+ stdio with `@agentclientprotocol/sdk` 1.4.0 - the versions the spike pinned,
1856
+ now the two dependencies in `bridge/package.json`. **`claude` is untouched and
1857
+ is still the default.** That is a ruling, not an accident: ACP gives no way to
1858
+ SET a spend ceiling and reports no dollar FIGURE, and the `claude` engine has
1859
+ both. Beside, permanently - not a migration step.
1860
+
1861
+ `bridge/lib/acp.mjs` owns the protocol. Loading it costs nothing on a checkout
1862
+ that never installed the adapter: the SDK is a dynamic `import()` reached only
1863
+ when an ACP session starts, so every other engine and every other test runs on
1864
+ a bare clone.
1865
+
1866
+ ### What maps onto what
1867
+
1868
+ The whole design is that `runRequest`, the live log, the stall watchdog, the
1869
+ receipt and the frame need **no branch** for this engine. `acp.mjs` runs the
1870
+ adapter, speaks JSON-RPC on its real stdio, and writes *stream-json lines* into
1871
+ a stream that `attachChild` reads exactly as it reads a Claude child's stdout.
1872
+
1873
+ | ACP | stream-json line the runner already understands |
1874
+ |---|---|
1875
+ | `session/update` `agent_message_chunk` | `{"type":"assistant", message.content:[{type:"text"}]}` |
1876
+ | `session/update` `tool_call` (pending) | `{"type":"assistant", message.content:[{type:"tool_use", id}]}` |
1877
+ | `session/update` `tool_call_update` (`completed`/`failed`) | `{"type":"user", message.content:[{type:"tool_result", tool_use_id}]}` |
1878
+ | `session/update` `usage_update` `{used, size}` | `{"type":"assistant", message.usage.input_tokens}` → `outcome.contextFill` |
1879
+ | `session/prompt` response | `{"type":"result", is_error, total_cost_usd, num_turns, duration_ms}` |
1880
+ | `session/new` response | `{"type":"system", subtype:"init", session_id}` → the sidecar's id |
1881
+
1882
+ Two details that are load-bearing rather than cosmetic:
1883
+
1884
+ - **A tool_use line carries no `usage`.** The scanner in `child.mjs` is an
1885
+ if/else-if chain in which a usage field wins over a content field, so an
1886
+ assistant event carrying both records the usage and never sees the
1887
+ `tool_use` - and the stall watchdog would never arm. The two facts travel as
1888
+ two lines.
1889
+ - **Only a *terminal* `tool_call_update` closes the tool.** The spike's capture
1890
+ shows four updates for one call; closing on the first would disarm the
1891
+ watchdog while the tool was still running, which is the exact condition it
1892
+ exists to distinguish.
1893
+
1894
+ `available_commands_update`, `current_mode_update` and the `_meta.claudeCode.*`
1895
+ side channels are deliberately not written at all: the log is capped and the
1896
+ frame draws it.
1897
+
1898
+ ### The rest of the mapping
1899
+
1900
+ - **Board tools.** Every engine reaches the board over the HOSTED endpoint
1901
+ (`supabase/functions/board-mcp`), authenticated by `lib/auth.mjs`'s
1902
+ `hostedToken()`/`hostedConfig()` (a `viewable login` credential, or
1903
+ `VIEWABLE_BOARD_TOKEN` for a script), resolved ONCE per request in
1904
+ `runRequest` so a token refresh mid-request cannot leave one engine's
1905
+ wiring disagreeing with another's. `acpServersFor` still builds what
1906
+ `session/new`'s `mcpServers` carries, inline rather than through a file -
1907
+ that half is unchanged - but the entry is now `{type: "http", name:
1908
+ "glytch", url, headers: [{name: "Authorization", value: "Bearer
1909
+ <token>"}, {name: "X-Viewable-Session", value: "<session.key>"}]}` (RENAMED
1910
+ 2026-09-12, brief 31, Move 3b: the key was `"board"` until then), the
1911
+ `McpServer::Http` shape checked against `@agentclientprotocol/sdk@1.4.0`'s
1912
+ own schema. `claude` gets the same URL and headers through
1913
+ `--strict-mcp-config --mcp-config '<json>'` (VERIFIED against the
1914
+ installed 2.1.34: `claude mcp add --transport http` writes exactly this
1915
+ JSON shape into a real `.mcp.json`) - `--strict-mcp-config` means any
1916
+ committed `.mcp.json` naming a different server is ignored outright, not
1917
+ merely superseded. `codex` gets a `-c mcp_servers.board={url
1918
+ = ..., bearer_token_env_var = "PM_BOARD_TOKEN", http_headers =
1919
+ {"X-Viewable-Session" = ...}}` override (`codexBoardArgs`, `lib/codex.mjs`) -
1920
+ the bearer is a NAME, never a literal, on that one engine's command line,
1921
+ because `codex mcp add --help` and the installed binary's own strings both
1922
+ say a literal `bearer_token` is refused. `opencode` gets
1923
+ `OPENCODE_CONFIG_CONTENT` (an env var, confirmed present in the installed
1924
+ 1.18.23 binary) carrying a `type: "remote"` entry, in place of the
1925
+ `opencode.json` `prepareWorkspace` used to write into the worktree - a
1926
+ bearer token is not a thing to leave sitting in a file for as long as a
1927
+ kept worktree lives. The bearer token's own lifetime is the one thing that
1928
+ needed a NEW decision rather than a rewiring: see `ACCESS_TTL_MS` /
1929
+ `RUNNER_ACCESS_TTL_MS` in `supabase/functions/board-oauth/oauth.mjs`.
1930
+ The **X-Viewable-Session** header is new on the endpoint side too
1931
+ (`supabase/functions/board-mcp/sessionKeyHeader.mjs`): it lets many
1932
+ children spawned under ONE hosted OAuth token each keep their own
1933
+ identity for `list_sessions`/`send_message`, and land their `report()`
1934
+ under the exact run key their receipt already carries.
1935
+ **X-Viewable-Board** rides beside it (2026-09-21, board reporting fix): the
1936
+ claimed run row always carries `board_id` (`src/runs/api.ts`'s
1937
+ `RunRequestRow`), `runRequest` reads it off `run.board_id` and passes it
1938
+ through every engine's board wiring the same way `sessionKey` travels -
1939
+ `acpServersFor`, the headless `claude` engine's `--mcp-config`,
1940
+ `prepareWorkspace`, `codexBoardArgs` (one shared `http_headers` table,
1941
+ since a second one would overwrite rather than merge). Without it,
1942
+ `board-mcp/tools.mjs`'s `boardFor` falls back to `lastBoardId`, unset
1943
+ until the session's first `get_board` - so a session whose first call is
1944
+ `report` used to land on the account's most recently touched board, not
1945
+ the one the prompt actually lived on. `index.ts` reads the header the same
1946
+ way it reads `X-Viewable-Session` (`boardHeader.mjs`) and seeds
1947
+ `lastBoardId` with it before any tool runs. `run.board_id` is the board's
1948
+ LOCAL id ("default", "bd-mua2gtcm"), not the `<account uuid>:<local id>`
1949
+ row id every board is actually keyed by, so the header value may be that
1950
+ local id alone - `tools.mjs`'s `readBoardById` prefixes it with the
1951
+ token's own account before matching a row, falling back to the bare value
1952
+ when that prefixed id is not this account's.
1953
+ - **Permissions.** `session/request_permission` is answered by the client from
1954
+ `CLAUDE_TOOLS` - the same list `--allowedTools` gets, one list at a second
1955
+ door. Matching is exact name, MCP namespace (`mcp__glytch` covers
1956
+ `mcp__glytch__report`; renamed from `mcp__board` 2026-09-12, brief 31,
1957
+ Move 3b), or command prefix (`Bash(git:*)` against
1958
+ `rawInput.command`). Anything else, including a rule the matcher cannot
1959
+ parse, is `reject_once`. **Never `allow_always`**: "always" outlives the
1960
+ decision that justified it. Each decision is written into the log as a
1961
+ `{"type":"system","subtype":"permission"}` line.
1962
+ - **`CLAUDE_DISALLOWED_TOOLS` has no equivalent and needs none.** Plan mode is
1963
+ a session *mode* on this engine, and `acp.mjs` selects `acceptEdits` and
1964
+ never `plan`.
1965
+ - **Model and effort.** The model goes through
1966
+ `session/set_config_option {sessionId, configId:"model", value}` - **not**
1967
+ `session/set_session_config_option`, which this adapter answers `-32601`
1968
+ (both attempts are in the spike's `capture/claude.probe.jsonl`). An option
1969
+ the adapter does not offer is skipped rather than sent and failed. There is
1970
+ no effort option at all on 0.73.0, so `commandFor` records effort as
1971
+ ignored, exactly as it does for `claude`.
1972
+ - **Replies into a live session.** A persistent request keeps the session open
1973
+ after its turn and delivers each reply file as a **second `session/prompt`
1974
+ into the same `sessionId`**. `acceptsMessages` now answers true for
1975
+ `acp-claude` as well as `claude`. Prompts are queued and sent one at a time:
1976
+ the adapter advertises `promptQueueing`, but nobody has watched it work.
1977
+ - **Stop.** `stopCheck` sends `session/cancel` first and *then* returns true, so
1978
+ `attachChild`'s SIGTERM/SIGKILL is the backstop rather than the mechanism.
1979
+ - **The stall watchdog and the wall clock** are byte for byte the rules
1980
+ `runStreamingChild` uses, because the events they read are now the same
1981
+ events. A finished stream is still finished: the wall clock closing an idle
1982
+ session is not a timeout.
1983
+ - **`CLAUDE_CODE_EXECUTABLE`.** The adapter ships its **own** Claude Code
1984
+ (2.1.257 against the 2.1.34 on this machine), so every run is pinned to a
1985
+ chosen binary: an explicit `CLAUDE_CODE_EXECUTABLE`, else `PM_CLAUDE_BIN`,
1986
+ else `claude` on `PATH`. Two Claude Codes on one box, one of them invisible,
1987
+ is how a session behaves differently for a reason nobody can see - and every
1988
+ bound in this bridge was checked against 2.1.34. When none resolves the
1989
+ adapter's bundled copy runs and the outcome says so.
1990
+
1991
+ ### What is an estimate
1992
+
1993
+ **Everything about the money.** ACP reports token counts and no dollars: the
1994
+ `session/prompt` response carries `usage` and `_meta.quota.model_usage`, and
1995
+ the schema's optional `cost {amount, currency}` on `usage_update` is not sent
1996
+ by this adapter. The bounds on this engine are the wall clock and the stall
1997
+ watchdog, as on every engine (#364).
1998
+
1999
+ So the figure is computed: tokens × `ACP_PRICES` in `acp.mjs`, a dated table
2000
+ (checked 2026-09-02 against published list prices per million tokens; cache
2001
+ write at the documented 1.25× of input, cache read at 0.1×). It is labelled
2002
+ wherever it is stored, and no receipt prints it (#281):
2003
+
2004
+ - the envelope carries `cost_source: "estimate"`;
2005
+ - the session sidecar carries `costSource: "estimate"` beside `costUsd`.
2006
+
2007
+ Three things about the number worth stating plainly. It is **cumulative over
2008
+ the session**, not per turn, because the runner keeps only the last envelope
2009
+ and that is what the receipt records. An **unknown model is priced at Opus
2010
+ rates**. And a session on a Claude
2011
+ subscription - which is how everything on this machine runs - is not billed
2012
+ per token at all, so the estimate answers "what would this have cost on the
2013
+ API", which is the only comparable number.
2014
+
2015
+ ### What is untested
2016
+
2017
+ - **Resuming an ACP session.** `loadSession: true` is advertised and
2018
+ `session/load` is *tried* when a request carries `resumeSessionId`, falling
2019
+ back to a fresh session on failure (the fallback is tested; a successful
2020
+ load is not). Nobody has resumed a real ACP session.
2021
+ - **Mid-turn steering.** `_meta.steering.supported` and
2022
+ `claudeCode.promptQueueing` are declared by the adapter and exercised by
2023
+ nothing. Replies are queued between turns instead.
2024
+ - **Codex, opencode, and every other adapter.** This engine is Claude Code
2025
+ only. The spike got `-32000 Authentication required` from Codex's
2026
+ `session/new` and stopped there, so nothing below Codex's handshake is
2027
+ known. `acp.mjs` refuses with a clear message when an adapter offers auth
2028
+ methods and `session/new` fails - it never logs anything in.
2029
+ - **Images on a real run.** Data-URL images are sent as ACP `image` content
2030
+ blocks (the adapter advertises `promptCapabilities.image`), and the one-shot
2031
+ temp-file path in `runRequest` correctly skips this engine because its
2032
+ prompt is not an argument. Neither half has been run with a real image.
2033
+ - **The permission door on a real adapter.** Answered from the allowlist in
2034
+ the spike (a stub MCP tool, allowed, turn continued) and covered both ways
2035
+ against the fake adapter here. The two real prompts run for this commit
2036
+ called no tools.
2037
+ - **`runRequest` end to end on this engine.** The two real prompts drove
2038
+ `runAcpChild` through `commandFor` with the runner's own options, not
2039
+ through the whole claim → frame → receipt path, because that path spends a
2040
+ session per attempt.
2041
+
2042
+ ### Tests
2043
+
2044
+ `bridge/test/acp.test.mjs`, against `bridge/test/fixtures/fake-acp-adapter.mjs`
2045
+ - a small node program speaking enough JSON-RPC to be indistinguishable from
2046
+ the real adapter for the methods `acp.mjs` calls. It covers the handshake,
2047
+ model/mode config, the board server reaching `session/new`, a streamed turn
2048
+ with a tool call whose permission is allowed from the allowlist, a rejected
2049
+ permission, a second prompt into the same session delivered as a reply file,
2050
+ `session/cancel` on Stop, the stall watchdog, an auth wall, a failed resume, a
2051
+ missing adapter, the token-to-cost estimate, and the translation itself.
2052
+ Nothing here spends money; the two real prompts that proved the engine end to
2053
+ end are recorded in the commit body.
2054
+
2055
+ ## Editable plans and explicit implementation approval (2026-09-08)
2056
+
2057
+ A prompt can opt into **Plan first, then ask me to approve**. Existing runs
2058
+ without this option keep their previous behavior. The runner advertises
2059
+ `planApproval: true` in its heartbeat; the app refuses plan mode on older runners.
2060
+
2061
+ The initial request carries `planMode: "draft"`. This is currently supported
2062
+ only by Claude Code; opencode, Codex and ACP requests are refused explicitly.
2063
+ The runner offers only Read, Glob, Grep, WebSearch and WebFetch. It removes
2064
+ MCP servers, skills, hooks, Chrome integration, saved-session inheritance,
2065
+ and command-line argument overrides. This is a CLI tool-capability boundary,
2066
+ not an operating-system sandbox. The final response is the proposed plan;
2067
+ the runner completes the draft and does not start implementation. Replies to
2068
+ a draft cannot be promoted into an implementation session.
2069
+
2070
+ The draft lives with its prompt as `planReview`. The expanded run shows the
2071
+ completed plan in an editable field. Edits save without starting a session.
2072
+ **Reject plan** closes that draft. **Approve and implement** is a human-only
2073
+ store action, absent from the Board API, agent tools and structured inbox ops.
2074
+ It consumes a transient approval, checks the matching successful completed
2075
+ run, and files one fresh request. A failed write restores the draft for an
2076
+ explicit retry. Running the prompt again while plan mode is selected creates
2077
+ a new draft, never an implicit approval.
2078
+
2079
+ An implementation request carries `planMode: "execute"`, the exact edited
2080
+ `approvedPlan`, `approvedPlanFrom` (the draft request ID), and the usual
2081
+ `approvedBy: "board"` / `approvedAt` metadata. The runner independently checks
2082
+ that the source was a successful draft for the same prompt IDs before starting
2083
+ any child. The filed request captures the reviewed text and original
2084
+ prompt/context. Implementation starts in a fresh session; the reviewed plan
2085
+ stays visible in the expanded run. As with existing spawn approval files,
2086
+ the folder is a trusted local transport, not a cryptographic identity system.
2087
+
2088
+ ### Operational timing receipts
2089
+
2090
+ Settled runs retain numeric phase measurements in `sidecar.performance`: queue
2091
+ (including approval wait), setup, execution, first protocol output, cleanup and
2092
+ total duration. Existing workspace metadata remains alongside the measurements,
2093
+ including when Stop or setup failure occurs before spawning. Local
2094
+ `runner.performance` and `runner.delivery` logs distinguish execution from
2095
+ receipt acknowledgment. These contain no prompt/output text; they are not a
2096
+ central analytics export. See `docs/sentry.md` for definitions and current gaps.
2097
+
2098
+ ### Explicit computer app discovery (#1132)
2099
+
2100
+ A runner advertising `computerAppsLook: true` receives `computer-apps` operations
2101
+ on its existing heartbeat after the person presses Look in Settings. Each
2102
+ operation targets a project and request id. `computerAppsTake` claims it once;
2103
+ `computerAppsResult` writes only names, source labels, availability states and
2104
+ which sources could not be read. No prompt/model turn or app tool runs. The
2105
+ saved result is informational: every real run still performs its existing
2106
+ computer-app approval preflight. Unclaimed requests expire after ten minutes;
2107
+ a failed or interrupted look needs another press. The old result survives a
2108
+ new request, and a late answer cannot overwrite a newer request.