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,288 @@
1
+ // Fresh configs write viewable (#2328). Existing glytch entries stay readable and keep their name (#2196).
2
+ // Add only the glytch entry, preserving comments, other servers, and any
3
+ // glytch entry the user already owns. Validate both before and after editing.
4
+ //
5
+ // RENAMED 2026-09-12 (brief 31, Move 3): the key written is `glytch`, not
6
+ // `board` - Codex gets `[mcp_servers.glytch]`, opencode gets `mcp.glytch`.
7
+ // An existing `board` entry recognised as ours - the old spawned-command
8
+ // shape, or a `board` entry already at our hosted URL from before this
9
+ // rename - is renamed to `glytch` in place, the same upgrade path
10
+ // `mcpjson.mjs` takes for `.mcp.json`. A `board` entry that is a stranger's
11
+ // own (a different URL, no recognisable legacy command) is left exactly
12
+ // alone, reported `present` as before.
13
+ //
14
+ // MOVED 2026-10-03: `prompt.glytch.ai` is retired for `app.viewable.one`
15
+ // (lib/mcpjson.mjs says why a redirect is not enough). An entry at the
16
+ // retired address counts as ours, and wherever a merge keeps an entry of ours
17
+ // (a `board` renamed to `glytch`, or an existing `glytch`) its `url` is
18
+ // replaced with the current one, other fields kept. A `glytch` entry at any
19
+ // other url is left alone.
20
+ import { mkdir, readFile, stat } from "node:fs/promises";
21
+ import { dirname, join } from "node:path";
22
+ import { parse as parseToml, stringify } from "smol-toml";
23
+ import { parse as parseJsonc, modify, applyEdits } from "jsonc-parser";
24
+ import { BOARD_MCP_URL, CODEX_TOOL_TIMEOUT_SEC, RETIRED_BOARD_MCP_URLS, entryIsAtRetiredUrl, isLegacyBoardCommand, moveRetiredUrl } from "./mcpjson.mjs";
25
+ import { writeFileAtomic } from "./atomic.mjs";
26
+
27
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
28
+ export async function readOptional(path) {
29
+ try { return await readFile(path, "utf8"); }
30
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
31
+ }
32
+
33
+ /** Every server name already on disk for this agent, newest first, so sign-out
34
+ * can reach each credential before its entry is removed (#2196, #2328). */
35
+ export async function projectMcpServerKeys(root, agent) {
36
+ if (!root) return [];
37
+ try {
38
+ let servers;
39
+ if (agent === "codex") {
40
+ servers = parseToml(await readOptional(join(root, ".codex/config.toml")) ?? "").mcp_servers;
41
+ } else if (agent === "opencode") {
42
+ const text = await readOptional(join(root, "opencode.jsonc")) ?? await readOptional(join(root, "opencode.json"));
43
+ servers = parseJsonc(text ?? "{}")?.mcp;
44
+ } else {
45
+ servers = JSON.parse(await readOptional(join(root, ".mcp.json")) ?? "{}").mcpServers;
46
+ }
47
+ // "glytch" is the compatibility read: keep existing MCP names.
48
+ return ["viewable", "glytch"].filter((name) => Object.hasOwn(servers ?? {}, name));
49
+ } catch { /* The writer reports malformed configs; sign-in must not rewrite them. */ }
50
+ return [];
51
+ }
52
+
53
+ /** Use the name already on disk for sign-in, including pre-Viewable installs (#2196).
54
+ * Reuse the same readers/parsers as the config writers below. */
55
+ export async function projectMcpServerKey(root, agent, fallback = "viewable") {
56
+ return (await projectMcpServerKeys(root, agent))[0] ?? fallback;
57
+ }
58
+
59
+ /** Remove a TOML table by its exact header - e.g. `"mcp_servers.board"` or
60
+ * `"mcp_servers.glytch"` - and any of its own sub-tables (`header.*`, e.g.
61
+ * the old `.env` table carrying `PM_BOARD_ROOT`), stopping at the next
62
+ * top-level table header or end of file. Returns null when no such header
63
+ * is found, so a caller never silently no-ops a rewrite it could not
64
+ * locate.
65
+ *
66
+ * This is a TEXT splice rather than a round trip through the TOML parser on
67
+ * purpose: smol-toml can stringify a whole document but not patch one line
68
+ * in place while keeping someone else's comments, which is the same reason
69
+ * `mergeCodexBoard` below always re-parses and checks the result before
70
+ * trusting it. Used here to remove a `board` table recognised as OURS
71
+ * (`boardEntryIsOurs` below, covering both the retired command shape and a
72
+ * `board` entry already at our hosted URL) - a hand-written custom entry
73
+ * never reaches this.
74
+ *
75
+ * EXPORTED (brief 31 review, 2026-09-12), parameterised on `header`, so
76
+ * lib/uninstall-project.mjs can remove `[mcp_servers.glytch]` with the exact
77
+ * same splice rather than keeping its own byte-identical copy. */
78
+ export function withoutTomlTable(text, header) {
79
+ const lines = text.split("\n");
80
+ const start = lines.findIndex((l) => l.trim() === `[${header}]`);
81
+ if (start === -1) return null;
82
+ let end = start + 1;
83
+ while (end < lines.length) {
84
+ const trimmed = lines[end].trim();
85
+ if (trimmed.startsWith("[") && !trimmed.startsWith(`[${header}.`)) break;
86
+ end++;
87
+ }
88
+ let from = start;
89
+ if (from > 0 && lines[from - 1].trim() === "") from--;
90
+ return [...lines.slice(0, from), ...lines.slice(end)].join("\n");
91
+ }
92
+
93
+ /** Replace the retired `url` inside one TOML table with the current one, as a
94
+ * TEXT edit of that single line (same reason as `withoutTomlTable`: keep
95
+ * someone else's comments and layout). Only the table's own key lines are
96
+ * searched - up to the next header of any kind. Returns null when the line
97
+ * cannot be located (an inline table, dotted keys), so the caller refuses
98
+ * rather than guesses. */
99
+ function withMovedTomlUrl(text, header, to) {
100
+ const lines = text.split("\n");
101
+ const start = lines.findIndex((l) => l.trim() === `[${header}]`);
102
+ if (start === -1) return null;
103
+ const pattern = new RegExp(`^(\\s*url\\s*=\\s*)(["'])(?:${RETIRED_BOARD_MCP_URLS.map(url => url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\2`);
104
+ for (let i = start + 1; i < lines.length && !lines[i].trim().startsWith("["); i++) {
105
+ if (pattern.test(lines[i])) {
106
+ lines[i] = lines[i].replace(pattern, (_all, lead, quote) => `${lead}${quote}${to}${quote}`);
107
+ return lines.join("\n");
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /** Is this Codex `board` table ours - the retired spawned-command shape, or
114
+ * a `board` entry already sitting at our hosted URL from before this
115
+ * rename? Mirrors `boardEntryIsOurs` in lib/mcpjson.mjs; kept as its own
116
+ * function here because the TOML shape (`{url}`, no `type`) is Codex's own.
117
+ *
118
+ * EXPORTED (brief 31 review, 2026-09-12) for the same reuse reason as
119
+ * `withoutTomlTable` above. */
120
+ export function boardEntryIsOurs(entry, env) {
121
+ if (!object(entry)) return false;
122
+ if (isLegacyBoardCommand(entry.command, entry.args)) return true;
123
+ return typeof entry.url === "string" && (entry.url === BOARD_MCP_URL(env) || RETIRED_BOARD_MCP_URLS.includes(entry.url));
124
+ }
125
+
126
+ /** opencode's own shape: a `command` ARRAY rather than separate
127
+ * `command`/`args` strings, plus `{type, url}` for the hosted shape. Kept as
128
+ * its own function (rather than reusing `boardEntryIsOurs` above) because
129
+ * opencode's `command` field is an array where Codex's and `.mcp.json`'s are
130
+ * separate `command`/`args` strings - EXPORTED for the same reuse reason as
131
+ * `withoutTomlTable` and `boardEntryIsOurs` above: lib/uninstall-project.mjs
132
+ * used to keep a byte-identical copy of exactly this check. */
133
+ export function opencodeBoardEntryIsOurs(entry, env) {
134
+ if (!object(entry)) return false;
135
+ if (Array.isArray(entry.command) && isLegacyBoardCommand(entry.command[0], entry.command.slice(1))) return true;
136
+ return typeof entry.url === "string" && (entry.url === BOARD_MCP_URL(env) || RETIRED_BOARD_MCP_URLS.includes(entry.url));
137
+ }
138
+
139
+ export function mergeCodexBoard(existing, env = process.env) {
140
+ try {
141
+ const parsed = parseToml(existing ?? "");
142
+ if (parsed.mcp_servers !== undefined && !object(parsed.mcp_servers)) return { kind: "unreadable" };
143
+ const servers = parsed.mcp_servers ?? {};
144
+ // Compatibility: keep existing names; only fresh entries use viewable (#2196).
145
+ if (Object.hasOwn(servers, "viewable")) return { kind: "present" };
146
+ // A fresh entry gets the tool timeout (see CODEX_TOOL_TIMEOUT_SEC); an
147
+ // entry already there keeps its own settings.
148
+ const server = { url: BOARD_MCP_URL(env), tool_timeout_sec: CODEX_TOOL_TIMEOUT_SEC };
149
+ const boardEntry = servers.board;
150
+ const boardIsOurs = boardEntryIsOurs(boardEntry, env);
151
+
152
+ if (!Object.hasOwn(servers, "glytch")) {
153
+ if (!Object.hasOwn(servers, "board")) {
154
+ const added = stringify({ mcp_servers: { viewable: server } });
155
+ const text = `${existing ?? ""}\n${added}\n`;
156
+ // Inline tables cannot be extended. Refuse rather than rewrite
157
+ // someone's entire config and lose comments or alter unrelated
158
+ // settings.
159
+ parseToml(text);
160
+ return { kind: existing === null ? "create" : "add", text };
161
+ }
162
+ if (!boardIsOurs) return { kind: "present" };
163
+ const stripped = withoutTomlTable(existing ?? "", "mcp_servers.board");
164
+ // Could not safely locate the old table's textual span (a hand-edited
165
+ // inline table, say) - never guess at a splice, leave it exactly as
166
+ // the presence rule always has.
167
+ if (stripped === null) return { kind: "present" };
168
+ const glytchValue = isLegacyBoardCommand(boardEntry.command, boardEntry.args) ? server : moveRetiredUrl(boardEntry, env);
169
+ const added = stringify({ mcp_servers: { glytch: glytchValue } });
170
+ const text = `${stripped}\n${added}\n`;
171
+ const check = parseToml(text);
172
+ if (JSON.stringify(check.mcp_servers?.glytch) !== JSON.stringify(glytchValue)) return { kind: "unreadable" };
173
+ return { kind: "legacy", ...(entryIsAtRetiredUrl(boardEntry, env) ? { moved: true } : {}), text };
174
+ }
175
+
176
+ // `glytch` is already there. Drop a leftover `board` table that is ours;
177
+ // leave everything else, including a stranger's own "board", untouched.
178
+ // A `glytch` table at the retired address is ours too: its `url` line is
179
+ // replaced in place.
180
+ const glytchMoved = entryIsAtRetiredUrl(servers.glytch, env);
181
+ if (!boardIsOurs && !glytchMoved) return { kind: "present" };
182
+ let text = existing ?? "";
183
+ if (boardIsOurs) {
184
+ const stripped = withoutTomlTable(text, "mcp_servers.board");
185
+ if (stripped === null) return { kind: "present" };
186
+ text = stripped;
187
+ }
188
+ if (glytchMoved) {
189
+ // Not locatable as a plain `url = "..."` line: say so rather than
190
+ // report a dead address as an entry that is kept.
191
+ const moved = withMovedTomlUrl(text, "mcp_servers.glytch", BOARD_MCP_URL(env));
192
+ if (moved === null) return { kind: "unreadable" };
193
+ text = moved;
194
+ }
195
+ const check = parseToml(text);
196
+ if (!object(check.mcp_servers) || !Object.hasOwn(check.mcp_servers, "glytch")) return { kind: "unreadable" };
197
+ if (glytchMoved && check.mcp_servers.glytch?.url !== BOARD_MCP_URL(env)) return { kind: "unreadable" };
198
+ return { kind: "legacy", ...(glytchMoved ? { moved: true } : {}), text };
199
+ } catch { return { kind: "unreadable" }; }
200
+ }
201
+
202
+ export function mergeOpencodeBoard(existing, env = process.env) {
203
+ const text = existing ?? "{}\n";
204
+ const errors = [];
205
+ const parsed = parseJsonc(text, errors, { allowTrailingComma: true });
206
+ if (errors.length || !object(parsed) || (parsed.mcp !== undefined && !object(parsed.mcp))) return { kind: "unreadable" };
207
+ if (Object.hasOwn(parsed.mcp ?? {}, "viewable")) return { kind: "present" };
208
+ const glytchPresent = Object.hasOwn(parsed.mcp ?? {}, "glytch");
209
+ const boardPresent = Object.hasOwn(parsed.mcp ?? {}, "board");
210
+ const existingBoard = parsed.mcp?.board;
211
+ const boardIsOurs = boardPresent && opencodeBoardEntryIsOurs(existingBoard, env);
212
+ const legacyCommand = boardIsOurs && object(existingBoard) && Array.isArray(existingBoard.command) &&
213
+ isLegacyBoardCommand(existingBoard.command[0], existingBoard.command.slice(1));
214
+ const server = { type: "remote", url: BOARD_MCP_URL(env), enabled: true };
215
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: text.includes("\r\n") ? "\r\n" : "\n" };
216
+
217
+ if (!glytchPresent) {
218
+ if (boardPresent && !boardIsOurs) return { kind: "present" };
219
+ if (!boardPresent) {
220
+ const updated = applyEdits(text, modify(text, ["mcp", "viewable"], server, { formattingOptions }));
221
+ const updatedErrors = [];
222
+ const checked = parseJsonc(updated, updatedErrors, { allowTrailingComma: true });
223
+ if (updatedErrors.length || JSON.stringify(checked?.mcp?.viewable) !== JSON.stringify(server)) {
224
+ return { kind: "unreadable" };
225
+ }
226
+ return { kind: existing === null ? "create" : "add", text: updated };
227
+ }
228
+ // A `board` entry recognised as ours: move it to `glytch` and remove
229
+ // `board`. A legacy command entry is replaced with the canonical
230
+ // remote-server value; a url-match entry (already ours, just under the
231
+ // old key) keeps its own value untouched.
232
+ const glytchValue = legacyCommand ? server : moveRetiredUrl(existingBoard, env);
233
+ const edits = [
234
+ ...modify(text, ["mcp", "glytch"], glytchValue, { formattingOptions }),
235
+ ];
236
+ const afterGlytch = applyEdits(text, edits);
237
+ const removed = applyEdits(afterGlytch, modify(afterGlytch, ["mcp", "board"], undefined, { formattingOptions }));
238
+ const updatedErrors = [];
239
+ const checked = parseJsonc(removed, updatedErrors, { allowTrailingComma: true });
240
+ if (updatedErrors.length || JSON.stringify(checked?.mcp?.glytch) !== JSON.stringify(glytchValue) || Object.hasOwn(checked?.mcp ?? {}, "board")) {
241
+ return { kind: "unreadable" };
242
+ }
243
+ return { kind: "legacy", ...(entryIsAtRetiredUrl(existingBoard, env) ? { moved: true } : {}), text: removed };
244
+ }
245
+
246
+ // `glytch` is already there. Drop a leftover `board` entry that is ours;
247
+ // leave everything else, including a stranger's own "board", untouched.
248
+ // A `glytch` entry at the retired address is ours too: its `url` moves.
249
+ const glytchMoved = entryIsAtRetiredUrl(parsed.mcp?.glytch, env);
250
+ if (!boardIsOurs && !glytchMoved) return { kind: "present" };
251
+ let updated = text;
252
+ if (boardIsOurs) updated = applyEdits(updated, modify(updated, ["mcp", "board"], undefined, { formattingOptions }));
253
+ if (glytchMoved) updated = applyEdits(updated, modify(updated, ["mcp", "glytch", "url"], BOARD_MCP_URL(env), { formattingOptions }));
254
+ const updatedErrors = [];
255
+ const checked = parseJsonc(updated, updatedErrors, { allowTrailingComma: true });
256
+ if (updatedErrors.length || (boardIsOurs && Object.hasOwn(checked?.mcp ?? {}, "board"))) return { kind: "unreadable" };
257
+ if (glytchMoved && checked?.mcp?.glytch?.url !== BOARD_MCP_URL(env)) return { kind: "unreadable" };
258
+ return { kind: "legacy", ...(glytchMoved ? { moved: true } : {}), text: updated };
259
+ }
260
+
261
+ export async function syncProjectMcp(root, agent, { write = true, env = process.env } = {}) {
262
+ let relative = agent === "codex" ? ".codex/config.toml" : "opencode.json";
263
+ // OpenCode gives JSONC precedence; extend that file if it exists.
264
+ if (agent === "opencode" && await readOptional(join(root, "opencode.jsonc")) !== null) relative = "opencode.jsonc";
265
+ const path = join(root, relative);
266
+ const existing = await readOptional(path);
267
+ const merge = (agent === "codex" ? mergeCodexBoard : mergeOpencodeBoard)(existing, env);
268
+ const row = (ok, state, detail) => ({ key: agent, label: `${agent} Viewable server`, ok, state, detail });
269
+ if (merge.kind === "present") return row(true, "present", `${relative}: existing board connection kept`);
270
+ if (merge.kind === "unreadable") return row(false, "unreadable", `${relative} could not be safely extended; fix its config and run viewable init again. Nothing was overwritten.`);
271
+ if (!write) {
272
+ return merge.kind === "legacy"
273
+ ? row(false, "legacy-command", merge.moved
274
+ ? `${relative} still names the retired board address - run viewable init to move it to app.viewable.one`
275
+ : `${relative} still names the retired local MCP server - run viewable init to switch it to the hosted board URL`)
276
+ : row(false, "missing", `${relative} needs the viewable entry - run: viewable init`);
277
+ }
278
+ await mkdir(dirname(path), { recursive: true });
279
+ // Atomic replacement creates a new inode: carry forward private config
280
+ // permissions instead of reverting to the process's default creation mode.
281
+ const mode = existing === null ? 0o600 : (await stat(path)).mode & 0o777;
282
+ await writeFileAtomic(path, merge.text, "utf8", { mode });
283
+ return merge.kind === "legacy"
284
+ ? row(true, "upgraded", merge.moved
285
+ ? `${relative}: glytch entry moved to app.viewable.one`
286
+ : `${relative}: glytch entry upgraded from the old command shape to the hosted URL`)
287
+ : row(true, merge.kind, `${relative}: viewable entry registered for this project`);
288
+ }
@@ -0,0 +1,31 @@
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+
4
+ /** Refuse symlinks below a linked root, including dangling links. The root
5
+ * itself may be an OS alias (for example macOS /var). Missing descendants
6
+ * are permitted for worktrees that have not yet been created. */
7
+ export function staysInProject(root, target) {
8
+ const base = resolve(root);
9
+ const path = resolve(root, target);
10
+ const rel = relative(base, path);
11
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`) || resolve(base, rel) !== path) return false;
12
+ let current = base;
13
+ for (const part of rel.split(sep).filter(Boolean)) {
14
+ current = resolve(current, part);
15
+ try {
16
+ if (lstatSync(current).isSymbolicLink()) return false;
17
+ } catch (error) {
18
+ if (error.code !== "ENOENT") return false;
19
+ }
20
+ }
21
+ return true;
22
+ }
23
+
24
+ /** One spelling per directory, for comparing two paths. The native realpath
25
+ * expands Windows 8.3 short names (`RUNNER~1`, as `os.tmpdir()` returns) to
26
+ * the long form Git prints, where the JS one keeps them. Windows paths are
27
+ * case-insensitive, so they are folded there. */
28
+ export function canonicalPath(path) {
29
+ const real = realpathSync.native(path);
30
+ return process.platform === "win32" ? real.toLowerCase() : real;
31
+ }
@@ -0,0 +1,70 @@
1
+ // Refusing a call HERE rather than letting the app refuse the file THERE.
2
+ //
3
+ // The failure this exists to stop, observed twice on 2026-08-25: a session
4
+ // called report with a 74-character title, this bridge cheerfully wrote the
5
+ // ops file and returned success, and the APP refused the whole file - so the
6
+ // session believed it had reported, not one circle existed, and the error
7
+ // landed as a red panel on the user's screen instead of in the transcript of
8
+ // the session that could have fixed it in one turn.
9
+ //
10
+ // Two rules come out of that, and both are enforced by this module's shape:
11
+ //
12
+ // 1. EVERY constraint the bridge states, the bridge checks - before it writes
13
+ // anything. A documented rule nothing enforces is worse than no rule: it
14
+ // reads as a guarantee and behaves as a wish.
15
+ // 2. EVERY violation is reported at once. Fixing item 4 and then discovering
16
+ // item 7 is two more round trips and two more chances for a doomed file to
17
+ // reach him.
18
+ //
19
+ // Refusing is never truncating. Standing ruling (INBOX_OPS.md, "Reporting"):
20
+ // longer is refused, never shortened. Truncation is what produced a report
21
+ // ending mid-word with an ellipsis, and silently editing a session's headline
22
+ // is worse than making it write a shorter one.
23
+
24
+ /** A call refused before anything was written, carrying every reason. */
25
+ export class Refusal extends Error {
26
+ constructor(problems, what) {
27
+ super(formatProblems(problems, what));
28
+ this.name = "Refusal";
29
+ this.problems = problems;
30
+ this.what = what;
31
+ }
32
+ }
33
+
34
+ export const isRefusal = (error) => error instanceof Refusal;
35
+
36
+ /** Collect problems, keep going, refuse once with all of them.
37
+ *
38
+ * A caller stops its pass early only when there is genuinely nothing left to
39
+ * learn - a `did` that is not an array has no items to walk. Everything
40
+ * per-item keeps walking, which is the whole point. */
41
+ export function problems() {
42
+ const found = [];
43
+ return {
44
+ note: (message) => found.push(message),
45
+ get list() {
46
+ return found;
47
+ },
48
+ get any() {
49
+ return found.length > 0;
50
+ },
51
+ /** Throw one Refusal carrying all of them, or return the value unchanged. */
52
+ settle(what, value) {
53
+ if (found.length > 0) throw new Refusal(found, what);
54
+ return value;
55
+ },
56
+ };
57
+ }
58
+
59
+ /** The refusal a session reads. Same voice as the app's own failures
60
+ * (src/inboxOps.ts, src/boardApi.ts): name the field, the offending index,
61
+ * the actual number, the cap, and what to do instead. */
62
+ export function formatProblems(list, what = "call") {
63
+ const nothing = `Nothing was written: no file reached inbox/ops/, so nothing is half-landed on the board. Fix and call again.`;
64
+ if (list.length === 1) return `This ${what} was refused. ${list[0]}\n${nothing}`;
65
+ return [
66
+ `This ${what} was refused: ${list.length} things to fix.`,
67
+ ...list.map((message, i) => ` ${i + 1}. ${message}`),
68
+ `${nothing} All ${list.length} are listed because the app stops at the FIRST one and refuses the whole file - and that refusal lands on the user's screen, not here.`,
69
+ ].join("\n");
70
+ }
@@ -0,0 +1,204 @@
1
+ // WHICH FOLDERS THIS MACHINE WATCHES: ~/.glytch/folders.json, a JSON list of
2
+ // absolute paths.
3
+ //
4
+ // The daemon that brief 19 installs runs ONCE per machine and not once per
5
+ // project, so something has to say which projects it is for. That is this
6
+ // file, and it is a list of paths and nothing else: no per-folder settings, no
7
+ // state, no cache. A folder is in it or it is not, and `glytch init` /
8
+ // `glytch forget` are the only two things that write it.
9
+ //
10
+ // GLYTCH_HOME EXISTS FOR THE TESTS, and only for them. Every path in this
11
+ // package that would otherwise land in the real ~/.glytch goes through
12
+ // glytchHome(), so a test suite that sets GLYTCH_HOME to a temp directory
13
+ // cannot touch the machine it runs on - which matters more here than usual,
14
+ // because the other half of this feature loads a LaunchAgent.
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { mkdir, readFile, rm, stat } from "node:fs/promises";
18
+ import { homedir, tmpdir } from "node:os";
19
+ import { dirname, join, resolve, sep } from "node:path";
20
+ import { createHash, randomUUID } from "node:crypto";
21
+ import { writeFileAtomic } from "./atomic.mjs";
22
+ import { acquireAdmission } from "./admission.mjs";
23
+
24
+ /** ~/.glytch, or GLYTCH_HOME when set. */
25
+ export const glytchHome = (env = process.env) =>
26
+ (env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim() || join(homedir(), ".glytch");
27
+
28
+ export const registryPath = (env = process.env) => join(glytchHome(env), "folders.json");
29
+
30
+ // Restore and Remove run in different processes. Reuse admission's exclusive
31
+ // file lock and dead-owner recovery, in a directory separate from run claims.
32
+ export async function withRegistryLock(fn, env = process.env) {
33
+ const deadline = Date.now() + 10_000;
34
+ for (;;) {
35
+ const held = await acquireAdmission(glytchHome(env), { directory: join(glytchHome(env), "registry") });
36
+ if (held.ok) {
37
+ try { return await fn(); }
38
+ finally { await held.release(); }
39
+ }
40
+ if (Date.now() >= deadline) throw new Error(held.detail);
41
+ await new Promise(resolve => setTimeout(resolve, 25));
42
+ }
43
+ }
44
+
45
+ const restorePath = (folder, env) => join(glytchHome(env), "registry", `${createHash("sha256").update(resolve(folder)).digest("hex")}.restore`);
46
+
47
+ export async function folderRestoreGeneration(folder, env = process.env) {
48
+ try { return await readFile(restorePath(folder, env), "utf8"); }
49
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
50
+ }
51
+
52
+ /** Called under the registry lock BEFORE init restores the hosted row, so a
53
+ * failed local write cannot leave a successful restore exposed to old ops. */
54
+ export async function markFolderRestore(folder, env = process.env) {
55
+ await writeFileAtomic(restorePath(folder, env), randomUUID());
56
+ }
57
+
58
+ // TEMPORARY FOLDERS ARE NEVER REGISTERED (QA 2026-10-01, ticket e7). An agent
59
+ // once started a runner from its scratchpad, and "proof-project
60
+ // (/private/tmp/claude-501/...)" sat in Settings > Connection for days. A
61
+ // folder under the OS temp dir (or /tmp, /private/tmp, /var/folders) is
62
+ // somebody's scratch work, not a project. The exception is a SCRATCH HOME:
63
+ // when GLYTCH_HOME itself lives under a temp root this is a test or a verifier
64
+ // (see the header), and its temp folders are the whole point.
65
+ const tempRoots = () => [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders"].map((p) => resolve(p));
66
+ const underTemp = (path) => tempRoots().some((root) => path === root || path.startsWith(`${root}${sep}`));
67
+ export const isScratchFolder = (folder, env = process.env) =>
68
+ underTemp(resolve(folder)) && !underTemp(resolve(glytchHome(env)));
69
+
70
+ /** The folders with no git repo, for the presence body (QA 2026-10-01,
71
+ * ticket e2). Such a folder runs IN PLACE - `makeWorktree` finds no repo - so
72
+ * the prompt editor says so instead of promising "Isolated". A `.git` entry
73
+ * (the directory, or the file a worktree has) in the folder or any parent is
74
+ * a repo; a plain stat walk, because this runs on every beat. */
75
+ export function foldersWithoutGit(folders) {
76
+ return folders.filter((folder) => {
77
+ for (let dir = resolve(folder); ; dir = dirname(dir)) {
78
+ if (existsSync(join(dir, ".git"))) return false;
79
+ if (dirname(dir) === dir) return true;
80
+ }
81
+ });
82
+ }
83
+
84
+ const isDir = async (path) => {
85
+ try {
86
+ return (await stat(path)).isDirectory();
87
+ } catch {
88
+ return false;
89
+ }
90
+ };
91
+
92
+ /** The registered folders, absolute and de-duplicated, oldest first.
93
+ *
94
+ * A missing file is an empty list, never an error: a machine that has never
95
+ * run `init` is not broken, it is new. A file that is not JSON, or is JSON of
96
+ * the wrong shape, is also an empty list rather than a throw - the daemon
97
+ * reads this on every start, and a corrupt registry must not be the reason
98
+ * nothing watches. `init` rewrites it either way. */
99
+ export async function readRegistry(env = process.env) {
100
+ return (await readRegistryChecked(env)).folders;
101
+ }
102
+
103
+ /** The same list, and whether the file could actually be read as one.
104
+ *
105
+ * `readRegistry` collapses "missing", "not JSON" and "genuinely empty" into
106
+ * one empty list, which is right for a one-shot read at start and wrong for
107
+ * the daemon's RE-read (lib/multiwatch.mjs, 2026-09-06): reading an
108
+ * unreadable registry as "every folder was just removed" would stop every
109
+ * watcher on the machine over a file someone was half-way through editing.
110
+ * `ok: false` means "this is not news - keep the last good list".
111
+ *
112
+ * A file that is not there is `ok: false` for the same reason. The only
113
+ * writer in this package is writeFileAtomic, which renames a whole file into
114
+ * place, so a registry that has gone missing is something outside taking it
115
+ * away, and the daemon fails toward keeping what it already watches. A
116
+ * first-ever start is unaffected: it reads through `readRegistry`, gets the
117
+ * empty list, and still says to run `viewable init`. */
118
+ export async function readRegistryChecked(env = process.env) {
119
+ const none = { folders: [], ok: false };
120
+ let raw;
121
+ try {
122
+ raw = await readFile(registryPath(env), "utf8");
123
+ } catch {
124
+ return none;
125
+ }
126
+ let parsed;
127
+ try {
128
+ parsed = JSON.parse(raw);
129
+ } catch {
130
+ return none;
131
+ }
132
+ if (!Array.isArray(parsed)) return none;
133
+ const seen = new Set();
134
+ const out = [];
135
+ for (const entry of parsed) {
136
+ if (typeof entry !== "string" || entry.trim() === "") continue;
137
+ const path = resolve(entry.trim());
138
+ if (seen.has(path)) continue;
139
+ seen.add(path);
140
+ out.push(path);
141
+ }
142
+ return { folders: out, ok: true };
143
+ }
144
+
145
+ async function writeRegistry(folders, env = process.env) {
146
+ await mkdir(glytchHome(env), { recursive: true });
147
+ await writeFileAtomic(registryPath(env), `${JSON.stringify(folders, null, 2)}\n`);
148
+ }
149
+
150
+ /** Add a folder. Idempotent by absolute path: running `init` twice in one
151
+ * folder leaves one entry, and the second run is not an error. Returns
152
+ * {folders, added}. */
153
+ export async function addFolder(folder, env = process.env) {
154
+ const path = resolve(folder);
155
+ if (isScratchFolder(path, env)) throw new Error(`${path} is a temporary folder, and those are never registered. Run viewable init in the project folder itself.`);
156
+ const folders = await readRegistry(env);
157
+ if (folders.includes(path)) return { folders, added: false };
158
+ const next = [...folders, path];
159
+ await writeRegistry(next, env);
160
+ return { folders: next, added: true };
161
+ }
162
+
163
+ /** Write the folder list back unchanged. The running daemon reads a rewrite
164
+ * as a request for one beat from every folder it watches (multiwatch.mjs),
165
+ * which is how `viewable init` in a folder already watched gets the fresh beat
166
+ * it waits for (#813 review). A registry that is not there stays absent. */
167
+ export async function touchRegistry(env = process.env) {
168
+ const { folders, ok } = await readRegistryChecked(env);
169
+ if (!ok || folders.length === 0) return false;
170
+ await writeRegistry(folders, env);
171
+ return true;
172
+ }
173
+
174
+ /** Remove a folder. Returns {folders, removed}. Forgetting a folder that was
175
+ * never registered is not an error either - the end state is what was asked
176
+ * for. */
177
+ export async function removeFolder(folder, env = process.env) {
178
+ const path = resolve(folder);
179
+ const folders = await readRegistry(env);
180
+ if (!folders.includes(path)) return { folders, removed: false };
181
+ const next = folders.filter((f) => f !== path);
182
+ await writeRegistry(next, env);
183
+ return { folders: next, removed: true };
184
+ }
185
+
186
+ /** Drop entries whose folder no longer exists. Returns the paths dropped, so
187
+ * the caller can log one line each (the sweep does). A folder that is gone is
188
+ * gone: nothing here creates it back, and nothing exits over it. */
189
+ export async function pruneMissing(env = process.env) {
190
+ const folders = await readRegistry(env);
191
+ const kept = [];
192
+ const dropped = [];
193
+ for (const folder of folders) {
194
+ if (await isDir(folder)) kept.push(folder);
195
+ else dropped.push(folder);
196
+ }
197
+ if (dropped.length > 0) await writeRegistry(kept, env);
198
+ return { kept, dropped };
199
+ }
200
+
201
+ /** Forget everything. Only the tests and an uninstall would want this. */
202
+ export async function clearRegistry(env = process.env) {
203
+ await rm(registryPath(env), { force: true }).catch(() => {});
204
+ }