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