viewable-one 0.0.0-stage → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +767 -0
  20. package/lib/codexApps.mjs +344 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +629 -0
  28. package/lib/devserver.mjs +670 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,323 @@
1
+ // Repo primer: a compact map of the tree plus exported symbols, deterministic,
2
+ // no model call. Built to reduce cache re-reads: a fresh session spends its
3
+ // first turns rediscovering the tree. Reference: tpm-flow's server/src/repomap.ts
4
+ // (idea, bounds, regex on purpose); reimplemented here, license boundary matters.
5
+ //
6
+ // Emits:
7
+ // - The file tree (always)
8
+ // - Top-level exported symbols per source file (functions, classes, consts)
9
+ // found by regex, capped at ~10KB for the whole map
10
+ // - Symbol detail dropped deepest-first past the cap, with a notice
11
+ //
12
+ // Cached keyed to HEAD sha in a dotfile beside the spawn dir; regenerate when
13
+ // HEAD moved, reuse otherwise.
14
+
15
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
16
+ import { join, relative, sep } from "node:path";
17
+ import { execFile } from "node:child_process";
18
+ import { promisify } from "node:util";
19
+ import { BOARD_DIR, segmentsOf } from "./paths.mjs";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ const MAX_MAP_SIZE = 10_000; // ~10KB cap for the whole map
24
+
25
+ // Directories to skip (dotdirs, build artifacts, dependencies)
26
+ const SKIP_DIRS = new Set([
27
+ "node_modules",
28
+ ".git",
29
+ "dist",
30
+ "build",
31
+ "coverage",
32
+ "canvas",
33
+ "inbox",
34
+ ".next",
35
+ ".turbo",
36
+ ".cache",
37
+ ".DS_Store",
38
+ ]);
39
+
40
+ // Extensions for source files where we extract symbols
41
+ const SOURCE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
42
+
43
+ /**
44
+ * Extract top-level exported symbols from source file content using regex.
45
+ * Deliberately regex-based (not tree-sitter): fast, deterministic, good enough.
46
+ * Returns array of symbol names.
47
+ */
48
+ function extractExports(content) {
49
+ const symbols = [];
50
+
51
+ // export function name(...) or export async function name(...)
52
+ const funcPattern = /export\s+(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
53
+ for (const match of content.matchAll(funcPattern)) {
54
+ symbols.push(match[1]);
55
+ }
56
+
57
+ // export class Name
58
+ const classPattern = /export\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
59
+ for (const match of content.matchAll(classPattern)) {
60
+ symbols.push(match[1]);
61
+ }
62
+
63
+ // export const name = ... or export let name = ...
64
+ const constPattern = /export\s+(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
65
+ for (const match of content.matchAll(constPattern)) {
66
+ symbols.push(match[1]);
67
+ }
68
+
69
+ // export { name1, name2 }
70
+ const namedExportPattern = /export\s*\{([^}]+)\}/g;
71
+ for (const match of content.matchAll(namedExportPattern)) {
72
+ const names = match[1].split(",").map(n => {
73
+ // Handle "foo as bar" - take the original name
74
+ const parts = n.trim().split(/\s+as\s+/);
75
+ return parts[0].trim();
76
+ }).filter(n => n && /^[a-zA-Z_$]/.test(n));
77
+ symbols.push(...names);
78
+ }
79
+
80
+ return symbols;
81
+ }
82
+
83
+ /**
84
+ * Walk a directory tree, collecting file paths and optionally extracting symbols.
85
+ * Returns { tree: string[], fileSymbols: Map<string, string[]> }
86
+ * where tree is relative paths and fileSymbols maps rel path to exported symbols.
87
+ */
88
+ async function walkTree(root, extractSymbols = true) {
89
+ const tree = [];
90
+ const fileSymbols = new Map();
91
+
92
+ async function walk(dir, depth = 0) {
93
+ let entries;
94
+ try {
95
+ entries = await readdir(dir, { withFileTypes: true });
96
+ } catch {
97
+ return; // Skip unreadable directories
98
+ }
99
+
100
+ for (const entry of entries) {
101
+ const fullPath = join(dir, entry.name);
102
+ // POSIX-SPELLED, ON EVERY PLATFORM (2026-09-07, brief 21, the first
103
+ // windows-latest run). This map is TEXT handed to a coding agent, and
104
+ // everything it will be compared against - the import specifiers in the
105
+ // files themselves, `git` output, the paths a person types into a brief -
106
+ // spells a separator "/" on Windows too. `relative` returns the host's,
107
+ // so the map said `src\index.js` and nothing downstream could match it.
108
+ const relPath = relative(root, fullPath).split(sep).join("/");
109
+
110
+ // Skip dotdirs at any depth
111
+ if (entry.name.startsWith(".")) {
112
+ continue;
113
+ }
114
+
115
+ if (entry.isDirectory()) {
116
+ if (SKIP_DIRS.has(entry.name)) {
117
+ continue;
118
+ }
119
+ tree.push(`${relPath}/`);
120
+ await walk(fullPath, depth + 1);
121
+ } else if (entry.isFile()) {
122
+ tree.push(relPath);
123
+
124
+ // Extract symbols from source files if requested
125
+ if (extractSymbols && SOURCE_EXTS.has(extOf(entry.name))) {
126
+ try {
127
+ const content = await readFile(fullPath, "utf8");
128
+ const symbols = extractExports(content);
129
+ if (symbols.length > 0) {
130
+ fileSymbols.set(relPath, symbols);
131
+ }
132
+ } catch {
133
+ // Skip files we can't read
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ await walk(root);
141
+ return { tree, fileSymbols };
142
+ }
143
+
144
+ function extOf(filename) {
145
+ const dot = filename.lastIndexOf(".");
146
+ return dot === -1 ? "" : filename.slice(dot);
147
+ }
148
+
149
+ /**
150
+ * Get current HEAD sha for cache keying.
151
+ * Returns null if not a git repo or HEAD unavailable.
152
+ */
153
+ async function getHeadSha(root) {
154
+ try {
155
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root });
156
+ return stdout.trim();
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Build a compact repo map: file tree + exported symbols per source file.
164
+ * Respects MAX_MAP_SIZE; past the cap, keeps the tree and drops symbol detail
165
+ * deepest-first (files furthest from root lose detail first).
166
+ * Returns the map as a string, with a notice if detail was dropped.
167
+ */
168
+ async function buildRepoMap(root) {
169
+ const { tree, fileSymbols } = await walkTree(root);
170
+
171
+ // Sort tree for deterministic output
172
+ tree.sort();
173
+
174
+ let output = "# Repository structure\n\n";
175
+ output += "```\n";
176
+ output += tree.join("\n");
177
+ output += "\n```\n\n";
178
+
179
+ if (fileSymbols.size === 0) {
180
+ return output;
181
+ }
182
+
183
+ output += "# Exported symbols by file\n\n";
184
+
185
+ // Sort files by depth (shallowest first) so we drop deepest-first if needed
186
+ const filesByDepth = Array.from(fileSymbols.entries())
187
+ .map(([path, symbols]) => ({ path, symbols, depth: path.split("/").length }))
188
+ .sort((a, b) => a.depth - b.depth);
189
+
190
+ const symbolLines = [];
191
+ let droppedCount = 0;
192
+
193
+ for (const { path, symbols } of filesByDepth) {
194
+ const line = `- **${path}**: ${symbols.join(", ")}\n`;
195
+ const testOutput = output + symbolLines.join("") + line;
196
+
197
+ if (testOutput.length > MAX_MAP_SIZE) {
198
+ droppedCount++;
199
+ } else {
200
+ symbolLines.push(line);
201
+ }
202
+ }
203
+
204
+ output += symbolLines.join("");
205
+
206
+ if (droppedCount > 0) {
207
+ output += `\n*Symbol detail dropped for ${droppedCount} file(s) to stay under size cap.*\n`;
208
+ }
209
+
210
+ return output;
211
+ }
212
+
213
+ /**
214
+ * Get or build the cached repo map.
215
+ * Cache is keyed to HEAD sha and stored in inbox/spawn/.repomap-{sha}.txt
216
+ * Returns null if not a git repo (no HEAD sha to key on).
217
+ */
218
+ export async function getRepoMap(root, log = () => {}) {
219
+ const headSha = await getHeadSha(root);
220
+ if (!headSha) {
221
+ log("Not a git repo or HEAD unavailable; no repo map generated");
222
+ return null;
223
+ }
224
+
225
+ const cacheDir = join(root, ...segmentsOf(`${BOARD_DIR}/inbox/spawn`));
226
+ const cacheFile = join(cacheDir, `.repomap-${headSha}.txt`);
227
+
228
+ // Try to read cached version
229
+ try {
230
+ const cached = await readFile(cacheFile, "utf8");
231
+ log(`Repo map cache hit for ${headSha.slice(0, 7)}`);
232
+ return cached;
233
+ } catch {
234
+ // Cache miss; build it
235
+ }
236
+
237
+ log(`Building repo map for ${headSha.slice(0, 7)}...`);
238
+ const map = await buildRepoMap(root);
239
+
240
+ // Write to cache (best effort; don't fail if we can't write)
241
+ try {
242
+ await mkdir(cacheDir, { recursive: true });
243
+ await writeFile(cacheFile, map, "utf8");
244
+ log(`Repo map cached (${map.length} chars)`);
245
+ await pruneRepoMapCache(cacheDir, log);
246
+ } catch (err) {
247
+ log(`Could not cache repo map: ${err.message}`);
248
+ }
249
+
250
+ return map;
251
+ }
252
+
253
+ /** How many HEAD shas' worth of cached map to keep. */
254
+ export const REPOMAP_CACHE_KEEP = 5;
255
+
256
+ /** Drop all but the newest `REPOMAP_CACHE_KEEP` cached maps.
257
+ *
258
+ * The cache is keyed to HEAD sha and NOTHING removed the old ones. One entry
259
+ * per commit, up to 10KB each, written into the board's own spawn folder for
260
+ * ever: a repo that sees a hundred commits between spawns leaves a hundred
261
+ * files behind, and every one of them is a map of a tree nobody will ask
262
+ * about again. Keyed to a sha is the right cache key; keeping every sha is
263
+ * not a cache, it is a log.
264
+ *
265
+ * Newest by mtime rather than by name, because a sha sorts alphabetically and
266
+ * that has nothing to do with when it was HEAD. Best-effort throughout: a
267
+ * failed prune must never fail a spawn. */
268
+ export async function pruneRepoMapCache(cacheDir, log = () => {}, keep = REPOMAP_CACHE_KEEP) {
269
+ let names;
270
+ try {
271
+ names = (await readdir(cacheDir)).filter((n) => n.startsWith(".repomap-") && n.endsWith(".txt"));
272
+ } catch {
273
+ return;
274
+ }
275
+ if (names.length <= keep) return;
276
+ const dated = [];
277
+ for (const name of names) {
278
+ try {
279
+ dated.push({ name, mtimeMs: (await stat(join(cacheDir, name))).mtimeMs });
280
+ } catch {
281
+ /* vanished under us: nothing to prune */
282
+ }
283
+ }
284
+ dated.sort((a, b) => b.mtimeMs - a.mtimeMs);
285
+ const doomed = dated.slice(keep);
286
+ for (const { name } of doomed) {
287
+ await rm(join(cacheDir, name), { force: true }).catch(() => {});
288
+ }
289
+ if (doomed.length > 0) log(`Pruned ${doomed.length} stale repo map cache file(s)`);
290
+ }
291
+
292
+ /**
293
+ * Format the repo primer section for a session prompt.
294
+ * Returns null if no map available or if we're over the combined bound.
295
+ *
296
+ * Combined bound: primer + attempts together must not exceed ~16KB.
297
+ * Primer is trimmed first if needed.
298
+ */
299
+ export function formatRepoPrimer(repoMap, attemptsLength = 0) {
300
+ if (!repoMap) return null;
301
+
302
+ const MAX_COMBINED = 16_000; // ~16KB for primer + attempts together
303
+ const availableForPrimer = MAX_COMBINED - attemptsLength;
304
+
305
+ if (availableForPrimer < 500) {
306
+ // Not enough room for a useful primer
307
+ return null;
308
+ }
309
+
310
+ let primer = "# Repository primer\n\n";
311
+ primer += "This map shows the repository structure and exported symbols. ";
312
+ primer += "It may be stale versus your working branch.\n\n";
313
+ primer += repoMap;
314
+
315
+ if (primer.length > availableForPrimer) {
316
+ // Truncate the map portion, keep the header
317
+ const header = "# Repository primer\n\nThis map shows the repository structure and exported symbols. It may be stale versus your working branch.\n\n";
318
+ const mapPortion = repoMap.slice(0, availableForPrimer - header.length - 100);
319
+ primer = header + mapPortion + "\n\n*[Primer truncated to fit size bound]*\n";
320
+ }
321
+
322
+ return primer;
323
+ }
@@ -0,0 +1,151 @@
1
+ // Recover a coding run whose computer lost its lease (#1252).
2
+ // The reaper keeps its error row and null result; the app shows that as a
3
+ // wait only for marked runs or legacy note sessions, with no age limit.
4
+ // The first returning watcher settles it with a
5
+ // write-once result and attempt nonce, then files one child in the same
6
+ // conversation and copy. Every child can itself be recovered later.
7
+ // Stop lives in stop_requested_at, including on a reaped row; both the
8
+ // candidate check and the hosted resume op honour it. The existing SQL
9
+ // row lock serializes competing settles. No migration is needed.
10
+
11
+ import { randomUUID } from "node:crypto";
12
+ import { stat } from "node:fs/promises";
13
+ import * as runnerApiModule from "./runnerApi.mjs";
14
+ import { acceptsMessages, agentFor } from "./agents.mjs";
15
+ import { fromIdOf } from "./boardside.mjs";
16
+ import { wallClockFor, isChatTurn } from "./spawn.mjs";
17
+
18
+ /** What the continued session is told. */
19
+ export const RESTART_MESSAGE = "Your computer restarted while you were working. Carry on from where you were.";
20
+ /** What the idea reads when the session cannot carry on by itself. */
21
+ export const RESTART_ENDED_REPLY = "Your computer restarted during this session, so it stopped.";
22
+
23
+ /** The runs THIS process is running right now. A second watcher in the same
24
+ * process (multiwatch rebuilds a folder's watcher while the old one drains)
25
+ * must not mistake them for orphans. */
26
+ export const liveRuns = new Set();
27
+
28
+ /** The second look: after the lease has run out and the reaper has run. */
29
+ export const RESTART_RECHECK_MS = 7 * 60_000;
30
+
31
+ const timeOf = (value) => (value ? Date.parse(value) : NaN);
32
+ const isReaped = (row) => row.status === "error" && Boolean(row.recovery_reason) && (row.result === null || row.result === undefined);
33
+
34
+ /** Is this row one the last process left behind? Pure. */
35
+ export function restartCandidate(row, { now = Date.now() } = {}) {
36
+ const request = row?.request;
37
+ if (!request || typeof request !== "object" || liveRuns.has(row.id)) return false;
38
+ // Older Run-button runs already ended in the app. Never start paid work
39
+ // again for those rows. Older note sessions have always waited for recovery.
40
+ if (row.sidecar?.restartable !== true && !(typeof request.note?.noteId === "string" && request.note.noteId.length > 0)) return false;
41
+ if (row.sidecar?.restarted === true) return false;
42
+ if (isChatTurn(request) || request.planMode === "draft") return false;
43
+ if (!fromIdOf(request) && !row.from_id) return false;
44
+ // A person pressed Stop and the computer died before it was heard: the
45
+ // stop stands. Nothing is carried on that a person ended.
46
+ if (row.stop_requested_at) return false;
47
+ if (row.status === "running") {
48
+ // A lease still running belongs to a live process (the server leases for
49
+ // five minutes and a process renews every thirty seconds); only one that
50
+ // ran out is an orphan. The reaper turns it into an error soon after, and
51
+ // the second look catches that too.
52
+ const lease = timeOf(row.lease_expires_at);
53
+ return !(Number.isFinite(lease) && lease > now);
54
+ }
55
+ return isReaped(row) || (row.status === "error" && row.sidecar?.recoveryPending === true);
56
+ }
57
+
58
+ const isDirectory = async (path) => {
59
+ try { return (await stat(path)).isDirectory(); } catch { return false; }
60
+ };
61
+
62
+ /** One pass over one folder. Returns what it did, for the log and the test:
63
+ * `{resumed: [runId], ended: [runId], skipped: [runId]}`. Never throws. */
64
+ export async function restartPass(project, settings, { log = () => {}, now = Date.now } = {}) {
65
+ const done = { resumed: [], ended: [], skipped: [] };
66
+ const api = settings.runnerApi ?? runnerApiModule;
67
+ const opts = { env: settings.env, fetchImpl: settings.fetchImpl };
68
+
69
+ let rows;
70
+ try {
71
+ const [running, errored] = await Promise.all([
72
+ api.runs({ projectIds: [project.id], status: "running" }, opts),
73
+ api.runs({ projectIds: [project.id], status: "error" }, opts),
74
+ ]);
75
+ if (!running.ok) { log(`restart pass: could not list the running sessions: ${running.error}`); return done; }
76
+ rows = [...(running.runs ?? []), ...(errored.ok ? errored.runs ?? [] : [])];
77
+ } catch (error) {
78
+ log(`restart pass failed: ${error instanceof Error ? error.message : String(error)}`);
79
+ return done;
80
+ }
81
+
82
+ for (const row of rows.filter((r) => restartCandidate(r, { now: now() }))) {
83
+ try {
84
+ const outcome = await carryOn(row, { api, opts, log, now });
85
+ done[outcome].push(row.id);
86
+ } catch (error) {
87
+ log(`[${row.id}] restart pass failed: ${error instanceof Error ? error.message : String(error)}`);
88
+ }
89
+ }
90
+ return done;
91
+ }
92
+
93
+ async function carryOn(row, { api, opts, log, now }) {
94
+ const request = row.request;
95
+ const sidecar = row.sidecar && typeof row.sidecar === "object" ? row.sidecar : {};
96
+ const treeKept = typeof sidecar.worktreePath === "string" && await isDirectory(sidecar.worktreePath);
97
+ const engine = agentFor(request.agent);
98
+ const sessionId = (engine === "codex" ? [sidecar.codexThreadId, request.resumeSessionId] : [sidecar.claudeSessionId, request.resumeSessionId, request.sessionId])
99
+ .find((v) => typeof v === "string" && v.trim() !== "")?.trim();
100
+ const canResume = ["claude", "codex"].includes(engine) && acceptsMessages(request) && treeKept && sessionId !== undefined;
101
+ // A running row without its copy gets a second look after the reaper.
102
+ if (!canResume && acceptsMessages(request) && !treeKept && row.status === "running") {
103
+ log(`[${row.id}] restart pass: no kept worktree to carry on in; leaving it to the reaper`);
104
+ return "skipped";
105
+ }
106
+
107
+ // Lease timestamps are server time: the final successful renewal is the
108
+ // last confirmed working instant, never the reaper's five-minute deadline.
109
+ // Each child inherits the remaining budget, so previous segments are not
110
+ // counted again. Tail work since the last successful renewal is unconfirmed.
111
+ const claimed = timeOf(row.claimed_at);
112
+ const lastWorking = timeOf(row.lease_expires_at) - 300_000;
113
+ const workingMs = Number.isFinite(claimed) && Number.isFinite(lastWorking)
114
+ ? Math.max(0, lastWorking - claimed) : 0;
115
+ const remainingMs = Math.max(0, wallClockFor(request) - workingMs);
116
+ const settledSidecar = sidecar.recoveryPending === true ? sidecar : { ...sidecar, recoveryPending: true, ...(!canResume || remainingMs <= 0 ? { recoveryFailed: true } : {}), remainingWorkingMs: remainingMs, ...(canResume ? { [engine === "codex" ? "codexThreadId" : "claudeSessionId"]: sessionId } : {}), restartedAt: new Date(now()).toISOString(), restartAttempt: randomUUID() };
117
+ const settled = sidecar.recoveryPending === true ? { ok: true } : await api.update({
118
+ runId: row.id,
119
+ patch: {
120
+ sidecar: settledSidecar,
121
+ // No "carried on" claim here (round 2, item 59): the resume does not
122
+ // exist yet, and result is write-once; the card says it from restartedTo.
123
+ result: `ERROR\n\nYour computer restarted while this session was working.\n`,
124
+ ...(row.status === "running" ? { status: "error", finished_at: new Date(now()).toISOString() } : {}),
125
+ },
126
+ }, opts);
127
+ if (!settled.ok) {
128
+ log(`[${row.id}] restart pass: another process already took this row (${settled.error})`);
129
+ return "skipped";
130
+ }
131
+
132
+ if (canResume && remainingMs > 0) {
133
+ // A Stop that lands between the listing and here wins (the op looks).
134
+ const resumed = await api.resume({ runId: row.id, message: RESTART_MESSAGE, unlessStopped: true }, opts);
135
+ const childId = resumed.run?.id ?? resumed.id;
136
+ if (resumed.ok && childId) {
137
+ log(`[${row.id}] restart pass: carried on as ${childId}`);
138
+ await api.update({ runId: row.id, patch: { sidecar: { ...settledSidecar, recoveryPending: false, restarted: true, restartedTo: childId } } }, opts)
139
+ .catch(() => {});
140
+ return "resumed";
141
+ }
142
+ if (resumed.transient) return "skipped";
143
+ log(`[${row.id}] restart pass: could not resume (${resumed.error ?? "no run came back"}); telling the idea instead`);
144
+ }
145
+
146
+ // Persist the outcome on the row. Every app tab derives the same ending;
147
+ // there is no racing generic comment with different words or missing chips.
148
+ await api.update({ runId: row.id, patch: { sidecar: { ...settledSidecar, recoveryPending: false, restarted: true, recoveryFailed: true } } }, opts);
149
+ log(`[${row.id}] restart pass: this session cannot carry on`);
150
+ return "ended";
151
+ }
@@ -0,0 +1,78 @@
1
+ // Previous production app addresses. Saved credentials and agent configs
2
+ // move to Viewable without losing tokens or unrelated configuration (#2197).
3
+ // The Worker keeps machine calls on app.glytch.ai working during rollout.
4
+
5
+ export const RETIRED_APP_URL = "https://prompt.glytch.ai";
6
+ export const RETIRED_APP_URLS = [RETIRED_APP_URL, "https://app.glytch.ai"];
7
+ const MOVED_FIELDS = ["appUrl", "mcpUrl", "tokenEndpoint"];
8
+
9
+ /** `~/.glytch/auth.json` as read, with every address on the retired origin
10
+ * moved to `to`. The token is kept: one issued for the old address is still
11
+ * accepted at the new one until it expires
12
+ * (supabase/functions/_shared/publicOrigin.mjs). */
13
+ export function movedAuth(stored, to) {
14
+ const out = { ...stored };
15
+ for (const key of MOVED_FIELDS) {
16
+ const value = out[key];
17
+ const retired = typeof value === "string" && RETIRED_APP_URLS.find(origin => value === origin || value.startsWith(`${origin}/`));
18
+ if (retired) {
19
+ out[key] = to + value.slice(retired.length);
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /** Where `claude mcp get` says an entry lives. Verified 2026-10-03 on
26
+ * 2.1.288: the output has a "Scope: Local config (...)" / "Scope: User
27
+ * config (...)" / "Scope: Project config (...)" line, a "URL: <address>"
28
+ * line, and (for an entry added with a header) the "Authorization: Bearer
29
+ * <token>" line under "Headers:"; `claude mcp remove <name> -s <scope>`
30
+ * takes local, user or project. A project entry is the folder's
31
+ * `.mcp.json`, which `viewable init` rewrites itself (lib/mcpjson.mjs), so it
32
+ * is never removed from here. */
33
+ export function claudeRegistration(text) {
34
+ const scope = /^\s*Scope:\s*(Local|User|Project)\b/im.exec(text)?.[1]?.toLowerCase() ?? null;
35
+ const url = /^\s*URL:\s*(\S+)/im.exec(text)?.[1] ?? null;
36
+ const bearer = /^\s*Authorization:\s*Bearer\s+(\S+)/im.exec(text)?.[1] ?? null;
37
+ return { scope, url, bearer };
38
+ }
39
+
40
+ /** Replace a Claude Code registration at the retired address: remove it at
41
+ * the scope it lives in, then add it back at `boardUrl` through the same
42
+ * exec seam. The new entry keeps a bearer: at local scope the one `init` was
43
+ * handed, else the old entry's own; at user scope only the old entry's own.
44
+ * An entry without one signs in again. If the add fails after the remove
45
+ * worked, the old entry is put back, so a failed move loses nothing.
46
+ *
47
+ * Returns `{status: "ok" | "fail", message}` when the step is settled, or
48
+ * `{status: "relogin", message}` when the new entry still needs a sign-in. */
49
+ export async function moveClaudeRegistration({ exec, bin, env, serverKey, scope, url, bearer, token, boardUrl, addArgs, stderrOf }) {
50
+ const carried = scope === "local" ? (token ?? bearer) : bearer;
51
+ const argsFor = (address, header) => header
52
+ ? addArgs(address, header, scope)
53
+ : ["mcp", "add", "-s", scope, "--transport", "http", serverKey, address];
54
+ const args = argsFor(boardUrl, carried);
55
+ let removed;
56
+ let added;
57
+ try {
58
+ removed = await exec(bin, ["mcp", "remove", serverKey, "-s", scope], { env });
59
+ } catch (error) {
60
+ return { status: "fail", message: error instanceof Error ? error.message : String(error) };
61
+ }
62
+ if (!removed?.ok) {
63
+ return { status: "fail", message: `could not remove the old ${scope} registration at ${url}: ${stderrOf(removed)}` };
64
+ }
65
+ added = await exec(bin, args, { env }).catch(() => null);
66
+ if (!added?.ok) {
67
+ const why = added ? stderrOf(added) : "the add did not run";
68
+ const restored = await exec(bin, argsFor(url, bearer), { env }).catch(() => null);
69
+ return {
70
+ status: "fail",
71
+ message: restored?.ok
72
+ ? `could not add the new registration (${why}); the old one was put back`
73
+ : `could not add the new registration (${why}) or put the old one back: run \`claude mcp add -s ${scope} --transport http ${serverKey} ${boardUrl}\``,
74
+ };
75
+ }
76
+ if (carried) return { status: "ok", message: `moved from ${url} to ${boardUrl} (${scope} scope, access token kept)` };
77
+ return { status: "relogin", message: `Moved the ${scope} ${serverKey} registration from ${url} to ${boardUrl}.` };
78
+ }