viewable-one 0.0.0-stage → 0.12.2

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