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,292 @@
1
+ // Provider-neutral lifecycle for sharing a worktree dev server, over rows
2
+ // (brief 30 Move 2b, 2026-09-11). The app asks with a `run_ops` row of
3
+ // `kind:"preview"` now (`heartbeat`'s `ops`, `opTake` claims it once,
4
+ // `opResult` is UPDATABLE - unlike merge/delete/relaunch, a preview's result
5
+ // ticks through starting/ready/offline/error on the SAME op row rather than
6
+ // one receipt per state). This module proves that the named loopback port is
7
+ // a verified server belonging to the named board run before a tunnel
8
+ // provider ever sees it - the proof is read from the CLAIMED run row (via
9
+ // `runnerApi.runs`) and from `get_node` over `hosted.callTool`, NEVER from
10
+ // `canvas/`: there is no local mirror requirement any more, so a preview
11
+ // works whether or not a browser has this folder linked at all.
12
+ //
13
+ // WHAT REPLACED WHAT. The file version polled a directory of `*.json`
14
+ // requests every tick and noticed a deleted file as "stop it, the request
15
+ // was removed"; there is no such signal over rows (a taken `run_ops` row
16
+ // carries no "cancel" op in the Move 2 contract - `run_op_cancel` only
17
+ // reaches an UNTAKEN one, server-side, before this module ever sees it). So
18
+ // every preview this module tracks ends the same way now: the next proof
19
+ // check fails - the owning run stopped, the browser node was deleted or its
20
+ // URL changed, the recorded server can no longer be verified - and that
21
+ // failure IS the removal signal, exactly as it already was for "the request
22
+ // changed under me" in the file version. `close()` still stops every
23
+ // tracked tunnel on its own shutdown.
24
+ //
25
+ // THE PROOF ITSELF is the same three checks the file version made, sourced
26
+ // differently: which run this preview belongs to (`runnerApi.runs`, matched
27
+ // by `runKey === run.id`, its OWN `from_id` naming the prompt directly -
28
+ // simpler than the old graph search for "a prompt whose `run.key` equals
29
+ // this one", because the row already knows), the browser node's kind, url
30
+ // and incoming edge from that prompt (`get_node`, parsed off its own text
31
+ // reply - the endpoint's only shape), and the recorded dev server itself
32
+ // (`run.server`, mirrored onto the row by `runnerCompletion.mjs` when the
33
+ // child exits, or the worktree's own live `.glytch-server.json` while the
34
+ // run is still going - `verifyPreviewServer`, unchanged).
35
+
36
+ import { readWorktreeRecord, verifyPreviewServer } from "./devserver.mjs";
37
+ import { callTool as hostedCallToolDefault } from "./hosted.mjs";
38
+ import { fromIdOf } from "./boardside.mjs";
39
+ import * as runnerApiModule from "./runnerApi.mjs";
40
+
41
+ export const PREVIEW_VERSION = 1;
42
+ export const MAX_RESTARTS = 5;
43
+ export const STABLE_RESET_MS = 60_000;
44
+
45
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
46
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
47
+ // Match the grant issuer's namespaced cloud-board contract. Values outside
48
+ // this shape can never receive a member grant, so reject them before startup.
49
+ const SAFE_CLOUD_BOARD_ID = /^[^\s:]+:[^\s:]+$/;
50
+ const OPAQUE_PREVIEW_ID = /^[A-Za-z0-9_-]{16,128}$/;
51
+
52
+ function localOrigin(value) {
53
+ let url;
54
+ try { url = new URL(value); } catch { return null; }
55
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
56
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return null;
57
+ if (url.username || url.password) return null;
58
+ const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
59
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? { url, port, origin: url.origin } : null;
60
+ }
61
+
62
+ /** Validate a `run_ops` row's own `payload` for `kind:"preview"` - the same
63
+ * fields the file request used to carry (Move 2 contract: `{nodeId, runKey,
64
+ * localUrl, boardId, workspaceId, cloudBoardId}`), unchanged in shape. */
65
+ function parsePayload(payload) {
66
+ const value = isObject(payload) ? payload : {};
67
+ if (value.version !== undefined && value.version !== PREVIEW_VERSION) {
68
+ throw new Error("Preview request needs version 1.");
69
+ }
70
+ for (const field of ["boardId", "workspaceId", "cloudBoardId", "nodeId", "runKey", "localUrl"]) {
71
+ if (typeof value[field] !== "string" || value[field].trim() === "") throw new Error(`Preview request needs ${field}.`);
72
+ }
73
+ for (const field of ["boardId", "workspaceId", "nodeId", "runKey"]) {
74
+ if (!SAFE_ID.test(value[field])) throw new Error(`Preview request ${field} contains unsafe characters.`);
75
+ }
76
+ if (!SAFE_CLOUD_BOARD_ID.test(value.cloudBoardId)) throw new Error("Preview request cloudBoardId contains unsafe characters.");
77
+ const origin = localOrigin(value.localUrl);
78
+ if (!origin) throw new Error("localUrl must be a credential-free loopback HTTP(S) URL.");
79
+ return { ...value, origin: origin.origin, port: origin.port };
80
+ }
81
+
82
+ /** Parse `get_node`'s own text reply (supabase/functions/board-mcp/tools.mjs)
83
+ * back into `{node, incoming, outgoing}`. It is a text protocol for a
84
+ * model, not a second JSON API - one line is the node's own JSON (an image
85
+ * value redacted, harmless here), then "Incoming edges from: a, b" and
86
+ * "Outgoing edges to: c, d" when either exists. */
87
+ export function parseNodeText(text) {
88
+ let node = null;
89
+ for (const line of String(text ?? "").split("\n")) {
90
+ if (!line.startsWith("{")) continue;
91
+ try { node = JSON.parse(line); break; } catch { /* not this line */ }
92
+ }
93
+ const split = (label) => {
94
+ const m = new RegExp(`^${label}: (.*)$`, "m").exec(text ?? "");
95
+ return m ? m[1].split(",").map((s) => s.trim()).filter(Boolean) : [];
96
+ };
97
+ return { node, incoming: split("Incoming edges from"), outgoing: split("Outgoing edges to") };
98
+ }
99
+
100
+ /** The proof, over rows. Throws with a human-readable reason on any failure
101
+ * - the caller turns that into an `error` opResult, unchanged in spirit
102
+ * from the file version. */
103
+ async function rowServerProof(request, projectId, deps) {
104
+ const listed = await deps.runs({ projectIds: [projectId] }, deps.rowsOpts);
105
+ if (!listed.ok) throw new Error(`Could not list this project's runs: ${listed.error}`);
106
+ const run = (listed.runs ?? []).find((r) => r.id === request.runKey);
107
+ if (!run || run.approved !== true) throw new Error(`No matching approved run for ${request.runKey}.`);
108
+
109
+ const promptId = fromIdOf(run.request);
110
+ if (!promptId) throw new Error(`Run ${request.runKey} has no idea to attach a preview to.`);
111
+
112
+ const nodeOut = await deps.callTool("get_node", { id: request.nodeId }, { ...deps.toolOpts, boardId: request.cloudBoardId });
113
+ if (nodeOut.isError) throw new Error(`Could not read node ${request.nodeId}: ${nodeOut.text}`);
114
+ const browser = parseNodeText(nodeOut.text);
115
+ if (browser.node?.data?.kind !== "browser") throw new Error(`Node ${request.nodeId} is not a browser on board ${request.boardId}.`);
116
+ if (browser.node?.data?.url !== request.localUrl) throw new Error("Browser node local URL does not match the preview request.");
117
+ if (!browser.incoming.includes(promptId)) throw new Error("Browser node is not linked from the idea that owns this run.");
118
+
119
+ // THE RECORDED DEV SERVER: the worktree's own live file while the run is
120
+ // still going (a session that just started its server, before the runner
121
+ // has mirrored anything onto the row), else the row's own `server` column,
122
+ // mirrored by `runnerCompletion.mjs` once the child exits.
123
+ let record = null;
124
+ if (typeof run.sidecar?.worktreePath === "string" && run.sidecar.worktreePath !== "") {
125
+ record = await deps.readLiveRecord(run.sidecar.worktreePath);
126
+ if (record) record = { ...record, worktreePath: run.sidecar.worktreePath };
127
+ }
128
+ if (!record && run.server) record = run.server;
129
+ if (!record) throw new Error("This run has no recorded dev server.");
130
+ if (Number(record.port) !== request.port) throw new Error("Preview URL port does not match the recorded dev server.");
131
+ const verified = await deps.verify(record);
132
+ if (!verified?.ours) throw new Error(`Recorded dev server could not be verified: ${verified?.reason ?? "unknown"}.`);
133
+ return { record, promptId, run };
134
+ }
135
+
136
+ /** A rows-driven preview tunnel service. `onOps(ops, projectId)` is fed
137
+ * `heartbeat`'s untaken ops list each beat (like `serviceRowOps`'s
138
+ * merge/delete handling) and claims (`opTake`) every `kind:"preview"` one
139
+ * it finds, once; `tick()` re-verifies and advances every op it is
140
+ * tracking, writing `opResult` on each lifecycle change; `close()` tears
141
+ * every tracked tunnel down. `projectId` is threaded through per call
142
+ * rather than fixed at construction, because `watch()` only learns its
143
+ * project id after the FIRST successful `register` - the service itself is
144
+ * made once, before that. */
145
+ export function createSharedPreviewService({ runnerApi = runnerApiModule, hostedCallTool = hostedCallToolDefault, env, fetchImpl, deps: overrides = {}, startTunnel }) {
146
+ if (typeof startTunnel !== "function") throw new TypeError("startTunnel is required");
147
+ const rowsOpts = { env, fetchImpl };
148
+ const toolOpts = { env, fetchImpl };
149
+ const deps = {
150
+ now: () => Date.now(),
151
+ runs: runnerApi.runs,
152
+ opResult: runnerApi.opResult,
153
+ callTool: hostedCallTool,
154
+ readLiveRecord: readWorktreeRecord,
155
+ verify: verifyPreviewServer,
156
+ rowsOpts,
157
+ toolOpts,
158
+ ...overrides,
159
+ };
160
+ const active = new Map(); // opId -> {request, stamp, handle, restarts, nextAttemptAt, readySince}
161
+ const lifetime = new AbortController();
162
+ let closed = false;
163
+
164
+ const writeResult = (opId, status, extra = {}) =>
165
+ deps.opResult({ opId, result: { status, ...extra, updatedAt: deps.now() } }, rowsOpts);
166
+
167
+ const stop = async (opId, state, detail) => {
168
+ active.delete(opId);
169
+ try { await state.handle?.stop?.(); } catch { /* best effort; the result still tells */ }
170
+ await writeResult(opId, "offline", { detail });
171
+ };
172
+
173
+ /** Claim every untaken preview op this beat's heartbeat surfaced. Bad
174
+ * payloads are refused at once, same as a malformed file used to be. */
175
+ async function onOps(ops, projectId) {
176
+ for (const op of ops ?? []) {
177
+ if (closed) return;
178
+ if (op.kind !== "preview" || active.has(op.id)) continue;
179
+ const taken = await runnerApi.opTake({ opId: op.id }, rowsOpts);
180
+ if (!taken.ok || !taken.op) continue; // already taken, gone, or not ours
181
+ if (closed) {
182
+ await writeResult(op.id, "offline", { detail: "Preview service stopped." });
183
+ return;
184
+ }
185
+ let request;
186
+ try {
187
+ request = parsePayload(taken.op.payload);
188
+ } catch (error) {
189
+ await writeResult(op.id, "error", { detail: error instanceof Error ? error.message : String(error) });
190
+ continue;
191
+ }
192
+ active.set(op.id, { request, projectId, handle: null, restarts: 0, nextAttemptAt: 0 });
193
+ }
194
+ }
195
+
196
+ let ticking = null;
197
+
198
+ /** Re-verify and advance every tracked preview. Called every beat.
199
+ * Single-flight: two concurrent calls share one pass rather than racing
200
+ * each other into starting the same tunnel twice. */
201
+ function tick() {
202
+ if (ticking) return ticking;
203
+ ticking = runTick().finally(() => { ticking = null; });
204
+ return ticking;
205
+ }
206
+
207
+ async function runTick() {
208
+ if (closed) return;
209
+ for (const [opId, state] of [...active]) {
210
+ if (closed) return;
211
+ const { request, projectId } = state;
212
+ if (state.handle) {
213
+ try {
214
+ await rowServerProof(request, projectId, deps);
215
+ } catch (error) {
216
+ await stop(opId, state, error instanceof Error ? error.message : String(error));
217
+ continue;
218
+ }
219
+ let alive = true;
220
+ try { alive = state.handle.alive ? await state.handle.alive() : true; } catch { alive = false; }
221
+ if (alive) {
222
+ if (deps.now() - state.readySince >= STABLE_RESET_MS) state.restarts = 0;
223
+ await writeResult(opId, "ready", { previewId: state.previewId, url: new URL(state.handle.url).toString() });
224
+ continue;
225
+ }
226
+ try { await state.handle.stop?.(); } catch { /* already gone */ }
227
+ state.handle = null;
228
+ state.nextAttemptAt = deps.now() + Math.min(30_000, 1000 * 2 ** state.restarts);
229
+ state.restarts += 1;
230
+ await writeResult(opId, "offline", { detail: "Tunnel stopped; restart is scheduled." });
231
+ continue;
232
+ }
233
+ if (state.restarts >= MAX_RESTARTS || deps.now() < state.nextAttemptAt) continue;
234
+ let provisional = null;
235
+ try {
236
+ const proof = await rowServerProof(request, projectId, deps);
237
+ if (closed) return;
238
+ await writeResult(opId, "starting");
239
+ const handle = await startTunnel(request.origin, {
240
+ ...request, fullLocalUrl: request.localUrl, record: proof.record, promptId: proof.promptId,
241
+ signal: lifetime.signal,
242
+ authorizeTarget: async () => {
243
+ if (closed) return false;
244
+ try { await rowServerProof(request, projectId, deps); return !closed; }
245
+ catch { return false; }
246
+ },
247
+ });
248
+ if (isObject(handle) && typeof handle.stop === "function") provisional = handle;
249
+ if (!isObject(handle) || typeof handle.url !== "string" || typeof handle.stop !== "function") {
250
+ throw new Error("Tunnel provider returned no preview id/URL/stop handle.");
251
+ }
252
+ if (typeof handle.previewId !== "string" || !OPAQUE_PREVIEW_ID.test(handle.previewId)) {
253
+ throw new Error("Tunnel provider returned no opaque preview id.");
254
+ }
255
+ const publicUrl = new URL(handle.url);
256
+ if (publicUrl.protocol !== "https:" || publicUrl.username || publicUrl.password ||
257
+ ["localhost", "127.0.0.1", "[::1]"].includes(publicUrl.hostname)) {
258
+ throw new Error("Tunnel provider URL must be a credential-free, non-loopback HTTPS URL.");
259
+ }
260
+ if (closed) throw new Error("Preview service stopped during tunnel startup.");
261
+ await rowServerProof(request, projectId, deps);
262
+ if (closed) throw new Error("Preview service stopped during tunnel startup.");
263
+ state.handle = handle;
264
+ state.previewId = handle.previewId;
265
+ state.readySince = deps.now();
266
+ provisional = null;
267
+ await writeResult(opId, "ready", { previewId: handle.previewId, url: publicUrl.toString() });
268
+ } catch (error) {
269
+ try { await provisional?.stop?.(); } catch { /* provider cleanup is best effort */ }
270
+ state.restarts += 1;
271
+ state.nextAttemptAt = deps.now() + Math.min(30_000, 1000 * 2 ** (state.restarts - 1));
272
+ await writeResult(opId, "error", { detail: error instanceof Error ? error.message : String(error) });
273
+ }
274
+ }
275
+ }
276
+
277
+ const close = async () => {
278
+ closed = true;
279
+ lifetime.abort();
280
+ // A provider start already in flight must finish (or abort, on the
281
+ // signal just fired) before we enumerate the handles to stop, otherwise
282
+ // shutdown can leave a late handle exposed.
283
+ let failure;
284
+ try { if (ticking) await ticking; } catch (error) { failure = error; }
285
+ for (const [opId, state] of [...active]) {
286
+ try { await stop(opId, state, "Preview service stopped."); }
287
+ catch (error) { failure ??= error; }
288
+ }
289
+ if (failure) throw failure;
290
+ };
291
+ return { onOps, tick, close };
292
+ }
package/lib/signIn.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // SIGN IN TO CODEX FROM THE BOARD (#482, ruling #470: "the Codex sign-in
2
+ // page opened for you"). The app files a run row whose request is
3
+ // `{kind: "sign-in", agent: "codex"}`; the runner answers it here by
4
+ // starting `codex login` on this computer. Codex opens the ChatGPT sign-in
5
+ // page in this computer's browser and exits once the sign-in lands, so no
6
+ // one types in a terminal. The runner then probes the agents again, and the
7
+ // app's box goes once the probe says Codex is signed in.
8
+ //
9
+ // Nothing is billed: `codex login` starts no model turn.
10
+ import { spawn } from "node:child_process";
11
+ import { runnableCommand } from "./windows-command.mjs";
12
+
13
+ /** How long a person has to finish signing in on the page that opened. */
14
+ export const SIGN_IN_WAIT_MS = 10 * 60_000;
15
+
16
+ /** The engines a sign-in request may name. Claude Code signs in from its
17
+ * own first run; opencode's sign-in cannot be checked (detect.mjs). */
18
+ export const SIGN_IN_AGENTS = ["codex"];
19
+
20
+ /** Start `codex login` and wait for it to end. Resolves `{ok, detail}`,
21
+ * never throws: a missing binary or a sign-in left open is an answer. */
22
+ export function codexLogin({ env = process.env, timeoutMs = SIGN_IN_WAIT_MS, platform = process.platform } = {}) {
23
+ const bin = env.PM_CODEX_BIN?.trim() || "codex";
24
+ const command = runnableCommand(bin, ["login"], { platform, env });
25
+ return new Promise((resolve) => {
26
+ let child;
27
+ try {
28
+ child = spawn(command.file, command.args, { env, shell: command.shell, windowsHide: true, stdio: "ignore" });
29
+ } catch (error) {
30
+ resolve({ ok: false, detail: error instanceof Error ? error.message : String(error) });
31
+ return;
32
+ }
33
+ const timer = setTimeout(() => {
34
+ child.kill();
35
+ resolve({ ok: false, detail: "The sign-in page was left open for 10 minutes." });
36
+ }, timeoutMs);
37
+ timer.unref?.();
38
+ child.on("error", (error) => {
39
+ clearTimeout(timer);
40
+ resolve({ ok: false, detail: error.code === "ENOENT" ? "Codex is not installed on this computer." : error.message });
41
+ });
42
+ child.on("exit", (code) => {
43
+ clearTimeout(timer);
44
+ resolve(code === 0 ? { ok: true, detail: "Codex is signed in." } : { ok: false, detail: `codex login ended with code ${code}.` });
45
+ });
46
+ });
47
+ }