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
package/lib/doctor.mjs ADDED
@@ -0,0 +1,464 @@
1
+ // ONE LIST, ONE ROW EACH, and a green run means "a session filed from the
2
+ // board in this folder will actually start".
3
+ //
4
+ // Everything a person could otherwise only learn by running a session and
5
+ // watching it fail: is this folder registered, is the daemon loaded, is it
6
+ // beating, is the board sign-in one the board will still accept, does this
7
+ // machine have an engine, is Claude Code logged in, is there a newer version.
8
+ // Six questions plus the four the sync already answers (ruling 5: "doctor
9
+ // lists each as a row").
10
+ //
11
+ // "IS THE APP WRITING THE MIRROR" WAS THE FIFTH, and it is retired 2026-09-13
12
+ // along with the row that asked it - the mirror has not existed since
13
+ // 2026-09-11. The count is unchanged because the sign-in row stopped being a
14
+ // file-exists check on the same day and became a real one. See both dated
15
+ // notes below.
16
+ //
17
+ // EXIT 0 WHEN ALL GREEN, 1 OTHERWISE, because a doctor whose exit code says
18
+ // nothing cannot be put in front of anything.
19
+ //
20
+ // A ROW IS NEVER RED FOR A FACT ABOUT THE WORLD rather than about this
21
+ // install. Offline is "unknown", not a failure - a machine on a plane is not
22
+ // broken. An engine that is not installed is only red when NO engine is.
23
+ //
24
+ // TWO ROWS JOINED THE LIST ON 2026-09-07 (brief 21, session 4).
25
+ //
26
+ // The FIRST row now names the platform, per ruling 6C: "macOS", "Windows" or
27
+ // "WSL (Linux)". It is there because every row under it means something
28
+ // slightly different depending on the answer - "login agent" is a LaunchAgent
29
+ // on one machine, a scheduled task on the next - and because "it works in
30
+ // WSL" being mistaken for "it works on Windows" is the exact confusion the
31
+ // ruling exists to end.
32
+ //
33
+ // The GIT row is a WARNING and not a FAIL, per ruling 8B. Without git there
34
+ // is no worktree, so `makeWorktreeImpl` already falls back to running the
35
+ // session in the linked folder itself - unisolated, which is a real cost and
36
+ // not a broken install. On Windows that also means no Bash tool, since Claude
37
+ // Code runs Bash calls through Git Bash. So the row says what is lost and
38
+ // doctor still exits 0: a person who has decided to run unisolated should not
39
+ // have to read a red list for ever.
40
+
41
+ import { row, warnRow } from "./cli-output.mjs";
42
+ import { PROBE_BINS, detectAgents, execProbe, versionFrom } from "./detect.mjs";
43
+ import { DAEMON_LABEL, daemonPackageVersion, daemonScriptPath, loginDaemon, noDaemonInstall } from "./logindaemon.mjs";
44
+ import { RUNNER_PUSH_STALE_MS } from "./spawn.mjs";
45
+ import { projectMcpServerKey } from "./project-mcp.mjs";
46
+ import { syncFolder } from "./sync.mjs";
47
+ import { readAgentSelection } from "./agent-selection.mjs";
48
+ import { compareVersions, latestFromNpm, packageVersion, verifyInstalledCli } from "./update.mjs";
49
+ import { PERSONAL_TOKEN_REJECTED_HINT, REAUTH_REQUIRED_HINT, hostedStatus } from "./auth.mjs";
50
+ import { presence as hostedPresence } from "./runnerApi.mjs";
51
+
52
+ // `row` is imported from cli-output.mjs (brief 31 review, 2026-09-12 - it
53
+ // used to be a local copy, byte-identical to lib/uninstall.mjs's own and
54
+ // lib/uninstall-project.mjs's own). `action` is the same escape hatch
55
+ // sync.mjs's own `row()` carries (2026-09-11): the generic "run glytch init"
56
+ // is wrong exactly when init is the thing that already failed, or fixes
57
+ // nothing at all - "not signed in to the board" needs `glytch login`, not
58
+ // `glytch init`, and printing the default there is the same loop ruling 6C
59
+ // named for Task Scheduler.
60
+
61
+ /** The one-line step that turns a WRITTEN board entry into an AUTHENTICATED
62
+ * one (brief 30, 2026-09-11). Writing `.mcp.json` / `.codex/config.toml` /
63
+ * `opencode.json` is this package's job; the OAuth handshake against
64
+ * `https://app.viewable.one/mcp` is each agent's own, dynamic client
65
+ * registration meaning there is no shared secret to write into a config file
66
+ * for it - so doctor names the step rather than performing it.
67
+ *
68
+ * VERIFIED AGAINST THE BINARIES INSTALLED ON THIS MACHINE, 2026-09-11:
69
+ * `codex mcp login <name>` (`codex mcp login --help`, Codex CLI) and
70
+ * `opencode mcp auth <name>` (`opencode mcp auth --help`, opencode 1.18.23)
71
+ * are both real subcommands that name the server.
72
+ *
73
+ * CLAUDE CODE'S LINE WAS MARKED UNVERIFIED HERE UNTIL 2026-09-12 (brief 31,
74
+ * Move 2), and the mark was true when it was written: on 2.1.34 `claude mcp
75
+ * --help` listed only `add`/`add-json`/`get`/`list`/`remove`/`serve`, no
76
+ * login verb, so `/mcp` inside the interactive TUI was the only path and all
77
+ * this file could do was repeat the docs. `claude mcp login <name>` exists
78
+ * from 2.1.186, and it was RUN on 2.1.269 against the real endpoint from
79
+ * outside a session: "Authenticated with "board". Its tools are now available
80
+ * in Claude Code." So the line names the verb and its version floor, and
81
+ * keeps `/mcp` as the fallback for an older install - `viewable init` now runs
82
+ * the verb itself when the floor is met (lib/init-connect.mjs). */
83
+ // Keyed by the ROW's key, not the agent name: Claude Code's row is always
84
+ // "mcp" (lib/sync.mjs's syncMcpJson names it that regardless of how it was
85
+ // selected), and "codex"/"opencode" already match the agent name because
86
+ // syncProjectMcp and the legacy syncOpencode both key their row on it.
87
+ // RENAMED 2026-09-12 (brief 31, Move 3, ruling 3): the server key each
88
+ // verb names is `glytch`, not `board` - Claude Code's own consent page then
89
+ // registers the client as "Claude Code (glytch)" (Move 4), matching what
90
+ // `.mcp.json`, `.codex/config.toml` and `opencode.json` now all call it.
91
+ export const BOARD_SIGN_IN = {
92
+ mcp: "Sign in: claude mcp login viewable, on Claude Code 2.1.186 or newer. Below that floor there is no shell verb: open a session on this project, run /mcp, and authenticate the board connection.",
93
+ codex: "Sign in: codex mcp login viewable",
94
+ opencode: "Sign in: opencode mcp auth viewable",
95
+ };
96
+
97
+ /** Append the sign-in step to a row that named a board entry, but only once
98
+ * the file itself is in a state worth signing in to - never on "unreadable"
99
+ * or a `write:false` "missing"/"legacy-command" report, where there is
100
+ * nothing on disk yet for the OAuth flow to reach. */
101
+ async function withSignIn(rows, root) {
102
+ for (const r of rows) {
103
+ if (r.ok && Object.hasOwn(BOARD_SIGN_IN, r.key)) {
104
+ const key = await projectMcpServerKey(root, r.key === "mcp" ? "claude" : r.key);
105
+ const hint = BOARD_SIGN_IN[r.key].replace("viewable", key);
106
+ r.detail = r.detail ? `${r.detail} ${hint}` : hint;
107
+ }
108
+ }
109
+ return rows;
110
+ }
111
+
112
+ // `warnRow` is imported from cli-output.mjs too (same dedup). Ruling 8B, and
113
+ // the only user of it here is the git row. `allGreen` counts it as green,
114
+ // deliberately.
115
+
116
+ /** "signed in as someone@example.com", or "signed in as account 4db90780..."
117
+ * when the board could name the id but not the address. Null when there is
118
+ * no account to name at all, which leaves the row's old reason standing.
119
+ *
120
+ * The id is SHORTENED to its first segment on purpose: this row is read by a
121
+ * person checking whether the account is the one they think it is, and the
122
+ * first eight characters settle that as well as thirty-six do while still
123
+ * fitting the line. The full id is in the app's own settings if the two ever
124
+ * need comparing character by character. */
125
+ const accountLine = (account) => {
126
+ if (!account) return null;
127
+ if (typeof account.email === "string" && account.email !== "") return `signed in as ${account.email}`;
128
+ if (typeof account.id === "string" && account.id !== "") return `signed in as account ${account.id.split("-")[0]}`;
129
+ return null;
130
+ };
131
+
132
+ const ago = (ms) => {
133
+ const s = Math.round(ms / 1000);
134
+ if (s < 90) return `${s}s ago`;
135
+ const m = Math.round(s / 60);
136
+ if (m < 90) return `${m}m ago`;
137
+ const h = Math.round(m / 60);
138
+ return h < 48 ? `${h}h ago` : `${Math.round(h / 24)} days ago`;
139
+ };
140
+
141
+ /* THE FOLDER-LINKED QUESTION. "Is this folder linked to a board?" is
142
+ * answered by the "daemon beating" row above: `runner_projects` is where a
143
+ * linked folder is recorded, and its "never" case already says `No runner
144
+ * heartbeat for this folder` with the step that fixes it. There is no
145
+ * second thing to check. */
146
+
147
+ /** Every row, in the order they are printed. Everything the outside world
148
+ * answers is injected, so the whole of doctor is testable without a daemon,
149
+ * a network or a launchctl. */
150
+ export async function doctorRows(
151
+ root,
152
+ {
153
+ env = process.env,
154
+ exec,
155
+ launchctl,
156
+ now = Date.now(),
157
+ version = packageVersion(),
158
+ latest = latestFromNpm,
159
+ loaded,
160
+ agents,
161
+ write = true,
162
+ verify = verifyInstalledCli,
163
+ // The machine, and what it installs a login daemon as. Both injected so
164
+ // the suite can print the Windows and WSL lists on this Mac.
165
+ platform = process.platform,
166
+ procVersion,
167
+ impl = loginDaemon({ env, platform, procVersion }),
168
+ // The "daemon beating" row's own network call (brief 31, "doctor asks
169
+ // the board") - injected the same way `latest` above is, so the suite
170
+ // exercises fresh/stale/no-row/offline without a real board-runner or a
171
+ // real fetch. Real default: bridge/lib/runnerApi.mjs's `presence`,
172
+ // reading `runner_projects.last_seen_at` under this row's own bearer.
173
+ presence = hostedPresence,
174
+ fetchImpl,
175
+ } = {},
176
+ ) {
177
+ const selected = await readAgentSelection(root);
178
+ const found = agents ?? (await detectAgents({ env, ...(exec ? { exec } : {}) }));
179
+
180
+ // Ruling 6C's row, first, because it says what every row below it means.
181
+ const what = impl.describe();
182
+ const rows = [
183
+ row("platform", "platform", true, what.platformLabel, `your computer runs it as ${what.mechanism} (${what.file})`),
184
+ ];
185
+ // Only a fresh terminal can prove the durable entry point. A same-version
186
+ // checkout or temporary copy must never become the login service (#749).
187
+ const service = write && !noDaemonInstall(env)
188
+ ? await verify({ env, platform, expectedVersion: version }) : { ok: true };
189
+ const synced = await syncFolder(root, { env, exec, launchctl, agents: found,
190
+ write: write && service.ok, impl, platform, scriptPath: service.scriptPath });
191
+ rows.push(...await withSignIn(synced.map((r) => !service.ok && r.key === "plist"
192
+ ? row("plist", impl.rowLabel, false, "not verified", service.error, service.error) : r), root));
193
+
194
+ // Move 5, brief 31: the row above only checks that a plist/launcher/unit
195
+ // is there and loaded - not that it runs the package a person thinks they
196
+ // installed. His Mac's plist pointed at an `~/.npm/_npx` cache running
197
+ // 0.5.0 while this same `doctor` ran from a 0.7.0 checkout, and the row
198
+ // above called the login agent fine because that is all it ever asked. So
199
+ // this row reads the daemon's OWN script path off disk and compares the
200
+ // package beside it to the one answering right now - never red for the
201
+ // FACT that there is no daemon file yet (the row above already says so) or
202
+ // that the path on disk cannot be resolved to a package, per the "never
203
+ // red for a fact about the world" rule at the top of this file.
204
+ const daemonPath = await daemonScriptPath({ env, platform, procVersion, impl });
205
+ if (daemonPath === null) {
206
+ rows.push(row("daemonVersion", "daemon package", true, "unknown", "no daemon file to check yet"));
207
+ } else {
208
+ const daemonVersion = await daemonPackageVersion(daemonPath);
209
+ if (daemonVersion === null) {
210
+ rows.push(row("daemonVersion", "daemon package", true, "unknown", `could not read the package.json beside ${daemonPath}`));
211
+ } else if (daemonVersion === version) {
212
+ rows.push(row("daemonVersion", "daemon package", true, "same", `${version} at ${daemonPath}`));
213
+ } else {
214
+ rows.push(
215
+ row(
216
+ "daemonVersion",
217
+ "daemon package",
218
+ false,
219
+ "mismatch",
220
+ `${daemonVersion} in ${daemonPath}, ${version} here`,
221
+ "run `viewable init` from this install so your computer runs it",
222
+ ),
223
+ );
224
+ }
225
+ }
226
+
227
+ // Read once, here, because the "daemon beating" row below needs it before
228
+ // its own row is pushed (a bearer this doctor already knows is missing is
229
+ // a wasted network call, not a diagnostic) - reused, unchanged, for the
230
+ // "board sign-in" row further down. No network call of its own
231
+ // (hostedStatus's own header): a doctor row read constantly should not
232
+ // cost one just to learn whether there is a credential on file at all.
233
+ const boardAuth = await hostedStatus(env);
234
+
235
+ // The daemon, in two halves that fail differently: the OS holding the
236
+ // registration, and the runner actually writing its mark. Ruling 1A chose
237
+ // Task Scheduler over a Startup shortcut precisely so this half survives on
238
+ // Windows - `schtasks /query` answers it, a shortcut could not.
239
+ const state = loaded === undefined ? await impl.loadedState({ env, ...(launchctl ? { exec: launchctl } : {}) }) : { loaded, unavailable: false, why: "" };
240
+ rows.push(
241
+ state.loaded
242
+ ? row("daemon", "daemon loaded", true, "loaded", `${impl.holder} is holding ${DAEMON_LABEL}`)
243
+ : state.unavailable
244
+ ? // Ruling 6C: an older WSL with no systemd is told the truth and given
245
+ // the fallback. The "daemon beating" row below is the honest proof
246
+ // either way, which is why this one does not go red over a machine
247
+ // that has no user systemd to install into.
248
+ warnRow("daemon", "daemon loaded", "no systemd here", state.why)
249
+ : row("daemon", "daemon loaded", false, "not loaded", `${impl.holder} does not have ${DAEMON_LABEL}: run viewable init`),
250
+ );
251
+
252
+ // THE ROW THIS FILE'S FINDING IS ABOUT (brief 31, 2026-09-12, "doctor asks
253
+ // the board"). Before this fix the row read a local mark file
254
+ // (`.glytch/inbox/spawn/.runner.json`, `readMark`) that nothing over rows
255
+ // writes any more - register/heartbeat replaced it with
256
+ // `runner_projects.last_seen_at` the day brief 30 Move 1/2 landed, and this
257
+ // row was simply never updated for that: a 0.7.0 daemon beating the board
258
+ // every few seconds still read FAIL "stopped" here forever, because the
259
+ // file it checked had not moved since an earlier daemon last wrote it. So
260
+ // the row now asks the board directly, through the new read-only
261
+ // `presence` op (bridge/lib/runnerApi.mjs, supabase/functions/board-runner/
262
+ // runner.mjs) - the same fact `runner_projects` already answers the app's
263
+ // own UI with (src/runs/projects.ts), reached the only way this CLI can
264
+ // reach it: over the hosted endpoint under its own bearer, not PostgREST.
265
+ //
266
+ // BOARD SIGN-IN IS CHECKED FIRST so a machine that never ran `glytch
267
+ // login` gets one clear answer ("sign in to see the heartbeat") rather
268
+ // than a network call this doctor already knows will refuse for lack of a
269
+ // bearer - the "board sign-in" row below is where the fix for THAT lives.
270
+ // NEVER RED for either "not signed in" or "offline": both are facts about
271
+ // the world, not this install, the same rule the file's own header states
272
+ // for every other row - a machine on a plane, or one that has not run
273
+ // `glytch login` yet, is not a daemon that stopped beating.
274
+ // WHICH ACCOUNT THE BEARER REACHES, filled from the same presence call the
275
+ // beat row makes (2026-09-14). Null when that call did not happen or did not
276
+ // come back: this must never turn a row red on its own.
277
+ let boardAccount = null;
278
+ if (!boardAuth.signedIn) {
279
+ rows.push(row("beat", "daemon beating", true, "unknown", "sign in to see the heartbeat"));
280
+ } else {
281
+ const presenceResult = await presence({ paths: [root] }, { env, ...(fetchImpl ? { fetchImpl } : {}) });
282
+ if (presenceResult.ok) boardAccount = presenceResult.account ?? null;
283
+ if (!presenceResult.ok) {
284
+ rows.push(row("beat", "daemon beating", true, "unknown", `could not reach the board to check: ${presenceResult.error}`));
285
+ } else {
286
+ // "local" is the exact host value bridge/lib/runner.mjs's
287
+ // registerAndFindProject registers this machine's projects under
288
+ // (`settings.host ?? "local"`) - matched here too, so a folder this
289
+ // account also runs on a different, remote host does not read as
290
+ // "watching" off a stranger's beat.
291
+ const mine = (presenceResult.projects ?? []).find((p) => p.path === root && p.host === "local");
292
+ if (!mine) {
293
+ rows.push(
294
+ row(
295
+ "beat",
296
+ "daemon beating",
297
+ false,
298
+ "never",
299
+ "No runner heartbeat for this folder. Run viewable init from the project folder linked to your board.",
300
+ ),
301
+ );
302
+ } else {
303
+ const age = now - Date.parse(mine.last_seen_at);
304
+ rows.push(
305
+ age <= RUNNER_PUSH_STALE_MS
306
+ ? row("beat", "daemon beating", true, "watching", `last beat ${ago(age)}`)
307
+ : row("beat", "daemon beating", false, "stopped", `last beat ${ago(age)}, past the ${RUNNER_PUSH_STALE_MS / 1000}s staleness window`),
308
+ );
309
+ }
310
+ }
311
+ }
312
+
313
+ // The "board mirror" row stood here until 2026-09-13. See the retirement
314
+ // note above mirrorFreshness's old home for what it was and why it is gone.
315
+
316
+ const names = PROBE_BINS.filter((b) => (selected === null || selected.includes(b.key)) && found?.[b.key]?.found).map(
317
+ (b) => `${b.label}${found[b.key].version ? ` ${found[b.key].version}` : ""}`,
318
+ );
319
+ const missing = selected?.filter((key) => !found?.[key]?.found) ?? [];
320
+ rows.push(
321
+ missing.length > 0
322
+ ? row("agents", "coding agents", false, "missing", `Selected but not installed: ${missing.join(", ")}. Install them or rerun viewable init to change the selection.`)
323
+ : names.length > 0
324
+ ? row("agents", "coding agents", true, "found", names.join(", "))
325
+ : row("agents", "coding agents", false, "none", "no coding agent on PATH: install Claude Code, opencode or Codex"),
326
+ );
327
+
328
+ rows.push(
329
+ selected !== null && !selected.includes("claude")
330
+ ? row("login", "Claude Code login", true, "n/a", "Claude Code is not selected for this project")
331
+ : found?.claude?.found !== true
332
+ ? row("login", "Claude Code login", true, "n/a", "Claude Code is not installed on your computer")
333
+ : found.claude.loggedIn
334
+ // The detail no longer names the file (2026-09-12). It said
335
+ // "~/.claude/.credentials.json is there" and on macOS that file is
336
+ // usually NOT there - the credential is a login-Keychain item, which
337
+ // is what lib/detect.mjs asks for first. A row whose state is right
338
+ // and whose reason is wrong is worse than one that just says the
339
+ // state: it sends someone looking for a file that was never written.
340
+ ? row("login", "Claude Code login", true, "logged in", "a stored credential was found: the login Keychain on macOS, ~/.claude/.credentials.json elsewhere")
341
+ : row("login", "Claude Code login", false, "logged out", "run `claude` once and log in, or sessions fail at spawn"),
342
+ );
343
+
344
+ // ONE ROW (brief 30, W2a): the local stdio MCP server is retired, and
345
+ // every agent reaches the board through the hosted endpoint now
346
+ // (bridge/lib/auth.mjs) - so a run's board tools need this to
347
+ // be true rather than a mirror on disk. No network call: a doctor row read
348
+ // constantly should not cost one, and `hostedToken`'s own refresh already
349
+ // covers the "signed in but the access token has aged" case at call time.
350
+ // `boardAuth` itself was already read above, before the "daemon beating"
351
+ // row, which needs the same answer first.
352
+ //
353
+ // THE CHECK THIS ROW DID NOT HAVE, added 2026-09-13 alongside the backoff
354
+ // in lib/auth.mjs. "Signed in" used to mean no more than "auth.json is
355
+ // there", so a machine whose credential the endpoint had revoked read GREEN
356
+ // on the one row a person checks when their runner cannot authenticate -
357
+ // the file is present, and presence was the whole test. It is the same
358
+ // fault the retired "board mirror" row had in the other direction: a row
359
+ // whose reason is wrong is worse than no row.
360
+ //
361
+ // `hostedStatus` now also reports whether the credential carries a
362
+ // dead-credential mark - written next to auth.json by whichever process met
363
+ // the refusal, usually the daemon, which is why doctor can report it from a
364
+ // different process. STILL NO NETWORK CALL: it is one more small file read,
365
+ // so the bargain the rest of doctor keeps is intact.
366
+ rows.push(
367
+ boardAuth.dead === true
368
+ ? boardAuth.via === "personal token"
369
+ // A REJECTED PERSONAL TOKEN READS DIFFERENTLY (2026-09-19, brief 43
370
+ // review, B2). It was never going to expire or refresh - it is
371
+ // revoked from Settings - so "expired and cannot be renewed" would
372
+ // have been the wrong sentence even after the mark started being
373
+ // written for it. "rejected" names what actually happened, and the
374
+ // hint names the actual fix: a fresh token, not `glytch login`.
375
+ ? row(
376
+ "boardAuth",
377
+ "board sign-in",
378
+ false,
379
+ "rejected",
380
+ `the personal token in ~/.glytch/auth.json was rejected by the board${boardAuth.deadSince ? ` (since ${boardAuth.deadSince})` : ""}`,
381
+ PERSONAL_TOKEN_REJECTED_HINT,
382
+ )
383
+ : row(
384
+ "boardAuth",
385
+ "board sign-in",
386
+ false,
387
+ "expired",
388
+ `the credential in ~/.glytch/auth.json was refused by the board${boardAuth.deadSince ? ` (since ${boardAuth.deadSince})` : ""} and cannot be renewed`,
389
+ REAUTH_REQUIRED_HINT,
390
+ )
391
+ : boardAuth.signedIn
392
+ ? row(
393
+ "boardAuth",
394
+ "board sign-in",
395
+ true,
396
+ "signed in",
397
+ // AND WHO AS (2026-09-14). "Signed in" was the whole reason, and a
398
+ // credential left on a previous account reads exactly like a
399
+ // credential on the right one - which is how two days of session
400
+ // reports landed on a board nobody was looking at. The name comes
401
+ // from the board's `presence` op, which resolved this very token,
402
+ // so it is the endpoint's answer and not a guess off a local file.
403
+ // Missing when the call did not happen or did not come back; the
404
+ // row then says what it always said rather than nothing.
405
+ accountLine(boardAccount) ??
406
+ // `via: "personal token"` (brief 43, 2026-09-19) is VIEWABLE_BOARD_TOKEN
407
+ // persisted to disk rather than read live from the environment -
408
+ // worded like its own case, not folded into the generic
409
+ // "auth.json is there" that only ever meant an OAuth login before.
410
+ (boardAuth.via === "VIEWABLE_BOARD_TOKEN"
411
+ ? "VIEWABLE_BOARD_TOKEN is set"
412
+ : boardAuth.via === "personal token"
413
+ ? "a personal access token is stored in ~/.glytch/auth.json"
414
+ : "~/.glytch/auth.json is there"),
415
+ )
416
+ : row(
417
+ "boardAuth",
418
+ "board sign-in",
419
+ false,
420
+ "not signed in",
421
+ "run `viewable login`, or set VIEWABLE_BOARD_TOKEN to a personal access token for a script",
422
+ "Run `viewable login`. It opens a browser to sign in and approve, and the credential lands in ~/.glytch/auth.json. Re-running `viewable init` does not do this step.",
423
+ ),
424
+ );
425
+
426
+ // Ruling 8B: no git is a WARNING and sessions run unisolated. On Windows it
427
+ // is Git for Windows, and it carries the Bash tool with it.
428
+ const git = await (exec ?? execProbe)("git", ["--version"]);
429
+ rows.push(
430
+ git?.ok === true
431
+ ? row("git", "git", true, "found", versionFrom(git.stdout) ? `git ${versionFrom(git.stdout)}` : "git is on PATH")
432
+ : warnRow(
433
+ "git",
434
+ "git",
435
+ "missing",
436
+ impl.platform === "windows"
437
+ ? "no git: sessions run unisolated in the linked folder, and Claude Code's Bash tool needs Git for Windows"
438
+ : "no git: sessions run unisolated in the linked folder, with no worktree of their own",
439
+ ),
440
+ );
441
+
442
+ const newestPublished = await latest().catch(() => null);
443
+ rows.push(
444
+ newestPublished === null
445
+ ? row("version", "version", true, "unknown", `${version} installed; npm did not answer (offline?)`)
446
+ : compareVersions(newestPublished, version) > 0
447
+ ? row("version", "version", true, "update available", `${version} installed, ${newestPublished} on npm: npm i -g glytch`)
448
+ : row("version", "version", true, "current", `${version} is the latest`),
449
+ );
450
+
451
+ return rows;
452
+ }
453
+
454
+ /** Format for a terminal. One row per line, the state in a column, the detail
455
+ * after it. */
456
+ export function formatRows(rows) {
457
+ return rows.map(
458
+ (r) => `${r.warn ? "warn" : r.ok ? "ok " : "FAIL"} ${r.label.padEnd(22)} ${r.state.padEnd(16)} ${r.detail}`,
459
+ );
460
+ }
461
+
462
+ /** A warn row is green (ruling 8B): the install works, and what it costs is
463
+ * printed rather than counted as a failure. */
464
+ export const allGreen = (rows) => rows.every((r) => r.ok);
@@ -0,0 +1,64 @@
1
+ // Exit code classification for child processes. Distinguishes crashes (SIGSEGV,
2
+ // SIGABRT, etc.) from clean exits and other failures, so runRequest can retry
3
+ // crashes that happen before any output streamed.
4
+
5
+ /** 128+N, the shell's own spelling of "died on signal N", which is how a
6
+ * Windows child reports a crash it has no signal to carry. Only the signals
7
+ * the set below already calls a crash are listed, and SIGKILL is deliberately
8
+ * absent: 137 there would be someone else's kill, and the runner's own kill
9
+ * arrives as `isKill` through the signal branch. */
10
+ const WINDOWS_SIGNAL_EXITS = new Map([
11
+ [132, "SIGILL"],
12
+ [133, "SIGTRAP"],
13
+ [134, "SIGABRT"],
14
+ [135, "SIGBUS"],
15
+ [136, "SIGFPE"],
16
+ [139, "SIGSEGV"],
17
+ ]);
18
+
19
+ /** Classify a child's exit code and signal.
20
+ * Returns { isCrash: boolean, isKill: boolean, description: string }. */
21
+ export function classifyExit(code, signal, platform = process.platform) {
22
+ // SIGKILL is never retried - it means the runner itself killed the child.
23
+ if (signal === "SIGKILL") {
24
+ return { isCrash: false, isKill: true, description: "killed (SIGKILL)" };
25
+ }
26
+
27
+ // Unix crash signals
28
+ const crashSignals = new Set(["SIGSEGV", "SIGABRT", "SIGBUS", "SIGILL", "SIGFPE", "SIGTRAP"]);
29
+ if (signal && crashSignals.has(signal)) {
30
+ return { isCrash: true, isKill: false, description: `crash (${signal})` };
31
+ }
32
+
33
+ // Windows crash codes (access violation, stack overflow, etc.)
34
+ // 0xC0000005 = access violation, 0xC00000FD = stack overflow
35
+ const windowsCrashCodes = new Set([0xC0000005, 0xC00000FD, 3221225477, 3221225725]);
36
+ if (typeof code === "number" && windowsCrashCodes.has(code)) {
37
+ const hex = `0x${code.toString(16).toUpperCase()}`;
38
+ return { isCrash: true, isKill: false, description: `crash (${hex})` };
39
+ }
40
+
41
+ // A CRASH ON WINDOWS ARRIVES AS A NUMBER, NEVER AS A SIGNAL (2026-09-07,
42
+ // brief 21, the first windows-latest run). Windows has no signals, so a child
43
+ // that aborts - `process.abort()`, an assertion failure, a Node fatal error -
44
+ // closes with `signal === null` and code 128+SIGABRT, the same 128+N
45
+ // convention a POSIX shell reports. The crash-signal set above therefore saw
46
+ // nothing, every abort read as "exit 134", and BOTH halves of the retry
47
+ // ruling were wrong on that platform: a crash before any output was not
48
+ // retried, and a crash after output was reported as a plain non-zero exit
49
+ // rather than as the crash it was. Only win32, because a POSIX child that
50
+ // aborts brings its signal with it and one that exits 134 deliberately means
51
+ // 134.
52
+ if (platform === "win32" && typeof code === "number" && WINDOWS_SIGNAL_EXITS.has(code)) {
53
+ return { isCrash: true, isKill: false, description: `crash (${WINDOWS_SIGNAL_EXITS.get(code)})` };
54
+ }
55
+
56
+ // Not a crash
57
+ if (signal) {
58
+ return { isCrash: false, isKill: false, description: signal };
59
+ }
60
+ if (typeof code === "number") {
61
+ return { isCrash: false, isKill: false, description: `exit ${code}` };
62
+ }
63
+ return { isCrash: false, isKill: false, description: "unknown" };
64
+ }
@@ -0,0 +1,105 @@
1
+ // THE GLYTCH FOLDER (2026-09-30, the owner's ruling: "like we have .claude we
2
+ // have .glytch by default ... cowork also created projects in Documents").
3
+ //
4
+ // ~/.glytch stays the runner's own, hidden. ~/Glytch is the person's: a
5
+ // visible folder, a git repository, registered like any folder `glytch init`
6
+ // adds. A chat on a board with no folder picked works here, in a subfolder
7
+ // named after the board (`boardFolder` on a chat-turn request, bridge/SPAWN.md),
8
+ // so every board has somewhere to work before anyone has linked a folder.
9
+ //
10
+ // Made when the daemon starts watching, and kept: nothing here removes it.
11
+
12
+ import { lstat, mkdir, realpath, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { basename, dirname, join, resolve } from "node:path";
15
+ import { addFolder } from "./registry.mjs";
16
+ import { git, isChatWorktreePath, resolveChatCwd } from "./worktree.mjs";
17
+
18
+ /** Where the Glytch folder lives, or null when there is none to make.
19
+ *
20
+ * GLYTCH_FOLDER names it, and "off" turns it off. A scratch GLYTCH_HOME (the
21
+ * tests, and every agent that runs glytch for real) gets none unless it names
22
+ * one: a test must never make a folder in the real home. */
23
+ export function glytchFolderPath(env = process.env) {
24
+ const named = (env.VIEWABLE_FOLDER ?? env.GLYTCH_FOLDER)?.trim();
25
+ if (named === "off") return null;
26
+ if (named) return resolve(named);
27
+ if ((env.VIEWABLE_HOME ?? env.GLYTCH_HOME)?.trim()) return null;
28
+ return join(homedir(), "Glytch");
29
+ }
30
+
31
+ const README =
32
+ "# Viewable\n\n" +
33
+ "Coding agents on your boards work here when a board has no folder of its own.\n" +
34
+ "Each board gets its own subfolder, named after the board.\n\n" +
35
+ "To have an agent work somewhere else, pick another folder in the chat's folder menu.\n";
36
+
37
+ // A first commit needs a name and an email, and a fresh machine may have
38
+ // neither. These apply to this one commit only; signing is off for the same
39
+ // reason (a signing prompt would hang the daemon).
40
+ const COMMITTER = ["-c", "user.name=Viewable", "-c", "user.email=glytch@localhost", "-c", "commit.gpgsign=false"];
41
+
42
+ /** Make the Glytch folder if it is missing, as a git repository with one
43
+ * commit (a chat worktree is cut from a commit), and register it. Returns
44
+ * the path, or null when there is none. Never throws: a daemon that cannot
45
+ * make it still watches every other folder. */
46
+ export async function ensureGlytchFolder(env = process.env, log = () => {}) {
47
+ const path = glytchFolderPath(env);
48
+ if (path === null) return null;
49
+ try {
50
+ await mkdir(path, { recursive: true });
51
+ // Its own repository, not one it happens to sit inside: a home folder
52
+ // kept under git would otherwise lend ~/Glytch its history.
53
+ const top = await git(["rev-parse", "--show-toplevel"], path);
54
+ if (!top.ok || (await realpath(top.stdout.trim())) !== (await realpath(path))) {
55
+ const init = await git(["init"], path);
56
+ if (!init.ok) throw new Error(`git init failed: ${init.stderr?.trim() || "no detail"}`);
57
+ await git(["symbolic-ref", "HEAD", "refs/heads/main"], path);
58
+ }
59
+ const head = await git(["rev-parse", "--verify", "HEAD"], path);
60
+ if (!head.ok) {
61
+ const readme = join(path, "README.md");
62
+ // Never write through a link or over a file already there. "wx" alone
63
+ // is enough on POSIX, but Windows follows a dangling link on create and
64
+ // writes its target, so anything at the name (a link included) is left.
65
+ const taken = await lstat(readme).then(() => true, (error) => { if (error?.code === "ENOENT") return false; throw error; });
66
+ if (!taken) {
67
+ try { await writeFile(readme, README, { flag: "wx" }); } catch (error) { if (error?.code !== "EEXIST") throw error; }
68
+ }
69
+ await git(["add", "README.md"], path);
70
+ const commit = await git([...COMMITTER, "commit", "-m", "Viewable folder"], path);
71
+ if (!commit.ok) throw new Error(`first commit failed: ${commit.stderr?.trim() || "no detail"}`);
72
+ log(`made the Viewable folder at ${path}`);
73
+ }
74
+ await addFolder(path, env);
75
+ return path;
76
+ } catch (error) {
77
+ log(`could not make the Viewable folder at ${path}: ${error instanceof Error ? error.message : String(error)}`);
78
+ return null;
79
+ }
80
+ }
81
+
82
+ // ---------- THE BOARD'S SUBFOLDER, on a chat turn ----------
83
+
84
+ /** The chat worktree and the board's subfolder a chat turn runs in. A first
85
+ * turn names the tree, and the subfolder is the board's name. Every turn
86
+ * after it hands back the runner's own cwd, the subfolder it ran in, and
87
+ * keeps it: a renamed board's conversation stays with its files. The parent
88
+ * is asked first, so a board named "chat-…" is not read as a tree. */
89
+ export function chatBoardOf(root, cwd, name) {
90
+ return isChatWorktreePath(root, dirname(cwd)) ? { tree: dirname(cwd), name: basename(cwd) } : { tree: cwd, name };
91
+ }
92
+
93
+ /** Make the board's subfolder inside the chat worktree and return it, or a
94
+ * refusal in words. Checked again once made: a link planted under that name
95
+ * would lead the agent out of the folder. */
96
+ export async function makeBoardCwd(root, tree, name) {
97
+ const path = join(tree, name);
98
+ try {
99
+ await mkdir(path, { recursive: true });
100
+ } catch (error) {
101
+ return { ok: false, reason: `Could not make the board's folder ${path}: ${error instanceof Error ? error.message : String(error)}` };
102
+ }
103
+ const checked = resolveChatCwd(root, path);
104
+ return checked.ok ? { ok: true, path } : { ok: false, reason: `The board's folder ${path}: ${checked.reason}` };
105
+ }