viewable-one 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
package/lib/runner.mjs ADDED
@@ -0,0 +1,1569 @@
1
+ import { serviceComputerApps, flushComputerApps } from "./computerApps.mjs";
2
+ // The spawn runner, over rows (brief 30 Move 2/2b, 2026-09-11). bridge/
3
+ // SPAWN.md is still the contract for the ENGINE half - the worktree, the
4
+ // wall clock, the resume/retry rules, the money bounds - and the brief's
5
+ // "Move 2 contract" section is the contract for everything this file reads
6
+ // and writes through instead of `.glytch/`: `bridge/lib/runnerApi.mjs`.
7
+ //
8
+ // One claimed row becomes, in this order and no other:
9
+ //
10
+ // register this machine's projects -> claim (exactly once, atomically,
11
+ // server-side) -> spawn the child, streaming its output as events ->
12
+ // persist a terminal receipt -> retry its idempotent `update` until acknowledged
13
+ //
14
+ // Claiming the hosted run row is the start receipt; no file acknowledgement.
15
+ //
16
+ // TWO STATES on the board (ruling q-mt9as5a0, unchanged): running, then
17
+ // done (or error, or stopped - the row's own `status` check constraint).
18
+ // Claimed sessions carry renewable ownership leases. Lost ownership stops
19
+ // local execution; expired leases become recoverable errors on the server.
20
+ //
21
+ // MOVE 2b (2026-09-11) FINISHES WHAT MOVE 2 LEFT: a chat-turn row now runs
22
+ // (`isChat`, the per-room lock `activeChatTurnForRoomRows`, the long-lived
23
+ // chat worktree mint - `worktree.mjs`'s `makeChatWorktree`/`resolveChatCwd`,
24
+ // unchanged); `heartbeat`'s `ops` are serviced for `kind:"relaunch"` too
25
+ // (`serviceRowOps`, reading the record off the run row's own `server`
26
+ // column) and `kind:"preview"` (`sharedPreview.mjs`'s
27
+ // `createSharedPreviewService`, tracked across beats from `watch()`'s own
28
+ // loop, since a preview - unlike the other three op kinds - is not
29
+ // one-shot).
30
+ //
31
+ // ONE DEVIATION REMAINS, confirmed rather than merely carried forward:
32
+ // `askedAndNeverReported` (the "asked N questions and reported nothing"
33
+ // line) has no rows equivalent - there is no op that answers "what did this
34
+ // run itself write to the board", only `collect_session`'s "what came
35
+ // back" - and is dropped; see `boardside.mjs`'s own header.
36
+
37
+ import { randomBytes } from "node:crypto";
38
+ import { realpathSync } from "node:fs";
39
+ import { access, mkdir, rm, stat, writeFile } from "node:fs/promises";
40
+ import { join, resolve } from "node:path";
41
+
42
+ import * as runnerApiModule from "./runnerApi.mjs";
43
+ import { callTool as hostedCallToolDefault, callRunnerTool } from "./hosted.mjs";
44
+ import { runnerMcpUrlFrom } from "./mcpjson.mjs";
45
+ import { hostedConfig, hostedToken } from "./auth.mjs";
46
+ import { folderRestoreGeneration, removeFolder, withRegistryLock } from "./registry.mjs";
47
+ import { createRowsAnswerPoll } from "./rowsAnswers.mjs";
48
+ import { createPermissionAsker } from "./permissionAsk.mjs";
49
+ import { makeRowEventStreamer } from "./rowsEvents.mjs";
50
+ import {
51
+ acceptsMessages,
52
+ agentFor,
53
+ AGENTS,
54
+ commandFor,
55
+ isCodingCli,
56
+ isPersistentRequest,
57
+ prepareWorkspace,
58
+ preambleFor,
59
+ runnerFor,
60
+ } from "./agents.mjs";
61
+ import { knownBinDirs, resolveAgentBinary } from "./detect.mjs";
62
+ import { getRepoMap, formatRepoPrimer } from "./repomap.mjs";
63
+ import { buildAttemptsSectionFromRows } from "./compose.mjs";
64
+ import { classifyExit } from "./exitClass.mjs";
65
+ import { DEFAULTS, childEnv, runChild, runStreamingChild } from "./child.mjs";
66
+ export { DEFAULTS, childEnv, runChild, runStreamingChild };
67
+ // The runner's own settings, read off the environment - runnerSettings.mjs.
68
+ export { settingsFromEnv } from "./runnerSettings.mjs";
69
+ import { BOARD_DIR } from "./paths.mjs";
70
+ import { imageAttachments, imageInspectionInstruction } from "./imageAttachments.mjs";
71
+ import { claimOne } from "./runnerAdmission.mjs";
72
+ import { checkRequest, isChatTurn, isSignIn, titleFor, wallClockFor } from "./spawn.mjs";
73
+ import { codexLogin } from "./signIn.mjs";
74
+ // Registration and presence (ticket e8 moved them out of this file).
75
+ import { presenceBody, registerAndFindProject } from "./runnerRegister.mjs";
76
+ export { registerAndFindProject };
77
+ import { commentOnAnchor, fromIdOf, formatVerdict } from "./boardside.mjs";
78
+ export { commentOnAnchor, fromIdOf, formatVerdict };
79
+ import { createRunnerPerformance } from "./runnerPerformance.mjs";
80
+ import { completeRequest } from "./runnerCompletion.mjs";
81
+ import { runCodingChecks } from "./codingChecks.mjs";
82
+ import { beforePicture, checkPage } from "./pagePictures.mjs";
83
+ import { startRunLease } from "./runnerLease.mjs";
84
+ import { liveRuns, restartPass, RESTART_RECHECK_MS } from "./restartPass.mjs";
85
+ import { flushCompletions, prepareCompletionStore, settleMeasuredRun, settleFailedRun, isMissingConversation } from "./runnerSettlement.mjs";
86
+ import { IN_PLACE_WORKPLACE, prepareRequestWorkspace } from "./runnerWorkspace.mjs";
87
+ import { chatBoardOf, makeBoardCwd } from "./glytchFolder.mjs";
88
+ import { changedFiles, defaultBranchOf, deleteWorktree, git, makeChatWorktree, mergeWorktree, resolveChatCwd, worktreeBranch, worktreePath, worktreeSummary } from "./worktree.mjs";
89
+ import { relaunchServer, readWorktreeRecord } from "./devserver.mjs";
90
+ import { SERVER_SWEEP_INTERVAL_MS, sweepDetachedServers } from "./serverSweep.mjs";
91
+ import { createSharedPreviewService } from "./sharedPreview.mjs";
92
+ import { createMemberPreviewTunnel } from "./memberPreview.mjs";
93
+ import { beatPace, claimPace, makeHeartbeatHub, makeRegistrar, REGISTER_REFRESH_MS, sleep } from "./runnerPace.mjs";
94
+ import { beatBaseMs } from "./runnerRealtime.mjs";
95
+ // COMPATIBILITY RE-EXPORTS. Nothing under this file's own rows path calls
96
+ // these any more; they stay re-exported because `bridge/test/`'s own tests
97
+ // still reach them through `runner.mjs`, exactly as before Move 2b.
98
+ import { housekeeping } from "./runnerMaintenance.mjs";
99
+ export { housekeeping };
100
+
101
+ export const newRunnerId = () => `r-${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`;
102
+
103
+ /** THE session key, minted in exactly one place - unchanged reasoning from
104
+ * the file-based runner: a run's own row id IS the key `report()`'s
105
+ * `PM_SESSION_KEY` and `X-Glytch-Session` both use. */
106
+ export const sessionKeyFor = (runId) => String(runId);
107
+
108
+ export function sessionRecord(runId, request) {
109
+ return { requestId: runId, key: sessionKeyFor(runId), title: titleFor(request) };
110
+ }
111
+
112
+ export { isMissingConversation } from "./runnerSettlement.mjs";
113
+
114
+ // ---------- the per-prompt lock, over rows ----------
115
+
116
+ const exists = async (path) => {
117
+ try {
118
+ await access(path);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ };
124
+
125
+ /** Is an unmerged worktree already serving this prompt? Admission reads
126
+ * this project's run rows, then checks the expected worktree path and the
127
+ * branch's relationship to main before treating that run as a lock. */
128
+ /** ONE PATH, WHATEVER SPELLING (round 3 item 69): Windows hands a temp dir
129
+ * out as an 8.3 short name and the app may send the long one, so the
130
+ * worktree comparisons below resolve both sides first. */
131
+ export function samePath(a, b) {
132
+ const norm = (p) => { try { return realpathSync.native(p); } catch { return resolve(p); } };
133
+ return typeof a === "string" && typeof b === "string" && a !== "" && b !== "" && norm(a) === norm(b);
134
+ }
135
+
136
+ /** Besides the lock, `trees` lists every existing worktree a run of this
137
+ * prompt made, held or not (an empty error tree, a merged one): a request
138
+ * may reuse one of ITS OWN trees only (round 3 item 67). */
139
+ export async function activeWorktreeForPromptRows(root, projectId, promptNodeId, settings, log = () => {}) {
140
+ const trees = [];
141
+ if (!promptNodeId) return { locked: false, trees };
142
+ const runnerApi = settings.runnerApi ?? runnerApiModule;
143
+ const listed = await runnerApi.runs({ projectIds: [projectId] }, { env: settings.env, fetchImpl: settings.fetchImpl });
144
+ if (!listed.ok) throw new Error(`Cannot verify prompt ownership: ${listed.error}`);
145
+ for (const run of listed.runs ?? []) {
146
+ if (fromIdOf(run.request) !== promptNodeId) continue;
147
+ const title = titleFor(run.request);
148
+ const expectedPath = worktreePath(root, run.id, title);
149
+ if (!(await exists(expectedPath))) continue;
150
+ trees.push(expectedPath);
151
+ const branch = worktreeBranch(run.id, title);
152
+ const resolved = await git(["rev-parse", "--verify", branch], root);
153
+ if (!resolved.ok) continue;
154
+ // A RUN THAT NEVER GOT PAST "COULD NOT START" HAS NOTHING TO LOSE
155
+ // (2026-09-20). `runnerCompletion.mjs` removes a spawn-failed run's
156
+ // worktree outright; this guard is the backstop for a row written before
157
+ // that fix, or a removal that failed: an ERROR run whose tree carries
158
+ // zero commits and zero changed files must not hold the NEXT request on
159
+ // this prompt hostage (spawn-mua51lhf-96f76b, "spawn claude ENOENT",
160
+ // kept a "0 commits, 0 files touched" tree and the follow-up was refused).
161
+ //
162
+ // 0/0 IS NOT "EMPTY" BY ITSELF (2026-09-21, review): `worktreeSummary`
163
+ // counts uncommitted edits too (#482), but a git read that failed also
164
+ // reads as 0, so the same porcelain check `cleanWorktreeImpl` runs
165
+ // decides before the lock goes; a porcelain check that fails to run is
166
+ // not evidence the tree is empty.
167
+ if (run.status === "error") {
168
+ const summary = await worktreeSummary(expectedPath, root).catch(() => null);
169
+ if (summary && summary.commits === 0 && summary.files === 0) {
170
+ const dirty = await git(["status", "--porcelain"], expectedPath);
171
+ const clean = dirty.ok && dirty.stdout.trim() === "";
172
+ if (clean) continue;
173
+ }
174
+ }
175
+ // SAME HELPER worktree.mjs's base-selection and merge op use
176
+ // (defaultBranchOf, 2026-09-21): a master-only repo asks about master.
177
+ const mainBranch = (await defaultBranchOf(root)) ?? "main";
178
+ const branchInMain = await git(["merge-base", "--is-ancestor", branch, mainBranch], root);
179
+ const mainInBranch = await git(["merge-base", "--is-ancestor", mainBranch, branch], root);
180
+ if (branchInMain.ok && !mainInBranch.ok) continue; // merged into the default branch
181
+ log(`[lock] prompt ${promptNodeId} is held by worktree ${expectedPath} on branch ${branch}`);
182
+ return { locked: true, worktreePath: expectedPath, branch, requestId: run.id, trees };
183
+ }
184
+ return { locked: false, trees };
185
+ }
186
+
187
+ /** ROWS VERSION of the file-based `activeChatTurnForRoom` (`spawn.mjs`,
188
+ * retired brief 30 Move 2b): is a chat turn already live in this room, on
189
+ * this project? Same question (one live turn per room, because two would
190
+ * otherwise resume the same Claude Code conversation in the same directory
191
+ * at once), `runnerApi.runs({..., status:'running'})` in place of scanning
192
+ * `runningRequests` for an unreceipted claim. `exceptId` is the caller's own
193
+ * run, which has usually just claimed itself. */
194
+ export async function activeChatTurnForRoomRows(projectId, roomId, exceptId, settings) {
195
+ const wanted = typeof roomId === "string" ? roomId.trim() : "";
196
+ if (wanted === "") return null;
197
+ const runnerApi = settings.runnerApi ?? runnerApiModule;
198
+ const listed = await runnerApi.runs({ projectIds: [projectId], status: "running" }, { env: settings.env, fetchImpl: settings.fetchImpl });
199
+ if (!listed.ok) throw new Error(`Cannot verify active chat turns: ${listed.error}`);
200
+ for (const run of listed.runs ?? []) {
201
+ if (run.id === exceptId) continue;
202
+ if (!isChatTurn(run.request)) continue;
203
+ const its = typeof run.request?.roomId === "string" ? run.request.roomId.trim() : "";
204
+ if (its === wanted) return { requestId: run.id, roomId: wanted };
205
+ }
206
+ return null;
207
+ }
208
+
209
+ // ---------- the daily spend cap, over rows (item 3) ----------
210
+
211
+ // ---------- one claimed row, start to finish ----------
212
+
213
+ /** Claim, spawn, settle - one `runnerApi.update` at the end.
214
+ *
215
+ * `project`: `{id, path}`. `run`: the row `claim` handed back - `{id,
216
+ * request, session_key}`. `settings.enabled` is not consulted here, on
217
+ * purpose: the caller has to have decided already. */
218
+ export async function runRequest(project, run, settings, log = () => {}) {
219
+ if (settings.enabled !== true) {
220
+ throw new Error(
221
+ "runRequest was called with the runner switched off. Nothing spawns unless PM_SPAWN_RUNNER=on AND a person typed the watch/run command; see bridge/SPAWN.md, \"Money\".",
222
+ );
223
+ }
224
+ if (settings.depth > 0) {
225
+ throw new Error(
226
+ `This process was itself spawned by a runner (PM_SPAWN_DEPTH=${settings.depth}), so it may not spawn.`,
227
+ );
228
+ }
229
+
230
+ const timing = createRunnerPerformance(run);
231
+ const receipt = { sidecar: { ...run.sidecar } };
232
+ const lease = startRunLease(project, run, settings, { api: settings.runnerApi ?? runnerApiModule, log });
233
+ // This process is running it, so the restart pass must never take it for
234
+ // an orphan (restartPass.mjs).
235
+ liveRuns.add(run.id);
236
+ // A claimed run always gets a terminal receipt, including preparation errors.
237
+ try {
238
+ await lease.ready;
239
+ await prepareCompletionStore(project, settings);
240
+ return await executeRequest(project, run, settings, log, timing, receipt);
241
+ } catch (error) {
242
+ if (error.terminalReceipt) throw error;
243
+ if (settings.leaseLost?.has(run.id)) return { id: run.id, state: "waiting" };
244
+ log(`[${run.id}] failed: ${error.message}`);
245
+ return settleFailedRun(project, run.id, error, settings, log, timing, receipt);
246
+ } finally { lease.stop(); liveRuns.delete(run.id); }
247
+ }
248
+
249
+ async function executeRequest(project, run, settings, log, timing, receipt) {
250
+
251
+ const root = project.path;
252
+ const id = run.id;
253
+ const request = run.request ?? {};
254
+ const runnerApi = settings.runnerApi ?? runnerApiModule;
255
+ const hostedEnv = settings.env ?? process.env;
256
+ const rowsOpts = { env: hostedEnv, fetchImpl: settings.fetchImpl };
257
+ const session = { key: run.session_key ?? sessionKeyFor(id), title: titleFor(request), requestId: id };
258
+ // CLAIMED, LOGGED (2026-09-21, the claim-to-spawn gap). `runRequest` runs
259
+ // immediately after `claimOne` hands back a claimed row, in both `watch`'s
260
+ // loop and `pollOnce` - so this is the earliest point in this file that
261
+ // can say a run was claimed at all, and `claimedAt` below is what the
262
+ // "spawning" line's elapsed-time note (`prepareRequestWorkspace`) is
263
+ // measured against. Before this, everything between the network claim and
264
+ // the first line `prepareRequestWorkspace` logs was silent - a run seen
265
+ // claimed at 11:35:57 and "spawning" only at 11:37:00, nothing between.
266
+ const claimedAt = Date.now();
267
+ log(`[${id}] claimed: agent ${request.agent ?? "claude"}, project ${root}`);
268
+ // THE RUN'S OWN BOARD (2026-09-21, board reporting fix). `run` is the row
269
+ // `claim` handed back (board-runner/store.mjs's `claimOldest`, `select("*")`
270
+ // on `runs`), which already carries `board_id` - src/runs/api.ts's
271
+ // RunRequestRow is what wrote it. Sent on to the spawned agent's MCP server
272
+ // config as `X-Glytch-Board`, next to `X-Glytch-Session`, so board-mcp's
273
+ // `report` (and every other board_id-less tool) lands on THIS board
274
+ // instead of guessing the account's most recently touched one
275
+ // (board-mcp/tools.mjs's `boardFor`). Absent on an older row or a chat
276
+ // turn with none recorded - `commandFor`/`prepareWorkspace`/`codexBoardArgs`
277
+ // all treat an empty string the same as "no header", today's behaviour.
278
+ const boardId = typeof run.board_id === "string" ? run.board_id : "";
279
+
280
+ const finish = (patch) => {
281
+ // Completion rebuilds the sidecar (including removal of a deleted copy).
282
+ // Retain the capability marker without restoring stale workspace fields.
283
+ if (receipt.sidecar.restartable === true && patch.sidecar) {
284
+ patch = { ...patch, sidecar: { ...patch.sidecar, restartable: true } };
285
+ }
286
+ return settings.leaseLost?.has(id)
287
+ ? { id, state: "waiting" }
288
+ : settleMeasuredRun(project, id, patch, settings, log, timing, receipt);
289
+ };
290
+
291
+ // A CHAT TURN IS A DIFFERENT KIND OF REQUEST (brief 25, ported onto rows
292
+ // brief 30 Move 2b, 2026-09-11). Absent `kind` means "session", so
293
+ // everything below this line that is not explicitly guarded by `isChat` is
294
+ // what it has always been. The five differences, and no others, are
295
+ // marked `chat turn:` where they happen; bridge/SPAWN.md, "Chat turns", is
296
+ // the contract - only the DOOR each one reaches through changed (a run row
297
+ // and its `runnerApi` calls, not a signal file).
298
+ const isChat = isChatTurn(request);
299
+
300
+ const problems = checkRequest(request);
301
+ if (problems.length > 0) {
302
+ return finish({ status: "error", result: `ERROR\n\nRefused: ${problems.join("\n")}\n`, finished_at: new Date().toISOString() });
303
+ }
304
+
305
+ // SIGN IN TO CODEX (#482, lib/signIn.mjs): `codex login` opens the sign-in
306
+ // page in this computer's browser. Then the agents are probed again, so the
307
+ // app's sign-in box goes as soon as Codex says it is signed in rather than
308
+ // on the probe's own five-minute clock.
309
+ if (isSignIn(request)) {
310
+ log(`[${id}] signing in to ${request.agent}`);
311
+ const signedIn = await (settings.codexLogin ?? codexLogin)({ env: settings.baseEnv ?? process.env });
312
+ await settings.refreshAgents?.();
313
+ return finish({ status: signedIn.ok ? "done" : "error", result: `${signedIn.ok ? "OK" : "ERROR"}\n\n${signedIn.detail}\n`, finished_at: new Date().toISOString() });
314
+ }
315
+
316
+ // A PROJECT FOLDER THAT IS GONE (brief 52 round 1, item 17): one fixed
317
+ // sentence the app matches for its Pick a folder chip (sessionReplies.ts
318
+ // FOLDER_GONE); otherwise the spawn fails in a folder that is not there
319
+ // and the person reads "not installed", the wrong cause.
320
+ if (!isChat && !(await stat(root).then((s) => s.isDirectory(), () => false))) {
321
+ log(`[${id}] refused: the project folder ${root} is not on this computer`);
322
+ return finish({ status: "error", result: `ERROR\n\nNOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\nThe project folder is not on this computer.\n${root}\n`, finished_at: new Date().toISOString() });
323
+ }
324
+
325
+ let referenceImages;
326
+ try {
327
+ referenceImages = imageAttachments(request.images);
328
+ } catch (error) {
329
+ return finish({ status: "error", result: `ERROR\n\nNOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n${error.message}\n`, finished_at: new Date().toISOString() });
330
+ }
331
+
332
+ if (request.planMode === "execute") {
333
+ // ONE LIST QUERY covers both checks below: the matching draft (contract
334
+ // point 3/5) and single-use consumption (point 4), so a request already
335
+ // seeded with everything runs() returns pays for one round trip rather
336
+ // than two.
337
+ const projectRuns = (await runnerApi.runs({ projectIds: [project.id] }, rowsOpts).catch(() => null))?.runs ?? [];
338
+ const source = projectRuns.find((r) => r.id === request.approvedPlanFrom);
339
+ // ENGINE IS PART OF THE BOUND SCOPE (contract point 3/5): absent means
340
+ // "claude" on both sides, the same default agentFor and checkRequest
341
+ // already use, so a draft and an execute that both left `agent` unset
342
+ // still match.
343
+ const sourceAgent = source?.request?.agent ?? "claude";
344
+ const requestAgent = request.agent ?? "claude";
345
+ const samePrompt = source?.request?.planMode === "draft" &&
346
+ sourceAgent === requestAgent &&
347
+ fromIdOf(source.request) === fromIdOf(request);
348
+ if (!samePrompt || source?.status !== "done") {
349
+ return finish({ status: "error", result: "ERROR\n\nRefused: implementation requires a successful draft run for this same prompt and the same engine.\n", finished_at: new Date().toISOString() });
350
+ }
351
+ // SINGLE-USE (contract point 4). The draft's approval is bound to AT
352
+ // MOST ONE implementation, ever - a duplicate click, a copied execute
353
+ // request, or a fresh run row spelling the same `approvedPlanFrom` must
354
+ // all lose to whichever execute run against this draft already got this
355
+ // far. There is no exclusive-create over rows the way
356
+ // `<id>.plan-consumed` was on disk (brief 30 Move 2 retired the file
357
+ // contract this bound to), so this is a scan for an earlier claimed row
358
+ // rather than a race-proof claim of its own - the same trade-off
359
+ // `activeChatTurnForRoomRows` above already accepts for its per-room
360
+ // lock, and for the same reason: the window between the scan and this
361
+ // run's own claim is a process's worth of code, not a poll interval.
362
+ const already = projectRuns.find((r) => {
363
+ // Continued/recovered segments carry on the same approved implementation.
364
+ // `continues` keeps its root session key across resumes; derived run ids
365
+ // also identify ancestors when an older request lacks that chain key.
366
+ const sameChain = (typeof request.continues === "string" && request.continues.length > 0 &&
367
+ (request.continues === r.id || request.continues === r.session_key || request.continues === r.request?.continues)) ||
368
+ id.startsWith(`${r.id}-r`);
369
+ return r.id !== id && !sameChain && r.request?.planMode === "execute" &&
370
+ r.request?.approvedPlanFrom === request.approvedPlanFrom &&
371
+ (r.claimed_by != null || r.status !== "pending");
372
+ });
373
+ if (already) {
374
+ return finish({ status: "error", result: "ERROR\n\nRefused: this approved plan has already started an implementation. Draft and approve a new plan to try again.\n", finished_at: new Date().toISOString() });
375
+ }
376
+ }
377
+
378
+ // ---------- chat turn: the things only the disk (or the room) can answer ----------
379
+ // checkRequest (pure) has said `cwd` and `roomId` are there. Each check
380
+ // here refuses in words a person reads in the run's result, never throws.
381
+ let chatCwd = null;
382
+ if (isChat) {
383
+ // WHERE IT RUNS: refused if it escapes the folder (Write, Edit and Bash).
384
+ const resolvedCwd = resolveChatCwd(root, request.cwd);
385
+ if (!resolvedCwd.ok) {
386
+ const detail =
387
+ `NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n` +
388
+ `This chat turn names ${String(request.cwd ?? "").trim()} as the directory to ` +
389
+ `run in, and ${resolvedCwd.reason}`;
390
+ log(`[${id}] refused: the chat turn's cwd ${String(request.cwd ?? "").trim()} is outside ${root}`);
391
+ return finish({ status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() });
392
+ }
393
+ // A BOARD'S SUBFOLDER (glytchFolder.mjs) is made inside the chat tree.
394
+ const board = request.boardFolder === undefined ? null : chatBoardOf(root, resolvedCwd.path, request.boardFolder);
395
+ chatCwd = board?.tree ?? resolvedCwd.path;
396
+ // THE DIRECTORY IS THE CONVERSATION (brief 25 difference 2): no worktree
397
+ // PER MESSAGE and no fallback to the linked checkout. `mintIfMissing`
398
+ // (ruling 1) cuts the folder's one long-lived chat worktree, once.
399
+ let isDir = false;
400
+ try { isDir = (await stat(chatCwd)).isDirectory(); } catch { isDir = false; }
401
+ if (!isDir && request.mintIfMissing === true) {
402
+ const minted = await makeChatWorktree(root, chatCwd, log);
403
+ if (!minted.ok) {
404
+ const detail = `NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n${minted.reason}`;
405
+ log(`[${id}] refused: the chat worktree ${chatCwd} could not be made`);
406
+ return finish({ status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() });
407
+ }
408
+ log(`[${id}] ${minted.detail}`);
409
+ isDir = true;
410
+ }
411
+ if (!isDir) {
412
+ const detail =
413
+ `NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n` +
414
+ `This chat turn names ${chatCwd} as the directory to run in, and there is no ` +
415
+ `directory there. A chat turn never mints a worktree: the directory IS which ` +
416
+ `conversation this is, and falling back to the linked folder would put a chat agent ` +
417
+ `in the checkout you are working in.\n\n` +
418
+ `Make the directory (the chat worktree for this agent) and send the message again.`;
419
+ log(`[${id}] refused: the chat turn's cwd ${chatCwd} is not a directory`);
420
+ return finish({ status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() });
421
+ }
422
+ if (board) {
423
+ const sub = await makeBoardCwd(root, chatCwd, board.name);
424
+ if (!sub.ok) {
425
+ log(`[${id}] refused: ${sub.reason}`);
426
+ return finish({ status: "error", result: `ERROR\n\nNOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n${sub.reason}\n`, finished_at: new Date().toISOString() });
427
+ }
428
+ chatCwd = sub.path;
429
+ }
430
+ // AND THE ENGINE HAS TO BE A CODING CLI THIS BRIDGE RUNS (agents.mjs).
431
+ if (request.agent !== undefined && !isCodingCli(request.agent)) {
432
+ const detail =
433
+ `NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n` +
434
+ `This chat turn names the engine "${request.agent}", and this bridge has no such ` +
435
+ `coding CLI. Answering on another engine would tell you that you are talking to ` +
436
+ `"${request.agent}" when you are not.`;
437
+ log(`[${id}] refused: "${request.agent}" is not a coding CLI this bridge runs`);
438
+ return finish({ status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() });
439
+ }
440
+ // difference 4: THE PER-ROOM LOCK, not the per-prompt one below.
441
+ const roomId = String(request.roomId).trim();
442
+ const live = await activeChatTurnForRoomRows(project.id, roomId, id, settings);
443
+ if (live) {
444
+ const detail =
445
+ `NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n` +
446
+ `A chat turn is already running in room ${roomId} (run ${live.requestId}). ` +
447
+ `One live turn per room: both would resume the same Claude Code conversation in the ` +
448
+ `same directory at the same time, and a conversation is one thread.\n\n` +
449
+ `Wait for that turn to finish, or stop it, then send this message again.`;
450
+ log(`[${id}] refused: room ${roomId} already has a live turn (${live.requestId})`);
451
+ return finish({ status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() });
452
+ }
453
+ }
454
+
455
+ const isResume = typeof request.resumeSessionId === "string" && request.resumeSessionId.trim() !== "";
456
+ // chat turn, difference 4 (cont.): no idea behind it, so no per-prompt lock.
457
+ const from = isChat ? undefined : fromIdOf(request);
458
+ // THE ONE TREE A REQUEST MAY NAME: one of this idea's own (round 3 item 67).
459
+ let ownTreePath = null;
460
+ if (from) {
461
+ const lock = await activeWorktreeForPromptRows(root, project.id, from, settings, log);
462
+ // The tree's own spelling goes down (round 4 item 70): reuseWorktree
463
+ // compares text, and the request may say /var for a /private/var tree.
464
+ ownTreePath = lock.trees.find((tree) => samePath(tree, request.worktreePath)) ?? null;
465
+ if (lock.locked) {
466
+ // A resume, or a new session (Codex, opencode) naming the kept tree
467
+ // (review 2026-10-04, D7): both run IN that tree, so neither is a second one.
468
+ const isOwnTree = samePath(request.worktreePath, lock.worktreePath);
469
+ if (!isOwnTree) {
470
+ const detail =
471
+ `Refused: this prompt already has an active session.\n\n` +
472
+ `Worktree ${lock.worktreePath} on branch ${lock.branch} (run ${lock.requestId}) ` +
473
+ `is still in progress for prompt ${from}. Only one session per prompt is allowed ` +
474
+ `while a worktree exists and its branch is not merged into main.\n\n` +
475
+ `Merge or delete the existing worktree first, then file a new request.`;
476
+ log(`[${id}] refused: prompt ${from} is locked by ${lock.worktreePath}`);
477
+ return finish({ status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() });
478
+ }
479
+ log(`[${id}] ${isResume ? "resume" : "new session"} into own worktree ${lock.worktreePath}, lock exempted`);
480
+ }
481
+ }
482
+
483
+ const agent = agentFor(request.agent);
484
+
485
+ // CAN THIS ENGINE EVEN BE SPAWNED ON THIS MACHINE, settled BEFORE a
486
+ // worktree is minted (2026-09-20): the daemon runs with its service's
487
+ // PATH, so a `claude` that answers from Terminal can still ENOENT here.
488
+ // `resolveAgentBinary` (detect.mjs, what `glytch doctor` probes through)
489
+ // tries the daemon's PATH, the well-known install directories, then a
490
+ // login shell's `command -v`, and only then refuses in words a person can
491
+ // act on, before prepareRequestWorkspace has minted anything.
492
+ // SKIPPED WHEN `settings.claudeBin` IS SET: the test/CI override already
493
+ // names a known-runnable binary (runner.test.mjs sets process.execPath).
494
+ let resolvedBinPath;
495
+ if (settings.claudeBin === undefined) {
496
+ const spec = AGENTS[agent];
497
+ const env = settings.env ?? process.env;
498
+ const platform = settings.platform ?? process.platform;
499
+ const wantedBin = env[spec.binEnv]?.trim() || spec.bin;
500
+ const resolvedBin = await resolveAgentBinary(wantedBin, { env, platform });
501
+ if (!resolvedBin.found) {
502
+ const machine = settings.host ?? "your computer";
503
+ const detail =
504
+ `NOTHING WAS SPAWNED AND NOTHING WAS BILLED.\n\n` +
505
+ `${spec.label} is not installed on ${machine}, or the runner cannot find it.\n\n` +
506
+ `Open a terminal and run \`${wantedBin} --version\`; if that works, restart the runner: ` +
507
+ `\`viewable stop\`, then \`viewable init --yes\` in the project folder. This lookup is ` +
508
+ `remembered until the runner restarts, so installing ${spec.label} after this refusal ` +
509
+ `changes nothing until you do.\n\n` +
510
+ `[Technical: spawn ${wantedBin} ENOENT. Searched this process's own PATH, ` +
511
+ `${knownBinDirs({ env, platform }).join(", ") || "no known install directories on this platform"}` +
512
+ `${env.SHELL ? `, and a login shell (${env.SHELL} -lc 'command -v ${wantedBin}')` : ", and no login shell ($SHELL is not set in this environment)"}.]`;
513
+ log(`[${id}] refused: could not resolve ${wantedBin} to a runnable path on your computer`);
514
+ return finish({ status: "error", result: `ERROR\n\n${detail}\n`, finished_at: new Date().toISOString() });
515
+ }
516
+ resolvedBinPath = resolvedBin.path;
517
+ }
518
+
519
+ const hostedBoard = await hostedConfig({ env: hostedEnv });
520
+ const childMcpUrl = runnerMcpUrlFrom(hostedBoard, hostedEnv);
521
+ // The runner's permission checks and collect_session (including the final
522
+ // sweep) use the same direct endpoint as its child. Keep the hosted
523
+ // transport's refresh/deadline/cancellation logic; only reroute MCP posts,
524
+ // never OAuth discovery or refresh requests made through the same fetch.
525
+ const hostedCallTool = settings.hostedCallTool ?? ((name, args, options) => hostedCallToolDefault(name, args, {
526
+ ...options,
527
+ mcpUrl: childMcpUrl, fetchImpl: settings.fetchImpl,
528
+ }));
529
+ const boardToken = await hostedToken({ env: hostedEnv, log });
530
+ if (!boardToken) {
531
+ log(`[${id}] not signed in to the hosted board (viewable login) and no VIEWABLE_BOARD_TOKEN - this session's board tools will have nothing to authenticate with`);
532
+ }
533
+ const isPersistent = isPersistentRequest(request);
534
+ const useStreaming = request.planMode !== "draft" && acceptsMessages(request);
535
+ const maxMs = wallClockFor(request);
536
+ const otherRunningCount = async () => {
537
+ const listed = await runnerApi.runs({ projectIds: [project.id], status: "running" }, rowsOpts);
538
+ if (!listed.ok) throw new Error(`Cannot verify competing runs: ${listed.error}`);
539
+ return (listed.runs ?? []).filter((r) => r.id !== id).length;
540
+ };
541
+ const workspace = await prepareRequestWorkspace({
542
+ root, id, request, isResume, session, from, log, isChat, chatCwd, claimedAt, ownTreePath,
543
+ otherRunningCount,
544
+ updateSidecarEarly: (sidecar) => {
545
+ // Settlement replaces the hosted JSON column; preserve workspace controls
546
+ // even if setup later throws or Stop arrives before the child launches.
547
+ receipt.sidecar = { ...receipt.sidecar, ...sidecar };
548
+ if (agentFor(request.agent) === "claude") {
549
+ receipt.sidecar.claudeSessionId = request.resumeSessionId ?? request.sessionId;
550
+ receipt.sidecar.restartable = true;
551
+ }
552
+ return runnerApi.update({ runId: id, patch: { sidecar: receipt.sidecar } }, rowsOpts);
553
+ },
554
+ });
555
+ if (!workspace.ok) return finish(workspace.patch);
556
+ const { made, resumeTreeGone, resumeSessionId } = workspace;
557
+
558
+ const TREE_GONE_NOTE =
559
+ `[From the runner] The earlier conversation for this prompt could not be resumed: ` +
560
+ `the worktree it lived in (${request.worktreePath}) is gone, and Claude Code keeps ` +
561
+ `conversations per working directory. This is a FRESH session in a new worktree, with ` +
562
+ `none of that history. Work from the prompt above and from the repository.`;
563
+ const NOT_FOUND_NOTE =
564
+ `[From the runner] The earlier conversation for this prompt could not be resumed: its ` +
565
+ `worktree is still here, but Claude Code has no record of session ` +
566
+ `${String(request.resumeSessionId ?? "").trim()} in it. This is a FRESH session in that ` +
567
+ `same worktree, with none of that history. Work from the prompt above, and from what is ` +
568
+ `already committed on this branch.`;
569
+ if (resumeTreeGone) {
570
+ log(`[${id}] the recorded worktree ${request.worktreePath} is gone; starting a FRESH session instead of resuming ${String(request.resumeSessionId).trim()}`);
571
+ }
572
+
573
+ const planRun = async ({ resumeId, note, fixing = false }) => {
574
+ const fresh = resumeId === "";
575
+ // chat turn, difference 3: NEITHER, EVER. Both the primer and the
576
+ // attempts log exist to save a FRESH session from rediscovering what an
577
+ // earlier one already learned; a chat turn is either the first message
578
+ // of a conversation or a resume of one, and there is no idea
579
+ // behind it to have attempted anything.
580
+ const repoMap = fresh && !isChat ? await getRepoMap(root, log) : null;
581
+ const attemptsSection = fresh && !isChat && from
582
+ ? await buildAttemptsSectionFromRows(project.id, from, id, { runnerApi, env: hostedEnv, log })
583
+ : null;
584
+ const repoPrimer = formatRepoPrimer(repoMap, attemptsSection ? attemptsSection.length : 0);
585
+ // chat turn, difference 1: NO PREAMBLE. Every sentence of it is about an
586
+ // idea on the board he is not looking at - "call report before you
587
+ // finish" - and a chat turn has neither: what it is told is the app's to
588
+ // compose (persona, the room's context, his message).
589
+ let text = request.planMode === "draft"
590
+ ? `Investigate the request using read-only research, then return an actionable implementation plan in your final response. Structure the response as a report: keep resolved or contextual findings in their own sections; put actionable work under a \`## Implementation plan\` heading with one \`### Step N: ...\` or \`### Issue X: ...\` heading per top-level item; put numbered substeps under a \`**Steps:**\` label; and label checks and open decisions clearly. Do not implement anything, use tools to modify files, or ask for permission to implement. The human will edit and approve or reject your final plan on the board.\n\n${String(request.prompt)}`
591
+ : `${isChat ? "" : preambleFor(agent, { ...(made.isolated ? {} : { workplace: IN_PLACE_WORKPLACE }), computerApps: request.computerApps === true })}${String(request.prompt)}`;
592
+ if (request.planMode === "execute") text += `\n\nHUMAN-APPROVED IMPLEMENTATION PLAN\nFollow this exact plan, including the human's edits. Ask the human about any material scope change before implementing it.\n\n${request.approvedPlan}`;
593
+ if (note) text = `${text}\n\n${note}`;
594
+ if (repoPrimer) text = `${text}\n\n${repoPrimer}`;
595
+ if (attemptsSection) text = `${text}\n\n${attemptsSection}`;
596
+ if (referenceImages.length > 0) {
597
+ text = `${imageInspectionInstruction(referenceImages.length, request.planMode).trim()}\n\n${text}`;
598
+ }
599
+ const resolved = commandFor(agent, settings.env ?? process.env, {
600
+ planMode: request.planMode,
601
+ model: request.model,
602
+ effort: request.effort,
603
+ sessionId: fixing ? undefined : request.sessionId,
604
+ resumeSessionId: resumeId,
605
+ streaming: useStreaming,
606
+ // ASK IN THE IDEA (#478): with a board to ask on, anything outside the
607
+ // usual list becomes a question in the idea instead of a silent refusal.
608
+ // Drafts are read-only; commandFor drops the ask for them.
609
+ askPermissions: Boolean(boardToken),
610
+ webSearch: request.webSearch === true,
611
+ boardUrl: childMcpUrl,
612
+ boardToken,
613
+ sessionKey: session.key,
614
+ boardId,
615
+ // The person's own apps (#481): only an explicit true from the app,
616
+ // which sends it when this person turned the switch on for this agent.
617
+ computerApps: request.computerApps === true,
618
+ });
619
+ const chosen = settings.claudeBin !== undefined
620
+ ? { ...resolved, bin: settings.claudeBin, args: request.planMode === "draft" ? resolved.args : settings.claudeArgs ?? resolved.args }
621
+ // THE RESOLVED PATH, NOT THE BARE NAME: the check above already found
622
+ // where this binary actually runs FROM - `spec.bin` unchanged when it
623
+ // was already on this process's own PATH, an absolute path when it
624
+ // took a known install directory or a login shell to find. Handing
625
+ // `spawn` the resolved path means it never repeats a PATH search this
626
+ // process's own (possibly narrower) PATH would have failed anyway.
627
+ : { ...resolved, bin: resolvedBinPath ?? resolved.bin };
628
+ for (const what of chosen.ignored ?? []) log(`[${id}] ignoring ${what}`);
629
+ // `brief` is what the person typed, on its own - the adapters echo it
630
+ // into the transcript instead of the preamble-wrapped `prompt`.
631
+ return { prompt: text, brief: String(request.prompt), chosen, args: chosen.args.map((a) => a.replaceAll("{prompt}", () => text)) };
632
+ };
633
+
634
+ const plan = await planRun({ resumeId: resumeSessionId, note: resumeTreeGone ? TREE_GONE_NOTE : null });
635
+ const { prompt, chosen, args } = plan;
636
+ let resumeNotFound = false;
637
+
638
+ let prepared = { wrote: null };
639
+ try {
640
+ prepared = await prepareWorkspace(agent, { mcpUrl: childMcpUrl, token: boardToken, sessionKey: session.key, boardId });
641
+ } catch (error) {
642
+ log(`[${id}] could not prepare the board config: ${error?.message ?? error}`);
643
+ }
644
+
645
+ // LOG STREAMING, over run_events (item 3). Uncapped live; the cap shows up
646
+ // in the final flush - see rowsEvents.mjs's own header for why.
647
+ const streamer = makeRowEventStreamer({
648
+ send: (events) => runnerApi.events({ runId: id, events }, rowsOpts),
649
+ log,
650
+ });
651
+ const flushTimer = setInterval(() => void streamer.tick(), settings.logFlushMs ?? 3000);
652
+ if (flushTimer.unref) flushTimer.unref();
653
+ let imagesDir = null;
654
+ let rowsPoll = null;
655
+ try {
656
+ const onChunk = (chunk) => { if (chunk.length > 0) timing.output(); streamer.onChunk(chunk); };
657
+
658
+ const childEnvObj = childEnv(settings.baseEnv ?? process.env, { root, session, depth: settings.depth, fromId: from });
659
+ if (boardToken) childEnvObj.PM_BOARD_TOKEN = boardToken;
660
+ Object.assign(childEnvObj, prepared.env ?? {});
661
+
662
+ // STOP, from the runner's own heartbeat beat (item 5: heartbeat's `stops`
663
+ // replaces the `.stop` file). `settings.stopped` is a Set the watch loop
664
+ // keeps current; a run started outside the watch loop (a test, `pollOnce`)
665
+ // simply never sees anything in it, which is the same as "never asked to
666
+ // stop".
667
+ const stopCheck = async () => Boolean(settings.stopped?.has(id));
668
+
669
+ rowsPoll = boardToken
670
+ ? createRowsAnswerPoll({ callTool: hostedCallTool, sessionKey: session.key, env: hostedEnv, log, realtime: settings.realtime })
671
+ : null;
672
+ const answerPoll = rowsPoll ? rowsPoll.poll : undefined;
673
+ // Who answers "can I do this?" for a run on this computer. Only for a run
674
+ // that can wait for a person: streaming Claude and non-draft Codex, with a
675
+ // board to file the question on. A draft never asks (it stays read-only).
676
+ const askPermission = boardToken && request.planMode !== "draft" && (useStreaming || agentFor(agent) === "codex")
677
+ ? createPermissionAsker({
678
+ folder: root, agentLabel: AGENTS[agentFor(agent)]?.label ?? "The agent", session, fromId: from, env: hostedEnv,
679
+ callTool: hostedCallTool, poll: rowsPoll, log: (message) => log(`[${id}] ${message}`),
680
+ pollMs: settings.permissionPollMs ?? 2000,
681
+ })
682
+ : undefined;
683
+
684
+ let imageSuffix = "";
685
+ const promptIsAnArgument = args.some((a) => typeof a === "string" && a.includes(prompt));
686
+ const protocolImages = useStreaming || ["acp", "codex-app-server"].includes(chosen.spec?.protocol);
687
+ if (referenceImages.length > 0 && !protocolImages) {
688
+ if (!promptIsAnArgument) {
689
+ clearInterval(flushTimer);
690
+ throw new Error("Reference images cannot be delivered by this command: it has no prompt argument or image transport. Restore the engine's standard command and try again.");
691
+ }
692
+ const imgDir = join(root, BOARD_DIR, "images", id);
693
+ imagesDir = imgDir;
694
+ try {
695
+ await mkdir(imgDir, { recursive: true });
696
+ const paths = [];
697
+ for (const [index, image] of referenceImages.entries()) {
698
+ const filePath = join(imgDir, `context-${index}.${image.extension}`);
699
+ await writeFile(filePath, image.bytes);
700
+ paths.push(filePath);
701
+ }
702
+ imageSuffix = `\n\n[${paths.length} screenshot${paths.length === 1 ? "" : "s"} from the idea's pictures, saved as files. Open each with your image-capable Read/view tool:${paths.map((p) => `\n ${p}`).join("")}]`;
703
+ log(`[${id}] wrote all ${paths.length} reference image(s) to ${imgDir}`);
704
+ } catch (error) {
705
+ clearInterval(flushTimer);
706
+ throw new Error(`Reference images could not be prepared; no coding session was started: ${error.message}`);
707
+ }
708
+ }
709
+
710
+ const makeRunOpts = (p, extra = {}) => {
711
+ const renderedPrompt = p.prompt + imageSuffix;
712
+ const withImages = imageSuffix
713
+ ? p.args.map((a) => (typeof a === "string" && a.includes(p.prompt) ? a.replace(p.prompt, () => p.prompt + imageSuffix) : a))
714
+ : p.args;
715
+ const platform = settings.platform ?? process.platform;
716
+ const promptArgIndex = p.chosen.args.findIndex((arg, index) => arg === "{prompt}" && ["-p", "--print"].includes(p.chosen.args[index - 1]));
717
+ const promptOnStdin = platform === "win32" && agentFor(agent) === "claude" && !useStreaming && promptArgIndex >= 0;
718
+ const childArgs = promptOnStdin ? withImages.filter((_arg, index) => index !== promptArgIndex) : withImages;
719
+ const base = {
720
+ bin: p.chosen.bin,
721
+ args: childArgs,
722
+ brief: p.brief,
723
+ cwd: made.cwd,
724
+ env: childEnvObj,
725
+ maxMs,
726
+ maxLogBytes: settings.maxLogBytes ?? DEFAULTS.maxLogBytes,
727
+ killGraceMs: settings.killGraceMs ?? DEFAULTS.killGraceMs,
728
+ stallMs: settings.stallMs ?? DEFAULTS.stallMs,
729
+ onChunk,
730
+ onSession: async (threadId) => {
731
+ receipt.sidecar = { ...receipt.sidecar, codexThreadId: threadId, restartable: true };
732
+ try {
733
+ const saved = await runnerApi.update({ runId: id, patch: { sidecar: receipt.sidecar } }, rowsOpts);
734
+ if (!saved.ok) log(`[${id}] could not record the conversation: ${saved.error}`);
735
+ } catch (error) {
736
+ log(`[${id}] could not record the conversation: ${error instanceof Error ? error.message : String(error)}`);
737
+ }
738
+ },
739
+ platform,
740
+ ...(promptOnStdin ? { input: renderedPrompt } : {}),
741
+ acp: p.chosen.acp ?? null,
742
+ codex: p.chosen.codex ?? null,
743
+ askPermission,
744
+ log: (message) => log(`[${id}] ${message}`),
745
+ };
746
+ return useStreaming
747
+ ? { ...base, prompt: renderedPrompt, images: request.images, stopCheck, answerPoll, answerPollMs: settings.rowsPollMs ?? 5000, ...extra }
748
+ : { ...base, prompt: renderedPrompt, images: request.images, stopCheck, ...extra };
749
+ };
750
+ const runOpts = makeRunOpts(plan);
751
+ const runFn = runnerFor(agent, useStreaming);
752
+
753
+ if (settings.stopped?.has(id)) return await finish({ status: "stopped", result: settings.leaseLost?.has(id) ? "STOPPED\n\nRunner connection expired before the process was started.\n" : "STOPPED\n\nStopped by user request before the process was started.\n", finished_at: new Date().toISOString() });
754
+ timing.launched();
755
+ const firstRunStartedAt = Date.now();
756
+ const firstTry = await runFn(runOpts);
757
+ if (request.recovery === true && (isMissingConversation(firstTry) || (!firstTry.ok && !firstTry.sawAssistant))) {
758
+ clearInterval(flushTimer);
759
+ await streamer.settle(firstTry.log ?? "", { dropped: firstTry.dropped ?? 0 });
760
+ return finish({ status: "error", result: "ERROR\n\nThe conversation could not carry on.\n", finished_at: new Date().toISOString(), sidecar: { ...receipt.sidecar, recoveryFailed: true } });
761
+ }
762
+ const exitClass = classifyExit(firstTry.code, firstTry.signal);
763
+ const remainingBudget = () => Math.max(1000, maxMs - (Date.now() - firstRunStartedAt));
764
+
765
+ let outcome;
766
+ let sessionIdSource = null;
767
+ if (resumeSessionId !== "" && isMissingConversation(firstTry)) {
768
+ log(`[${id}] the conversation ${resumeSessionId} was not found in ${made.cwd} ($0.00, 0 turns - nothing was spent); retrying once as a FRESH session there`);
769
+ resumeNotFound = true;
770
+ const freshPlan = await planRun({ resumeId: "", note: NOT_FOUND_NOTE });
771
+ const resumeLog = firstTry.log ?? "";
772
+ outcome = await runFn(makeRunOpts(freshPlan, { maxMs: remainingBudget() }));
773
+ sessionIdSource = outcome.log ?? "";
774
+ outcome.log = `=== Resume attempt (no conversation found; $0.00, 0 turns) ===\n${resumeLog}\n\n=== Fresh session in the same worktree ===\n${outcome.log ?? ""}`;
775
+ outcome.retried = true;
776
+ outcome.firstCrashClass = "the resumed conversation was not found";
777
+ } else if (exitClass.isCrash && !firstTry.sawAssistant && !firstTry.finished) {
778
+ log(`[${id}] first run ${exitClass.description}, no output streamed; retrying once`);
779
+ outcome = await runFn({ ...runOpts, maxMs: remainingBudget() });
780
+ if (firstTry.log) {
781
+ outcome.log = `=== First attempt (${exitClass.description}) ===\n${firstTry.log}\n\n=== Retry ===\n${outcome.log ?? ""}`;
782
+ outcome.retried = true;
783
+ outcome.firstCrashClass = exitClass.description;
784
+ }
785
+ } else {
786
+ outcome = firstTry;
787
+ }
788
+
789
+ // Checking is part of this run, before its one closing reply and cleanup.
790
+ // Drafts and chat turns must never start scripts or an automatic edit.
791
+ if (outcome.ok && !isChat && request.planMode !== "draft" && !await stopCheck()) {
792
+ const deadline = firstRunStartedAt + maxMs;
793
+ const checkOptions = {
794
+ cwd: made.cwd, env: childEnvObj, deadline, stopCheck,
795
+ platform: settings.platform ?? process.platform,
796
+ killGraceMs: settings.killGraceMs ?? DEFAULTS.killGraceMs,
797
+ onChunk,
798
+ };
799
+ // The page check and After come from the copy, only when the run changed
800
+ // a screen (#2194). A run with no copy has no list of what it changed.
801
+ const pageOptions = {
802
+ cwd: made.cwd, folder: root, env: childEnvObj, baseEnv: settings.baseEnv ?? process.env,
803
+ platform: checkOptions.platform, deadline, stopCheck, killGraceMs: checkOptions.killGraceMs,
804
+ ...(settings.browser !== undefined ? { browser: settings.browser } : {}),
805
+ };
806
+ const checkAll = async () => {
807
+ const ran = await runCodingChecks(checkOptions);
808
+ const paged = made.isolated && !ran.stopped && settings.pictures !== false
809
+ ? await checkPage({ ...pageOptions, files: await changedFiles(made.cwd, root) }) : null;
810
+ if (!paged) return { ...ran, paged };
811
+ return {
812
+ ...ran, paged, stopped: ran.stopped || Boolean(paged.stopped),
813
+ checks: [...ran.checks, paged.check],
814
+ failures: paged.failure ? [...ran.failures, paged.failure] : ran.failures,
815
+ };
816
+ };
817
+ let checked = await checkAll();
818
+ outcome.log = (outcome.log ?? "") + checked.log;
819
+ let fixAttempted = false;
820
+ if (checked.failures.length && !checked.stopped && !await stopCheck() && Date.now() < deadline) {
821
+ const fixPlan = await planRun({
822
+ resumeId: outcome.codexThreadId ?? outcome.envelope?.session_id ?? "",
823
+ fixing: true,
824
+ note: `The folder's checks failed after your work. Try ONE fix in this same folder. Reproduce the failed check, fix the cause without removing or weakening checks, and leave checking to the runner when you finish. Do not start another automatic attempt.\n\n${checked.failures.join("\n\n")}`,
825
+ });
826
+ if (!await stopCheck() && Date.now() < deadline) {
827
+ fixAttempted = true;
828
+ const step = `${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "Coding is trying one fix." }] } })}\n`;
829
+ onChunk(step);
830
+ outcome.log = (outcome.log ?? "") + step;
831
+ const fixed = await runFn(makeRunOpts(fixPlan, { maxMs: deadline - Date.now() }));
832
+ const combinedLog = (outcome.log ?? "") + (fixed.log ?? "");
833
+ // A repair failure is a failed check, not a crash of the finished run.
834
+ // Keep its closing reply, and always verify the files left behind.
835
+ if (fixed.ok) {
836
+ outcome = { ...outcome, ...fixed };
837
+ sessionIdSource = fixed.log ?? "";
838
+ }
839
+ outcome.log = combinedLog;
840
+ outcome.stoppedByUser ||= fixed.stoppedByUser;
841
+ const previous = checked.checks;
842
+ checked = await checkAll();
843
+ outcome.log += checked.log;
844
+ checked.checks = checked.checks.map((check) => check.status === "skipped" && previous.some((p) => p.kind === check.kind && p.status === "failed")
845
+ ? { ...check, status: "failed", failed: 1 } : check);
846
+ }
847
+ }
848
+ outcome.stoppedByUser ||= checked.stopped;
849
+ // Before is the folder's page, which the run has not touched yet.
850
+ const pictures = checked.paged?.after && !checked.stopped
851
+ ? await beforePicture({ ...pageOptions, page: checked.paged.page, after: checked.paged.after, browser: checked.paged.browser })
852
+ : {};
853
+ outcome.result = { checks: checked.checks, fixAttempted, ...pictures };
854
+ if (checked.paged?.note) outcome.picturesNote = checked.paged.note;
855
+ }
856
+
857
+ timing.exited();
858
+ clearInterval(flushTimer);
859
+ await streamer.settle(outcome.log ?? "", { dropped: outcome.dropped ?? 0 });
860
+ if (imagesDir) await rm(imagesDir, { recursive: true, force: true }).catch(() => {});
861
+
862
+ // A lease expiry is an interruption, not a human Stop or an ending.
863
+ // Leave result null for the reaper/restart pass, and keep the copy intact.
864
+ if (settings.leaseLost?.has(id)) return { id, state: "waiting" };
865
+
866
+ const completed = await completeRequest({
867
+ root, id, outcome, made, isPersistent, isChat, sessionIdSource, from, session, maxMs, agent,
868
+ readStopRequested: () => Boolean(settings.stopped?.has(id)) || outcome.stoppedByUser === true,
869
+ resumeTreeGone, resumeNotFound, request, log,
870
+ rowsPoll, hostedCallTool, hostedEnv,
871
+ resumeFn: (args, opts) => runnerApi.resume(args, { ...rowsOpts, ...opts }),
872
+ });
873
+ return await finish(completed.patch);
874
+ } finally {
875
+ rowsPoll?.close();
876
+ clearInterval(flushTimer);
877
+ if (imagesDir) await rm(imagesDir, { recursive: true, force: true }).catch(() => {});
878
+ }
879
+ }
880
+
881
+ // ---------- forget: this folder leaves the machine (2026-09-12, ruling 3) ----------
882
+
883
+ /**
884
+ * Act on a `forget` op for THIS project, if this beat's heartbeat carried one.
885
+ * Returns true when it did, which tells `watch()` to stop beating: it has
886
+ * just unregistered the folder it was watching.
887
+ *
888
+ * The whole gesture is three steps and this is the middle one. A person
889
+ * clicks Remove on a row in the Project panel; the app raises `forgotten_at`
890
+ * on that `runner_projects` row, which hides it from the picker at once; the
891
+ * next heartbeat hands this daemon the op; and the row only actually goes
892
+ * once this call has acknowledged it. That ordering is what makes a forget
893
+ * survive a daemon that was asleep at the time - the signal is re-surfaced
894
+ * every beat until it is answered.
895
+ *
896
+ * IT IS NOT `opTake`n. There is no `run_ops` row to take: a forget belongs to
897
+ * a project, not to a run, and board-runner synthesises the op straight off
898
+ * the column (see supabase/functions/board-runner/store.mjs's
899
+ * `forgetSignals`). Acting and then acknowledging is the whole protocol.
900
+ *
901
+ * THE REGISTRY FIRST, THE ACK SECOND, and the order is load-bearing. Dropping
902
+ * the folder from `~/.glytch/folders.json` is what stops the NEXT beat's
903
+ * `registerAndFindProject` naming it again - register upserts on
904
+ * (user, host, path), so a row deleted while the folder is still registered
905
+ * would simply be recreated on the following beat, and the person's Remove
906
+ * would visibly undo itself. It is also what stops the watcher: multiwatch
907
+ * re-reads the registry (fs.watch, and a one-minute floor) and aborts the
908
+ * watcher of any folder that has left it.
909
+ *
910
+ * A FAILED ACK IS NOT A FAILED FORGET. The folder is already gone locally and
911
+ * the row is already invisible to the picker; the ack is what deletes the row
912
+ * for good, and an ack that did not land leaves a tombstone nobody can see.
913
+ * So it is logged and never thrown - the same posture every other beat-time
914
+ * call in this file keeps.
915
+ */
916
+ export async function serviceForgetOps(root, project, ops, settings, log = () => {}) {
917
+ const mine = (ops ?? []).find((op) => op.kind === "forget" && op.project_id === project.id);
918
+ if (!mine) return false;
919
+
920
+ const runnerApi = settings.runnerApi ?? runnerApiModule;
921
+ const env = settings.env ?? process.env;
922
+
923
+ // Capture init's local generation BEFORE the confirmation request. init may
924
+ // restore after that request reads Remove but before its response arrives.
925
+ // Read under the lock too: never capture init's new generation while its
926
+ // hosted restore is still in flight and the server still reports Remove.
927
+ const generation = await withRegistryLock(() => folderRestoreGeneration(root, env), env);
928
+ const latest = await runnerApi.heartbeat({ projectIds: [project.id] }, { env, fetchImpl: settings.fetchImpl });
929
+ if (!latest.ok) throw new Error(latest.error ?? "Could not confirm folder removal.");
930
+ if (!(latest.ops ?? []).some((op) => op.kind === "forget" && op.project_id === project.id)) return false;
931
+
932
+ return withRegistryLock(async () => {
933
+ // The compare, local removal and acknowledgement share init's restore lock:
934
+ // no restore can slip between this check and either destructive operation.
935
+ if (await folderRestoreGeneration(root, env) !== generation) return false;
936
+ const { removed } = await removeFolder(root, env);
937
+ log(
938
+ removed
939
+ ? `forget: the board asked for ${root} to be removed - dropped it from the folder registry`
940
+ : `forget: the board asked for ${root} to be removed - it was not in the folder registry`,
941
+ );
942
+
943
+ const acked = await runnerApi.forget({ projectIds: [project.id] }, { env, fetchImpl: settings.fetchImpl });
944
+ if (!acked.ok) log(`forget: could not acknowledge it to the board: ${acked.error}`);
945
+ else if ((acked.kept ?? []).length > 0) {
946
+ log(`forget: ${root}'s row is kept as a tombstone - runs still reference it - and is hidden from the picker`);
947
+ }
948
+ return true;
949
+ }, env);
950
+ }
951
+
952
+ // ---------- ops: merge, delete and relaunch, over heartbeat/opTake/opResult (item 2/5) ----------
953
+
954
+ /** Take and act on every merge/delete/relaunch op this beat's heartbeat
955
+ * surfaced. `preview` is NOT here: unlike the other three, it is not a
956
+ * one-shot op - it is a tunnel this process has to keep re-verifying and
957
+ * re-reporting on for as long as it stands, so it needs state across beats
958
+ * rather than one pass per op. `watch()`'s own beat loop owns a
959
+ * `createSharedPreviewService` instance for exactly that, feeding it this
960
+ * same untaken-ops list (`onOps`) and ticking it (`tick`) every beat
961
+ * alongside this function - see there. */
962
+ /** Write an op's result back, CHECKED and RETRIED (2026-09-20).
963
+ *
964
+ * THIS IS THE OTHER HALF OF op-mua5l39p-0bdcf7's STUCK "Deleting...": even
965
+ * once `stopsAndOps` (board-runner/store.mjs) surfaces an op and `opTake`
966
+ * claims it, the git operation's own result used to be logged
967
+ * "succeeded"/"failed" and then fired at `opResult` with the return value
968
+ * never read - so a write-back that itself failed (a token mid-refresh, a
969
+ * dropped connection) left `taken_at` set, `result` still null, the op
970
+ * genuinely done on disk but invisible to the app, and the log line claiming
971
+ * an outcome the row never recorded. The caller's own "succeeded" log line
972
+ * now waits for THIS to report the write landed, not for the git command to
973
+ * finish.
974
+ *
975
+ * THREE TRIES, SHORT BACKOFF: a board-runner call is one HTTP request against
976
+ * a function this same beat already reached (the heartbeat that surfaced the
977
+ * op just answered), so a failure here is almost always a blip worth a
978
+ * second attempt, not a standing outage worth a long wait. If every attempt
979
+ * fails, the op is left exactly where the server-side reclaim window (see
980
+ * `takeOp` in board-runner/store.mjs) expects a stuck claim to be: taken,
981
+ * unresolved, and takeable again once it goes stale - so this failing loud
982
+ * is a log line, never a throw that would leave the beat itself looking
983
+ * broken. */
984
+ async function writeOpResult(runnerApi, opId, result, rowsOpts, log, { attempts = 3, delayMs = 500 } = {}) {
985
+ let last = { ok: false, error: "never attempted" };
986
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
987
+ last = await runnerApi.opResult({ opId, result }, rowsOpts);
988
+ if (last.ok) return last;
989
+ if (attempt < attempts) {
990
+ log(`[${opId}] opResult write-back failed (attempt ${attempt}/${attempts}): ${last.error}; retrying`);
991
+ await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
992
+ }
993
+ }
994
+ log(
995
+ `[${opId}] opResult write-back did NOT land after ${attempts} attempt(s): ${last.error}. ` +
996
+ `The op stays taken with no result recorded - it becomes takeable again once the server's ` +
997
+ `reclaim window passes (see takeOp, board-runner/store.mjs).`,
998
+ );
999
+ return last;
1000
+ }
1001
+
1002
+ export async function serviceRowOps(root, project, ops, settings, log = () => {}) {
1003
+ const runnerApi = settings.runnerApi ?? runnerApiModule;
1004
+ const rowsOpts = { env: settings.env, fetchImpl: settings.fetchImpl };
1005
+ // OVERRIDABLE SO A TEST DOES NOT PAY FOR A REAL BACKOFF: every other
1006
+ // timing knob in this file (`pollMs`, `beatMs`, `killGraceMs`, ...) already
1007
+ // rides `settings`, and a write-back retry belongs beside them rather than
1008
+ // being the one delay a test cannot shorten.
1009
+ const retryOpts = { attempts: settings.opResultAttempts ?? 3, delayMs: settings.opResultRetryMs ?? 500 };
1010
+ let ok = await flushComputerApps(root, project, runnerApi, rowsOpts);
1011
+ for (const op of ops ?? []) {
1012
+ if (op.kind === "computer-apps") {
1013
+ ok = await serviceComputerApps(root, project, op, runnerApi, rowsOpts, settings.lookForComputerApps) && ok;
1014
+ continue;
1015
+ }
1016
+ if (op.kind === "merge" || op.kind === "delete") {
1017
+ const taken = await runnerApi.opTake({ opId: op.id }, rowsOpts);
1018
+ if (!taken.ok) ok = false;
1019
+ if (!taken.ok || !taken.op) continue; // already taken, gone, or not ours
1020
+ const opWorktreePath = taken.op.payload?.worktreePath;
1021
+ if (!opWorktreePath) {
1022
+ ok = false;
1023
+ await writeOpResult(runnerApi, op.id, { ok: false, detail: "No worktreePath on this op." }, rowsOpts, log, retryOpts);
1024
+ continue;
1025
+ }
1026
+ log(`[${op.id}] ${op.kind} requested for ${opWorktreePath}`);
1027
+ const result = op.kind === "merge"
1028
+ ? await mergeWorktree(root, opWorktreePath, log)
1029
+ : await deleteWorktree(root, opWorktreePath, log);
1030
+ // THE CONFLICT TRAVELS WITH THE RESULT (#483): the app reads `conflict`
1031
+ // and `files` and sends Coding back to fit its changes around the
1032
+ // person's. No question is filed here any more: Coding starts the fix,
1033
+ // not the person, so a failed Keep is never a "Retry merge?" ask.
1034
+ const written = await writeOpResult(
1035
+ runnerApi,
1036
+ op.id,
1037
+ {
1038
+ ok: result.ok,
1039
+ detail: result.detail,
1040
+ ...(result.conflict ? { conflict: true, files: result.files ?? [] } : {}),
1041
+ ...(result.ok && typeof result.files === "number" ? { files: result.files } : {}),
1042
+ },
1043
+ rowsOpts,
1044
+ log,
1045
+ retryOpts,
1046
+ );
1047
+ if (!result.ok || !written.ok) ok = false;
1048
+ if (written.ok) log(`[${op.id}] ${op.kind} ${result.ok ? "succeeded" : "failed"}: ${result.detail}`);
1049
+ continue;
1050
+ }
1051
+ if (op.kind === "relaunch") {
1052
+ const taken = await runnerApi.opTake({ opId: op.id }, rowsOpts);
1053
+ if (!taken.ok) ok = false;
1054
+ if (!taken.ok || !taken.op) continue;
1055
+ // THE RECORD: the owning run's own `server` column (mirrored there by
1056
+ // `runnerCompletion.mjs` when the child exits), or the hinted
1057
+ // worktree's own live `.glytch-server.json` file when the row has
1058
+ // none yet - the row equivalent of the file version's
1059
+ // `<id>.server`-then-`.glytch-server.json` fallback chain.
1060
+ const listed = await runnerApi.runs({ projectIds: [project.id] }, rowsOpts);
1061
+ if (!listed.ok) ok = false;
1062
+ const owner = listed.ok ? listed.runs?.find((r) => r.id === op.run_id) : null;
1063
+ const hinted = taken.op.payload?.worktreePath;
1064
+ const record = owner?.server ?? (hinted ? await readWorktreeRecord(hinted) : null);
1065
+ const outcome = await relaunchServer(record, { root });
1066
+ const written = await writeOpResult(runnerApi, op.id, { ok: outcome.ok, detail: outcome.detail }, rowsOpts, log, retryOpts);
1067
+ if (!outcome.ok || !written.ok) ok = false;
1068
+ if (written.ok) log(`[${op.id}] relaunch ${outcome.ok ? "ok" : "refused"}: ${outcome.detail}`);
1069
+ // THE FRESH RECORD LANDS ON THE RUN ROW, the row equivalent of the
1070
+ // file version's second write to `<id>.server` - a later relaunch (or
1071
+ // lib/serverSweep.mjs) reads the SAME copy this one just wrote.
1072
+ if (outcome.ok && outcome.record && owner) {
1073
+ const updated = await runnerApi.update({ runId: owner.id, patch: { server: outcome.record } }, rowsOpts);
1074
+ if (!updated.ok) ok = false;
1075
+ }
1076
+ }
1077
+ }
1078
+ if (!ok) log("Operation results remain pending; retained Look answers will retry on heartbeat.");
1079
+ return { ok };
1080
+ }
1081
+
1082
+ // ---------- the loop ----------
1083
+
1084
+ /** The sessions THIS process has started and not yet seen finish. Unchanged
1085
+ * from the file-based runner - per-process, not persisted, because what
1086
+ * survives a restart is the CLAIM (server-side now), and inventing a second
1087
+ * durable record of the same fact is how the two would start disagreeing. */
1088
+ export function makePool() {
1089
+ const active = new Map();
1090
+ return {
1091
+ ids: () => [...active.keys()],
1092
+ size: () => active.size,
1093
+ add(id, promise) {
1094
+ const done = Promise.resolve(promise).finally(() => active.delete(id));
1095
+ active.set(id, done);
1096
+ return done;
1097
+ },
1098
+ drain: () => Promise.allSettled([...active.values()]),
1099
+ };
1100
+ }
1101
+
1102
+ /** One pass, waited out - for tests and for a person asking for a single
1103
+ * pass with an answer at the end of it. */
1104
+ export async function pollOnce(root, settings, log = () => {}) {
1105
+ const reg = await registerAndFindProject(root, settings);
1106
+ if (!reg.ok || !reg.myProjectId) return { ran: [], skipped: [{ reason: reg.error ?? "could not register this project" }] };
1107
+ const project = { id: reg.myProjectId, path: root };
1108
+ if (!(await flushCompletions(project, settings, log))) return { ran: [], skipped: [{ reason: "Saved completions are waiting to sync." }] };
1109
+ const attempt = await claimOne(root, project, settings, log);
1110
+ if (!attempt.claimed) return { ran: [], skipped: [{ reason: attempt.detail }] };
1111
+ const result = await runRequest(project, attempt.run, settings, log);
1112
+ return { ran: [result] };
1113
+ }
1114
+
1115
+ /** Poll until stopped by `signal` or until this folder is removed. */
1116
+ export async function watch(root, settings, { signal, log = () => {}, startPreviewTransport } = {}) {
1117
+ if (settings.enabled !== true) {
1118
+ throw new Error('The runner is off. Set PM_SPAWN_RUNNER=on and run the watch command by hand.');
1119
+ }
1120
+ log(`watching ${root}: ${settings.maxConcurrent === Infinity ? "any number at once" : `up to ${settings.maxConcurrent} at once`}, binary "${settings.claudeBin ?? "per request"}"`);
1121
+
1122
+ const pool = makePool();
1123
+ const stopped = settings.stopped ?? new Set();
1124
+ settings.stopped = stopped;
1125
+ let project = null;
1126
+ // ONE HEARTBEAT AND ONE REGISTER FOR THE WHOLE PROCESS (#616). multiwatch
1127
+ // hands every folder the same hub and registrar; a lone watch gets its own.
1128
+ const beatMs = settings.beatMs ?? DEFAULTS.beatMs;
1129
+ // PUSH (#813): `settings.realtime` is the process's channel
1130
+ // (lib/runnerRealtime.mjs). While it is joined the beat is every 5 minutes
1131
+ // and events bring the claim, the Stop and the ops forward; while it is not,
1132
+ // or with none, the beat is every 10 s as before.
1133
+ const realtime = settings.realtime ?? null;
1134
+ const beatBase = () => beatBaseMs(realtime, beatMs, settings.pushBeatMs);
1135
+ // A failed answer is kept only as long as a 10 s beat would, never the
1136
+ // 5 minutes a joined success lives, so a retry really asks (#813 review).
1137
+ const heartbeats = settings.heartbeats ??= makeHeartbeatHub({ ttlMs: () => 0.9 * beatBase(), failureTtlMs: 0.9 * beatMs });
1138
+ settings.registrar ??= makeRegistrar({ ttlMs: REGISTER_REFRESH_MS, failureTtlMs: 0.9 * beatMs });
1139
+ // WHETHER A RUN IS WAITING HERE (#616). The heartbeat's `pending` names the
1140
+ // folders with an approved run nobody has claimed, so the claim loop only
1141
+ // calls `claim` when this folder is among them. null: the function predates
1142
+ // `pending`, and the loop polls `claim` as it always did.
1143
+ let claimHint = null;
1144
+ // Bumped whenever claimHint becomes true or null, so a hint that arrives
1145
+ // while a claim or heartbeat is in flight is not overwritten by its
1146
+ // older empty answer (#813 review, #1048).
1147
+ let hintSeq = 0;
1148
+ let nudge = null;
1149
+ const nudged = () => (nudge ??= (() => {
1150
+ let resolve;
1151
+ const promise = new Promise((r) => { resolve = r; });
1152
+ return { promise, resolve };
1153
+ })()).promise;
1154
+ const wake = () => {
1155
+ nudge?.resolve();
1156
+ nudge = null;
1157
+ };
1158
+ // A waiting run pushed to this folder ends the claim loop's wait too, not
1159
+ // only its no-call wait above (#813).
1160
+ let kick = null;
1161
+ const kicked = () => (kick ??= (() => {
1162
+ let resolve;
1163
+ const promise = new Promise((r) => { resolve = r; });
1164
+ return { promise, resolve };
1165
+ })()).promise;
1166
+ const kickClaim = () => {
1167
+ kick?.resolve();
1168
+ kick = null;
1169
+ };
1170
+ // Both CLI entry points and multiwatch arrive here. Always service preview
1171
+ // rows through the member gateway; tests can replace the public transport,
1172
+ // but the membership and target-ownership checks stay on this path. Missing
1173
+ // access configuration becomes an error on the op, without exposing a port.
1174
+ const previewService = createSharedPreviewService({
1175
+ runnerApi: settings.runnerApi ?? runnerApiModule,
1176
+ hostedCallTool: settings.hostedCallTool ?? callRunnerTool,
1177
+ env: settings.env,
1178
+ fetchImpl: settings.fetchImpl,
1179
+ startTunnel: (origin, context) => createMemberPreviewTunnel(origin, context, {
1180
+ env: settings.env, startTransport: startPreviewTransport,
1181
+ }),
1182
+ });
1183
+ let previewClosing;
1184
+ const closePreview = () => previewClosing ??= previewService.close().catch((error) => {
1185
+ log(`preview shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
1186
+ });
1187
+ // Abort startup too: the preview worker may still be waiting for a tunnel URL.
1188
+ const onAbort = () => { wake(); void closePreview(); };
1189
+ signal?.addEventListener("abort", onAbort, { once: true });
1190
+
1191
+ // THIS FOLDER HAS BEEN FORGOTTEN (2026-09-12, ruling 3). Once a forget
1192
+ // op has been serviced, this loop stops registering, stops beating and stops
1193
+ // claiming and exits, so multiwatch can restart it if init re-adds it.
1194
+ // Without it the beat would re-register the folder it just unregistered (`registerAndFindProject`
1195
+ // falls back to `[root]` when the registry is empty, which is exactly the
1196
+ // case after forgetting the last folder on a machine) and the person's
1197
+ // Remove would undo itself. multiwatch aborts this watcher within the
1198
+ // minute anyway - this is what holds the line until it does.
1199
+ let forgotten = false;
1200
+ // THE DETACHED DEV SERVERS this folder's finished runs left behind, checked
1201
+ // once a day off the beat (lib/serverSweep.mjs). Rows, not files: the
1202
+ // record lives on the run row since brief 30.
1203
+ let serverSweptAt = 0;
1204
+ const sweepServers = async () => {
1205
+ if (Date.now() - serverSweptAt < (settings.serverSweepMs ?? SERVER_SWEEP_INTERVAL_MS)) return;
1206
+ serverSweptAt = Date.now();
1207
+ try {
1208
+ await sweepDetachedServers(project, settings, { log });
1209
+ } catch (error) {
1210
+ log(`dev server sweep failed: ${error instanceof Error ? error.message : String(error)}`);
1211
+ }
1212
+ };
1213
+ // Recheck periodically too: the same watcher may lose touch and recover
1214
+ // more than once without restarting its own process (#1252).
1215
+ let restartDueAt = 0;
1216
+ let previewTick;
1217
+ const tickPreview = () => {
1218
+ if (previewTick || previewClosing) return;
1219
+ // Download/connect can take minutes. The preview owns that wait; runner
1220
+ // presence, Stop/forget delivery and first-run admission must keep moving.
1221
+ previewTick = previewService.tick()
1222
+ .catch((error) => log(`preview tick failed: ${error instanceof Error ? error.message : String(error)}`))
1223
+ .finally(() => { previewTick = null; });
1224
+ };
1225
+
1226
+ // Ownership misses (including a deleted id) use this exact refusal. After
1227
+ // glytch login changes accounts, retry registration on the next beat without
1228
+ // letting either shared cache hand back the old account's project id.
1229
+ const clearRefusedProject = (answer) => {
1230
+ if (!project || answer.ok !== false || answer.transient === true || answer.error !== "not yours") return;
1231
+ heartbeats.leave(project.id);
1232
+ realtime?.setFolder(project.id, false);
1233
+ project = null;
1234
+ claimHint = false;
1235
+ settings.registrar.invalidate?.();
1236
+ };
1237
+
1238
+ // The Retry-After of the last beat that failed, for the beat's own pace.
1239
+ let beatRetryAfterMs;
1240
+ const runBeat = async () => {
1241
+ beatRetryAfterMs = undefined;
1242
+ if (forgotten) return;
1243
+ const registered = project !== null;
1244
+ // A known folder must hear Remove before register can clear forgotten_at
1245
+ // through its link RPC. Only startup needs register to obtain the id.
1246
+ if (!registered) {
1247
+ const reg = await registerAndFindProject(root, settings);
1248
+ if (!reg.ok) {
1249
+ log(`could not register with the board: ${reg.error}`);
1250
+ beatRetryAfterMs = reg.retryAfterMs;
1251
+ return reg.transient ? "failed" : "steady";
1252
+ }
1253
+ if (!reg.myProjectId) {
1254
+ log(`this folder is not among the projects the board acknowledged; nothing will be claimed`);
1255
+ return "steady";
1256
+ }
1257
+ project = { id: reg.myProjectId, path: root };
1258
+ realtime?.setFolder(project.id, true);
1259
+ }
1260
+ const flushed = await flushCompletions(project, settings, log);
1261
+ const runnerApi = settings.runnerApi ?? runnerApiModule;
1262
+ const seen = hintSeq;
1263
+ const beat = await heartbeats.beat(project.id, (projectIds) => runnerApi.heartbeat({ projectIds, presence: presenceBody(settings) }, { env: settings.env, fetchImpl: settings.fetchImpl }));
1264
+ if (!beat.ok) {
1265
+ clearRefusedProject(beat);
1266
+ log(`heartbeat failed: ${beat.error}`);
1267
+ beatRetryAfterMs = beat.retryAfterMs;
1268
+ return beat.transient ? "failed" : "steady";
1269
+ }
1270
+ // The shared answer holds every folder's ops; keep this folder's. An op
1271
+ // naming no project is only taken from an answer for this folder alone.
1272
+ const alone = (beat.coveredProjectIds ?? [project.id]).every((id) => id === project.id);
1273
+ const ops = (beat.ops ?? []).filter((op) => (op.project_id ? op.project_id === project.id : alone));
1274
+ if (Array.isArray(beat.pending)) {
1275
+ claimHint = beat.pending.includes(project.id) || hintSeq !== seen;
1276
+ if (claimHint) hintSeq += 1, wake();
1277
+ } else {
1278
+ claimHint = null;
1279
+ hintSeq += 1;
1280
+ wake();
1281
+ }
1282
+ try { await settings.onHealthy?.(root); }
1283
+ catch (error) { log(`Could not write local connection check: ${error.message}`); }
1284
+ // Stops are run ids: another folder's in this set is never acted on.
1285
+ for (const runId of beat.stops ?? []) stopped.add(runId);
1286
+ let opsFailed = false;
1287
+ if (ops.length > 0) {
1288
+ // FIRST, and it returns early: there is no point merging a worktree or
1289
+ // standing a preview up for a folder that is leaving this machine.
1290
+ try {
1291
+ if (await serviceForgetOps(root, project, ops, settings, log)) {
1292
+ forgotten = true;
1293
+ heartbeats.leave(project.id);
1294
+ realtime?.setFolder(project.id, false);
1295
+ wake();
1296
+ await closePreview();
1297
+ return "work";
1298
+ }
1299
+ } catch (error) {
1300
+ opsFailed = true;
1301
+ log(`forget failed: ${error instanceof Error ? error.message : String(error)}`);
1302
+ }
1303
+ }
1304
+ // Retry retained Look answers even though claimed requests are absent
1305
+ // from later heartbeat operations. Forget above always takes priority.
1306
+ try { opsFailed = !(await serviceRowOps(root, project, ops, settings, log)).ok || opsFailed; }
1307
+ catch (error) {
1308
+ opsFailed = true;
1309
+ log(`ops sweep failed: ${error instanceof Error ? error.message : String(error)}`);
1310
+ }
1311
+ if (ops.length > 0) {
1312
+ try { await previewService.onOps(ops, project.id); }
1313
+ catch (error) {
1314
+ opsFailed = true;
1315
+ log(`preview claim failed: ${error instanceof Error ? error.message : String(error)}`);
1316
+ }
1317
+ }
1318
+ // The shared register names ALL folders. If another folder is leaving,
1319
+ // let its watcher consume the same forget before refreshing that list.
1320
+ // A failed heartbeat or forget never reaches a register that could erase it.
1321
+ if (registered && !opsFailed && !(beat.ops ?? []).some((op) => op.kind === "forget")) {
1322
+ const refreshSettings = {
1323
+ ...settings,
1324
+ registrar: {
1325
+ register: (key, send) => settings.registrar.register(key, async () => {
1326
+ // Row ops may have awaited a slow merge after the beat snapshot.
1327
+ // Bypass the heartbeat cache, only when register is actually due.
1328
+ // The remaining read/write gap is closed server-side by
1329
+ // https://github.com/glytch-ai/glytch/issues/1065.
1330
+ const rowsOpts = { env: settings.env, fetchImpl: settings.fetchImpl };
1331
+ // The registrar key is the exact path list captured by send().
1332
+ // Presence includes forgotten rows; scope to this register's host.
1333
+ const listed = await runnerApi.presence({ paths: JSON.parse(key) }, rowsOpts);
1334
+ if (!listed.ok) return listed;
1335
+ const projectIds = (listed.projects ?? [])
1336
+ .filter((p) => p.host === (settings.host ?? "local"))
1337
+ .map((p) => p.id);
1338
+ // New paths have no id yet and cannot have a pending forget.
1339
+ // This bare heartbeat only bumps last_seen_at: it neither replaces
1340
+ // presence nor claims, acknowledges or marks ops delivered.
1341
+ const latest = projectIds.length > 0
1342
+ ? await runnerApi.heartbeat({ projectIds }, rowsOpts)
1343
+ : { ok: true, ops: [] };
1344
+ if (!latest.ok) return latest;
1345
+ if ((latest.ops ?? []).some((op) => op.kind === "forget")) {
1346
+ return { ok: false, error: "Registration deferred: a folder removal is pending." };
1347
+ }
1348
+ return send();
1349
+ }),
1350
+ },
1351
+ };
1352
+ const reg = await registerAndFindProject(root, refreshSettings);
1353
+ settings.noGit = refreshSettings.noGit;
1354
+ if (!reg.ok) {
1355
+ clearRefusedProject(reg);
1356
+ log(`could not register with the board: ${reg.error}`);
1357
+ beatRetryAfterMs = reg.retryAfterMs;
1358
+ return reg.transient ? "failed" : "steady";
1359
+ }
1360
+ if (!reg.myProjectId) {
1361
+ log(`this folder is not among the projects the board acknowledged; nothing will be claimed`);
1362
+ return "steady";
1363
+ }
1364
+ }
1365
+ // THE RESTART PASS (brief 52, ruling 7): sessions the last process left
1366
+ // running pick up where they were. Only once receipts that were saved but
1367
+ // not yet delivered have gone out, or a run that really finished would be
1368
+ // continued over its own ending.
1369
+ if (flushed && Date.now() >= restartDueAt) {
1370
+ restartDueAt = Date.now() + (settings.restartRecheckMs ?? RESTART_RECHECK_MS);
1371
+ try { await restartPass(project, settings, { log }); }
1372
+ catch (error) { log(`restart pass failed: ${error instanceof Error ? error.message : String(error)}`); }
1373
+ }
1374
+ await sweepServers();
1375
+ tickPreview();
1376
+ // Use the existing failure backoff while an answer is waiting, rather
1377
+ // than the five-minute idle cadence used with realtime connected.
1378
+ return opsFailed ? "failed" : "work";
1379
+ };
1380
+ let beatInFlight;
1381
+ let finished = false;
1382
+ // THE PACE OF BOTH LOOPS (ticket e8, lib/runnerPace.mjs). The beat keeps
1383
+ // its 10 s while the board answers and backs off to 60 s on 5xx or network
1384
+ // failures; the claim loop does the same under errors. While the queue
1385
+ // stays empty it makes no call at all when the beat carries `pending`, and
1386
+ // slows to 30 s against an older function that does not (#616).
1387
+ const beatPacer = beatPace(settings, DEFAULTS.beatMs);
1388
+ const claimPacer = claimPace(settings);
1389
+ // The claim loop's wait. A test passes `settings.sleep` to step the loop
1390
+ // and read the waits it asks for, instead of timing them on a wall clock.
1391
+ const wait = settings.sleep ?? sleep;
1392
+ let beatDelay = beatPacer.work();
1393
+ // A failed beat backs off from 10 s whatever the cadence; any other waits
1394
+ // the cadence of the moment: 5 minutes while joined, 10 s while not.
1395
+ const beat = () => {
1396
+ if (forgotten || finished) return Promise.resolve();
1397
+ // Serialize the row sweeps, independently of the long-lived preview work.
1398
+ return beatInFlight ??= runBeat()
1399
+ .then((outcome) => {
1400
+ if (outcome === "failed") beatDelay = beatPacer.failed(beatRetryAfterMs);
1401
+ else {
1402
+ beatPacer.work();
1403
+ beatDelay = beatBase();
1404
+ }
1405
+ }, (error) => {
1406
+ beatPacer.steady();
1407
+ beatDelay = beatBase();
1408
+ throw error;
1409
+ })
1410
+ .finally(() => { beatInFlight = null; });
1411
+ };
1412
+ const beatFailed = (error) => log(`heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
1413
+ // A chain of timeouts rather than an interval, so the wait after each beat
1414
+ // can depend on how that beat went. One timer at most: every schedule
1415
+ // replaces the last.
1416
+ let beating;
1417
+ let beatAgain = false;
1418
+ const scheduleBeat = () => {
1419
+ clearTimeout(beating);
1420
+ if (forgotten || finished) return;
1421
+ const delay = beatAgain ? 0 : beatDelay;
1422
+ beatAgain = false;
1423
+ beating = setTimeout(() => {
1424
+ void beat().catch(beatFailed).finally(scheduleBeat);
1425
+ }, delay);
1426
+ beating.unref?.();
1427
+ };
1428
+ // A beat now, for an event: after the one in flight if there is one.
1429
+ const beatNow = () => {
1430
+ if (forgotten || finished) return;
1431
+ if (beatInFlight) {
1432
+ beatAgain = true;
1433
+ return;
1434
+ }
1435
+ clearTimeout(beating);
1436
+ void beat().catch(beatFailed).finally(scheduleBeat);
1437
+ };
1438
+
1439
+ // WHAT THE CHANNEL SAYS (#813). Each event becomes the call this loop
1440
+ // already makes; none of them claims or stops by itself.
1441
+ // - joined: one heartbeat now, the catch-up for whatever was sent while
1442
+ // the socket was down, then every 5 minutes
1443
+ // - left: back to every 10 s, from now
1444
+ // - pending (this folder): claim now, through claimOne (#364)
1445
+ // - stop: the run id joins `stopped`, which the session checks every 2 s
1446
+ // - op (this folder): a heartbeat now, which hands the op over
1447
+ // `at` is the same for every folder, so the hub still sends one heartbeat
1448
+ // for all of them.
1449
+ const onPush = (event) => {
1450
+ if (forgotten || finished) return;
1451
+ const payload = event.payload ?? {};
1452
+ const mine = () => project !== null && (payload.project_id ?? project.id) === project.id;
1453
+ if (event.type === "joined") {
1454
+ heartbeats.invalidate(event.at);
1455
+ beatNow();
1456
+ } else if (event.type === "left") {
1457
+ heartbeats.invalidate(event.at);
1458
+ beatDelay = beatBase();
1459
+ if (!beatInFlight) scheduleBeat();
1460
+ } else if (event.type === "pending" && mine()) {
1461
+ claimHint = true;
1462
+ hintSeq += 1;
1463
+ wake();
1464
+ kickClaim();
1465
+ } else if (event.type === "stop" && typeof payload.run_id === "string") {
1466
+ if (project === null || mine()) stopped.add(payload.run_id);
1467
+ } else if (event.type === "op" && mine()) {
1468
+ heartbeats.invalidate(event.at);
1469
+ beatNow();
1470
+ }
1471
+ };
1472
+ const unsubscribePush = realtime?.subscribe(onPush);
1473
+ // `glytch init` asking for a fresh beat (multiwatch's beatRequests).
1474
+ const unsubscribeBeatAsk = settings.beatRequests?.subscribe((at) => {
1475
+ if (forgotten || finished) return;
1476
+ heartbeats.invalidate(at);
1477
+ beatNow();
1478
+ });
1479
+ // The preview keeps its own 10 s tick, whatever the beat: a tunnel that
1480
+ // dropped restarts within seconds, not at the next 5-minute beat. With no
1481
+ // preview open it makes no call.
1482
+ const previewTimer = setInterval(tickPreview, beatMs);
1483
+ previewTimer.unref?.();
1484
+
1485
+ // A bare nudge promise does not keep Node alive when the queue is empty.
1486
+ // Own liveness here, independently of the unref'd heartbeat/backoff timers.
1487
+ const keepAlive = setInterval(() => {}, 2 ** 30);
1488
+ try {
1489
+ if (signal?.aborted) return;
1490
+ await beat();
1491
+ scheduleBeat();
1492
+ while (!signal?.aborted && !forgotten) {
1493
+ // The base interval, without touching the pace's counts, when nothing
1494
+ // was asked of the board this pass (no project yet, or forgotten).
1495
+ let delay = settings.pollMs;
1496
+ // `forgotten` gates the claim loop too: a folder on its way off this
1497
+ // machine must not pick up one last run on the way out.
1498
+ if (project && !forgotten && claimHint === false) {
1499
+ // Nothing waits here: no call until a beat says otherwise (#616).
1500
+ await nudged();
1501
+ continue;
1502
+ }
1503
+ if (project && !forgotten) {
1504
+ const seen = hintSeq;
1505
+ try {
1506
+ const attempt = await claimOne(root, project, settings);
1507
+ if (attempt.claimed) {
1508
+ const promise = pool.add(attempt.run.id, runRequest(project, attempt.run, settings, log).catch((error) => {
1509
+ log(`[${attempt.run.id}] failed: ${error instanceof Error ? error.message : String(error)}`);
1510
+ return { id: attempt.run.id, state: "failed", detail: error instanceof Error ? error.message : String(error) };
1511
+ }));
1512
+ // A slot is free: a run refused at the limit is claimed now, not
1513
+ // at the next beat, which joined is 5 minutes away (#813 review).
1514
+ void promise.finally(() => {
1515
+ if (forgotten || finished) return;
1516
+ if (claimHint !== null) claimHint = true;
1517
+ hintSeq += 1;
1518
+ wake();
1519
+ kickClaim();
1520
+ });
1521
+ delay = claimPacer.work();
1522
+ } else if (attempt.transient) {
1523
+ delay = claimPacer.failed(attempt.retryAfterMs);
1524
+ log(`waiting ${Math.round(delay / 1000)}s: ${attempt.detail}`);
1525
+ } else if (attempt.idle) {
1526
+ // Snap back to the base interval while a run is active here.
1527
+ delay = pool.size() > 0 ? claimPacer.work() : claimPacer.idle();
1528
+ // The run the beat saw went elsewhere: wait for the next one. The
1529
+ // idle pace still applies first, so a row the beat keeps naming
1530
+ // but claim never hands out is asked about at most every 30 s.
1531
+ if (claimHint === true && hintSeq === seen) claimHint = false;
1532
+ } else {
1533
+ delay = claimPacer.steady();
1534
+ if (attempt.detail) log(`waiting: ${attempt.detail}`);
1535
+ if (claimHint === true && hintSeq === seen) claimHint = false;
1536
+ }
1537
+ } catch (error) {
1538
+ log(`poll failed: ${error instanceof Error ? error.message : String(error)}`);
1539
+ }
1540
+ // A run pushed while this claim was in flight: ask again now.
1541
+ if (hintSeq !== seen && !signal?.aborted) continue;
1542
+ }
1543
+ await Promise.race([wait(delay, signal), kicked()]);
1544
+ }
1545
+ } finally {
1546
+ await closePreview();
1547
+ try {
1548
+ // Keep presence and stop delivery alive while already-running sessions
1549
+ // finish, as before; the closed preview service cannot take new ops.
1550
+ if (pool.size() > 0) {
1551
+ log(`stopping: waiting for ${pool.size()} session(s) already running to finish.`);
1552
+ await pool.drain();
1553
+ }
1554
+ } finally {
1555
+ finished = true;
1556
+ unsubscribePush?.();
1557
+ unsubscribeBeatAsk?.();
1558
+ clearInterval(keepAlive);
1559
+ clearInterval(previewTimer);
1560
+ clearTimeout(beating);
1561
+ signal?.removeEventListener("abort", onAbort);
1562
+ if (beatInFlight) await beatInFlight;
1563
+ if (project) {
1564
+ heartbeats.leave(project.id);
1565
+ realtime?.setFolder(project.id, false);
1566
+ }
1567
+ }
1568
+ }
1569
+ }