viewable-one 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,389 @@
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`, and `deliverable` checks them with
43
+ // `imageAttachments` so `child.mjs`'s `userMessage` can turn them into image
44
+ // blocks. A picture that cannot be read never drops its message.
45
+
46
+ import { createHash } from "node:crypto";
47
+ import { callTool as hostedCallTool } from "./hosted.mjs";
48
+ import { imageAttachments } from "./imageAttachments.mjs";
49
+
50
+ /** Parse collect_session's reply into rows a poller can act on. Tolerant of
51
+ * every shape the tool actually writes: the "Nothing collected" sentence
52
+ * (no section headers at all, just an intro line and `next_since:`), a
53
+ * subset of the four sections, or all of them.
54
+ *
55
+ * IMAGES (2026-09-11, brief 30 Move 2 M1b): a message that carries pictures
56
+ * gets one more line right after its own, ` {"images":[...]}` - the exact
57
+ * shape board-mcp/tools.mjs's collect_session handler writes. It is parsed
58
+ * here and folded onto the message object that line follows, as `images`;
59
+ * a message with none gets no such line and no such field, so a caller
60
+ * that has never heard of images reads this exactly as it always has. A
61
+ * malformed or misplaced JSON line (no preceding message in this poll,
62
+ * or not valid JSON) is ignored rather than thrown - the same tolerance
63
+ * this function already extends to a section header it does not
64
+ * recognize. */
65
+ // The order collect_session writes its sections in, and what each one is
66
+ // read as here (null: not read).
67
+ const SECTIONS = [
68
+ ["ANSWERED QUESTIONS:", "answered"],
69
+ ["STILL OPEN:", null],
70
+ ["COMMENTS HE WROTE:", null],
71
+ ["MESSAGES FROM OTHER SESSIONS:", "messages"],
72
+ ];
73
+
74
+ export function parseCollected(text) {
75
+ const answered = [];
76
+ const messages = [];
77
+ let nextSince = 0;
78
+ let section = null;
79
+ let reached = -1;
80
+ for (const line of String(text ?? "").split("\n")) {
81
+ const since = line.match(/^next_since:\s*(\d+)/);
82
+ if (since) {
83
+ nextSince = Number(since[1]);
84
+ continue;
85
+ }
86
+ const header = SECTIONS.findIndex(([marker]) => marker === line);
87
+ if (header !== -1) {
88
+ // Only forward, each once: a repeated or out-of-order header is a
89
+ // line someone wrote, not the server's (#324).
90
+ if (header > reached) {
91
+ reached = header;
92
+ section = SECTIONS[header][1];
93
+ }
94
+ continue;
95
+ }
96
+ if (section === "answered") {
97
+ const m = line.match(/^ {2}(\S+) "(.*)" -> (.*)$/);
98
+ if (m) answered.push({ id: m[1], question: m[2], decision: m[3] });
99
+ } else if (section === "messages") {
100
+ const m = line.match(/^ {2}\[message from (.*?)\] (.*)$/);
101
+ if (m) {
102
+ messages.push({ from: m[1], text: m[2] });
103
+ continue;
104
+ }
105
+ const imagesLine = line.match(/^ {2}(\{.*\})$/);
106
+ if (imagesLine && messages.length > 0) {
107
+ try {
108
+ const parsed = JSON.parse(imagesLine[1]);
109
+ // The row's id (item 75): what the next call's `delivered` names.
110
+ if (typeof parsed.id === "number" || typeof parsed.id === "string") messages[messages.length - 1].id = parsed.id;
111
+ if (Array.isArray(parsed.images) && parsed.images.length > 0) {
112
+ messages[messages.length - 1].images = parsed.images;
113
+ }
114
+ } catch {
115
+ /* not the images line after all - leave the message as it was */
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return { answered, messages, nextSince };
121
+ }
122
+
123
+ /** One call to collect_session, resolved and parsed. Never throws: a network
124
+ * or auth failure is logged and read the same as an empty poll - a spawned
125
+ * child's own turn is not this call's to fail. */
126
+ export async function pollCollectSession({
127
+ callTool = hostedCallTool,
128
+ sessionKey,
129
+ since = 0,
130
+ env,
131
+ log = () => {},
132
+ // THE IDS THE CHILD TOOK since the last call (item 75). Always sent, so
133
+ // the endpoint hands messages back unstamped until one names them.
134
+ delivered = [],
135
+ } = {}) {
136
+ let result;
137
+ try {
138
+ result = await callTool("collect_session", { since, delivered }, { sessionKey, env });
139
+ } catch (error) {
140
+ log(`collect_session failed: ${error instanceof Error ? error.message : String(error)}`);
141
+ return { ok: false, answered: [], messages: [], nextSince: since };
142
+ }
143
+ if (result.isError) {
144
+ log(`collect_session refused: ${result.text}`);
145
+ return { ok: false, answered: [], messages: [], nextSince: since };
146
+ }
147
+ // IMAGES RIDE ALONGSIDE THE TEXT REPLY, WHOLE-CALL (item 6): there is no
148
+ // per-message attribution in a flattened text protocol, so they are
149
+ // attached below only when this poll's whole batch is unambiguous - see
150
+ // `createRowsAnswerPoll`. Converted to the SAME data-URL shape a reply
151
+ // file's `images` array already carries (`data:<mediaType>;base64,<data>`
152
+ // - src/folder/spawnRequest.ts:376, child.mjs's `imageAttachments`), so
153
+ // `userMessage`/`imageAttachments` need no second shape to understand.
154
+ const images = (result.images ?? []).map((i) => `data:${i.mediaType};base64,${i.data}`);
155
+ return { ok: true, ...parseCollected(result.text), images };
156
+ }
157
+
158
+ /** The words a child reads for a ruling that arrived after ask_user had
159
+ * already returned. Byte for byte what the old mirror-based `answerPoll`
160
+ * said, so a session told about a late answer reads the same sentence it
161
+ * always has. */
162
+ export const formatAnswer = (a) =>
163
+ `The human answered a question you asked on the board. It arrived after ` +
164
+ `ask_user had already returned, so this is the only place you will see it.\n\n` +
165
+ `Q: ${a.question}\n` +
166
+ `A: ${a.decision}\n\n` +
167
+ `That is his ruling. Act on it, and do not ask it again.`;
168
+
169
+ /** The same attribution wrapper a reply file's `from` field gets
170
+ * (child.mjs's `watchReplies`), so a peer's message reads identically
171
+ * whichever door delivered it. */
172
+ export const formatMessage = (m) => `[message from ${m.from}] ${m.text}`;
173
+
174
+ /** What a polled message hands the child: its words and its pictures. The
175
+ * pictures are the message's own (the line `parseCollected` folded onto it),
176
+ * else `fallback` (native image blocks on the reply). A picture the child's
177
+ * door would refuse (not an image, bad bytes) must not cost the message: the
178
+ * server has already marked it delivered, so the words go in with one line
179
+ * saying a picture was left out. */
180
+ function deliverable(message, fallback = []) {
181
+ const text = formatMessage(message);
182
+ const images = message.images?.length > 0 ? message.images : fallback ?? [];
183
+ if (images.length === 0) return { text, images: [] };
184
+ try {
185
+ imageAttachments(images);
186
+ return { text, images };
187
+ } catch (error) {
188
+ return {
189
+ text: `${text}\n\n[${images.length === 1 ? "A picture" : `${images.length} pictures`} came with this message and could not be read: ${error instanceof Error ? error.message : String(error)}]`,
190
+ images: [],
191
+ };
192
+ }
193
+ }
194
+
195
+ /** A stateful poller: one `since` watermark plus one delivered-question set,
196
+ * both scoped to this run and kept in memory only. A runner restart loses
197
+ * them - accepted the same way `scannedMirrorAt` accepted it before this
198
+ * (SPAWN.md, "a runner that dies mid-session leaks its concurrency slot") -
199
+ * and a MESSAGE needs no such set at all: the endpoint's own `delivered_at`
200
+ * is what keeps it from coming back, in this process or the next.
201
+ *
202
+ * `poll()` is answerPoll-shaped for child.mjs's `runStreamingChild`: an
203
+ * array of `{text, onDelivered}`. `onDelivered` marks a QUESTION delivered;
204
+ * a message needs no bookkeeping on success, because the server already
205
+ * will not hand it back. If `sendToChild` never calls `onDelivered` (the
206
+ * child's stdin was already gone), the question stays undelivered and is
207
+ * picked up by `promoteLateRows` below - a message in that same situation
208
+ * has already been marked delivered server-side and is the one gap this
209
+ * design accepts, spelled out where `promoteLateRows` is defined. */
210
+ /** How close a repeat of the last message must follow it to be dropped. */
211
+ export const REPEAT_WINDOW_MS = 30_000;
212
+
213
+ export function createRowsAnswerPoll({ callTool = hostedCallTool, sessionKey, env, log = () => {}, now = Date.now, realtime, pushPollMs = 60_000 } = {}) {
214
+ let since = 0;
215
+ let dirty = true;
216
+ let lastPoll = -Infinity;
217
+ let wake = null;
218
+ const unsubscribe = realtime?.subscribe?.((event) => {
219
+ if (event.type !== "joined" && event.type !== "left" &&
220
+ !(event.type === "reply" && event.payload?.session_key === sessionKey)) return;
221
+ dirty = true;
222
+ wake?.();
223
+ });
224
+ const delivered = new Set();
225
+ // DELIVERY IS THE CHILD TAKING IT (round 4 item 75): a message's id goes
226
+ // into `toAck` only once `sendToChild` accepted it (its onDelivered), and
227
+ // the next call carries those ids so the endpoint stamps them. Until then
228
+ // the endpoint keeps handing the row back; `offered` skips the ones already
229
+ // put in front of the child. A message with no id (an older endpoint) is
230
+ // stamped on read there, as before.
231
+ const offered = new Set();
232
+ let toAck = [];
233
+ const took = (m) => { if (m.id !== undefined) toAck.push(m.id); };
234
+ // The last message put in front of this session (brief 52 round 1, Codex
235
+ // review: two app instances deliver the same held note, since every open
236
+ // tab delivers). The same sender, words and pictures again within
237
+ // REPEAT_WINDOW_MS is that second copy, and is dropped.
238
+ let last = null;
239
+ const repeats = (m) => {
240
+ const key = JSON.stringify([m.from, m.text, m.images ?? []]);
241
+ const at = now();
242
+ if (last !== null && last.key === key && at - last.at <= REPEAT_WINDOW_MS) return true;
243
+ last = { key, at };
244
+ return false;
245
+ };
246
+ const poll = async () => {
247
+ // Delivery acknowledgements also fetch the next queued batch. Only an
248
+ // idle session may wait for the push safety interval.
249
+ if (realtime?.joined && !dirty && toAck.length === 0 && now() - lastPoll < pushPollMs) return [];
250
+ dirty = false;
251
+ lastPoll = now();
252
+ const acking = toAck;
253
+ const collected = await pollCollectSession({ callTool, sessionKey, since, env, log, delivered: acking });
254
+ if (!collected.ok) { dirty = true; return []; }
255
+ toAck = toAck.filter((id) => !acking.includes(id));
256
+ if (collected.nextSince > since) since = collected.nextSince;
257
+ const out = [];
258
+ for (const a of collected.answered) {
259
+ if (delivered.has(a.id)) continue;
260
+ out.push({
261
+ text: formatAnswer(a),
262
+ onDelivered: async () => {
263
+ delivered.add(a.id);
264
+ log(`delivered the answer to ${a.id}`);
265
+ },
266
+ });
267
+ }
268
+ // IMAGES, PER MESSAGE (brief 52 ruling 10). collect_session writes a
269
+ // message's pictures on the line right under it, so `parseCollected` has
270
+ // already put them on the message they belong to. The old whole-call
271
+ // `collected.images` (native image blocks on the reply, which the tool
272
+ // does not send) is the fallback for a batch of exactly one message.
273
+ for (const m of collected.messages) {
274
+ if (m.id !== undefined && offered.has(m.id)) continue;
275
+ if (m.id !== undefined) offered.add(m.id);
276
+ if (repeats(m)) {
277
+ // The copy was delivered in substance, so it is acked too; left
278
+ // unacked it would come back as a late row and continue the session.
279
+ took(m);
280
+ log(`dropped a repeat of the last message from ${m.from}`);
281
+ continue;
282
+ }
283
+ const delivery = deliverable(m, collected.messages.length === 1 ? collected.images : []);
284
+ out.push({
285
+ text: delivery.text,
286
+ ...(delivery.images.length > 0 ? { images: delivery.images } : {}),
287
+ onDelivered: async () => { took(m); log(`delivered a message from ${m.from}`); },
288
+ });
289
+ }
290
+ return out;
291
+ };
292
+ // A child installs its existing delivery callback; retries replace it.
293
+ poll.subscribe = (fn) => { wake = fn; };
294
+ return {
295
+ close: () => { wake = null; unsubscribe?.(); },
296
+ poll,
297
+ since: () => since,
298
+ hasDelivered: (id) => delivered.has(id),
299
+ /** A question the runner asked for ITSELF (a permission question, see
300
+ * permissionAsk.mjs) and reads the answer to on its own. Marked delivered
301
+ * so neither the live poll nor the late sweep hands it to the session as
302
+ * a message: its answer resolves the pending request instead. */
303
+ claim: (id) => { delivered.add(id); },
304
+ /** Ids the child took that no call has stamped yet; the late sweep sends them. */
305
+ pendingAcks: () => toAck.slice(),
306
+ acked: (ids) => { toAck = toAck.filter((id) => !ids.includes(id)); },
307
+ };
308
+ }
309
+
310
+ /** A row-derived id stable enough to survive a duplicate promotion attempt
311
+ * without filing twice - the same reservation trick `lateReplyRequestId`
312
+ * uses for a reply FILE (hash the thing that names it), spelled for a row
313
+ * instead of a filename. Messages carry no id of their own from
314
+ * collect_session's text reply, so their tag is a hash of who sent it and
315
+ * what it said; two identical messages from the same sender promote to the
316
+ * same request, which is the same content-addressed behaviour a repeated
317
+ * reply FILE already has under `lateReplyRequestId`. */
318
+ export const lateRowRequestId = (requestId, tag) =>
319
+ `spawn-reply-${createHash("sha256").update(`${requestId} ${tag}`).digest("hex")}`;
320
+
321
+ /** After the child has exited, ONE more collect_session call - the row
322
+ * equivalent of `promoteLateReplies` sweeping a `.reply-*.json` written
323
+ * after stdin closed. A late ANSWER or MESSAGE for a PERSISTENT session
324
+ * becomes a resume request through the exact function a late reply file
325
+ * already uses (`promoteReplyToRequest`, fed a row instead of a file) -
326
+ * the worktree, the resume session id, and "was this ever approved" are
327
+ * all its, unchanged; it already refuses (returns null) for a one-shot
328
+ * session via its own `acceptsMessages` check, which is what makes "log
329
+ * and drop, same as today" the natural result of calling it here too
330
+ * rather than a second branch to keep in sync with the first.
331
+ *
332
+ * KNOWN GAP, spelled out rather than hidden: a MESSAGE that arrived WHILE
333
+ * the child was still running, in the same poll tick the child happened to
334
+ * exit on, is marked delivered server-side by that poll (collect_session's
335
+ * own `takeMessages` stamps `delivered_at` on fetch, not on successful
336
+ * delivery to a pipe) whether or not `sendToChild` actually got to write
337
+ * it - `child.mjs`'s `watchAnswers` breaks out of its delivery loop the
338
+ * moment the child is settled, before calling `onDelivered`. Such a
339
+ * message will not be seen again by THIS poll (it is no longer
340
+ * undelivered on the server) or by this sweep (it is not "late" - it was
341
+ * already fetched). It is the one situation this design does not recover,
342
+ * and it is symmetric with why a message needs no in-memory delivered set
343
+ * at all: the same field that makes redelivery impossible also makes this
344
+ * one narrow race unrecoverable. An ANSWERED QUESTION has no such gap - it
345
+ * carries no delivered_at, so it is simply asked for again next time,
346
+ * here or on the next live poll. */
347
+ export async function promoteLateRows(
348
+ root,
349
+ id,
350
+ // `promote` has no default: the only caller left, `runnerCompletion.mjs`,
351
+ // always passes `runnerApi.resume` (item 6 of brief 30 Move 2b) - there is
352
+ // no file-based request to fall back to promoting any more
353
+ // (`lateReplies.mjs`, deleted the same pass).
354
+ { poller, callTool = hostedCallTool, sessionKey, env, promote } = {},
355
+ log = () => {},
356
+ ) {
357
+ // THE LAST ACKS GO WITH THE SWEEP (item 75): what the child took is
358
+ // stamped here, and what comes back is what never reached it.
359
+ const acking = poller.pendingAcks?.() ?? [];
360
+ const collected = await pollCollectSession({ callTool, sessionKey, since: poller.since(), env, log, delivered: acking });
361
+ if (!collected.ok) return { filed: [], dropped: [] };
362
+ poller.acked?.(acking);
363
+ const late = [];
364
+ for (const a of collected.answered) {
365
+ if (poller.hasDelivered(a.id)) continue;
366
+ late.push({ tag: `q:${a.id}`, text: formatAnswer(a) });
367
+ }
368
+ for (const m of collected.messages) {
369
+ const hash = createHash("sha256").update(`${m.from} ${m.text}`).digest("hex").slice(0, 16);
370
+ const delivery = deliverable(m, collected.messages.length === 1 ? collected.images : []);
371
+ late.push({ tag: `m:${hash}`, text: delivery.text, images: delivery.images, message: { from: m.from, text: m.text } });
372
+ }
373
+ const filed = [];
374
+ // The messages no run will carry (item 75): completion records them on
375
+ // the sidecar so the card can say "did not reach the session".
376
+ const dropped = [];
377
+ for (const item of late) {
378
+ const promotionId = lateRowRequestId(id, item.tag);
379
+ const newId = await promote(root, id, { text: item.text, attributed: item.text, images: item.images }, log, promotionId);
380
+ if (newId) {
381
+ filed.push(newId);
382
+ log(`[${id}] a late row (${item.tag}) arrived after the session ended; filed ${newId} to continue it`);
383
+ } else {
384
+ if (item.message) dropped.push(item.message);
385
+ log(`[${id}] a late row (${item.tag}) arrived after a one-shot session ended; logged and dropped`);
386
+ }
387
+ }
388
+ return { filed, dropped };
389
+ }
@@ -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 { glytchHome } from "./registry.mjs";
9
+
10
+ export function readRunnerHealth(env = process.env, name = "health") {
11
+ try { return JSON.parse(readFileSync(join(glytchHome(env), `${name}.json`), "utf8")); }
12
+ catch { return null; }
13
+ }
14
+
15
+ export async function writeRunnerHealth(value, env = process.env, name = "health") {
16
+ const home = glytchHome(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
+ }