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,659 @@
1
+ // THE DEV SERVER A SESSION LEFT RUNNING ON PURPOSE, and how it is stopped later.
2
+ //
3
+ // The ruling, 2026-09-06, option A of three. Since brief 19's addendum a
4
+ // finished session EXITS the moment its result envelope lands ("the worktree
5
+ // is the session's life"), and the 2026-09-06 test pass caught what that costs
6
+ // a browser frame: the session ran `npx serve -l 8765` in the background, filed
7
+ // the frame with that URL, finished, and the server died with the child
8
+ // seconds later. The frame on the board pointed at a dead port - curl to
9
+ // 127.0.0.1:8765 got nothing, no listener (evidence:
10
+ // ~/Projects/glytch-e2e/.glytch/inbox/spawn/spawn-mtq5kdpg-9d7131.log).
11
+ //
12
+ // Two other options were on the table and he rejected both: keeping the child
13
+ // alive while a frame exists (it walks back the addendum) and greying the
14
+ // frame out at DONE (it makes the feature a screenshot). So the server is
15
+ // started DETACHED from the session's own process, it outlives the session,
16
+ // and something later stops it. This file is the "later".
17
+ //
18
+ // THE RECORD IS THE WHOLE CONTRACT. The session writes ONE file in the root of
19
+ // its worktree, `.glytch-server.json`:
20
+ //
21
+ // { "pid": 41231, "port": 8765, "cmd": "npx serve -l 8765",
22
+ // "startedAt": "2026-09-06T21:03:11.412Z" }
23
+ //
24
+ // The runner mirrors it into the spawn channel as `<id>.server` when the child
25
+ // ends, because the worktree can be deleted (merge, delete, cleanWorktree) and
26
+ // a record that dies with the tree cannot stop anything. `.server` does not end
27
+ // in `.json` on purpose: the request scan lists `*.json` and would otherwise
28
+ // read it as a spawn request, which is the exact bug SESSION_SIDECAR_SUFFIX
29
+ // exists to remember (47 sidecars refused as requests). The sweep's `${id}.`
30
+ // rule removes it with everything else the request owns.
31
+ //
32
+ // AND THE PID IS NEVER TRUSTED ON ITS OWN. A record can outlive its process by
33
+ // days, and pids are reused: SIGTERM to a stale pid is a SIGTERM to whatever
34
+ // the machine handed that number next, which on his Mac is as likely to be his
35
+ // editor as anything. So `verifyRecordedServer` has to find POSITIVE evidence
36
+ // that the process alive under that pid is the one the record describes, and
37
+ // anything short of that is logged and left alone. Nothing here ever kills on
38
+ // doubt.
39
+
40
+ import { execFile, spawn as spawnProcess } from "node:child_process";
41
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
42
+ import { join } from "node:path";
43
+
44
+ import { killTree } from "./kill.mjs";
45
+ import { devServerEnv } from "./child.mjs";
46
+ import { runnableCommand } from "./windows-command.mjs";
47
+ import { resolveContainedCwd } from "./worktree.mjs";
48
+
49
+ /** The file a session writes in the root of its worktree. Named in the spawn
50
+ * instruction (src/store/spawnPayload.ts) and in bridge/SPAWN.md; both have to
51
+ * say the same string, so it is spelled once here. */
52
+ export const SERVER_RECORD_FILE = ".glytch-server.json";
53
+
54
+ /** How far a process's real start time may sit from the record's `startedAt`
55
+ * before the record is treated as describing a different process. The session
56
+ * writes the record right after starting the server, so the honest gap is
57
+ * seconds; ten minutes is slack for a slow start, a clock nudge and a session
58
+ * that wrote the file late. */
59
+ export const START_SLACK_MS = 10 * 60_000;
60
+
61
+ /** The record a session left in its worktree, or null. Never throws: no file,
62
+ * a half-written one and a session that never started a server are all "there
63
+ * is nothing to stop", which is the ordinary case. */
64
+ export async function readWorktreeRecord(sessionDir) {
65
+ if (!sessionDir) return null;
66
+ try {
67
+ const parsed = JSON.parse(await readFile(join(sessionDir, SERVER_RECORD_FILE), "utf8"));
68
+ return Number.isFinite(Number(parsed?.pid)) ? parsed : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ const run = (bin, args, timeout = 5_000) =>
75
+ new Promise((resolve) => {
76
+ execFile(bin, args, { timeout, windowsHide: true, env: { ...process.env, LC_ALL: "C" } }, (error, stdout) => {
77
+ if (error) return resolve("");
78
+ resolve(String(stdout ?? ""));
79
+ });
80
+ });
81
+
82
+ /** `{ startedAt, command }` for a live pid, or null when there is no such
83
+ * process. `lstart` is a fixed-width local time ("Sun Sep 6 22:01:47 2026",
84
+ * the C locale's spelling: `run` sets LC_ALL=C, since en_NZ prints "Sun 6 Sep")
85
+ * padded before the command, so the split is a regex. A start time that will
86
+ * not parse is null, never a number: the caller says "cannot check". */
87
+ export function parsePsRow(row) {
88
+ const line = String(row ?? "").trim();
89
+ if (line === "") return null;
90
+ const match = line.match(/^(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.*)$/);
91
+ if (!match) return { startedAt: null, command: line };
92
+ const parsed = Date.parse(match[1]);
93
+ return { startedAt: Number.isFinite(parsed) ? parsed : null, command: match[2].trim() };
94
+ }
95
+
96
+ const psRowFor = async (pid) => parsePsRow(await run("ps", ["-p", String(pid), "-o", "lstart=,command="]));
97
+
98
+ /** The pids listening on a TCP port right now, as a Set. An absent or failing
99
+ * `lsof` is an empty set, never an exception: this is corroboration, and the
100
+ * caller has a second way to find it. */
101
+ const lsofListeners = async (port) => {
102
+ const out = await run("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
103
+ return new Set(
104
+ out
105
+ .trim()
106
+ .split("\n")
107
+ .map((n) => parseInt(n, 10))
108
+ .filter((n) => Number.isFinite(n) && n > 1),
109
+ );
110
+ };
111
+
112
+ // ---------- the same two pieces of evidence, on Windows ----------
113
+ //
114
+ // Brief 21, ruling 4A (2026-09-07). The guard above is the whole reason
115
+ // this file exists - never a kill on a bare pid - and on Windows neither of
116
+ // its two sources exists: no `ps -o lstart`, no `lsof`. The Windows pair he
117
+ // ruled for is `netstat -ano` for the pid listening on the recorded port, and
118
+ // `Get-CimInstance Win32_Process` (through `ConvertTo-Json`) for the start
119
+ // time and the command line. Both are parsed by pure functions with their own
120
+ // tests, and both are injected into `verifyRecordedServer` exactly the way
121
+ // `ps` and `listeners` already were: option B of the ruling - kill with no
122
+ // verification - was rejected on its face.
123
+
124
+ /** The pids listening on `port`, read out of `netstat -ano`.
125
+ *
126
+ * A row is ` TCP 0.0.0.0:8765 0.0.0.0:0 LISTENING 41231`, and the
127
+ * IPv6 form puts the port after a bracketed address (`[::]:8765`), so the port
128
+ * is taken from after the LAST colon of the local address rather than by
129
+ * splitting on ":". UDP rows have no state column and are skipped: only a
130
+ * listening TCP socket is evidence that this is the recorded server. */
131
+ export function parseNetstatListeners(output, port) {
132
+ const wanted = Number(port);
133
+ const pids = new Set();
134
+ if (!Number.isFinite(wanted) || wanted <= 0) return pids;
135
+ for (const line of String(output ?? "").split(/\r?\n/)) {
136
+ const cols = line.trim().split(/\s+/);
137
+ if (cols.length < 5) continue;
138
+ if (cols[0].toUpperCase() !== "TCP") continue;
139
+ if (cols[3].toUpperCase() !== "LISTENING") continue;
140
+ const local = cols[1];
141
+ const colon = local.lastIndexOf(":");
142
+ if (colon < 0) continue;
143
+ if (parseInt(local.slice(colon + 1), 10) !== wanted) continue;
144
+ const pid = parseInt(cols[4], 10);
145
+ if (Number.isFinite(pid) && pid > 1) pids.add(pid);
146
+ }
147
+ return pids;
148
+ }
149
+
150
+ /** `{ startedAt, command }` out of `Get-CimInstance Win32_Process | ConvertTo-Json`,
151
+ * or null when the process is gone (PowerShell prints nothing, or `null`).
152
+ *
153
+ * Three shapes have to be read, because ConvertTo-Json does not spell a
154
+ * DateTime the same way twice across PowerShell versions:
155
+ *
156
+ * "/Date(1757192507412)/" Windows PowerShell 5.1, milliseconds since epoch
157
+ * "2026-09-06T22:01:47.412+03:00" PowerShell 7, ISO 8601
158
+ * "20260906220147.412000+180" the raw CIM_DATETIME, if the property arrives
159
+ * as a string rather than as a date
160
+ *
161
+ * A start time that will not parse is null, never a number, so the caller says
162
+ * "cannot check" rather than guessing - the same contract parsePsRow has. */
163
+ export function parseCimProcess(json) {
164
+ const text = String(json ?? "").trim();
165
+ if (text === "" || text === "null") return null;
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(text);
169
+ } catch {
170
+ return null;
171
+ }
172
+ const row = Array.isArray(parsed) ? parsed[0] : parsed;
173
+ if (!row || typeof row !== "object") return null;
174
+ return {
175
+ startedAt: parseCimDate(row.CreationDate),
176
+ command: String(row.CommandLine ?? row.Name ?? "").trim(),
177
+ };
178
+ }
179
+
180
+ /** The three spellings above, as a millisecond epoch or null. */
181
+ export function parseCimDate(value) {
182
+ if (value == null) return null;
183
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
184
+ const text = String(value).trim();
185
+ if (text === "") return null;
186
+
187
+ const dotNet = text.match(/^\/Date\((-?\d+)\)\/$/);
188
+ if (dotNet) {
189
+ const ms = Number(dotNet[1]);
190
+ return Number.isFinite(ms) ? ms : null;
191
+ }
192
+
193
+ // CIM_DATETIME: yyyymmddHHMMSS.mmmmmm{+|-}UUU, offset in MINUTES from UTC.
194
+ const cim = text.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-]\d{3})$/);
195
+ if (cim) {
196
+ const [, y, mo, d, h, mi, s, us, off] = cim;
197
+ const minutes = Number(off);
198
+ const utc = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s), Math.round(Number(us) / 1000));
199
+ return Number.isFinite(utc) && Number.isFinite(minutes) ? utc - minutes * 60_000 : null;
200
+ }
201
+
202
+ const iso = Date.parse(text);
203
+ return Number.isFinite(iso) ? iso : null;
204
+ }
205
+
206
+ /** The PowerShell one-liner asked for a pid. Spelled here so the test can
207
+ * assert the argv rather than a string built somewhere else. */
208
+ export const cimProcessArgs = (pid) => [
209
+ "-NoProfile",
210
+ "-NonInteractive",
211
+ "-Command",
212
+ `Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}" | Select-Object ProcessId,CreationDate,CommandLine | ConvertTo-Json -Compress`,
213
+ ];
214
+
215
+ const cimRowFor = async (pid) => parseCimProcess(await run("powershell.exe", cimProcessArgs(pid), 15_000));
216
+
217
+ const netstatListeners = async (port) => parseNetstatListeners(await run("netstat", ["-ano"], 15_000), port);
218
+
219
+ const defaultPs = (pid) => (process.platform === "win32" ? cimRowFor(pid) : psRowFor(pid));
220
+ const defaultListeners = (port) => (process.platform === "win32" ? netstatListeners(port) : lsofListeners(port));
221
+
222
+ const parentPidFor = async (pid) => {
223
+ const output = process.platform === "win32"
224
+ ? await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
225
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").ParentProcessId`], 15_000)
226
+ : await run("ps", ["-p", String(pid), "-o", "ppid="]);
227
+ const value = Number(output.trim());
228
+ return Number.isInteger(value) && value > 1 ? value : null;
229
+ };
230
+
231
+ /** Publishing a socket needs stronger evidence than identifying a process to
232
+ * stop. A live npm/shell wrapper can survive its server, while another service
233
+ * takes its port. Every listener must belong to this recorded process tree;
234
+ * missing process inspection is a refusal, never a command-line fallback. */
235
+ export async function verifyPreviewServer(record, {
236
+ ps = defaultPs, listeners = defaultListeners, parentPid = parentPidFor,
237
+ } = {}) {
238
+ const pid = Number(record?.pid);
239
+ const port = Number(record?.port);
240
+ if (!Number.isInteger(pid) || pid <= 1 || !Number.isInteger(port) || port < 1 || port > 65535) {
241
+ return { ours: false, reason: "no usable preview pid/port in the record" };
242
+ }
243
+ const row = await ps(pid);
244
+ const startedAt = Date.parse(record?.startedAt ?? "");
245
+ // The record is written after launch (including the relaunch settle wait).
246
+ // An older OS start can therefore be legitimate, but a newer start means
247
+ // PID reuse. Allow only the one-second resolution of POSIX process times.
248
+ if (!row || !Number.isFinite(startedAt) || !Number.isFinite(row.startedAt) ||
249
+ row.startedAt > startedAt + 1000 || row.startedAt < startedAt - START_SLACK_MS) {
250
+ return { ours: false, reason: "preview process start time could not be verified" };
251
+ }
252
+ const owners = await listeners(port);
253
+ if (!owners.size) return { ours: false, reason: "preview port has no verified listener" };
254
+ for (const owner of owners) {
255
+ let current = owner;
256
+ const seen = new Set();
257
+ while (current !== pid && Number.isInteger(current) && current > 1 && seen.size < 32 && !seen.has(current)) {
258
+ seen.add(current);
259
+ current = await parentPid(current);
260
+ }
261
+ if (current !== pid) return { ours: false, reason: `preview port ${port} has an unrelated or unverified listener` };
262
+ }
263
+ return { ours: true, reason: `recorded process tree owns every listener on ${port}` };
264
+ }
265
+
266
+ /** Two strings that name the same command, allowing for how differently a
267
+ * command is spelled by the person who typed it and by `ps`. `npx serve -l
268
+ * 8765` runs as `node .../serve/build/main.js -l 8765`, so a containment test
269
+ * either way is as far as this can honestly go - and it is only ever one of
270
+ * two pieces of evidence, never the deciding one on its own. */
271
+ export function commandsAgree(recorded, running) {
272
+ const a = normaliseCommand(recorded);
273
+ const b = normaliseCommand(running);
274
+ if (a === "" || b === "") return false;
275
+ return a === b || a.includes(b) || b.includes(a);
276
+ }
277
+
278
+ /** Whitespace collapsed, and DOUBLE QUOTES DROPPED.
279
+ *
280
+ * 2026-09-07, brief 21, the first windows-latest run. Win32_Process's
281
+ * CommandLine hands back the line as Windows stores it, with the executable
282
+ * and every argument containing a space wrapped in quotes:
283
+ * `"C:\...\node.exe" -e "setInterval(...)"`. The record is written from
284
+ * `process.execPath` and an argv array, so it carries none of them, and the
285
+ * containment test below could not see that the two were the same line - a
286
+ * real detached server was therefore reported as "neither its port nor its
287
+ * command line matches the record" and left running. Quoting is the shell's
288
+ * punctuation, not part of the command, and this comparison was already
289
+ * deliberately fuzzy in both directions; dropping quotes on BOTH sides makes
290
+ * it no fuzzier about anything a person would call a different command. */
291
+ const normaliseCommand = (value) =>
292
+ String(value ?? "").replace(/"/g, "").replace(/\s+/g, " ").trim();
293
+
294
+ /** Is the process under `record.pid` the server this record describes?
295
+ *
296
+ * Returns `{ ours, reason }`. `ours` is true only when the pid is alive, its
297
+ * start time is inside START_SLACK_MS of the record's `startedAt` (or cannot
298
+ * be read at all), and there is positive evidence it is the same server:
299
+ * either it is listening on the recorded port, or its command line agrees with
300
+ * the recorded one. Everything else is a reason, and a reason means the caller
301
+ * logs and leaves the process alone.
302
+ *
303
+ * `ps` and `listeners` are injected so the decision can be tested without a
304
+ * machine's process table under it. */
305
+ export async function verifyRecordedServer(record, { ps = defaultPs, listeners = defaultListeners } = {}) {
306
+ const pid = Number(record?.pid);
307
+ if (!Number.isFinite(pid) || pid <= 1) return { ours: false, reason: "no usable pid in the record" };
308
+
309
+ const row = await ps(pid);
310
+ if (!row) return { ours: false, reason: "gone" };
311
+
312
+ const recordedStart = Date.parse(record?.startedAt ?? "");
313
+ if (Number.isFinite(recordedStart) && row.startedAt !== null) {
314
+ const drift = Math.abs(row.startedAt - recordedStart);
315
+ if (drift > START_SLACK_MS) {
316
+ return {
317
+ ours: false,
318
+ reason: `pid ${pid} started ${Math.round(drift / 60_000)} min from the recorded time: a different process has this pid now`,
319
+ };
320
+ }
321
+ }
322
+
323
+ const port = Number(record?.port);
324
+ if (Number.isFinite(port) && port > 0) {
325
+ const pids = await listeners(port);
326
+ if (pids.has(pid)) return { ours: true, reason: `listening on ${port}` };
327
+ }
328
+ if (commandsAgree(record?.cmd, row.command)) return { ours: true, reason: "command line matches the record" };
329
+
330
+ return { ours: false, reason: `pid ${pid} is alive but neither its port nor its command line matches the record` };
331
+ }
332
+
333
+ /** Stop a recorded server: SIGTERM, then SIGKILL after a short grace - and on
334
+ * Windows `taskkill /pid <pid> /t` then `/t /f`, which is the same pair of
335
+ * steps (brief 21, ruling 4A). BEHIND THE SAME GUARD either way: the kill
336
+ * is only reached once `verifyRecordedServer` found positive evidence that
337
+ * this pid is the recorded server, and `killTree` decides nothing about that.
338
+ *
339
+ * Refuses on anything the verify step could not establish, and says why. The
340
+ * caller logs ONE line either way - a sweep that kills silently is a sweep
341
+ * nobody can debug at the moment a port is mysteriously free.
342
+ *
343
+ * `process.kill(pid, 0)` is kept for the liveness check between the two
344
+ * passes: signal 0 is the one signal use in this package that means the same
345
+ * thing on Windows as it does on a Mac. */
346
+ export async function stopRecordedServer(record, { graceMs = 3_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), kill = killTree, ...deps } = {}) {
347
+ const check = await verifyRecordedServer(record, deps);
348
+ if (!check.ours) return { stopped: false, reason: check.reason };
349
+
350
+ const pid = Number(record.pid);
351
+ try {
352
+ await kill(pid, { force: false });
353
+ } catch {
354
+ return { stopped: false, reason: "gone" };
355
+ }
356
+ await sleep(graceMs);
357
+ try {
358
+ process.kill(pid, 0);
359
+ await kill(pid, { force: true });
360
+ return { stopped: true, reason: `forced after ${graceMs}ms: it ignored the first stop` };
361
+ } catch {
362
+ return { stopped: true, reason: "stopped on the first ask" };
363
+ }
364
+ }
365
+
366
+ // ---------- RELAUNCHING ONE, on a button in the terminal ----------
367
+ //
368
+ // The ask, 2026-09-06: "When a dev server gets detached, add a button in
369
+ // terminal to relaunch dev server in this branch. Allows me to test browser."
370
+ //
371
+ // The sweep above is the reason the button has to exist: a server started
372
+ // detached is stopped later, so a session card that finished this morning has
373
+ // a browser frame beside it pointing at a port that stopped answering at
374
+ // lunchtime. Nothing on the board can start a process, so the RUNNER does it -
375
+ // the same actor that spawns sessions.
376
+ //
377
+ // OVER ROWS, 2026-09-11 (brief 30 Move 2b): the app asks with a `run_ops` row
378
+ // of `kind:"relaunch"` now, surfaced by `heartbeat`'s `ops`, claimed with
379
+ // `opTake`, answered with `opResult` (updatable - the button can be pressed
380
+ // again) - `bridge/lib/runner.mjs`'s `serviceRowOps` is the runner's half.
381
+ // `relaunchServer` below is the pure core, unchanged in what it decides;
382
+ // what moved is where the RECORD comes from and where the OUTCOME goes.
383
+ // Before this pass both were the mailbox: `<id>.server` (a mirror of the
384
+ // session's own `.glytch-server.json`) was the record a `<id>.relaunch`
385
+ // signal file read, and `<id>.relaunch.result.txt` was the answer. Now the
386
+ // record is the run row's own `server` column (mirrored there by
387
+ // `runnerCompletion.mjs` when the child exits, exactly as before except the
388
+ // destination), falling back to the worktree's own `.glytch-server.json`
389
+ // (`readWorktreeRecord`, unchanged - local scratch, not a mailbox) when the
390
+ // row has none yet; the outcome is the `run_ops` row's own `result`,
391
+ // written by `opResult`, not a second file.
392
+ //
393
+ // NO LLM TURN, and that is the design rather than an economy. Relaunching is
394
+ // re-running a command that is already written down; a session would cost real
395
+ // credits to read a record and type a line the runner can read and type
396
+ // itself, and it could not do it at all once its worktree stopped being a
397
+ // session's home.
398
+ //
399
+ // THE RECORDED COMMAND, AND NOTHING ELSE. `cmd` comes from the record the
400
+ // session wrote (the run row's `server`, or `.glytch-server.json` in the
401
+ // tree), never from the op's own payload, and a missing record is a refusal
402
+ // rather than a prompt for one. A relaunch that could carry its own command
403
+ // line would be a way to run arbitrary shell from the canvas through a row
404
+ // the app wrote without a human ever seeing that particular string.
405
+
406
+ /** How long to wait after spawning before believing the server started. A
407
+ * command that is missing, mistyped or instantly fatal is gone well inside
408
+ * this; a real dev server is still starting up and that is fine - the check
409
+ * is "did it die immediately", not "is it serving yet". */
410
+ export const RELAUNCH_SETTLE_MS = 700;
411
+
412
+ const defaultExists = async (path) => {
413
+ try {
414
+ await access(path);
415
+ return true;
416
+ } catch {
417
+ return false;
418
+ }
419
+ };
420
+
421
+ // A RECORD IS DATA SOMEONE ELSE WROTE (#324). The session that wrote
422
+ // `.glytch-server.json` is an agent, and anything in the linked folder can
423
+ // write that file too, so its `cmd` is never handed to a shell. It is read
424
+ // as plain words (a program and its arguments), run with no shell, with the
425
+ // environment a dev server needs and nothing more (`devServerEnv`: no model
426
+ // keys, no cloud credentials), and only inside the linked folder.
427
+
428
+ /** One word of a command a relaunch will run: letters, digits and the
429
+ * punctuation a path, a flag or a version carries. Anything a shell would
430
+ * act on (`;|&$<>` quotes, backticks, globs, `~`) is not in it. */
431
+ const PLAIN_WORD = /^[A-Za-z0-9_@%+=:,./\\-]+$/;
432
+ const ENV_WORD = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_@%+:,./\\-]*)$/;
433
+
434
+ /** `{ env, argv }` for a recorded command, or null when it is not plain words.
435
+ *
436
+ * The spawn instruction tells a session to start its server inside a detach
437
+ * wrapper (`setsid <cmd> >/dev/null 2>&1 &`, `nohup <cmd> ... & disown`), and
438
+ * some write that whole line down. The wrapper is how it was started, not
439
+ * what it ran, so it is taken off first; the relaunch detaches on its own.
440
+ * Leading `NAME=value` words (`PORT=5273 npm run dev`) become environment. */
441
+ export function relaunchCommand(cmd) {
442
+ let line = String(cmd ?? "").trim();
443
+ line = line.replace(/\s*>\s*\/dev\/null\s+2>&1\s*&\s*(?:disown\s*)?$/, "").trim();
444
+ line = line.replace(/^(?:(?:setsid|nohup)\s+)+/, "").trim();
445
+ const words = line.split(/\s+/).filter(Boolean);
446
+ const env = {};
447
+ while (words.length > 0 && ENV_WORD.test(words[0])) {
448
+ const [, name, value] = words.shift().match(ENV_WORD);
449
+ env[name] = value;
450
+ }
451
+ if (words.length === 0 || !words.every((w) => PLAIN_WORD.test(w)) || words[0].includes("=")) return null;
452
+ return { env, argv: words };
453
+ }
454
+
455
+ /** Start the command in `cwd`, detached, with nothing attached to it - the
456
+ * same shape the spawn instruction tells a session to use
457
+ * (store/spawnPayload.ts), for the same reason: it has to outlive the process
458
+ * that started it, and the runner is no more permanent than a session. No
459
+ * shell on any platform: `runnableCommand` resolves a Windows npm shim to
460
+ * its own entry point rather than going through cmd.exe. */
461
+ const defaultSpawn = ({ env, argv }, cwd) => {
462
+ const fullEnv = { ...devServerEnv(process.env), ...env };
463
+ const run = runnableCommand(argv[0], argv.slice(1), { env: fullEnv });
464
+ const child = spawnProcess(run.file, run.args, { cwd, env: fullEnv, shell: false, detached: true, stdio: "ignore" });
465
+ // A missing program is an async 'error' on the child; without a listener
466
+ // it would take the runner down. The settle check reports it instead.
467
+ child.on("error", () => {});
468
+ child.unref();
469
+ return child;
470
+ };
471
+
472
+ const stillAlive = (pid) => {
473
+ try {
474
+ process.kill(pid, 0);
475
+ return true;
476
+ } catch {
477
+ return false;
478
+ }
479
+ };
480
+
481
+ /** Re-run the recorded dev server for one run.
482
+ *
483
+ * `record` is the run row's own `server` column, or `null` when the row has
484
+ * none yet (a session that started no server) - the caller
485
+ * (`bridge/lib/runner.mjs`'s `serviceRowOps`) falls back to
486
+ * `readWorktreeRecord(hinted)` when the row is empty but the op's payload
487
+ * carries a `worktreePath` hint, exactly as the file-based signal used to.
488
+ *
489
+ * Returns `{ ok, detail, record? }`. Every refusal names what was missing,
490
+ * because the detail is what the human reads on the run's card (DATED
491
+ * REWRITE 2026-09-07, brief 26 ruling 4: was "in the frame"):
492
+ *
493
+ * - no record at all (the session never started a server, or none has
494
+ * been mirrored onto the run row yet);
495
+ * - a record with no usable `cmd`, or one that is not plain words
496
+ * (`relaunchCommand`, #324);
497
+ * - a directory outside the linked folder `root`, or no `root` to check;
498
+ * - the worktree it ran in is gone, so there is nowhere to run it;
499
+ * - the port is already held by something that is not this record;
500
+ * - the command exited inside RELAUNCH_SETTLE_MS.
501
+ *
502
+ * A server that is STILL RUNNING is not a refusal: the answer is the pid and
503
+ * port it is already on, and nothing is started twice.
504
+ *
505
+ * Every dependency is injected so the decision is testable without a machine's
506
+ * process table or a real dev server under it. */
507
+ export async function relaunchServer(
508
+ record,
509
+ {
510
+ spawn = defaultSpawn,
511
+ exists = defaultExists,
512
+ listeners = defaultListeners,
513
+ ps = defaultPs,
514
+ settleMs = RELAUNCH_SETTLE_MS,
515
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
516
+ now = () => new Date(),
517
+ // The linked folder. A record may only run inside it (#324).
518
+ root,
519
+ } = {},
520
+ ) {
521
+ if (!record) {
522
+ return {
523
+ ok: false,
524
+ detail:
525
+ `No dev server record for this run. Only a session that started one and wrote ` +
526
+ `${SERVER_RECORD_FILE} can be relaunched: the runner re-runs the command that was ` +
527
+ `recorded and never invents one.`,
528
+ };
529
+ }
530
+
531
+ const cmd = typeof record.cmd === "string" ? record.cmd.trim() : "";
532
+ if (cmd === "") {
533
+ return { ok: false, detail: `The dev server record has no cmd, so there is nothing to re-run.` };
534
+ }
535
+
536
+ const command = relaunchCommand(cmd);
537
+ if (!command) {
538
+ return {
539
+ ok: false,
540
+ detail:
541
+ `The dev server record's command \`${cmd}\` is not a plain command, so it is not run. A relaunch ` +
542
+ `runs one program and its arguments, with no shell: no pipes, \`&&\`, quotes or \`$\`. Start it ` +
543
+ `by hand in the session's folder instead.`,
544
+ };
545
+ }
546
+
547
+ const recordedPath = typeof record.worktreePath === "string" && record.worktreePath !== "" ? record.worktreePath : null;
548
+ if (!recordedPath) {
549
+ return { ok: false, detail: `The dev server record does not say which directory it ran in.` };
550
+ }
551
+ const contained = typeof root === "string" && root !== "" ? resolveContainedCwd(root, recordedPath, "a relaunched dev server") : null;
552
+ if (!contained?.ok) {
553
+ return {
554
+ ok: false,
555
+ detail: `The dev server record names ${recordedPath} as its directory, and ${contained?.reason ?? "there is no linked folder to check it against."}`,
556
+ };
557
+ }
558
+ // THE COPY IS GONE (#485). Keep changes merges the branch and deletes the
559
+ // separate copy, so the recorded directory is not there any more. The app
560
+ // then starts again from the linked folder, which now holds the kept code.
561
+ // Nothing else changes: the recorded command still runs as one program, and
562
+ // the folder is the one the record was already checked against.
563
+ let worktreePath = contained.path;
564
+ // A record written by an earlier start from the folder says so: its path is
565
+ // the linked folder now, which a path check alone cannot tell from a session
566
+ // that ran there itself.
567
+ let fromFolder = record.fromFolder === true;
568
+ if (!(await exists(worktreePath))) {
569
+ if (typeof root === "string" && root !== "" && (await exists(root))) {
570
+ worktreePath = root;
571
+ fromFolder = true;
572
+ } else {
573
+ return {
574
+ ok: false,
575
+ detail:
576
+ `The worktree ${worktreePath} is gone, so \`${cmd}\` has nowhere to run. Merging or ` +
577
+ `deleting a branch takes its dev server with it.`,
578
+ };
579
+ }
580
+ }
581
+
582
+ // ALREADY UP. The button sits on the frame whether or not the server died,
583
+ // so pressing it on a live one must not start a second copy on the same
584
+ // port. Same evidence the sweep demands before it kills anything.
585
+ const standing = await verifyRecordedServer(record, { ps, listeners });
586
+ if (standing.ours) {
587
+ return {
588
+ ok: true,
589
+ record,
590
+ detail: `The dev server is already running: pid ${record.pid}${record.port ? ` on port ${record.port}` : ""} (${standing.reason}).`,
591
+ };
592
+ }
593
+
594
+ const port = Number(record.port);
595
+ const hasPort = Number.isFinite(port) && port > 0;
596
+ if (hasPort) {
597
+ const holding = await listeners(port);
598
+ if (holding.size > 0) {
599
+ return {
600
+ ok: false,
601
+ detail:
602
+ `Port ${port} is already held by pid ${[...holding].join(", ")}, which is not the server this ` +
603
+ `record describes. Stop whatever is on that port and press it again.`,
604
+ };
605
+ }
606
+ }
607
+
608
+ let child;
609
+ try {
610
+ child = spawn(command, worktreePath);
611
+ } catch (error) {
612
+ return {
613
+ ok: false,
614
+ detail: `\`${cmd}\` could not be started: ${error instanceof Error ? error.message : String(error)}`,
615
+ };
616
+ }
617
+ const pid = Number(child?.pid);
618
+ if (!Number.isFinite(pid) || pid <= 1) {
619
+ return {
620
+ ok: false,
621
+ detail: `\`${cmd}\` was started but the operating system gave back no pid, so nothing can be recorded for it.`,
622
+ };
623
+ }
624
+
625
+ await sleep(settleMs);
626
+ if (!stillAlive(pid)) {
627
+ return {
628
+ ok: false,
629
+ detail:
630
+ `\`${cmd}\` exited within ${settleMs}ms of starting. A detached server has no terminal, so its ` +
631
+ `output went nowhere - run it by hand in ${worktreePath} to see why.`,
632
+ };
633
+ }
634
+
635
+ // A FRESH RECORD. The old one names a pid that is not this process any
636
+ // more, so the worktree's own copy is rewritten - `runnerCompletion.mjs`
637
+ // has no session left to mirror it off, but the caller (`serviceRowOps`)
638
+ // reads THIS return value straight back into the run row's own `server`
639
+ // column via `runnerApi.update`, which is what used to be the second file
640
+ // write (`<id>.server`) and is a row now.
641
+ const stamp = now().toISOString();
642
+ const fresh = { ...record, pid, cmd, startedAt: stamp, worktreePath, relaunchedAt: stamp, ...(fromFolder ? { fromFolder: true } : {}) };
643
+ try {
644
+ // Not into the person's own folder: only a session's copy carries the file.
645
+ if (!fromFolder) await writeFile(join(worktreePath, SERVER_RECORD_FILE), `${JSON.stringify(fresh, null, 2)}\n`, "utf8");
646
+ } catch {
647
+ /* the tree may be read-only or on its way out; the run row is what a
648
+ later relaunch and the sweep both read, and it is the copy that has
649
+ to land - this write is a courtesy to anyone looking at the tree. */
650
+ }
651
+
652
+ return {
653
+ ok: true,
654
+ record: fresh,
655
+ detail:
656
+ `Started \`${cmd}\` in ${worktreePath}: pid ${pid}${hasPort ? ` on port ${port}` : ""}. It is ` +
657
+ `detached, so it outlives this runner and the daily sweep stops it.`,
658
+ };
659
+ }