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.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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
|
+
}
|