viewable-one 0.0.0-stage → 0.12.1

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 +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -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 +747 -0
  20. package/lib/codexApps.mjs +332 -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 +605 -0
  28. package/lib/devserver.mjs +659 -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/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -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,2105 @@
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 glytch - [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
+ `GLYTCH_BROWSER` 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 `.glytch-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`). `glytch 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 `.glytch-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 `GLYTCH_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. When the child ends, the runner copies that
672
+ record plus `worktreePath` onto the run row's own `server` column
673
+ (`bridge/lib/runnerCompletion.mjs`), so it outlives a tree that is merged,
674
+ deleted or cleaned. Nothing is written beside the request; the row is the
675
+ record.
676
+
677
+ **Who stops it.** `sweepDetachedServers` (`bridge/lib/serverSweep.mjs`), once
678
+ a day from the runner's own beat, per registered folder: it lists the folder's
679
+ finished run rows that still carry a `server`, stops the server when the run
680
+ finished more than 14 days ago or as soon as the worktree it was serving is
681
+ gone, and clears the column. A running or pending row is never touched.
682
+ Nothing else kills it - not the child's close, not `killProcessesIn`, whose
683
+ parent-chain guard spares a process that reparented to init.
684
+ `bridge/test/serverSweep.test.mjs` pins the age, the missing worktree and the
685
+ untouched running row.
686
+
687
+ **And it never kills on a bare pid.** A record can outlive its process by days
688
+ and pids are reused, so `verifyRecordedServer` (`lib/devserver.mjs`) requires
689
+ positive evidence: the pid must be listening on the recorded port, or carry a
690
+ command line that agrees with the recorded one, and its start time must sit
691
+ within ten minutes of the record's `startedAt`. Anything short of that is one
692
+ log line and the process is left alone. `bridge/test/detachedserver.test.mjs`
693
+ pins both halves - a detached grandchild is still alive after the envelope
694
+ closes its parent, and an unverifiable pid is never signalled.
695
+
696
+ **Who starts it again.** Added 2026-09-06, the ask: "When a dev server gets
697
+ detached, add a button in terminal to relaunch dev server in this branch.
698
+ Allows me to test browser." The sweep above is what makes the button necessary
699
+ - the frame outlives the server - and nothing in a browser can start a process,
700
+ so the runner does it. The app files a `relaunch` op on the run (see "Signal
701
+ files" below); on its next beat the runner re-runs the record's `cmd`,
702
+ detached, in the recorded worktree, writes a fresh record to the row, and
703
+ answers through the op's result.
704
+
705
+ **No session, and no command.** A relaunch costs nothing: it is one process
706
+ started from a command that is already written down, so there is no LLM turn to
707
+ pay for and no session to file. The `cmd` comes from the record and only from
708
+ the record - the op may carry `{ worktreePath }` as a hint for finding one,
709
+ and nothing else. An op that could name its own command would be a way to run
710
+ arbitrary shell out of the canvas, which is a different feature and not one
711
+ anybody asked for. So a missing record is a refusal, never a prompt for one.
712
+
713
+ It refuses, and says which, when:
714
+
715
+ | refusal | why |
716
+ | --- | --- |
717
+ | no record | the session started no server, or the sweep cleared the record when the worktree went |
718
+ | the record has no `cmd` | there is nothing to re-run, and nothing may be guessed |
719
+ | 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) |
720
+ | 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 |
721
+ | the command exits at once | it died inside `RELAUNCH_SETTLE_MS` (700ms); a detached server has no terminal, so its output went nowhere |
722
+
723
+ A server that is STILL RUNNING is not a refusal: the answer is the pid and port
724
+ it is already on - verified by the same evidence the sweep demands before it
725
+ kills anything - and nothing is started twice.
726
+ `bridge/test/relaunchserver.test.mjs` pins the lot.
727
+
728
+ **How it is stopped, per platform.** The stop is two passes with a grace
729
+ between them, and the words for those two passes differ (brief 21, ruling
730
+ 4A of 2026-09-07). On macOS and Linux it is `SIGTERM` and then `SIGKILL`; on
731
+ Windows, where Node maps both signals to TerminateProcess and reaches only the
732
+ one pid while the real server usually runs as a grandchild of a `.cmd` shim, it
733
+ is `taskkill /pid <pid> /t` and then `taskkill /pid <pid> /t /f`. One seam,
734
+ `killTree` in `lib/kill.mjs`, holds both, and `lib/child.mjs`'s kill closure and
735
+ `lib/procs.mjs` go through the same seam. THE GUARD IS UNCHANGED AND COMES
736
+ FIRST either way: `killTree` decides nothing about whether a pid may be killed.
737
+ The Windows evidence for that guard is `netstat -ano` for the pid listening on
738
+ the recorded port and `Get-CimInstance Win32_Process` (through
739
+ `ConvertTo-Json`) for the start time and the command line, injected exactly
740
+ where `ps` and `lsof` are; `bridge/test/windowsevidence.test.mjs` pins the
741
+ parsers against sample output, and `bridge/test/kill.test.mjs` the two argv
742
+ sequences.
743
+
744
+ ## Windows
745
+
746
+ *Brief 21, the rulings of 2026-09-07. Written against sessions 1-5; session 6
747
+ is the end-to-end on a real machine and nothing here has met one yet.*
748
+
749
+ **What is Windows and what is not.** Ruling 6C: native Windows and WSL both
750
+ count, and `glytch doctor` names which one it is running in. WSL is Linux for
751
+ everything in this file - it has `setsid`, it has real npm shims, it gets the
752
+ POSIX spelling of every command below - so the runner reports it as its own
753
+ name, `wsl`, rather than as either `linux` or `win32`
754
+ (`bridge/lib/platform.mjs`).
755
+
756
+ **How the runner says which.** Every beat of the liveness mark
757
+ (`.glytch/inbox/spawn/.runner.json`) carries a `platform` key beside `at`,
758
+ `version`, `updateAvailable` and `agents`: `process.platform`, or `wsl` when
759
+ `/proc/version` names Microsoft. It is optional and stays optional - a runner
760
+ from before this brief writes none, and the app reads that as "nothing has
761
+ said" and falls back to the POSIX shape, exactly as it reads a missing
762
+ `agents` block. THE APP MUST NOT USE `navigator` FOR THIS: the board runs in
763
+ a browser that need not be on the machine the session runs on.
764
+
765
+ **`.mcp.json` names the board server differently.** Ruling 2A: a writer on
766
+ Windows writes `{"command":"cmd","args":["/c","glytch","mcp"]}`, and every
767
+ other platform keeps `{"command":"glytch","args":["mcp"]}`. npm installs the
768
+ bin as `glytch.cmd` there, and an MCP client spawning `glytch` with no shell
769
+ hits the refusal Node has enforced since 18.20.2 / 20.12.2. `bridge/lib/mcpjson.mjs` branches on `process.platform`, because `glytch init`
770
+ IS the machine, and `bridge/test/fixtures/mcp-json-cases.json` carries the
771
+ Windows cases. (The app's second writer went with the old Help, #494.) **A file
772
+ that already carries EITHER shape is left exactly as it is**, which is the same
773
+ presence rule as always and is what stops a folder shared between two machines
774
+ being rewritten forever.
775
+
776
+ **Starting and stopping processes.** `runnableCommand`
777
+ (`bridge/lib/windows-command.mjs`) resolves `claude`, `opencode` and `npm`
778
+ through `PATHEXT` before spawning them, and `childEnv`'s allowlist matches
779
+ `Path` case-insensitively and passes `SystemRoot` (without which a child cannot
780
+ resolve DNS). `killTree` (`bridge/lib/kill.mjs`) is `taskkill /pid <pid> /t`
781
+ then `/t /f`, behind the same verification guard. See the two sections above.
782
+
783
+ **Git for Windows is a warning, not a refusal** (ruling 8B). Without it there
784
+ is no worktree isolation and no Bash tool, so `glytch init` and `glytch doctor`
785
+ say so in a row that is a WARNING - and a session then runs unisolated in the
786
+ linked folder, which is what `makeWorktreeImpl` already does anywhere git is
787
+ missing. Option A, a FAIL row and `exit 1`, was rejected: an unisolated session
788
+ is worth more than no session.
789
+
790
+ **CI gates it** (ruling 7A). `.github/workflows/bridge-tests.yml` runs the
791
+ bridge suite on `windows-latest` as well as `ubuntu-latest`, and the Windows
792
+ job BLOCKS.
793
+
794
+ ## Exactly once, and the one place this deliberately differs from ops
795
+
796
+ The ops channel keys on `(path, lastModified)`: editing a file makes a new
797
+ version and the new version runs. **The spawn channel does not do that.** A
798
+ request runs AT MOST ONCE, EVER, per request id, no matter how many times the
799
+ file is edited or how the run ended. The claim file is the record of that.
800
+
801
+ The reason is money and nothing else. Re-running an ops file costs a proposal
802
+ the user can reject in one click. Re-running a spawn costs credits that are
803
+ already spent by the time he sees it. Symmetry with the ops channel is worth
804
+ less than that, so it goes. To run something again, write a new request.
805
+
806
+ Consequence, stated so nobody rediscovers it: a request that fails is **not
807
+ retried**, by the runner or by anything else. Failure is terminal. That is also
808
+ why there is no backoff, no retry count and no dead-letter queue - each of
809
+ those is a mechanism for spending money again without being asked.
810
+
811
+ **The two exceptions, and what they have in common** (crash retry, 2026-09-01;
812
+ the No-conversation retry, 2026-09-07). Both re-run the child once, and both
813
+ are allowed for the same reason: the first attempt provably spent nothing and
814
+ produced nothing. A crash before any assistant event streamed has no output to
815
+ duplicate; a `No conversation found` envelope says `num_turns: 0` and
816
+ `total_cost_usd: 0` on its own face. Neither is a retry of work - each is the
817
+ first attempt actually happening. A third exception is a money decision and
818
+ needs a ruling, not a patch.
819
+
820
+ **A runner that dies mid-session leaks its concurrency slot**, because its
821
+ claim has no receipt and nothing infers death from silence. That is the ruling's
822
+ direct cost and it is accepted rather than worked around. `spawn-runner release
823
+ <id>` is the explicit human way to free it - a person deciding a session is
824
+ gone, which is the only actor allowed to decide that.
825
+
826
+ **Caught failures (2026-09-08):** a runner that catches an exception after
827
+ claiming a request writes an ERROR receipt whenever the spawn directory is
828
+ still writable, and attempts to close its board run with the failure reason.
829
+ The receipt distinguishes failure before invoking a child from failure after
830
+ invocation; neither path retries the request. Its live-log timer and temporary
831
+ images are cleaned up even when finalization fails. This does not release an
832
+ unobserved crash's request claim.
833
+
834
+ **Admission (2026-09-08):** direct runs and polling share an atomic mutex at
835
+ `inbox/spawn/.admission.lock` around room checks and the request
836
+ claim. The claim is the durable reservation; the mutex is released before the
837
+ board start and child execution. A busy mutex leaves the request unclaimed for
838
+ the next poll. Concurrent runners therefore share the configured concurrent
839
+ limit, and cannot claim two live turns in the same room.
840
+
841
+ The mutex publishes a complete owner atomically. A verified-dead PID on the
842
+ same host can be recovered automatically; elapsed time alone never permits
843
+ stealing it. Recovery keeps a tiny `.admission-recovered-<token>` marker so a
844
+ delayed competing cleaner cannot remove a newer owner's lock. A remote or
845
+ unreadable owner, or a cleaner that itself crashed during recovery, requires
846
+ manual removal of `.admission.lock` after confirming its owner is stopped.
847
+ This short admission mutex is separate from durable request claims, whose
848
+ manual `release` contract above remains unchanged.
849
+
850
+ ## The run record - the "predictable place"
851
+
852
+ The run ROW is the record: it exists the moment `run_request` inserted it,
853
+ `claim` moving it to `status:'running'` IS the start (no wait, because
854
+ there is no window between "claimed" and "on the board" left to guard),
855
+ and `update`'s `status:'done'|'error'|'stopped'` is the end. `frameId` -
856
+ still the idea's own id, unrenamed - is `fromIdOf(request)`, read
857
+ straight off the row.
858
+
859
+ **Built in the app, not here.** `boardApi.startSession({key, title, brief,
860
+ fromId})` and `boardApi.endSession({key})` land on the app side; this runner
861
+ is a caller of them and defines none of it. What they do, as of brief 26:
862
+
863
+ - `startSession` finds the IDEA named by `fromId` (the idea whose run
864
+ this is - `fromIdOf(request)`, the first id on the request) and writes a
865
+ `RunRecord` onto it: `key`, `title`, `brief`, `status: "running"`,
866
+ `startedAt`, `openedBy`, replacing any earlier record that idea carried.
867
+ It lands live, not as a proposal. With no `fromId`, or one that no longer
868
+ names an idea, the op returns **OK with no id** - the run happens and its
869
+ report reaches him through the Glytch Agent when `report` is called, and
870
+ nothing is drawn. Refusing would mean a stale idea id costs a session,
871
+ which is the worse trade.
872
+ - The `key` is the same key `report()` takes. That is the whole trick: the
873
+ session's report reaches the Glytch Agent, which places it beside the
874
+ ideas it is about, and its questions land as question notes on the idea
875
+ that asked. Nothing is wired. No nesting logic anywhere in this runner.
876
+ - `endSession` flips `status` to `done`, stamps `endedAt`, and **proposes
877
+ nothing** - there is no node to propose deleting, because starting the run
878
+ created none.
879
+
880
+ The runner's whole part is: call one, spawn, call the other, with the request id
881
+ as the `key`.
882
+
883
+ ## Answers and comments coming back
884
+
885
+ This is item 2's tail: "as soon as I finish answering questions or commenting,
886
+ to be able to send those to agent directly from this board."
887
+
888
+ A runner-spawned child reaches the board through the HOSTED endpoint, under
889
+ its own run key (`X-Glytch-Session`, `session.key` - the same key `report()`'s
890
+ `PM_SESSION_KEY` already uses). The runner reaches for `collect_session`,
891
+ exactly the tool a session would call by hand - `bridge/lib/rowsAnswers.mjs`
892
+ is the whole of it. Only a runner-spawned session ever has an idea behind it
893
+ at all - a CLI request or a conversational session has nothing to collect, and
894
+ `collect_session` says so plainly rather than erroring.
895
+
896
+ - **While the child runs**, the runner polls `collect_session` through
897
+ `hosted.callTool`, passing the child's OWN run key as `X-Glytch-Session`
898
+ and a `since` watermark it advances from each reply's `next_since`. A
899
+ matching reply hint on the runner topic wakes collection immediately. While
900
+ joined, a one-minute safety read catches missed hints; without push,
901
+ `PM_ROWS_POLL_MS` (default 5000ms, `settingsFromEnv`) remains the fallback.
902
+ Rejoining also wakes collection. `collect_session`
903
+ already matches "this run's own rows" server-side; what the runner adds
904
+ is remembering which ANSWERED QUESTION it has already delivered (the
905
+ tool hands back every one of this key's answered questions on every
906
+ call, with no time filter). A MESSAGE is stamped `delivered_at` only once
907
+ the child TOOK it (round 4 item 75): the poll passes `delivered`, the ids
908
+ the child accepted since its last call, and the endpoint hands a message
909
+ back unstamped until a call names it; the post-exit sweep sends the last
910
+ acks, and what still comes back is carried on as a resume or, when no run
911
+ can carry it, recorded on the sidecar as `undelivered` for the card. Each new
912
+ row becomes a stdin message through the exact door a typed reply already
913
+ uses (`sendToChild` in `child.mjs`'s `runStreamingChild`) - a message as
914
+ `[message from <from>] <text>`, matching a reply file's own attribution.
915
+ Wired only when the run has a bearer to send at all (`glytch login` or
916
+ `GLYTCH_BOARD_TOKEN`); with neither, there is nothing on the hosted
917
+ endpoint for the child to have asked in the first place.
918
+ - **After the child exits**, ONE more `collect_session` call
919
+ (`promoteLateRows`) catches whatever arrived in the gap between the last
920
+ live poll and the exit, called from `completeRequest` right after the
921
+ receipt is written. `promoteLateRows`'s `promote` callback is
922
+ `runnerApi.resume`, for every request kind, chat turns included: the
923
+ runner sends one `resume {runId, message}` call and the board-runner
924
+ function inserts the approved child run row server-side.
925
+ `acceptsMessages`' one-shot-vs-persistent distinction decides "logged and
926
+ dropped" vs "resumed" the same way it always has. A content-addressed id
927
+ (`lateRowRequestId`) keeps a duplicate promotion attempt from filing the
928
+ same resume twice.
929
+ - **The one known gap**, spelled out rather than hidden: a MESSAGE that
930
+ arrives in the same poll tick the child happens to exit on is marked
931
+ `delivered_at` by that fetch whether or not `sendToChild` actually got to
932
+ write it before the pipe closed - `watchAnswers` breaks its delivery loop
933
+ the moment the child is settled, before calling `onDelivered`. Such a
934
+ message is not "late" (it was already fetched) and will not come back on
935
+ a later poll either (the server will not hand it back). An ANSWERED
936
+ QUESTION has no such gap - it carries no `delivered_at`, so it is simply
937
+ asked for again, here or on the next live poll, until this run's own
938
+ delivered-set has actually recorded it.
939
+
940
+ The file-based reply path (`<id>.reply-*.json`) stays working alongside this:
941
+ a human typing into a running session still goes straight down the same
942
+ stdin door, file or row.
943
+
944
+ A session collects with the `collect_session` tool, passing `since`. The
945
+ watermark is the caller's, not the daemon's, so it survives a restart, and
946
+ `since: 0` reads everything. An EXITED session cannot be woken - that is a
947
+ new session, which is a spend, which is a click.
948
+
949
+ **Images travel the same doors.** A message's `images` travel into `resume`
950
+ alongside `message` when the poll has any (`rowsAnswers.mjs`'s
951
+ `createRowsAnswerPoll`/`promoteLateRows`), and a LIVE answer's images reach a
952
+ still-running child through `child.mjs`'s `userMessage`, which turns them into
953
+ native image blocks. `collect_session` writes a message's pictures as a
954
+ `{"images":[...]}` line right under it; the poll reads that line per message,
955
+ or, when a batch is one message, the block the tool wrote. A picture that
956
+ cannot be read (bad data URL, too large) never drops its message: the text
957
+ goes through with a short line saying a picture came and could not be read.
958
+ `bridge/test/rowsAnswers.test.mjs` runs this through the fake binary.
959
+
960
+ ## Money - the constraint that outranks the feature
961
+
962
+ A spawned session bills the user's subscription. A spawned session that loops
963
+ bills it repeatedly and does so overnight. Every one of these is a bound, and
964
+ each is enforced in code, not in a comment:
965
+
966
+ | bound | value | what it stops |
967
+ | --- | --- | --- |
968
+ | runner off by default | `PM_SPAWN_RUNNER=on` AND an explicit `watch` command | a runner that starts itself because something imported it |
969
+ | per-request approval | `approved: true`, or the request is refused | a row nobody clicked "run" on ever running |
970
+ | `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 |
971
+ | 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 |
972
+ | 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 |
973
+ | 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 |
974
+ | no retry, ever | a failed request is terminal | paying twice for the same failure |
975
+ | no re-run on edit | one request id, one run | a saved file re-billing |
976
+ | the fake binary | `PM_CLAUDE_BIN` | every test in this suite costing money |
977
+ | 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 |
978
+ | 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 |
979
+
980
+ **The default of one session at once is gone, since 2026-08-28**, on the ruling
981
+ "No cap, my click is the cap", asked for out loud: *"I would like to operate
982
+ multiple sessions at once on the board."*
983
+
984
+ Worth recording because the number was never the thing that stopped a second
985
+ session, and reading the table would have told you it was. `pollOnce` awaited
986
+ each child inside its own loop, so ONE WATCHER RAN ONE SESSION AT A TIME
987
+ whatever the cap said - the cap was decoration, and being set to 1 is what
988
+ made the serial behaviour look deliberate. Worse, the same await starved the
989
+ runner's own liveness mark, which was refreshed once per pass: with a session
990
+ running the mark aged past the app's 30s window, the board said "A runner was
991
+ watching but has stopped" over a session that was running fine, and the gate
992
+ that message drives refused to file anything else. One running session made a
993
+ second one impossible, and nothing in the money table was doing it.
994
+
995
+ What replaces the number: `startPending` starts runs without awaiting them,
996
+ slots are counted as the union of the claims on disk and the runner's own
997
+ in-flight set, and the mark beats on its own timer (`RUNNER_BEAT_MS`, a third
998
+ of the staleness window). A clean stop now drains its children before clearing
999
+ the mark, so Ctrl-C still writes every receipt.
1000
+
1001
+ **"One click files a session" is gone, 2026-08-26 (later the same day)**, and
1002
+ the halo wedge that did it. He ruled the confirm dialog off that morning
1003
+ (`q-mt9q8qrg`, "One click is what I said, drop it"), then reversed the rule
1004
+ himself once the shape of the feature was clear: *"Yes reverse the one click
1005
+ rule.. Already we're making things efficient enough."*
1006
+
1007
+ What replaces it is not a confirm step - it is the idea becoming the
1008
+ one place a run is configured. An idea carries a TARGET (an OpenRouter model,
1009
+ or a Claude Code session, the latter unselectable while the setup is
1010
+ incomplete) alongside the run settings it already has, and Run means run,
1011
+ whichever target is set. So a session costs the same one gesture a model run
1012
+ costs, and the second "click" is a property you set once rather than a
1013
+ question asked every time.
1014
+
1015
+ Recorded because the two rulings read as contradictory out of context - the
1016
+ first removed a step from a control that was about to be deleted, the second
1017
+ deleted the control. Neither is a reversal of the money principle: every
1018
+ session still needs a deliberate human action, and `approved: true` is still
1019
+ set by that action alone.
1020
+
1021
+ **"The board's own button confirms - one dialog, then the click that
1022
+ approves" is gone from this table, since 2026-08-26.** It shipped and he ruled it out the same
1023
+ day (`q-mt9q8qrg`, "One click is what I said, drop it"), consistent with the
1024
+ brief that asked for the work. Recorded rather than deleted because a later
1025
+ session reading the money section would otherwise reinvent it as an obvious
1026
+ improvement. It is not an improvement he wants: the click IS the approval, and
1027
+ a dialog in front of it is a machine being careful with his money on his
1028
+ behalf, which is the thing this product exists to stop. The bound that
1029
+ replaces it is the row above - a control that spends has to say so on its own
1030
+ label, and `src/store.spawn.test.ts` pins the caller list so a third caller
1031
+ cannot appear quietly.
1032
+
1033
+ **No run has a spend or token limit (#364).** The wall clock is the one
1034
+ bound on a single run; the runner still counts cost and tokens off the
1035
+ child's own usage events, for the receipt, and nothing it counts stops a run.
1036
+ The free AI that comes with a new account is the only limit, and it is the
1037
+ model proxy's, not the runner's.
1038
+
1039
+ **No autonomous spawning.** Nothing in this repo files a request on its own.
1040
+ The runner runs what it is given and refuses what is not approved; the deciding
1041
+ is the human's, exactly as `ask_user` and proposals already are. That is not a
1042
+ policy bolted on top, it is the rule the rest of the bridge already keeps:
1043
+ `runPrompt`, `generateMore`, `composeDoc` and `composeBrief` are all refused by
1044
+ the ops vocabulary for the same reason - "they spend the user's model credits"
1045
+ (`INBOX_OPS.md`).
1046
+
1047
+ ## The one thing that needed `src/` - BUILT 2026-08-26
1048
+
1049
+ This section records the ruling that closed a real gap: the runner half was
1050
+ complete before the app could FILE a request, so spawning still needed a
1051
+ terminal. The ruling on `q-mt9kh6xc` - "Yes, build the button next" - closed
1052
+ it, and it is built.
1053
+
1054
+ The app's half is `src/folder/spawnRequest.ts` (the request shape, REIMPLEMENTED
1055
+ rather than imported - nothing under `src/` may import from `bridge/`), plus
1056
+ `fileSpawnRequest` in `src/store.ts`, which is the one call this section
1057
+ predicted:
1058
+
1059
+ ```ts
1060
+ await writeFolderFile(handle, `inbox/spawn/${id}.json`, JSON.stringify(request));
1061
+ ```
1062
+
1063
+ Two ways in, and each is ONE click: the Go button on an idea
1064
+ (`src/nodes/IdeaCard.tsx`), and a row in the selection bar's overflow menu
1065
+ for anything else (`src/components/SelectionBar.tsx`). That click writes the
1066
+ file, and that click IS the human gesture that sets `approved: true`.
1067
+
1068
+ The ruling, twice. The brief that asked for this work: "Filing and approving
1069
+ may be one gesture here BECAUSE it is a human click; the CLI keeps them
1070
+ separate only because a script can call the CLI." Then `q-mt9q8qrg`, after it
1071
+ shipped with a confirm dialog in front of it and he was asked whether to keep
1072
+ the second click: "One click is what I said, drop it." The fork this section
1073
+ used to say was "not guessed at here" was closed by him, not by a session.
1074
+
1075
+ So the gate is the CALLER LIST, and it is two controls, both a human click,
1076
+ both labelled with what they cost. Nothing inside `fileSpawnRequest` checks
1077
+ that a person asked - it cannot, and it is not supposed to. That safety
1078
+ property lives in the call graph and in no type, so it has a test:
1079
+ `src/store.spawn.test.ts` reads `src/` and fails if a third caller appears.
1080
+
1081
+ Filing does not start anything, and the app says so without a dialog to say it
1082
+ in. Nothing in the app starts a runner or could - a browser cannot spawn a
1083
+ process - so both controls carry the caveat on their own label, and the
1084
+ toolbar's receipt pill says "Needs the runner" rather than reporting a
1085
+ session.
1086
+
1087
+ `node bridge/spawn-runner.mjs request ...` still works and is still the only
1088
+ way for a SCRIPT to file one, unapproved, with `approve` as a separate command.
1089
+ The app collapses the two because there is a person at the other end of the
1090
+ click; the CLI cannot, because a script can call the CLI.
1091
+
1092
+ **This is a RULING, not a session's invention, and it is worth saying so.**
1093
+ It was set directly in the brief that opened the session which built the
1094
+ button, 2026-08-26: filing and approving may be one gesture here BECAUSE it
1095
+ is a human click, and the CLI keeps them separate only because a script can
1096
+ call the CLI. He confirmed it again the same day when asked to choose between
1097
+ one confirm, splitting filing from approving, and forcing the full prompt
1098
+ into the dialog: "One confirm is enough."
1099
+
1100
+ It is recorded here because until now it was not written down anywhere a
1101
+ reader could check. This file previously said the fork was "a UI decision on
1102
+ contested files, so it is not guessed at here", and the paragraph above
1103
+ replaced that with the rule but not with its source - so a later reader would
1104
+ have found an unattributed rule about money and no way to tell whose it was.
1105
+ The shipped design is in fact STRICTER than he allowed: he permitted one
1106
+ click, the button uses two, and the first writes nothing.
1107
+
1108
+ **He ruled against this route, 2026-08-26.** Asked on `q-mt9kh6xb`
1109
+ whether answering a question should be able to file a spawn request, he
1110
+ answered "No, keep spending on its own gesture". Do not build it. The
1111
+ reasoning below is kept because it explains what was rejected and why the
1112
+ option is tempting; it is not a design still on the table.
1113
+
1114
+ ~~There is a second route that needs no app UI at all~~, and it was worth knowing
1115
+ before the button was designed: a question note IS a human approval gate.
1116
+ `addQuestion` lands live, only the human can answer it, the answer reaches the
1117
+ mirror, and a dismissal means no. A session could propose "Spawn a Claude Code
1118
+ session to do X?" with options, and the runner could treat the answered question
1119
+ as the approval and file the request itself. It needs no app change and it works
1120
+ today. It is deliberately NOT built, because it turns every question note into
1121
+ a potential spend, and that is a bigger decision than a night's work should make
1122
+ on its own.
1123
+
1124
+ ## Signal files - stop, merge, delete, relaunch
1125
+
1126
+ `stop`, `merge`, `delete` and `relaunch` are all rows - `heartbeat`'s `stops`
1127
+ array and its `ops` + `opTake`/`opResult` (`bridge/lib/runner.mjs`'s
1128
+ `serviceRowOps`) - for every request kind, chat turns included. `relaunch`'s
1129
+ record is the claimed run row's own `server` column (mirrored there by
1130
+ `runnerCompletion.mjs` when the child exits), falling back to the op's
1131
+ `worktreePath` hint plus the worktree's own live `.glytch-server.json` when
1132
+ the row has none yet. The pure decision core, `relaunchServer`
1133
+ (`bridge/lib/devserver.mjs`), decides exactly what is below - only where the
1134
+ record comes from and where the answer goes is rows rather than files.
1135
+
1136
+ Kept below: the SHAPE of each signal, named for the file it once travelled as
1137
+ - what it decides, not how it travels.
1138
+
1139
+ | signal | shape | what the runner does |
1140
+ | --- | --- | --- |
1141
+ | stop | `{ worktreePath }` | sends SIGTERM to the child, writes receipt |
1142
+ | 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 |
1143
+ | delete | `{ worktreePath }` | Throw away: removes the worktree and deletes the branch without merging |
1144
+ | 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 |
1145
+
1146
+ Each of the first three carries at least `{ worktreePath }`. The runner
1147
+ consumes the op after acting on it (on success only for merge, so a failed
1148
+ merge can be retried). A merge that clashes writes `conflict: true` and
1149
+ `files` (the clashing paths) into its result; a merge that lands writes
1150
+ `files` as the count changed. No question is filed: the app sends Coding back
1151
+ to fit its changes around the person's (#483).
1152
+
1153
+ `relaunch` is the odd one: its body is optional, nothing more than a hint for
1154
+ finding the record, it is consumed on refusal as well as on success - the
1155
+ receipt is the answer, and a signal left behind would relaunch on every pass
1156
+ - and the command it runs is never in the op. See "Who starts it again" above
1157
+ for what it refuses and why.
1158
+
1159
+ **Keep safety (#483).** `mergeWorktree` in `bridge/lib/worktree.mjs` first
1160
+ commits what the agent left uncommitted in the copy, as the person or as
1161
+ Glytch when git has no name, never its `node_modules` link (#482), then tries
1162
+ the merge with `git merge-tree --write-tree`, which touches no file. A clash,
1163
+ including one with the person's uncommitted changes or a file of theirs git
1164
+ does not track or ignores where the merge adds, renames or copies a file
1165
+ (read from the disk, since a fast-forward overwrites an ignored file
1166
+ without a word), stops with nothing in the folder
1167
+ changed and names the files. When the folder is on the default branch, the
1168
+ person's changes are set aside with a named stash, the default branch
1169
+ fast-forwards to the merge commit, and the stash is applied with `--index`
1170
+ and dropped by its sha. When the folder is on another branch, only the
1171
+ default branch's ref moves (compare-and-swap) and the folder's files stay
1172
+ put. Then the copy and its branch are deleted. Nothing is pushed.
1173
+
1174
+ ## Per-idea lock
1175
+
1176
+ Added 2026-08-30. The ruling on `q-mtf2x89x`: "Only one session per idea,
1177
+ unless the old session lost its worktree."
1178
+
1179
+ `activeWorktreeForPromptRows` in `bridge/lib/runner.mjs` reads this project's
1180
+ run rows through `runnerApi.runs`, then checks whether a worktree for the
1181
+ given idea still exists on disk AND its branch has not been merged into
1182
+ main. Both halves matter: without the merged check, a finished session
1183
+ blocks its own idea forever. The former file-scanning
1184
+ `activeWorktreeForPrompt` helper in `worktree.mjs` was deleted on
1185
+ 2026-09-14; admission has used the rows version since 2026-09-11.
1186
+
1187
+ **Own-tree exemption.** A request whose `worktreePath` is one of the same
1188
+ idea's own kept worktrees bypasses the lock and runs IN that tree, on its
1189
+ branch: a resume (Claude, a Codex thread, and opencode, which tries to resume
1190
+ and falls back to a fresh session; D8), and since 2026-10-04 (brief 52
1191
+ review, D7) a new session naming the tree without `resumeSessionId` (a note
1192
+ after a done session that recorded no id). The lock prevents a SECOND tree,
1193
+ not the idea from working on its own branch. A request naming no tree (Rerun)
1194
+ is still refused, and a named tree that is gone gets a new one
1195
+ (`runnerWorkspace.mjs`). A request naming a tree no run of ITS idea made
1196
+ (another idea's tree, or a path no row holds) is treated as tree gone (round
1197
+ 3, item 67). The lock's exemption and the own-tree match compare paths through
1198
+ `realpathSync.native` (items 69, 70), and the tree is reused under its own
1199
+ recorded spelling, so an 8.3 short name or a `/var` spelling of a
1200
+ `/private/var` tree still reuses it.
1201
+
1202
+ ## Chat turns
1203
+
1204
+ Added 2026-09-07, brief 25 session 1, on the rulings of the same day. Ruling
1205
+ 1A: **a coding agent's chat turn runs through the daemon**, as a persistent
1206
+ Claude Code conversation, one per DM, on his subscription, with Claude Code's
1207
+ own tools plus the board tools. Until this, a DM with an agent whose engine
1208
+ said `claude-code` was an OpenRouter API call like every other chat turn -
1209
+ so "Coder, engine claude-code" had never once run on Claude Code when you
1210
+ talked to it.
1211
+
1212
+ **DONE OVER ROWS, 2026-09-11 (brief 30 Move 2b).** Move 2 (the same day,
1213
+ earlier) shipped the ordinary session path over rows and refused a
1214
+ chat-turn row outright, naming the per-room lock and the chat worktree mint
1215
+ as file-shaped work not migrated in that pass. Both are rows now: the
1216
+ per-room lock is `activeChatTurnForRoomRows` (`bridge/lib/runner.mjs`,
1217
+ `runnerApi.runs({..., status:'running'})` filtered to `isChatTurn(request)`
1218
+ and a matching `roomId`) in place of a disk scan for an unreceipted claim;
1219
+ the chat worktree mint (`makeChatWorktree`/`resolveChatCwd`,
1220
+ `bridge/lib/worktree.mjs`) is UNCHANGED - it was never file-shaped, only
1221
+ called from the file-based runner, and is called from the rows one now the
1222
+ same way. Every difference below is unchanged in what it means; only the
1223
+ DOOR each one reaches through moved (a run row and `runnerApi` calls, not a
1224
+ signal file) - marked `chat turn:` at each site in `runner.mjs` exactly as
1225
+ they are marked here.
1226
+
1227
+ **The request is the same shape, riding a row's `request` column, with one
1228
+ new field.**
1229
+
1230
+ ```json
1231
+ {
1232
+ "version": 1,
1233
+ "id": "spawn-mtr...",
1234
+ "kind": "chat-turn",
1235
+ "roomId": "room-dm-coder",
1236
+ "cwd": ".claude/worktrees/chat-a-coder",
1237
+ "mintIfMissing": true,
1238
+ "agent": "claude",
1239
+ "model": "",
1240
+ "prompt": "<the room's composed message>",
1241
+ "approved": true,
1242
+ "resumeSessionId": "…",
1243
+ "worktreePath": "/Users/n/proj/.claude/worktrees/chat-a-coder"
1244
+ }
1245
+ ```
1246
+
1247
+ | field | required | what it is |
1248
+ | --- | --- | --- |
1249
+ | `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. |
1250
+ | `roomId` | for a chat turn | Which DM this turn belongs to. It is what the per-room lock locks. |
1251
+ | `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. |
1252
+ | `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. |
1253
+ | `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 Glytch folder. The app sends it when the chat's folder is the Glytch folder (`~/Glytch`, 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 Glytch 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. |
1254
+ | `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. |
1255
+ | `resumeSessionId` | no | Absent on the first message of a conversation, present on every one after it. |
1256
+ | `worktreePath` | no | What the sidecar recorded, handed back. For a chat turn it equals `cwd`. |
1257
+
1258
+ **The five differences, and no others.**
1259
+
1260
+ 1. **No frame on the board.** No `startSession`, no outcome written into a
1261
+ frame, no `endSession` - a chat turn writes NOTHING into `inbox/ops/`. It
1262
+ therefore does not wait for the app's receipt either, so it answers with
1263
+ the app closed. Ruling 3: chat bubble only. A streaming terminal on the
1264
+ canvas for every message he types is the noise the board is not for.
1265
+ 2. **No worktree is minted PER MESSAGE.** It runs in `cwd` and refuses if that
1266
+ is not a directory - it never falls back to the linked checkout, which is
1267
+ his own working tree with an agent holding Write, Edit and Bash. The one
1268
+ exception is asked for by name, `mintIfMissing`, and it makes the agent's
1269
+ ONE long-lived tree (below). The spawn-time orphan sweep is skipped either
1270
+ way: that sweep exists to clear the way for a tree about to be made per
1271
+ run, and `ps -eo pid,args` over every process on the machine is not a thing
1272
+ to do on the path a person waits on for every message they type.
1273
+ 3. **No repo primer and no attempts log**, and no runner preamble. All three
1274
+ exist to save a FRESH session from rediscovery, and every sentence of the
1275
+ preamble is about a frame ("call `report` before you finish...") that a
1276
+ chat turn does not have. What a chat turn is told is the app's to compose:
1277
+ persona, the room's context blocks, his message.
1278
+ 4. **A per-ROOM lock instead of the per-prompt one.** OVER ROWS (brief 30
1279
+ Move 2b): `activeChatTurnForRoomRows` (`lib/runner.mjs`) asks
1280
+ `runnerApi.runs({..., status:'running'})` and filters to
1281
+ `isChatTurn(request)` with a matching `roomId`, so two runners sharing a
1282
+ project share one answer the same way the file version's disk scan did.
1283
+ One live turn per room: both would otherwise resume the same conversation
1284
+ in the same directory at once, and a conversation is one thread. A chat
1285
+ turn has no idea behind it, so the per-idea lock above never applies to
1286
+ it, and it never takes one.
1287
+ 5. **No `chat` word needed to tell traffic apart.** The file version's
1288
+ receipt needed one on its own first line so a person reading
1289
+ `.glytch/inbox/spawn/` - and `readSpawnReceipt`/`store/actions/folder.ts`,
1290
+ which both parsed a receipt by `startsWith("OK"/"ERROR")` - could tell the
1291
+ traffic apart without a second meaning colliding with the first. A row
1292
+ has no such ambiguity: `run.request.kind` says which it is, structurally,
1293
+ on the SAME row a session's `status`/`result` already live on - there is
1294
+ nothing left for the result TEXT to also have to say.
1295
+
1296
+ **Everything else is deliberately the same.** Same money gate
1297
+ (`approved: true`), same claim-exactly-once (server-side now, `claim`),
1298
+ same live log (`run_events` now, `<id>.log` then), same stop signal
1299
+ (`heartbeat`'s `stops` now, `<id>.stop` then),
1300
+ same wall clock, same stall watchdog, same sidecar, same tool fence
1301
+ (`CLAUDE_TOOLS`) plus the board through the cwd's own `.mcp.json`.
1302
+
1303
+ **Where the conversation lives, and who makes it** (added 2026-09-07, session
1304
+ 1b, ruling 1: "one long-lived chat worktree per agent per folder, made
1305
+ once and kept").
1306
+
1307
+ 1. **The cwd is resolved, once, against the folder being watched.** A relative
1308
+ `cwd` is joined onto the folder root; an absolute one is used as it stands.
1309
+ The result is what the child runs in AND what the sidecar records as
1310
+ `worktreePath`, so the app's next turn sends an absolute path back and the
1311
+ conversation stays in one directory.
1312
+ 2. **A cwd that escapes the folder is refused**, in a receipt that names the
1313
+ resolved path. A chat turn holds Write, Edit and Bash; the linked folder is
1314
+ the boundary the person chose. The folder root ITSELF is allowed - a caller
1315
+ naming it said so - and nothing ever falls back to it.
1316
+ 3. **`mintIfMissing: true` cuts the tree, once.** `git worktree add -b
1317
+ glytch/chat-<agentId> <root>/.claude/worktrees/chat-<agentId> HEAD`, off the
1318
+ checked-out commit for the same reason `makeWorktree` picks it (whatever he
1319
+ is looking at is what the agent starts from, and naming a branch breaks on a
1320
+ detached head), with `node_modules` symlinked the way a session's tree gets
1321
+ it. A branch that already exists is checked out into the new tree rather
1322
+ than re-created, so a hand-removed tree comes back. The branch name is read
1323
+ off the directory name, which is the only thing both sides have to agree on.
1324
+ 4. **Only there.** The mint refuses any path that is not
1325
+ `<root>/.claude/worktrees/chat-*` (`isChatWorktreePath`), so a request file -
1326
+ an injection surface - cannot turn "mint if missing" into "make a directory
1327
+ anywhere in my folder". It also refuses a folder that is not a git
1328
+ repository, in words that say a chat needs one for its own worktree; there
1329
+ is no unisolated fallback to offer.
1330
+ 5. **Nothing removes it.** `cleanWorktree` refuses a chat path (and the runner
1331
+ never calls it for a chat turn), the sweep has never removed a worktree and
1332
+ now says so in its never-touch list, and the orphan reaper steps over a
1333
+ `chat-` name. A chat worktree is removed when the AGENT is deleted, which is
1334
+ not built yet: today the way out is `git worktree remove` by hand, and the
1335
+ next message mints it again.
1336
+
1337
+ **The sidecar is written for EVERY chat turn**, not only a persistent one. A
1338
+ first message carries no session id to resume, so `isPersistentRequest` is
1339
+ false for it and no tree is kept - and without this it would have written no
1340
+ sidecar at all. That file is how the room learns which conversation it just
1341
+ opened (`claudeSessionId`) and where it lives (`worktreePath`, equal to the
1342
+ cwd). No sidecar, no second turn.
1343
+
1344
+ **Stop keeps the conversation** (ruling 5). `<id>.stop` kills the child
1345
+ and the receipt says `Stopped by user request`, and the sidecar still carries
1346
+ the conversation id with no `expired` mark - so the next message resumes where
1347
+ it left off. Stop cancels the turn, not the thread.
1348
+
1349
+ **No limits are checked here** (#364). The `approved: true` gate and the wall
1350
+ clock are what bound a chat turn. `PM_SPAWN_MAX_CONCURRENT` counts a chat turn
1351
+ like anything else; it is a machine bound, `Infinity` by default.
1352
+
1353
+ **An empty `model` passes no `--model`** (ruling 7): an empty field means
1354
+ "you choose", and the daemon is what is running it. This needs no code - it is
1355
+ what `commandFor` already does for an unset model.
1356
+
1357
+ **What is NOT here.** The app half: the room's routing, the streamed bubble,
1358
+ Stop from the room, and the ledger are sessions 2, 3 and 4 of the brief.
1359
+ Channels are phase 2 (ruling 2A). See `brief 25`.
1360
+
1361
+ ## Worktree op receipts
1362
+
1363
+ `opResult`'s `result` on the `run_ops` row is the merge and delete receipt,
1364
+ in the same `{ok, detail}` shape this section describes below. `relaunch`'s
1365
+ receipt works the same way (see "Signal files" above).
1366
+
1367
+ Kept below: the SHAPE of each op's outcome, named for the file it once
1368
+ travelled as - separate from the session's own result, so a merge outcome
1369
+ never overwrites what the session did:
1370
+
1371
+ | op | what it records |
1372
+ | --- | --- |
1373
+ | merge | outcome of a merge operation |
1374
+ | delete | outcome of a delete operation |
1375
+ | relaunch | outcome of a dev server relaunch: the pid and port, or the refusal |
1376
+
1377
+ The app reads the op's result to know when it is done and whether it
1378
+ succeeded. `ok: true` means success; `ok: false` means failure, and `detail`
1379
+ carries why.
1380
+
1381
+ ## Worktree summary
1382
+
1383
+ Added 2026-08-30. The ruling on `q-mtf66we4`: "One summary line: branch,
1384
+ commits, files touched."
1385
+
1386
+ `worktreeSummary` in `bridge/lib/worktree.mjs` produces
1387
+ `{ branch, commits, files, uncommitted, line }`. `files` counts every file the
1388
+ branch touched, committed or not; `uncommitted` is how many are not committed
1389
+ yet (#482). The runner writes it into the session
1390
+ sidecar (`.session.json`) for every kept worktree, not just persistent ones.
1391
+ The app shows the summary line in the session frame before the merge/delete
1392
+ buttons.
1393
+
1394
+ ### The sidecar is written TWICE, 2026-09-08
1395
+
1396
+ The write above happens after the child exits, because the summary counts
1397
+ commits. That made `worktreePath` - the only thing that tells the app where a
1398
+ session works - arrive only once the run was over, so "Where it is working"
1399
+ was empty for the whole life of every run. Evidence: `spawn-mtrrmcux-513704`,
1400
+ running for hours in
1401
+ `.claude/worktrees/glytch-investigate-...-mtrrmcux-513704`, with no
1402
+ `.session.json` in `inbox/spawn/` at all.
1403
+
1404
+ So the runner now writes a FIRST sidecar as soon as the worktree is minted or
1405
+ reused, before the child spawns, carrying `worktreePath` and `fromId` and
1406
+ nothing else. The end-of-run write replaces it whole; nothing is carried
1407
+ forward. It is written **only for a tree that really is one** (`isolated` and
1408
+ a `path`): a run that fell back to the linked checkout has no worktree, and
1409
+ recording the checkout under `worktreePath` would put Merge and Delete over
1410
+ the linked folder itself.
1411
+
1412
+ The app's side of the same gap is in `src/store/actions/folder.ts`: the
1413
+ running branch of `refreshSessionPersistence` pushed a patch only when the
1414
+ sidecar carried a `worktreeSummary`, so a path-only sidecar was read and
1415
+ discarded on every tick.
1416
+
1417
+ ## Orphan reaper
1418
+
1419
+ Added 2026-08-30. `reapOrphanedWorktreeProcesses` in `bridge/lib/worktree.mjs`
1420
+ finds processes whose command line references a `.claude/worktrees/<name>` path
1421
+ that no longer exists on disk and kills them. Called at spawn/teardown time,
1422
+ never as a daemon (ruling `q-mt9as5a0`: no reaper daemon).
1423
+
1424
+ **Corrected 2026-09-02: it WAS a daemon, and this paragraph was the only place
1425
+ that said otherwise.** `watch`'s loop called both the reaper and the sidecar
1426
+ backfill on every five-second pass, for the life of the runner - `ps -eo
1427
+ pid,args` over every process on the machine plus two `git` processes per stale
1428
+ sidecar, twelve times a minute, learning the same nothing each time. A doc and
1429
+ a loop disagreeing about whether something is a daemon is settled by the loop,
1430
+ so the loop moved.
1431
+
1432
+ `housekeeping(root, log, { reap, backfill })` in `bridge/lib/runner.mjs` now
1433
+ holds both, and it runs at the three moments a sweep can find anything:
1434
+
1435
+ | when | reap | backfill | why |
1436
+ | --- | --- | --- | --- |
1437
+ | the watcher starts | yes | yes | whatever a previous runner left behind |
1438
+ | 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 |
1439
+ | a session tears down | yes | yes | its tree may have just gone |
1440
+
1441
+ The backfill also **remembers what it tried**. A sidecar whose worktree is
1442
+ missing is skipped on a `stat` rather than on two `git` processes, and one
1443
+ whose git call produced no summary is recorded and never asked again for the
1444
+ life of the process. In memory rather than on disk: the fact is about this
1445
+ process's attempts, not about the session, and writing "could not summarise"
1446
+ into the sidecar would put a second meaning into a file the app reads.
1447
+ `bridge/test/housekeeping.test.mjs` asserts a poll pass does not sweep and
1448
+ that `housekeeping` called directly still does.
1449
+
1450
+ ## Attempts log
1451
+
1452
+ Added 2026-08-30. When the runner composes a FRESH child's prompt (new session,
1453
+ new worktree - NOT a resume, which carries its own conversation memory), it
1454
+ looks up previous requests with the same `fromId` (the idea) whose
1455
+ receipts were errors, and appends a 'Previous attempts' section to the prompt.
1456
+
1457
+ The section includes:
1458
+
1459
+ - Attempt number (chronological, oldest first when displaying)
1460
+ - Request ID
1461
+ - What it tried: closing summary from the log tail, or the receipt reason when
1462
+ the log is unavailable
1463
+
1464
+ **Bounds** (borrowed deliberately from tpm-flow's `attempts.ts`):
1465
+
1466
+ - ~700 characters per attempt
1467
+ - ~6000 characters for the whole section
1468
+ - Oldest attempts dropped first when the cap is reached
1469
+ - When something is dropped, the section SAYS so with a count
1470
+
1471
+ A silently truncated history reads as a complete one, which is why the omission
1472
+ notice is mandatory. Summaries only, never a previous prompt: feeding attempt
1473
+ N-1's prompt into attempt N doubles the prompt every re-run.
1474
+
1475
+ **What stays unchanged:**
1476
+
1477
+ - Read-only over other sessions' files: never edit a receipt or log
1478
+ - Exactly-once stays untouched: this changes what a NEW request is told, never
1479
+ retries anything
1480
+ - Composed at spawn time from disk, no model call
1481
+ - Resume requests see no section: they carry their own conversation memory
1482
+
1483
+ The history a re-run needs is already on disk in `.glytch/inbox/spawn/`: earlier
1484
+ requests carry `fromId`, their `.result.txt` receipts carry the failure reason,
1485
+ and their `.log` tails carry the session's own closing summary.
1486
+
1487
+ ## Repository primer
1488
+
1489
+ Added 2026-08-30. When the runner composes a FRESH child's prompt (new session,
1490
+ new worktree - NOT a resume), it prepends a compact repo map showing the file
1491
+ tree plus top-level exported symbols per source file. Built to reduce cache
1492
+ re-reads: the 3-day spend audit showed 69% of Opus cost was cache re-reads
1493
+ across 100+ turn sessions, and a big share of those turns is rediscovering the
1494
+ tree through `grep` and `find`.
1495
+
1496
+ **Implementation** (`bridge/lib/repomap.mjs`):
1497
+
1498
+ - Walks the repo from its root, skipping `node_modules`, `.git`, `dist`,
1499
+ `build`, `coverage`, `canvas`, `inbox`, and dotdirs
1500
+ - Emits the file tree (always included)
1501
+ - Extracts top-level exported symbols (functions, classes, consts) from source
1502
+ files (`.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.jsx`) using regex
1503
+ - Deliberately regex-based (not tree-sitter): fast, deterministic, dependency-free,
1504
+ good enough. Reference: tpm-flow's `server/src/repomap.ts` (idea, bounds,
1505
+ regex on purpose); reimplemented here, license boundary matters
1506
+ - Caps the whole map at ~10KB; past the cap, keeps the tree and drops symbol
1507
+ detail deepest-first (files furthest from root lose detail first)
1508
+ - When detail is dropped, the map SAYS so with a count
1509
+
1510
+ **Caching:**
1511
+
1512
+ - Cached keyed to HEAD sha in `inbox/spawn/.repomap-{sha}.txt`
1513
+ - Regenerate when HEAD moved, reuse otherwise
1514
+ - Cache miss rebuilds, cache write is best-effort (doesn't fail the spawn)
1515
+
1516
+ **Bounds:**
1517
+
1518
+ - Primer + attempts section together must not exceed ~16KB
1519
+ - Primer is trimmed first when the combined size exceeds the bound
1520
+ - If less than 500 chars remain for the primer after accounting for attempts,
1521
+ no primer is included
1522
+ - Truncation includes a notice so a truncated primer doesn't read as complete
1523
+
1524
+ **What stays unchanged:**
1525
+
1526
+ - Fresh sessions only: resume requests get nothing (their context already knows
1527
+ the tree through conversation memory)
1528
+ - Composed at spawn time from disk, no model call
1529
+ - Appended after the preamble, before the user's prompt and attempts section
1530
+ - The primer says it may be stale versus the session's working branch (HEAD
1531
+ points to main, session works in a worktree on a branch)
1532
+
1533
+ ## The conversation on an idea
1534
+
1535
+ Brief 52, 2026-10-04. A note a person writes on an idea starts a coding
1536
+ session; the session answers on that same idea; the computer restarting does
1537
+ not lose it. Four pieces, all in the bridge.
1538
+
1539
+ **The request's `note` and `agentId` are not the runner's to touch.** The app
1540
+ puts `note: {noteId, text}` and `agentId` on the request. The runner copies
1541
+ neither, strips neither and never repeats the text: the child gets the prompt
1542
+ once, ahead of which the output contract (`preambleFor` in `lib/agents.mjs`)
1543
+ sits. `bridge/test/runner.test.mjs` has the test.
1544
+
1545
+ **Who is speaking.** A message that reaches a live session arrives as
1546
+ `[message from <name>] <text>`, so the session can tell the person from a
1547
+ teammate. The contract tells the session to answer through `propose` with an
1548
+ `addComment` op and no `id` (it lands as its reply on its own idea), to attach
1549
+ pictures with `images` (up to 6, data URLs) and to choose between looks with
1550
+ `ask_user` and `images`. It also tells the session not to file a closing reply
1551
+ of its own, because the runner files one.
1552
+
1553
+ **One reply at the end, for a session that finished.** When a session
1554
+ finishes (the verdict is `finished`, or a Codex run exits 0),
1555
+ `reportOutcomeOnBoard` posts exactly one `addComment` on the idea, with
1556
+ `agentId`, `runId` (the session's own key), `summary` (first sentence of the
1557
+ closing text, at most 200 characters), `images` (at most 6), and
1558
+ `branch`, `files`, `preview`, `report` for the links. Its text is the closing
1559
+ text of the session (the tail, at most 2000 characters), then a single figures
1560
+ line led by "Finished": "Finished; 12 turns, 4m, branch x, 3 files". It
1561
+ never carries a cost (#281). A stop, an error, a stall, a timeout and a binary that would not start
1562
+ write NO comment from the bridge: the app writes those endings itself from the
1563
+ row (`src/store/runs/sessionReplies.ts`), so a second one would double it.
1564
+ Comments the bridge used to write on those paths are gone.
1565
+
1566
+ **Carry on after a restart.** `lib/restartPass.mjs`. When the runner starts,
1567
+ and once more seven minutes later, it lists this folder's rows that the last
1568
+ process left behind and carries each on once:
1569
+
1570
+ - Which rows: idea-bound, not a chat, not a plan, not already flagged
1571
+ `sidecar.restarted`, not run by this process, and either `running` with a
1572
+ lease that was not renewed in the last four minutes, or `error` with a
1573
+ `recovery_reason` and no result when a person's note started it, whatever
1574
+ its age (other reaped rows stay the app's). The second look
1575
+ exists because a lease only runs out five minutes after the process died.
1576
+ - A Claude Code session with its worktree kept and a conversation: the row is
1577
+ settled as an error that says why (a `resume` needs a finished parent), then
1578
+ ONE `resume` (with `unlessStopped`, so a Stop that landed after the listing
1579
+ wins) files a child that continues the same conversation in the same
1580
+ worktree with "Your computer restarted while you were working. Carry on from
1581
+ where you were." The child keeps `agentId` and `note`, so its closing reply is
1582
+ signed and keyed like the first. The `resume` op carries the parent's
1583
+ `sidecar.worktreePath` into the child's request; without it the per-idea lock
1584
+ would refuse the child as a stranger.
1585
+ - A session that cannot resume (Codex, opencode, or a failed `resume`): the row
1586
+ is settled the same way and ONE reply lands on the idea, "Your computer
1587
+ restarted during this session, so it stopped.", with the
1588
+ branch and file count. The branch is kept.
1589
+ - A Claude Code session whose worktree is gone is left alone for the reaper.
1590
+ - ONE ATTEMPT. The settle writes `sidecar.restarted: true` first and `result`
1591
+ is write-once, so of two runners racing for a row exactly one wins and goes
1592
+ on. The winner records `sidecar.restartedTo`. A flagged row is never a
1593
+ candidate again. The app must not write its own ending reply for a row with
1594
+ `sidecar.restarted`.
1595
+
1596
+ `bridge/test/restartPass.test.mjs` runs the pass against the fake binary.
1597
+
1598
+ ## Verdict and cost
1599
+
1600
+ Added 2026-09-01. The runner now tracks the full result envelope (not just two
1601
+ booleans) and surfaces turn count, time and context fill in the receipt. The
1602
+ cost is kept in the sidecar for the run's record, and no receipt or reply
1603
+ shows it (#281).
1604
+
1605
+ **Envelope tracking:** `runChild` and `runStreamingChild` scan stdout for
1606
+ result envelopes (`{"type": "result", ...}`) and keep the LAST one parsed as
1607
+ `outcome.envelope`. Context fill is tracked separately: the last assistant
1608
+ event's `usage` (ignoring events with `parent_tool_use_id`, so subagent turns
1609
+ cannot overwrite the lead's fill). Fill = `input_tokens` +
1610
+ `cache_read_input_tokens` + `cache_creation_input_tokens`, exposed as
1611
+ `outcome.contextFill`.
1612
+
1613
+ **Verdict line:** one-line summary computed from the envelope and outcome.
1614
+ Format: `finished` / `ended with an error result` / `stopped responding` /
1615
+ `killed at wall clock` / `exited N`, followed by figures from the envelope when
1616
+ present: `41 turns, 38m, context 212k`.
1617
+
1618
+ **Where it lands:**
1619
+
1620
+ - `reportOutcomeOnBoard` writes the one closing reply on the run's idea, and
1621
+ only for a session that finished (see "The conversation on an idea"). Its
1622
+ figures line reads "Finished;" then turns and time, without context or
1623
+ cost. The words above stay exactly as they are in the row's
1624
+ `result`: the app parses them (`verdictOf`, `resultTail`).
1625
+ - `describeOutcome` puts the verdict line at the start of the receipt text.
1626
+ - `writeSessionSidecar` gains `costUsd`, `numTurns`, `contextFill` fields so
1627
+ `spendSince` and other readers can use them when present.
1628
+
1629
+ No new model calls, no new file shapes beyond sidecar fields.
1630
+
1631
+ ## Stall watchdog
1632
+
1633
+ Added 2026-09-01. A streaming session (`runStreamingChild` only) that goes
1634
+ silent for `stallMs` (default 15 minutes) with a turn open is killed with
1635
+ SIGTERM then SIGKILL. The watchdog is armed only when the MODEL should be
1636
+ producing output: a turn is open (no result envelope since the last user
1637
+ message) AND no `tool_use` is awaiting its `tool_result` (a running tool is
1638
+ allowed to be silent; the wall clock covers a hung tool).
1639
+
1640
+ **How it works:** The stream-json scanner tracks `tool_use` blocks (by id) and
1641
+ their `tool_result` events. A `tool_use` opens, a `tool_result` closes. Tool
1642
+ results arrive as USER events (`{"type":"user","message":{"content":[{"type":"tool_result",...}]}}`),
1643
+ and that is what re-arms the watchdog. The stall timer checks every `stallMs / 4`
1644
+ whether it is armed (turn open, no open tools) and whether `Date.now() - lastStdoutAt >= stallMs`.
1645
+ If both, it kills the child. Sending a new user message resets `lastStdoutAt` (a new turn starts).
1646
+
1647
+ **Outcome:** `stalled: true` and `stallMs` when it fires. Receipt and frame say
1648
+ "stopped responding" with the silence duration. It is not a success and not a
1649
+ timeout.
1650
+
1651
+ **Settings:** `PM_SPAWN_STALL_MS` sets the timeout (default 900000, 15 minutes).
1652
+
1653
+ ## Crash retry
1654
+
1655
+ Added 2026-09-01. A crash that happens BEFORE any assistant event streamed is
1656
+ retried exactly once, inside the same wall-clock budget. SIGKILL is never
1657
+ retried (the runner itself killed it). A crash after output streamed is not
1658
+ retried (partial work would be duplicated).
1659
+
1660
+ **Exit code classification** (`bridge/lib/exitClass.mjs`): `classifyExit(code,
1661
+ signal)` distinguishes crashes (SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE,
1662
+ SIGTRAP on Unix; 0xC0000005 and 0xC00000FD on Windows) from clean exits and
1663
+ other failures. SIGKILL is classified as `isKill: true`, not a crash. Returns
1664
+ `{ isCrash, isKill, description }`.
1665
+
1666
+ **Retry logic in runRequest:** After the first run, if `exitClass.isCrash &&
1667
+ !outcome.envelope && !outcome.finished`, the runner logs "retrying once" and
1668
+ runs the child again with `maxMs` set to the remaining wall-clock budget
1669
+ (original `maxMs` minus the first run's duration). Both logs are kept:
1670
+ `outcome.log` becomes `=== First attempt (crash class) ===\n...\n\n=== Retry
1671
+ ===\n...`. The outcome carries `retried: true` and `firstCrashClass`.
1672
+
1673
+ **Receipt:** When retried, the receipt says "Retried once after [class] before
1674
+ any output" in the `where` footer.
1675
+
1676
+ ## What the tool fence stops, and what it does not
1677
+
1678
+ Added 2026-09-02, correcting a claim that stood in `lib/agents.mjs` and
1679
+ `bridge/README.md`: that a Claude session "cannot open a shell". It can.
1680
+ `CLAUDE_TOOLS` carries `Bash(git:*)`, `Bash(npm:*)`, `Bash(npx:*)` and
1681
+ `Bash(node:*)`, and `Bash(node:*)` alone is `node -e '<anything>'` - an
1682
+ arbitrary program with the session's own file access. `Write` and `Edit` are
1683
+ unscoped, so the session may write any path the process can reach.
1684
+
1685
+ | | stopped | not stopped |
1686
+ | --- | --- | --- |
1687
+ | tools | anything not on `CLAUDE_TOOLS` | everything on it |
1688
+ | interactive prompts | `EnterPlanMode`, `AskUserQuestion` | - |
1689
+ | arbitrary code | - | `node -e`, `npx <anything>` |
1690
+ | writes | the checkout you are looking at (the **worktree** does this, not the list) | any other path the process can reach |
1691
+ | money | wall clock, no retry | - |
1692
+
1693
+ **The specific hole this opens.** A child gets `PM_BOARD_ROOT` (`childEnv`)
1694
+ and has `Write`, so it can file its own `.glytch/inbox/spawn/<id>.json`
1695
+ carrying `approved: true` and have the runner that spawned it pick it up.
1696
+ `PM_SPAWN_DEPTH` refuses a session that starts its OWN runner; it does nothing
1697
+ about a session asking the runner above it.
1698
+
1699
+ **There is no cheap discriminator, and one was looked for.** Every field the
1700
+ app writes - `createdBy: "board"`, `approvedBy: "board"`, `version: 1`,
1701
+ `nodeIds` (`src/folder/spawnRequest.ts` `buildSpawnRequest`) - is a field a child with
1702
+ Write can spell exactly. Refusing requests filed *while a session is running*
1703
+ would be a real narrowing but it would break a shipped ruling: "I would like
1704
+ to operate multiple sessions at once on the board" (2026-08-28), which is
1705
+ precisely a person clicking Go on a second idea while the first is going.
1706
+ Anything stronger (an HMAC over the request, a nonce the app and the runner
1707
+ share) means app work and a shared secret in a folder both sides can read, and
1708
+ that is a bigger change than this class of risk earns while the runner is off
1709
+ by default and every real request starts with a click.
1710
+
1711
+ So it is **recorded rather than guarded**. Narrowing `CLAUDE_TOOLS` is the
1712
+ lever that actually closes it, and that is the user's ruling to make: it changes
1713
+ what a brief can do.
1714
+
1715
+ ## Asks in the idea (#478)
1716
+
1717
+ A coding run that wants something outside its list (`CLAUDE_TOOLS`, or
1718
+ what Codex runs without approval) asks the person instead of being refused
1719
+ silently. `lib/permissionAsk.mjs` owns it; `child.mjs` answers Claude's
1720
+ `control_request` and `codex.mjs` answers Codex's approval requests with it.
1721
+
1722
+ - One `ask_user` question on the run's idea: "Can I run pip install requests
1723
+ in my-app?", with Allow (recommended), Not this and Always allow in
1724
+ <folder>, and a line on what it is for.
1725
+ - The asker claims its question on the answer poll and reads the answer with
1726
+ `check_answer` itself: an answer resolves the pending request, it is never
1727
+ typed into the conversation.
1728
+ - No answer means keep waiting. Stop and the wall clock still end the run;
1729
+ the stall watchdog does not count time spent waiting for a person.
1730
+ - Always allow is remembered per folder in
1731
+ `<glytch home>/permissions/<sha256 of the folder path>.json`: a shell
1732
+ command by its first word (`Bash(pip:*)`), anything else by its tool name.
1733
+ A remembered rule is answered without asking. A chained, piped or
1734
+ redirected command is asked every time.
1735
+ - A plan draft is read-only and is never given the asker.
1736
+
1737
+ ## What a spawned session inherits
1738
+
1739
+ Added 2026-09-02. `childEnv` in `bridge/lib/runner.mjs` used to be
1740
+ `{ ...process.env }`. A spawned session is an autonomous agent with Write,
1741
+ Edit and Bash in a worktree on this machine, so that handed it every secret
1742
+ the shell that started the runner happened to be carrying - a deploy token, a
1743
+ database URL, a payment key, whatever `direnv` loaded for the project. A leak
1744
+ through that door is invisible: no error, no log line, nothing on the board.
1745
+
1746
+ It is an ALLOWLIST now, and everything on it is there for one of two stated
1747
+ reasons: a process has to be able to BE a process, and the two engines this
1748
+ runner knows have to be able to AUTHENTICATE. Anything else is dropped.
1749
+
1750
+ **Exact names.** `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`, `TMP`,
1751
+ `TEMP`, `LANG`, `TERM`, `TZ`; `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and
1752
+ their lowercase spellings, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`,
1753
+ `SSL_CERT_DIR`; `CLOUD_ML_REGION`; `CLAUDECODE`.
1754
+
1755
+ **Prefixes.** `LC_`, `PM_`, `ANTHROPIC_`, `CLAUDE_`, `AWS_`, `GOOGLE_`,
1756
+ `XDG_`, `OPENCODE_`, `OPENAI_`, `OPENROUTER_`, `FAKE_CLAUDE_`.
1757
+
1758
+ **Authentication is mostly `HOME`, and that is worth saying out loud.** Claude
1759
+ Code's subscription credentials live in `~/.claude/.credentials.json` (or the
1760
+ macOS keychain); opencode's live under `~/.local/share/opencode/auth.json`.
1761
+ Neither needs a key in the environment to work. The variables above are for
1762
+ the people who set one instead: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`,
1763
+ `CLAUDE_CODE_OAUTH_TOKEN`, `AWS_*` for Bedrock, `GOOGLE_APPLICATION_CREDENTIALS`
1764
+ plus `CLOUD_ML_REGION` for Vertex, `OPENAI_API_KEY` / `OPENROUTER_API_KEY` for
1765
+ opencode. Every one of those names was read out of the shipped binaries on
1766
+ 2026-09-02 (`strings` on `claude` 2.1.x and on `opencode`), not assumed.
1767
+
1768
+ `FAKE_CLAUDE_` is on the list because the fake binary is a shipped seam, not a
1769
+ test detail: `PM_CLAUDE_BIN` is a row in the money table above, and
1770
+ `test/fixtures/fake-claude.mjs` is driven entirely by `FAKE_CLAUDE_*`. Drop
1771
+ them and the crash, timeout and runaway paths lose their only tests.
1772
+
1773
+ **`PM_SPAWN_ENV_ALLOW` is the escape hatch**, because opencode ships around
1774
+ two hundred `*_API_KEY` provider names and enumerating them here would make
1775
+ this list a lie. It is a comma-separated list of variable names, and a
1776
+ trailing `*` makes one a prefix:
1777
+
1778
+ ```bash
1779
+ PM_SPAWN_ENV_ALLOW='CEREBRAS_API_KEY,MYCO_*' PM_SPAWN_RUNNER=on node bridge/spawn-runner.mjs watch
1780
+ ```
1781
+
1782
+ The three doors `childEnv` closes on purpose are closed AFTER the allowlist
1783
+ runs, so the `PM_` prefix does not quietly re-open them: `PM_SPAWN_RUNNER` is
1784
+ deleted (a spawned session must not be able to switch a runner on),
1785
+ `PM_SPAWN_DEPTH` is set rather than inherited, and `PM_SESSION_FROM` is
1786
+ dropped unless this run has an idea of its own (a stale idea is worse than
1787
+ no idea). `bridge/test/childenv.test.mjs` asserts both ends of the list.
1788
+
1789
+ ## Per-repo worktree lock
1790
+
1791
+ Added 2026-09-01. Git mutations on the same repo root run one at a time,
1792
+ serialized by an in-process lock. Two sessions finishing concurrently can no
1793
+ longer both `git checkout main`, pull, and merge at the same time.
1794
+
1795
+ **How it works:** A promise chain keyed by repo root (normalized with
1796
+ `resolve()`). Acquiring the lock means waiting for the existing chain and
1797
+ setting a new promise; releasing resolves that promise so the next waiter can
1798
+ proceed.
1799
+
1800
+ **Amended 2026-09-02: it is two locks, because there are two ways to collide.**
1801
+ This section said "one runner process owns a root (claim files keep two runners
1802
+ apart), so an in-process lock is enough", and the code beside it disagreed:
1803
+ `spawn-runner.mjs merge <path>` and `spawn-runner.mjs delete-worktree <path>`
1804
+ are a SECOND process, typed by a person, calling straight into `mergeWorktree`
1805
+ while a watcher is running. Claim files keep two runners off the same REQUEST;
1806
+ they say nothing about two processes running `git checkout main` in the same
1807
+ second.
1808
+
1809
+ So `withRepoLock` also takes a **lockdir**, `mkdir` being the one atomic
1810
+ exclusive create every filesystem agrees on - the same reasoning the claim file
1811
+ uses `flag: "wx"`. It lives at `<root>/.git/pm-repo.lock`, and that location is
1812
+ load-bearing rather than tidy: a lock `git status` can see would read as the
1813
+ person's own change, and Keep sets those aside and puts them back.
1814
+ `.git/` is the one directory git never reports, it is exactly per-repo, and it
1815
+ is already where git keeps its own locks.
1816
+
1817
+ An `owner.json` inside it records pid, host and time, which is how the lock
1818
+ survives its holder being killed: a pid that is gone **on this host** frees it
1819
+ at once, a lock from another host frees on age (`LOCK_STALE_MS`, 10 minutes).
1820
+ Waiting is bounded (`LOCK_WAIT_MS`, 60s) and losing the wait is not fatal - it
1821
+ logs loudly and goes ahead, because refusing to clean up a worktree is worse
1822
+ than the race that was there all along. A `.git` that is a file (the root is
1823
+ itself a linked worktree) or a read-only checkout takes the same path.
1824
+
1825
+ **No `git pull`.** `mergeWorktreeImpl` used to run `git pull --ff-only` on the
1826
+ user's main before merging, best-effort and unexplained. Removed 2026-09-02: it
1827
+ is a network operation on his main branch that nobody asked for. It moves main
1828
+ under him, brings down commits he has not looked at, can prompt for credentials
1829
+ with nobody watching, and hangs on a slow or absent remote while a button on
1830
+ his board spins. Merging is local work on local branches - the same reasoning
1831
+ the code already gave for not pushing.
1832
+
1833
+ **What is locked:** `makeWorktree`, `cleanWorktree`, `reuseWorktree`,
1834
+ `mergeWorktree`, `deleteWorktree`. Each wraps its implementation (`*Impl`) with
1835
+ `withRepoLock(root, fn)`.
1836
+
1837
+ **What stays unlocked:** Reads (`branchOfWorktree`, `worktreeSummary`) and
1838
+ the runner's `activeWorktreeForPromptRows` admission check do not mutate git
1839
+ state and run without the lock.
1840
+
1841
+ **Test:** `bridge/test/worktree.test.mjs` proves two concurrent `mergeWorktree`
1842
+ calls on the same root serialize (order preserved, second starts after first
1843
+ completes, both commits land in main).
1844
+
1845
+ ## The ACP engine
1846
+
1847
+ Added 2026-09-02 (brief 15 decision 8, on the amendment written by the spike
1848
+ in `spikes/acp-2026-09-02/`). Checked against the code in this commit.
1849
+
1850
+ `acp-claude` is a third engine in `bridge/lib/agents.mjs`, labelled "Claude
1851
+ Code via ACP". It runs `@agentclientprotocol/claude-agent-acp` 0.73.0 over
1852
+ stdio with `@agentclientprotocol/sdk` 1.4.0 - the versions the spike pinned,
1853
+ now the two dependencies in `bridge/package.json`. **`claude` is untouched and
1854
+ is still the default.** That is a ruling, not an accident: ACP gives no way to
1855
+ SET a spend ceiling and reports no dollar FIGURE, and the `claude` engine has
1856
+ both. Beside, permanently - not a migration step.
1857
+
1858
+ `bridge/lib/acp.mjs` owns the protocol. Loading it costs nothing on a checkout
1859
+ that never installed the adapter: the SDK is a dynamic `import()` reached only
1860
+ when an ACP session starts, so every other engine and every other test runs on
1861
+ a bare clone.
1862
+
1863
+ ### What maps onto what
1864
+
1865
+ The whole design is that `runRequest`, the live log, the stall watchdog, the
1866
+ receipt and the frame need **no branch** for this engine. `acp.mjs` runs the
1867
+ adapter, speaks JSON-RPC on its real stdio, and writes *stream-json lines* into
1868
+ a stream that `attachChild` reads exactly as it reads a Claude child's stdout.
1869
+
1870
+ | ACP | stream-json line the runner already understands |
1871
+ |---|---|
1872
+ | `session/update` `agent_message_chunk` | `{"type":"assistant", message.content:[{type:"text"}]}` |
1873
+ | `session/update` `tool_call` (pending) | `{"type":"assistant", message.content:[{type:"tool_use", id}]}` |
1874
+ | `session/update` `tool_call_update` (`completed`/`failed`) | `{"type":"user", message.content:[{type:"tool_result", tool_use_id}]}` |
1875
+ | `session/update` `usage_update` `{used, size}` | `{"type":"assistant", message.usage.input_tokens}` → `outcome.contextFill` |
1876
+ | `session/prompt` response | `{"type":"result", is_error, total_cost_usd, num_turns, duration_ms}` |
1877
+ | `session/new` response | `{"type":"system", subtype:"init", session_id}` → the sidecar's id |
1878
+
1879
+ Two details that are load-bearing rather than cosmetic:
1880
+
1881
+ - **A tool_use line carries no `usage`.** The scanner in `child.mjs` is an
1882
+ if/else-if chain in which a usage field wins over a content field, so an
1883
+ assistant event carrying both records the usage and never sees the
1884
+ `tool_use` - and the stall watchdog would never arm. The two facts travel as
1885
+ two lines.
1886
+ - **Only a *terminal* `tool_call_update` closes the tool.** The spike's capture
1887
+ shows four updates for one call; closing on the first would disarm the
1888
+ watchdog while the tool was still running, which is the exact condition it
1889
+ exists to distinguish.
1890
+
1891
+ `available_commands_update`, `current_mode_update` and the `_meta.claudeCode.*`
1892
+ side channels are deliberately not written at all: the log is capped and the
1893
+ frame draws it.
1894
+
1895
+ ### The rest of the mapping
1896
+
1897
+ - **Board tools.** Every engine reaches the board over the HOSTED endpoint
1898
+ (`supabase/functions/board-mcp`), authenticated by `lib/auth.mjs`'s
1899
+ `hostedToken()`/`hostedConfig()` (a `glytch login` credential, or
1900
+ `GLYTCH_BOARD_TOKEN` for a script), resolved ONCE per request in
1901
+ `runRequest` so a token refresh mid-request cannot leave one engine's
1902
+ wiring disagreeing with another's. `acpServersFor` still builds what
1903
+ `session/new`'s `mcpServers` carries, inline rather than through a file -
1904
+ that half is unchanged - but the entry is now `{type: "http", name:
1905
+ "glytch", url, headers: [{name: "Authorization", value: "Bearer
1906
+ <token>"}, {name: "X-Glytch-Session", value: "<session.key>"}]}` (RENAMED
1907
+ 2026-09-12, brief 31, Move 3b: the key was `"board"` until then), the
1908
+ `McpServer::Http` shape checked against `@agentclientprotocol/sdk@1.4.0`'s
1909
+ own schema. `claude` gets the same URL and headers through
1910
+ `--strict-mcp-config --mcp-config '<json>'` (VERIFIED against the
1911
+ installed 2.1.34: `claude mcp add --transport http` writes exactly this
1912
+ JSON shape into a real `.mcp.json`) - `--strict-mcp-config` means any
1913
+ committed `.mcp.json` naming a different server is ignored outright, not
1914
+ merely superseded. `codex` gets a `-c mcp_servers.board={url
1915
+ = ..., bearer_token_env_var = "PM_BOARD_TOKEN", http_headers =
1916
+ {"X-Glytch-Session" = ...}}` override (`codexBoardArgs`, `lib/codex.mjs`) -
1917
+ the bearer is a NAME, never a literal, on that one engine's command line,
1918
+ because `codex mcp add --help` and the installed binary's own strings both
1919
+ say a literal `bearer_token` is refused. `opencode` gets
1920
+ `OPENCODE_CONFIG_CONTENT` (an env var, confirmed present in the installed
1921
+ 1.18.23 binary) carrying a `type: "remote"` entry, in place of the
1922
+ `opencode.json` `prepareWorkspace` used to write into the worktree - a
1923
+ bearer token is not a thing to leave sitting in a file for as long as a
1924
+ kept worktree lives. The bearer token's own lifetime is the one thing that
1925
+ needed a NEW decision rather than a rewiring: see `ACCESS_TTL_MS` /
1926
+ `RUNNER_ACCESS_TTL_MS` in `supabase/functions/board-oauth/oauth.mjs`.
1927
+ The **X-Glytch-Session** header is new on the endpoint side too
1928
+ (`supabase/functions/board-mcp/sessionKeyHeader.mjs`): it lets many
1929
+ children spawned under ONE hosted OAuth token each keep their own
1930
+ identity for `list_sessions`/`send_message`, and land their `report()`
1931
+ under the exact run key their receipt already carries.
1932
+ **X-Glytch-Board** rides beside it (2026-09-21, board reporting fix): the
1933
+ claimed run row always carries `board_id` (`src/runs/api.ts`'s
1934
+ `RunRequestRow`), `runRequest` reads it off `run.board_id` and passes it
1935
+ through every engine's board wiring the same way `sessionKey` travels -
1936
+ `acpServersFor`, the headless `claude` engine's `--mcp-config`,
1937
+ `prepareWorkspace`, `codexBoardArgs` (one shared `http_headers` table,
1938
+ since a second one would overwrite rather than merge). Without it,
1939
+ `board-mcp/tools.mjs`'s `boardFor` falls back to `lastBoardId`, unset
1940
+ until the session's first `get_board` - so a session whose first call is
1941
+ `report` used to land on the account's most recently touched board, not
1942
+ the one the prompt actually lived on. `index.ts` reads the header the same
1943
+ way it reads `X-Glytch-Session` (`boardHeader.mjs`) and seeds
1944
+ `lastBoardId` with it before any tool runs. `run.board_id` is the board's
1945
+ LOCAL id ("default", "bd-mua2gtcm"), not the `<account uuid>:<local id>`
1946
+ row id every board is actually keyed by, so the header value may be that
1947
+ local id alone - `tools.mjs`'s `readBoardById` prefixes it with the
1948
+ token's own account before matching a row, falling back to the bare value
1949
+ when that prefixed id is not this account's.
1950
+ - **Permissions.** `session/request_permission` is answered by the client from
1951
+ `CLAUDE_TOOLS` - the same list `--allowedTools` gets, one list at a second
1952
+ door. Matching is exact name, MCP namespace (`mcp__glytch` covers
1953
+ `mcp__glytch__report`; renamed from `mcp__board` 2026-09-12, brief 31,
1954
+ Move 3b), or command prefix (`Bash(git:*)` against
1955
+ `rawInput.command`). Anything else, including a rule the matcher cannot
1956
+ parse, is `reject_once`. **Never `allow_always`**: "always" outlives the
1957
+ decision that justified it. Each decision is written into the log as a
1958
+ `{"type":"system","subtype":"permission"}` line.
1959
+ - **`CLAUDE_DISALLOWED_TOOLS` has no equivalent and needs none.** Plan mode is
1960
+ a session *mode* on this engine, and `acp.mjs` selects `acceptEdits` and
1961
+ never `plan`.
1962
+ - **Model and effort.** The model goes through
1963
+ `session/set_config_option {sessionId, configId:"model", value}` - **not**
1964
+ `session/set_session_config_option`, which this adapter answers `-32601`
1965
+ (both attempts are in the spike's `capture/claude.probe.jsonl`). An option
1966
+ the adapter does not offer is skipped rather than sent and failed. There is
1967
+ no effort option at all on 0.73.0, so `commandFor` records effort as
1968
+ ignored, exactly as it does for `claude`.
1969
+ - **Replies into a live session.** A persistent request keeps the session open
1970
+ after its turn and delivers each reply file as a **second `session/prompt`
1971
+ into the same `sessionId`**. `acceptsMessages` now answers true for
1972
+ `acp-claude` as well as `claude`. Prompts are queued and sent one at a time:
1973
+ the adapter advertises `promptQueueing`, but nobody has watched it work.
1974
+ - **Stop.** `stopCheck` sends `session/cancel` first and *then* returns true, so
1975
+ `attachChild`'s SIGTERM/SIGKILL is the backstop rather than the mechanism.
1976
+ - **The stall watchdog and the wall clock** are byte for byte the rules
1977
+ `runStreamingChild` uses, because the events they read are now the same
1978
+ events. A finished stream is still finished: the wall clock closing an idle
1979
+ session is not a timeout.
1980
+ - **`CLAUDE_CODE_EXECUTABLE`.** The adapter ships its **own** Claude Code
1981
+ (2.1.257 against the 2.1.34 on this machine), so every run is pinned to a
1982
+ chosen binary: an explicit `CLAUDE_CODE_EXECUTABLE`, else `PM_CLAUDE_BIN`,
1983
+ else `claude` on `PATH`. Two Claude Codes on one box, one of them invisible,
1984
+ is how a session behaves differently for a reason nobody can see - and every
1985
+ bound in this bridge was checked against 2.1.34. When none resolves the
1986
+ adapter's bundled copy runs and the outcome says so.
1987
+
1988
+ ### What is an estimate
1989
+
1990
+ **Everything about the money.** ACP reports token counts and no dollars: the
1991
+ `session/prompt` response carries `usage` and `_meta.quota.model_usage`, and
1992
+ the schema's optional `cost {amount, currency}` on `usage_update` is not sent
1993
+ by this adapter. The bounds on this engine are the wall clock and the stall
1994
+ watchdog, as on every engine (#364).
1995
+
1996
+ So the figure is computed: tokens × `ACP_PRICES` in `acp.mjs`, a dated table
1997
+ (checked 2026-09-02 against published list prices per million tokens; cache
1998
+ write at the documented 1.25× of input, cache read at 0.1×). It is labelled
1999
+ wherever it is stored, and no receipt prints it (#281):
2000
+
2001
+ - the envelope carries `cost_source: "estimate"`;
2002
+ - the session sidecar carries `costSource: "estimate"` beside `costUsd`.
2003
+
2004
+ Three things about the number worth stating plainly. It is **cumulative over
2005
+ the session**, not per turn, because the runner keeps only the last envelope
2006
+ and that is what the receipt records. An **unknown model is priced at Opus
2007
+ rates**. And a session on a Claude
2008
+ subscription - which is how everything on this machine runs - is not billed
2009
+ per token at all, so the estimate answers "what would this have cost on the
2010
+ API", which is the only comparable number.
2011
+
2012
+ ### What is untested
2013
+
2014
+ - **Resuming an ACP session.** `loadSession: true` is advertised and
2015
+ `session/load` is *tried* when a request carries `resumeSessionId`, falling
2016
+ back to a fresh session on failure (the fallback is tested; a successful
2017
+ load is not). Nobody has resumed a real ACP session.
2018
+ - **Mid-turn steering.** `_meta.steering.supported` and
2019
+ `claudeCode.promptQueueing` are declared by the adapter and exercised by
2020
+ nothing. Replies are queued between turns instead.
2021
+ - **Codex, opencode, and every other adapter.** This engine is Claude Code
2022
+ only. The spike got `-32000 Authentication required` from Codex's
2023
+ `session/new` and stopped there, so nothing below Codex's handshake is
2024
+ known. `acp.mjs` refuses with a clear message when an adapter offers auth
2025
+ methods and `session/new` fails - it never logs anything in.
2026
+ - **Images on a real run.** Data-URL images are sent as ACP `image` content
2027
+ blocks (the adapter advertises `promptCapabilities.image`), and the one-shot
2028
+ temp-file path in `runRequest` correctly skips this engine because its
2029
+ prompt is not an argument. Neither half has been run with a real image.
2030
+ - **The permission door on a real adapter.** Answered from the allowlist in
2031
+ the spike (a stub MCP tool, allowed, turn continued) and covered both ways
2032
+ against the fake adapter here. The two real prompts run for this commit
2033
+ called no tools.
2034
+ - **`runRequest` end to end on this engine.** The two real prompts drove
2035
+ `runAcpChild` through `commandFor` with the runner's own options, not
2036
+ through the whole claim → frame → receipt path, because that path spends a
2037
+ session per attempt.
2038
+
2039
+ ### Tests
2040
+
2041
+ `bridge/test/acp.test.mjs`, against `bridge/test/fixtures/fake-acp-adapter.mjs`
2042
+ - a small node program speaking enough JSON-RPC to be indistinguishable from
2043
+ the real adapter for the methods `acp.mjs` calls. It covers the handshake,
2044
+ model/mode config, the board server reaching `session/new`, a streamed turn
2045
+ with a tool call whose permission is allowed from the allowlist, a rejected
2046
+ permission, a second prompt into the same session delivered as a reply file,
2047
+ `session/cancel` on Stop, the stall watchdog, an auth wall, a failed resume, a
2048
+ missing adapter, the token-to-cost estimate, and the translation itself.
2049
+ Nothing here spends money; the two real prompts that proved the engine end to
2050
+ end are recorded in the commit body.
2051
+
2052
+ ## Editable plans and explicit implementation approval (2026-09-08)
2053
+
2054
+ A prompt can opt into **Plan first, then ask me to approve**. Existing runs
2055
+ without this option keep their previous behavior. The runner advertises
2056
+ `planApproval: true` in its heartbeat; the app refuses plan mode on older runners.
2057
+
2058
+ The initial request carries `planMode: "draft"`. This is currently supported
2059
+ only by Claude Code; opencode, Codex and ACP requests are refused explicitly.
2060
+ The runner offers only Read, Glob, Grep, WebSearch and WebFetch. It removes
2061
+ MCP servers, skills, hooks, Chrome integration, saved-session inheritance,
2062
+ and command-line argument overrides. This is a CLI tool-capability boundary,
2063
+ not an operating-system sandbox. The final response is the proposed plan;
2064
+ the runner completes the draft and does not start implementation. Replies to
2065
+ a draft cannot be promoted into an implementation session.
2066
+
2067
+ The draft lives with its prompt as `planReview`. The expanded run shows the
2068
+ completed plan in an editable field. Edits save without starting a session.
2069
+ **Reject plan** closes that draft. **Approve and implement** is a human-only
2070
+ store action, absent from the Board API, agent tools and structured inbox ops.
2071
+ It consumes a transient approval, checks the matching successful completed
2072
+ run, and files one fresh request. A failed write restores the draft for an
2073
+ explicit retry. Running the prompt again while plan mode is selected creates
2074
+ a new draft, never an implicit approval.
2075
+
2076
+ An implementation request carries `planMode: "execute"`, the exact edited
2077
+ `approvedPlan`, `approvedPlanFrom` (the draft request ID), and the usual
2078
+ `approvedBy: "board"` / `approvedAt` metadata. The runner independently checks
2079
+ that the source was a successful draft for the same prompt IDs before starting
2080
+ any child. The filed request captures the reviewed text and original
2081
+ prompt/context. Implementation starts in a fresh session; the reviewed plan
2082
+ stays visible in the expanded run. As with existing spawn approval files,
2083
+ the folder is a trusted local transport, not a cryptographic identity system.
2084
+
2085
+ ### Operational timing receipts
2086
+
2087
+ Settled runs retain numeric phase measurements in `sidecar.performance`: queue
2088
+ (including approval wait), setup, execution, first protocol output, cleanup and
2089
+ total duration. Existing workspace metadata remains alongside the measurements,
2090
+ including when Stop or setup failure occurs before spawning. Local
2091
+ `runner.performance` and `runner.delivery` logs distinguish execution from
2092
+ receipt acknowledgment. These contain no prompt/output text; they are not a
2093
+ central analytics export. See `docs/sentry.md` for definitions and current gaps.
2094
+
2095
+ ### Explicit computer app discovery (#1132)
2096
+
2097
+ A runner advertising `computerAppsLook: true` receives `computer-apps` operations
2098
+ on its existing heartbeat after the person presses Look in Settings. Each
2099
+ operation targets a project and request id. `computerAppsTake` claims it once;
2100
+ `computerAppsResult` writes only names, source labels, availability states and
2101
+ which sources could not be read. No prompt/model turn or app tool runs. The
2102
+ saved result is informational: every real run still performs its existing
2103
+ computer-app approval preflight. Unclaimed requests expire after ten minutes;
2104
+ a failed or interrupted look needs another press. The old result survives a
2105
+ new request, and a late answer cannot overwrite a newer request.