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
@@ -0,0 +1,388 @@
1
+ // ANSWERS AND MESSAGES OVER ROWS (brief 30, W2b, 2026-09-11).
2
+ //
3
+ // A runner-spawned child now reaches the board through the hosted endpoint
4
+ // (lib/hosted.mjs, brief 30 W2a) under its own run key (X-Glytch-Session,
5
+ // session.key) instead of a locally-spawned stdio server. This file is what
6
+ // replaces the mirror-and-receipt push runner.mjs used to make while the
7
+ // child was still running - `answerPoll`'s `latestGraphWriteMs` scan and
8
+ // `undeliveredAnswers` over `inbox/ops/*.result.txt` receipts and `canvas/`
9
+ // (see the dated retirement note above `undeliveredAnswers` in
10
+ // questions.mjs). The new door is the SAME one everything else on the hosted
11
+ // endpoint uses: `collect_session`, the tool `check_answer` already used to
12
+ // answer "did HE answer me", now asked for the whole run.
13
+ //
14
+ // `collect_session` (supabase/functions/board-mcp/tools.mjs) already matches
15
+ // "this run's own rows" server-side - every question stamped with THIS run's
16
+ // sessionKey/reportKey (`applyOps` stamps every node it creates with
17
+ // `data.sessionKey: store.sessionKey`, and `X-Glytch-Session` is what makes
18
+ // that the RUN's key rather than the runner's own OAuth token's), and every
19
+ // `session_messages` row addressed to it, drained (`delivered_at` stamped)
20
+ // on read (supabase/functions/_shared/rows.mjs `takeMessages`). What the
21
+ // endpoint does NOT deduplicate is an ANSWERED QUESTION: it returns every
22
+ // question this key ever asked that carries an answer, on every single call,
23
+ // so a poller has to remember which ones it has already delivered. A MESSAGE
24
+ // is different - the server marks it delivered the moment it is fetched, so
25
+ // `delivered_at` is what survives a runner restart for it, the same way a
26
+ // file's absence is what survives one for a reply file.
27
+ //
28
+ // THE TOOL'S ANSWER IS TEXT FOR A MODEL, not a second JSON API - there is no
29
+ // structured door into the same call, so this parses the exact shape the
30
+ // handler writes (its "ANSWERED QUESTIONS:" / "MESSAGES FROM OTHER SESSIONS:"
31
+ // / "next_since:" lines, `flat()`-ed to one line per row on that end already,
32
+ // which is what makes a per-line parser safe here). The sections also come in
33
+ // one fixed order, so the parser takes each header once and only moving
34
+ // forward: a header that turns up again or out of order is text, never a
35
+ // section. Messages come last, so nothing a peer sends (its label or its
36
+ // text) can open the answered section and pass as the human's ruling (#324).
37
+ //
38
+ // A MESSAGE'S IMAGES (2026-09-11, brief 30 Move 2 M1b) ride the same text
39
+ // reply, one line below the message they belong to - a bare JSON object,
40
+ // the same "put a JSON block in a text result" idiom get_node already uses
41
+ // for a node's own body. `parseCollected` below reads it back onto the
42
+ // message object as `images`. Keep the payload intact so each transport
43
+ // validates it before delivery; invalid pictures must not become text-only
44
+ // success or disappear from late-reply recovery.
45
+
46
+ import { createHash } from "node:crypto";
47
+ import { callTool as hostedCallTool } from "./hosted.mjs";
48
+
49
+ /** Parse collect_session's reply into rows a poller can act on. Tolerant of
50
+ * every shape the tool actually writes: the "Nothing collected" sentence
51
+ * (no section headers at all, just an intro line and `next_since:`), a
52
+ * subset of the four sections, or all of them.
53
+ *
54
+ * IMAGES (2026-09-11, brief 30 Move 2 M1b): a message that carries pictures
55
+ * gets one more line right after its own, ` {"images":[...]}` - the exact
56
+ * shape board-mcp/tools.mjs's collect_session handler writes. It is parsed
57
+ * here and folded onto the message object that line follows, as `images`;
58
+ * a message with none gets no such line and no such field, so a caller
59
+ * that has never heard of images reads this exactly as it always has. A
60
+ * malformed or misplaced JSON line (no preceding message in this poll,
61
+ * or not valid JSON) is ignored rather than thrown - the same tolerance
62
+ * this function already extends to a section header it does not
63
+ * recognize. */
64
+ // The order collect_session writes its sections in, and what each one is
65
+ // read as here (null: not read).
66
+ const SECTIONS = [
67
+ ["ANSWERED QUESTIONS:", "answered"],
68
+ ["STILL OPEN:", null],
69
+ ["COMMENTS HE WROTE:", null],
70
+ ["MESSAGES FROM OTHER SESSIONS:", "messages"],
71
+ ];
72
+
73
+ export function parseCollected(text) {
74
+ const answered = [];
75
+ const messages = [];
76
+ let nextSince = 0;
77
+ let section = null;
78
+ let reached = -1;
79
+ for (const line of String(text ?? "").split("\n")) {
80
+ const since = line.match(/^next_since:\s*(\d+)/);
81
+ if (since) {
82
+ nextSince = Number(since[1]);
83
+ continue;
84
+ }
85
+ const header = SECTIONS.findIndex(([marker]) => marker === line);
86
+ if (header !== -1) {
87
+ // Only forward, each once: a repeated or out-of-order header is a
88
+ // line someone wrote, not the server's (#324).
89
+ if (header > reached) {
90
+ reached = header;
91
+ section = SECTIONS[header][1];
92
+ }
93
+ continue;
94
+ }
95
+ if (section === "answered") {
96
+ const m = line.match(/^ {2}(\S+) "(.*)" -> (.*)$/);
97
+ if (m) answered.push({ id: m[1], question: m[2], decision: m[3] });
98
+ } else if (section === "messages") {
99
+ const m = line.match(/^ {2}\[message from (.*?)\] (.*)$/);
100
+ if (m) {
101
+ messages.push({ from: m[1], text: m[2] });
102
+ continue;
103
+ }
104
+ const imagesLine = line.match(/^ {2}(\{.*\})$/);
105
+ if (imagesLine && messages.length > 0) {
106
+ try {
107
+ const parsed = JSON.parse(imagesLine[1]);
108
+ // The row's id (item 75): what the next call's `delivered` names.
109
+ if (typeof parsed.id === "number" || typeof parsed.id === "string") messages[messages.length - 1].id = parsed.id;
110
+ if (Object.hasOwn(parsed, "images")) {
111
+ messages[messages.length - 1].images = parsed.images;
112
+ }
113
+ } catch {
114
+ /* not the images line after all - leave the message as it was */
115
+ }
116
+ }
117
+ }
118
+ }
119
+ return { answered, messages, nextSince };
120
+ }
121
+
122
+ /** One call to collect_session, resolved and parsed. Never throws: a network
123
+ * or auth failure is logged and read the same as an empty poll - a spawned
124
+ * child's own turn is not this call's to fail. */
125
+ export async function pollCollectSession({
126
+ callTool = hostedCallTool,
127
+ sessionKey,
128
+ since = 0,
129
+ env,
130
+ log = () => {},
131
+ // THE IDS THE CHILD TOOK since the last call (item 75). Always sent, so
132
+ // the endpoint hands messages back unstamped until one names them.
133
+ delivered = [],
134
+ } = {}) {
135
+ let result;
136
+ try {
137
+ result = await callTool("collect_session", { since, delivered }, { sessionKey, env });
138
+ } catch (error) {
139
+ log(`collect_session failed: ${error instanceof Error ? error.message : String(error)}`);
140
+ return { ok: false, answered: [], messages: [], nextSince: since };
141
+ }
142
+ if (result.isError) {
143
+ log(`collect_session refused: ${result.text}`);
144
+ return { ok: false, answered: [], messages: [], nextSince: since };
145
+ }
146
+ // IMAGES RIDE ALONGSIDE THE TEXT REPLY, WHOLE-CALL (item 6): there is no
147
+ // per-message attribution in a flattened text protocol, so they are
148
+ // attached below only when this poll's whole batch is unambiguous - see
149
+ // `createRowsAnswerPoll`. Converted to the SAME data-URL shape a reply
150
+ // file's `images` array already carries (`data:<mediaType>;base64,<data>`
151
+ // - src/folder/spawnRequest.ts:376, child.mjs's `imageAttachments`), so
152
+ // `userMessage`/`imageAttachments` need no second shape to understand.
153
+ const images = (result.images ?? []).map((i) => `data:${i.mediaType};base64,${i.data}`);
154
+ return { ok: true, ...parseCollected(result.text), images };
155
+ }
156
+
157
+ /** The words a child reads for a ruling that arrived after ask_user had
158
+ * already returned. Byte for byte what the old mirror-based `answerPoll`
159
+ * said, so a session told about a late answer reads the same sentence it
160
+ * always has. */
161
+ export const formatAnswer = (a) =>
162
+ `The human answered a question you asked on the board. It arrived after ` +
163
+ `ask_user had already returned, so this is the only place you will see it.\n\n` +
164
+ `Q: ${a.question}\n` +
165
+ `A: ${a.decision}\n\n` +
166
+ `That is his ruling. Act on it, and do not ask it again.`;
167
+
168
+ /** The same attribution wrapper a reply file's `from` field gets
169
+ * (child.mjs's `watchReplies`), so a peer's message reads identically
170
+ * whichever door delivered it. */
171
+ export const formatMessage = (m) => `[message from ${m.from}] ${m.text}`;
172
+
173
+ /** Preserve the complete attachment payload for transport validation and
174
+ * late-reply recovery. Presence, not validity, chooses the per-message
175
+ * payload over the legacy single-message fallback. */
176
+ function deliverable(message, fallback = []) {
177
+ return { text: formatMessage(message), images: message.images !== undefined ? message.images : fallback };
178
+ }
179
+
180
+ /** A stateful poller: one `since` watermark plus one delivered-question set,
181
+ * both scoped to this run and kept in memory only. A runner restart loses
182
+ * them - accepted the same way `scannedMirrorAt` accepted it before this
183
+ * (SPAWN.md, "a runner that dies mid-session leaks its concurrency slot") -
184
+ * and a MESSAGE needs no such set at all: the endpoint's own `delivered_at`
185
+ * is what keeps it from coming back, in this process or the next.
186
+ *
187
+ * `poll()` is answerPoll-shaped for child.mjs's `runStreamingChild`: an
188
+ * array of `{text, onDelivered}`. `onDelivered` marks a QUESTION delivered;
189
+ * a message needs no bookkeeping on success, because the server already
190
+ * will not hand it back. If `sendToChild` never calls `onDelivered` (the
191
+ * child's stdin was already gone), the question stays undelivered and is
192
+ * picked up by `promoteLateRows` below - a message in that same situation
193
+ * has already been marked delivered server-side and is the one gap this
194
+ * design accepts, spelled out where `promoteLateRows` is defined. */
195
+ /** How close a repeat of the last message must follow it to be dropped. */
196
+ export const REPEAT_WINDOW_MS = 30_000;
197
+
198
+ export function createRowsAnswerPoll({ callTool = hostedCallTool, sessionKey, env, log = () => {}, now = Date.now, realtime, pushPollMs = 60_000 } = {}) {
199
+ let since = 0;
200
+ let dirty = true;
201
+ let lastPoll = -Infinity;
202
+ let wake = null;
203
+ const unsubscribe = realtime?.subscribe?.((event) => {
204
+ if (event.type !== "joined" && event.type !== "left" &&
205
+ !(event.type === "reply" && event.payload?.session_key === sessionKey)) return;
206
+ dirty = true;
207
+ wake?.();
208
+ });
209
+ const delivered = new Set();
210
+ // DELIVERY IS THE CHILD TAKING IT (round 4 item 75): a message's id goes
211
+ // into `toAck` only once `sendToChild` accepted it (its onDelivered), and
212
+ // the next call carries those ids so the endpoint stamps them. Until then
213
+ // the endpoint keeps handing the row back; `offered` skips the ones already
214
+ // put in front of the child. A message with no id (an older endpoint) is
215
+ // stamped on read there, as before.
216
+ const offered = new Set();
217
+ let toAck = [];
218
+ const took = (m) => { if (m.id !== undefined) toAck.push(m.id); };
219
+ // The last message put in front of this session (brief 52 round 1, Codex
220
+ // review: two app instances deliver the same held note, since every open
221
+ // tab delivers). The same sender, words and pictures again within
222
+ // REPEAT_WINDOW_MS is that second copy, and is dropped.
223
+ let last = null;
224
+ const repeats = (m, images) => {
225
+ const key = JSON.stringify([m.from, m.text, images]);
226
+ const at = now();
227
+ if (last !== null && last.key === key && at - last.at <= REPEAT_WINDOW_MS) {
228
+ if (last.accepted) took(m);
229
+ else last.copies.push(m);
230
+ return true;
231
+ }
232
+ last = { key, at, accepted: false, copies: [] };
233
+ return false;
234
+ };
235
+ const poll = async () => {
236
+ // Delivery acknowledgements also fetch the next queued batch. Only an
237
+ // idle session may wait for the push safety interval.
238
+ if (realtime?.joined && !dirty && toAck.length === 0 && now() - lastPoll < pushPollMs) return [];
239
+ dirty = false;
240
+ lastPoll = now();
241
+ // Only retire IDs serialized into this request, not deliveries that
242
+ // arrive while collect_session is in flight.
243
+ const acking = toAck.slice();
244
+ const collected = await pollCollectSession({ callTool, sessionKey, since, env, log, delivered: acking });
245
+ if (!collected.ok) { dirty = true; return []; }
246
+ toAck = toAck.filter((id) => !acking.includes(id));
247
+ if (collected.nextSince > since) since = collected.nextSince;
248
+ const out = [];
249
+ for (const a of collected.answered) {
250
+ if (delivered.has(a.id)) continue;
251
+ out.push({
252
+ deliveryKey: `answer:${a.id}`,
253
+ text: formatAnswer(a),
254
+ onDelivered: async () => {
255
+ delivered.add(a.id);
256
+ log(`delivered the answer to ${a.id}`);
257
+ },
258
+ });
259
+ }
260
+ // IMAGES, PER MESSAGE (brief 52 ruling 10). collect_session writes a
261
+ // message's pictures on the line right under it, so `parseCollected` has
262
+ // already put them on the message they belong to. The old whole-call
263
+ // `collected.images` (native image blocks on the reply, which the tool
264
+ // does not send) is the fallback for a batch of exactly one message.
265
+ for (const m of collected.messages) {
266
+ if (m.id !== undefined && offered.has(m.id)) continue;
267
+ if (m.id !== undefined) offered.add(m.id);
268
+ const delivery = deliverable(m, collected.messages.length === 1 ? collected.images : []);
269
+ if (repeats(m, delivery.images)) {
270
+ // A copy is acknowledged only after the original was accepted.
271
+ // In particular, invalid images must leave every source row unacked.
272
+ log(`dropped a repeat of the last message from ${m.from}`);
273
+ continue;
274
+ }
275
+ const pending = last;
276
+ out.push({
277
+ text: delivery.text,
278
+ ...(!(Array.isArray(delivery.images) && delivery.images.length === 0) ? { images: delivery.images } : {}),
279
+ onDelivered: async () => {
280
+ if (pending.accepted) return;
281
+ pending.accepted = true;
282
+ took(m);
283
+ for (const copy of pending.copies) took(copy);
284
+ pending.copies.length = 0;
285
+ log(`delivered a message from ${m.from}`);
286
+ },
287
+ });
288
+ }
289
+ return out;
290
+ };
291
+ // A child installs its existing delivery callback; retries replace it.
292
+ poll.subscribe = (fn) => { wake = fn; };
293
+ return {
294
+ close: () => { wake = null; unsubscribe?.(); },
295
+ poll,
296
+ since: () => since,
297
+ hasDelivered: (id) => delivered.has(id),
298
+ /** A question the runner asked for ITSELF (a permission question, see
299
+ * permissionAsk.mjs) and reads the answer to on its own. Marked delivered
300
+ * so neither the live poll nor the late sweep hands it to the session as
301
+ * a message: its answer resolves the pending request instead. */
302
+ claim: (id) => { delivered.add(id); },
303
+ /** Ids the child took that no call has stamped yet; the late sweep sends them. */
304
+ pendingAcks: () => toAck.slice(),
305
+ acked: (ids) => { toAck = toAck.filter((id) => !ids.includes(id)); },
306
+ };
307
+ }
308
+
309
+ /** A row-derived id stable enough to survive a duplicate promotion attempt
310
+ * without filing twice - the same reservation trick `lateReplyRequestId`
311
+ * uses for a reply FILE (hash the thing that names it), spelled for a row
312
+ * instead of a filename. Messages carry no id of their own from
313
+ * collect_session's text reply, so their tag is a hash of who sent it and
314
+ * what it said; two identical messages from the same sender promote to the
315
+ * same request, which is the same content-addressed behaviour a repeated
316
+ * reply FILE already has under `lateReplyRequestId`. */
317
+ export const lateRowRequestId = (requestId, tag) =>
318
+ `spawn-reply-${createHash("sha256").update(`${requestId} ${tag}`).digest("hex")}`;
319
+
320
+ /** After the child has exited, ONE more collect_session call - the row
321
+ * equivalent of `promoteLateReplies` sweeping a `.reply-*.json` written
322
+ * after stdin closed. A late ANSWER or MESSAGE for a PERSISTENT session
323
+ * becomes a resume request through the exact function a late reply file
324
+ * already uses (`promoteReplyToRequest`, fed a row instead of a file) -
325
+ * the worktree, the resume session id, and "was this ever approved" are
326
+ * all its, unchanged; it already refuses (returns null) for a one-shot
327
+ * session via its own `acceptsMessages` check, which is what makes "log
328
+ * and drop, same as today" the natural result of calling it here too
329
+ * rather than a second branch to keep in sync with the first.
330
+ *
331
+ * KNOWN GAP, spelled out rather than hidden: a MESSAGE that arrived WHILE
332
+ * the child was still running, in the same poll tick the child happened to
333
+ * exit on, is marked delivered server-side by that poll (collect_session's
334
+ * own `takeMessages` stamps `delivered_at` on fetch, not on successful
335
+ * delivery to a pipe) whether or not `sendToChild` actually got to write
336
+ * it - `child.mjs`'s `watchAnswers` breaks out of its delivery loop the
337
+ * moment the child is settled, before calling `onDelivered`. Such a
338
+ * message will not be seen again by THIS poll (it is no longer
339
+ * undelivered on the server) or by this sweep (it is not "late" - it was
340
+ * already fetched). It is the one situation this design does not recover,
341
+ * and it is symmetric with why a message needs no in-memory delivered set
342
+ * at all: the same field that makes redelivery impossible also makes this
343
+ * one narrow race unrecoverable. An ANSWERED QUESTION has no such gap - it
344
+ * carries no delivered_at, so it is simply asked for again next time,
345
+ * here or on the next live poll. */
346
+ export async function promoteLateRows(
347
+ root,
348
+ id,
349
+ // `promote` has no default: the only caller left, `runnerCompletion.mjs`,
350
+ // always passes `runnerApi.resume` (item 6 of brief 30 Move 2b) - there is
351
+ // no file-based request to fall back to promoting any more
352
+ // (`lateReplies.mjs`, deleted the same pass).
353
+ { poller, callTool = hostedCallTool, sessionKey, env, promote } = {},
354
+ log = () => {},
355
+ ) {
356
+ // THE LAST ACKS GO WITH THE SWEEP (item 75): what the child took is
357
+ // stamped here, and what comes back is what never reached it.
358
+ const acking = poller.pendingAcks?.() ?? [];
359
+ const collected = await pollCollectSession({ callTool, sessionKey, since: poller.since(), env, log, delivered: acking });
360
+ if (!collected.ok) return { filed: [], dropped: [] };
361
+ poller.acked?.(acking);
362
+ const late = [];
363
+ for (const a of collected.answered) {
364
+ if (poller.hasDelivered(a.id)) continue;
365
+ late.push({ tag: `q:${a.id}`, text: formatAnswer(a) });
366
+ }
367
+ for (const m of collected.messages) {
368
+ const hash = createHash("sha256").update(`${m.from} ${m.text}`).digest("hex").slice(0, 16);
369
+ const delivery = deliverable(m, collected.messages.length === 1 ? collected.images : []);
370
+ late.push({ tag: `m:${hash}`, text: delivery.text, images: delivery.images, message: { from: m.from, text: m.text } });
371
+ }
372
+ const filed = [];
373
+ // The messages no run will carry (item 75): completion records them on
374
+ // the sidecar so the card can say "did not reach the session".
375
+ const dropped = [];
376
+ for (const item of late) {
377
+ const promotionId = lateRowRequestId(id, item.tag);
378
+ const newId = await promote(root, id, { text: item.text, attributed: item.text, images: item.images }, log, promotionId);
379
+ if (newId) {
380
+ filed.push(newId);
381
+ log(`[${id}] a late row (${item.tag}) arrived after the session ended; filed ${newId} to continue it`);
382
+ } else {
383
+ if (item.message) dropped.push(item.message);
384
+ log(`[${id}] a late row (${item.tag}) arrived after a one-shot session ended; logged and dropped`);
385
+ }
386
+ }
387
+ return { filed, dropped };
388
+ }
@@ -0,0 +1,86 @@
1
+ // Live transcript chunks are authoritative. Sends are serialized, and a
2
+ // failed batch retains its sequence number: board-runner upserts on
3
+ // (run_id, seq), so retrying after a lost HTTP response cannot duplicate it.
4
+ import { createHash } from "node:crypto";
5
+
6
+ /** Stream a run's log to hosted rows. tick() is safe to call without awaiting
7
+ * it; settle() closes input and drains all earlier sends before returning.
8
+ * Failed events remain queued for the next tick or settlement retry. */
9
+ export function makeRowEventStreamer({ send, log = () => {} } = {}) {
10
+ let seq = 0;
11
+ let pending = "";
12
+ let closed = false;
13
+ let received = 0;
14
+ const streamedHash = createHash("sha256");
15
+ const queue = [];
16
+ let inFlight = null;
17
+ let settlement = null;
18
+
19
+ const enqueue = (kind, text) => {
20
+ if (text !== "") queue.push({ seq: ++seq, kind, text });
21
+ };
22
+ const flushPending = () => {
23
+ enqueue("log", pending);
24
+ pending = "";
25
+ };
26
+ const drain = () => {
27
+ if (inFlight) return inFlight;
28
+ inFlight = (async () => {
29
+ while (queue.length > 0) {
30
+ const row = queue[0];
31
+ try {
32
+ const outcome = await send([row]);
33
+ if (!outcome.ok) throw new Error(outcome.error ?? "unknown error");
34
+ } catch (error) {
35
+ log(`events flush failed (seq ${row.seq}): ${error?.message ?? error}`);
36
+ return false;
37
+ }
38
+ queue.shift();
39
+ }
40
+ return true;
41
+ })().finally(() => { inFlight = null; });
42
+ return inFlight;
43
+ };
44
+
45
+ return {
46
+ onChunk(chunk) {
47
+ if (closed) return;
48
+ pending += chunk;
49
+ received += chunk.length;
50
+ streamedHash.update(chunk);
51
+ },
52
+ async tick() {
53
+ if (closed) return;
54
+ flushPending();
55
+ return drain();
56
+ },
57
+ settle(finalText, { dropped = 0 } = {}) {
58
+ if (settlement) return settlement;
59
+ if (!closed) {
60
+ closed = true;
61
+ flushPending();
62
+ const text = finalText ?? "";
63
+ // A capped ring or a retry receipt can rewrite the final text. It
64
+ // cannot be indexed by a live-stream offset. Only append a suffix
65
+ // when the final view has the exact streamed prefix; otherwise the
66
+ // live chunks already carry the transcript without those wrappers.
67
+ if (received === 0) enqueue("log", text);
68
+ else if (dropped === 0 && text.length > received &&
69
+ createHash("sha256").update(text.slice(0, received)).digest("hex") === streamedHash.digest("hex")) {
70
+ enqueue("log", text.slice(received));
71
+ }
72
+ if (dropped > 0) enqueue("cut", `[The local log summary elided ${dropped} characters; the streamed transcript is retained above.]`);
73
+ }
74
+ settlement = (async () => {
75
+ // An earlier drain may already have observed an empty queue before
76
+ // its promise settled; run a fresh drain for the closing rows too.
77
+ if (inFlight) await inFlight;
78
+ let ok = await drain();
79
+ // Retry once at exit, using the same immutable rows and sequence ids.
80
+ if (!ok) ok = await drain();
81
+ return { ok };
82
+ })().finally(() => { settlement = null; });
83
+ return settlement;
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,16 @@
1
+ // RunEngine is the boundary shared by every coding engine. The runner owns
2
+ // approval, worktrees, receipts, and budgets; adapters own only their child
3
+ // protocol and translate its events to the existing session-log contract.
4
+ import { runChild, runStreamingChild } from "./child.mjs";
5
+ import { runAcpChild } from "./acp.mjs";
6
+ import { runCodexChild } from "./codex.mjs";
7
+
8
+ /** @typedef {{run: Function, streaming: Function, acceptsMessages: boolean}} RunEngine */
9
+ /** @type {Record<string, RunEngine>} */
10
+ export const RUN_ENGINES = {
11
+ "stream-json": { run: runChild, streaming: runStreamingChild, acceptsMessages: true },
12
+ acp: { run: runAcpChild, streaming: runAcpChild, acceptsMessages: true },
13
+ "codex-app-server": { run: runCodexChild, streaming: runCodexChild, acceptsMessages: true },
14
+ process: { run: runChild, streaming: runChild, acceptsMessages: false },
15
+ };
16
+ export const runEngineFor = (spec) => RUN_ENGINES[spec.protocol ?? (spec.streamingArgs ? "stream-json" : "process")];
@@ -0,0 +1,57 @@
1
+ // Local proof of a running process, separate from package.json on disk.
2
+ // Written only after a successful hosted heartbeat. No credentials or content.
3
+ import { randomUUID } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
5
+ import { mkdir } from "node:fs/promises";
6
+ import { writeFileAtomic } from "./atomic.mjs";
7
+ import { join } from "node:path";
8
+ import { viewableHome } from "./registry.mjs";
9
+
10
+ export function readRunnerHealth(env = process.env, name = "health") {
11
+ try { return JSON.parse(readFileSync(join(viewableHome(env), `${name}.json`), "utf8")); }
12
+ catch { return null; }
13
+ }
14
+
15
+ export async function writeRunnerHealth(value, env = process.env, name = "health") {
16
+ const home = viewableHome(env);
17
+ await mkdir(home, { recursive: true });
18
+ await writeFileAtomic(join(home, `${name}.json`), JSON.stringify(value), "utf8", { mode: 0o600 });
19
+ }
20
+
21
+ export function makeRunnerHealth({ env = process.env, version, now = Date.now, instance = randomUUID() } = {}) {
22
+ const startedAt = now();
23
+ const paths = {};
24
+ let pending = Promise.resolve();
25
+ return {
26
+ beat(path) {
27
+ paths[path] = now();
28
+ const value = { instance, startedAt, version, paths: { ...paths } };
29
+ pending = pending.catch(() => {}).then(() => writeRunnerHealth(value, env));
30
+ return pending;
31
+ },
32
+ mark() {
33
+ const proof = readRunnerHealth(env, "cli");
34
+ return proof && proof.version === version ? { cliVersion: version, setupId: proof.setupId } : {};
35
+ },
36
+ };
37
+ }
38
+
39
+ export async function waitForRunnerHealth({
40
+ env = process.env, version, after = 0, previousInstance, root,
41
+ read = () => readRunnerHealth(env), now = Date.now,
42
+ pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
43
+ timeoutMs = 45_000, requireStartedAfter = true,
44
+ } = {}) {
45
+ const deadline = now() + timeoutMs;
46
+ do {
47
+ const health = read();
48
+ const beats = root ? [health?.paths?.[root]] : Object.values(health?.paths ?? {});
49
+ if (health?.version === version && health.instance && health.instance !== previousInstance &&
50
+ (!requireStartedAfter || health.startedAt >= after) && beats.some((at) => typeof at === "number" && at >= after && now() - at < 30_000)) {
51
+ return { ok: true, health };
52
+ }
53
+ if (now() >= deadline) break;
54
+ await pause(500);
55
+ } while (now() <= deadline);
56
+ return { ok: false, error: "No fresh connection from the installed version. Run viewable doctor, then try again. Folders and sign-in were kept." };
57
+ }