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
package/lib/detect.mjs ADDED
@@ -0,0 +1,629 @@
1
+ // WHAT CODING AGENTS THIS MACHINE ACTUALLY HAS, asked once and answered as
2
+ // data.
3
+ //
4
+ // The board can show a Run button for an engine that is not installed, and
5
+ // until something looks, the first proof is a failed session. This module is
6
+ // the looking: `<bin> --version` for each engine, plus the one login fact
7
+ // that is cheap and true (Claude Code writes ~/.claude/.credentials.json when
8
+ // you log in).
9
+ //
10
+ // STAGE 1 PRINTS IT, STAGE 2 WRITES IT INTO THE MARK FILE and the app shows
11
+ // dots. That is why the probe is its own module now rather than a few lines
12
+ // inside `glytch init`: the caller that needs it next is the daemon, not the
13
+ // installer.
14
+ //
15
+ // "FOUND" IS NOT "READY" and the labels here say installed, never working.
16
+ // A binary on PATH that is logged out, out of date, or broken still answers
17
+ // --version. The first real run stays the proof.
18
+ //
19
+ // WHY THE LIST IS SPELLED HERE rather than derived from AGENTS: two of the
20
+ // three names are the same set (claude, opencode), but `acp-claude` runs on
21
+ // `process.execPath` - probing node's version would report "found" on every
22
+ // machine that can run this file at all, which is every machine. So the probe
23
+ // list is what a PERSON would install, and AGENTS stays what a runner can
24
+ // spawn. They are different questions.
25
+
26
+ import { discoverCodex } from "./codex-rpc.mjs";
27
+ import { execFile, spawn } from "node:child_process";
28
+ import { existsSync } from "node:fs";
29
+ import { stat } from "node:fs/promises";
30
+ import { homedir } from "node:os";
31
+ import { join, posix, win32 } from "node:path";
32
+
33
+ import { findWindowsCommand, runnableCommand } from "./windows-command.mjs";
34
+
35
+ /** How long a `--version` may take before it is called not-found. Generous
36
+ * for a cold binary, short enough that a machine with three of them does not
37
+ * make `viewable init` feel hung. */
38
+ export const PROBE_TIMEOUT_MS = 5_000;
39
+
40
+ export const PROBE_BINS = [
41
+ { key: "claude", bin: "claude", label: "Claude Code" },
42
+ { key: "opencode", bin: "opencode", label: "opencode" },
43
+ // Native app-server sessions use the same installed CLI we probe here.
44
+ { key: "codex", bin: "codex", label: "codex" },
45
+ ];
46
+
47
+ /** Where the NATIVE (non-npm) Claude Code installer puts the binary, tried
48
+ * only as a fallback when the bare `claude` on PATH could not be run. npm
49
+ * installs a `.cmd`/`.exe` shim that `runnableCommand`'s PATHEXT search
50
+ * already finds through `Path`; the native installer writes a real
51
+ * executable straight to one of two fixed places and edits PATH itself - and
52
+ * a probe run in the SAME terminal session that just ran the installer often
53
+ * still has the OLD PATH, because Windows only broadcasts the change (a
54
+ * shell may not be listening) and a POSIX profile edit needs a new shell to
55
+ * be sourced. "Not on PATH this second" is not "not installed", so these two
56
+ * places are checked directly before giving up: `~/.local/bin` (the
57
+ * installer's own default, both platforms) and, Windows only, the
58
+ * standalone installer's per-user Program Files
59
+ * (`%LOCALAPPDATA%\Programs\claude\claude.exe`). `platform`/`env` are the
60
+ * same seams `runnableCommand` takes, so this is provable on macOS. */
61
+ export function nativeClaudeCandidates({ platform = process.platform, env = process.env } = {}) {
62
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
63
+ if (platform === "win32") {
64
+ const localAppData = env.LOCALAPPDATA?.trim();
65
+ return [
66
+ win32.join(home, ".local", "bin", "claude.exe"),
67
+ ...(localAppData ? [win32.join(localAppData, "Programs", "claude", "claude.exe")] : []),
68
+ ];
69
+ }
70
+ return [posix.join(home, ".local", "bin", "claude")];
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // RESOLVING A BINARY FOR A REAL SPAWN, not just a probe (2026-09-20).
75
+ //
76
+ // THE BUG THIS SECTION EXISTS FOR: a run spawned "spawn claude ENOENT" while
77
+ // `claude --version` worked fine in the person's own terminal on the same
78
+ // machine. The runner daemon is started by launchd (or systemd/a Windows
79
+ // service), and that PROCESS'S PATH is the service manager's own default -
80
+ // typically `/usr/bin:/bin:/usr/sbin:/sbin` on macOS - not the PATH a login
81
+ // shell builds from `.zshrc`/`.bash_profile`/nvm/a brew `shellenv` line. On
82
+ // POSIX, `runnableCommand` above (windows-command.mjs's sibling concern) does
83
+ // NOTHING for a bare name - `{file: bin, shell: false}`, unchanged - so
84
+ // `spawn("claude", ...)` was always relying on THIS process's inherited PATH,
85
+ // and a daemon's inherited PATH is the wrong one to trust.
86
+ //
87
+ // `nativeClaudeCandidates` above already covers one case of this (the native
88
+ // installer's `~/.local/bin/claude`, tried only for `claude` itself, from
89
+ // `execProbe`'s retry). What follows generalizes past that: every engine this
90
+ // bridge can spawn (`claude`, `opencode`, `codex`), not one name, and a login
91
+ // shell asked directly rather than three more hard-coded paths guessed at -
92
+ // the well-known directories are a fast, no-process-spawned first guess, and
93
+ // the login shell is the same answer typing the bare name at that person's
94
+ // own prompt would give, aliases aside (which a headless spawn could not use
95
+ // anyway).
96
+
97
+ /** Directories a login shell's PATH usually has that a service manager's does
98
+ * not: an installer's own default, homebrew's two prefixes (Apple Silicon
99
+ * and Intel share this list because checking a directory that is not there
100
+ * costs one `existsSync` and nothing else), a manual PATH addition Claude
101
+ * Code's own docs mention (`~/.claude/local`), and opencode's own installer
102
+ * directory (`~/.opencode/bin` - 2026-09-21, measured on a Mac where
103
+ * `opencode` lives ONLY there and in an nvm bin dir, so under bash, or with
104
+ * no `$SHELL` at all, the daemon could not find it any other way). Windows
105
+ * already gets a PATH/PATHEXT search at spawn time (`runnableCommand`),
106
+ * so this is empty there rather than a second, competing guess. */
107
+ export function knownBinDirs({ platform = process.platform, env = process.env } = {}) {
108
+ if (platform === "win32") return [];
109
+ const home = env.HOME?.trim() || homedir();
110
+ return [
111
+ posix.join(home, ".local", "bin"),
112
+ "/opt/homebrew/bin",
113
+ "/usr/local/bin",
114
+ posix.join(home, ".claude", "local"),
115
+ posix.join(home, ".opencode", "bin"),
116
+ ];
117
+ }
118
+
119
+ /** The USABLE line out of a login shell's `command -v` output (2026-09-21,
120
+ * review): a shell that prints a startup banner before running the `-c`
121
+ * command (p10k, direnv, nvm's own version line) puts that banner FIRST and
122
+ * the actual path LAST, so taking `split("\n")[0]` was reading the banner,
123
+ * not the answer. Scan from the end instead and accept the first line that
124
+ * is both an absolute path and something that actually exists on disk - a
125
+ * bare name (an alias or a shell function `command -v` can also report) is
126
+ * not a spawnable path and is skipped rather than returned. */
127
+ function lastUsablePath(stdout, { exists }) {
128
+ const lines = String(stdout ?? "").split("\n").map((line) => line.trim()).filter(Boolean);
129
+ for (let i = lines.length - 1; i >= 0; i--) {
130
+ const line = lines[i];
131
+ if (posix.isAbsolute(line) && exists(line)) return line;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** `command -v <bin>` under the person's own login shell, asked at most ONCE
137
+ * PER (shell, bin) FOR THE LIFE OF THIS PROCESS: a login shell can be slow
138
+ * to start (nvm, a heavy `.zshrc`), a daemon asks this for at most a small
139
+ * handful of names (one per engine it ever tries to spawn), and every
140
+ * request after the first must not pay for a shell spawn it already has the
141
+ * answer to. Returns `null` on any failure - no `$SHELL` in this
142
+ * environment, the shell exits non-zero, the binary really is not on that
143
+ * PATH either - never throws: "could not resolve" is the caller's fact to
144
+ * report, not this function's to raise.
145
+ *
146
+ * TWO SHELLS, NOT ONE (2026-09-21, review): `$SHELL -lc` runs a login shell
147
+ * but NOT an interactive one, and a non-interactive login zsh does not read
148
+ * `.zshrc` - which is exactly where most macOS PATH additions (nvm, `npm
149
+ * install -g`, bun) live. `-lc` alone answered `null` on the machine this
150
+ * whole file was written for. When `-lc` comes back with nothing usable,
151
+ * this retries once with `-ilc` (interactive login), which does read
152
+ * `.zshrc`. Both spawns carry the same `timeoutMs` guard - an interactive
153
+ * shell is the slower of the two to start, not a reason to wait longer for
154
+ * it. */
155
+ const loginShellCache = new Map();
156
+ export async function loginShellWhich(bin, { env = process.env, execImpl = execFile, timeoutMs = PROBE_TIMEOUT_MS, exists = existsSync } = {}) {
157
+ const shell = env.SHELL?.trim();
158
+ if (!shell) return null;
159
+ const key = `${shell}::${bin}`;
160
+ if (loginShellCache.has(key)) return loginShellCache.get(key);
161
+
162
+ const runShell = (flag) => new Promise((resolve) => {
163
+ try {
164
+ execImpl(
165
+ shell,
166
+ [flag, 'command -v -- "$PM_RESOLVE_BIN"'],
167
+ { timeout: timeoutMs, env: { ...env, PM_RESOLVE_BIN: bin } },
168
+ (error, stdout) => resolve(error ? null : lastUsablePath(stdout, { exists })),
169
+ );
170
+ } catch {
171
+ resolve(null);
172
+ }
173
+ });
174
+
175
+ let found = await runShell("-lc");
176
+ if (!found) found = await runShell("-ilc");
177
+ loginShellCache.set(key, found);
178
+ return found;
179
+ }
180
+
181
+ /** Every directory on `env.PATH`, POSIX-split - the SAME list a bare
182
+ * `spawn(bin, ...)` would already search on its own, read here too so a
183
+ * caller that resolved successfully gets back a real path rather than
184
+ * "probably fine". Only ever called on POSIX (`resolveAgentBinary` returns
185
+ * before this on `win32`), so there is no `Path`/`;`-split branch to carry -
186
+ * `windows-command.mjs`'s `runnableCommand` already owns that search. */
187
+ function ownPathDirs({ env = process.env } = {}) {
188
+ return String(env.PATH ?? "").split(":").map((s) => s.trim()).filter(Boolean);
189
+ }
190
+
191
+ /** Can `bin` actually be spawned on this machine, and from where?
192
+ *
193
+ * THREE STEPS, cheapest and most-likely-to-answer first: this process's own
194
+ * PATH (what a bare `spawn(bin, ...)` would already find - checked here too,
195
+ * with `existsSync`, so a caller gets back a concrete path instead of a
196
+ * boolean), the well-known install directories (`knownBinDirs`, also no
197
+ * process spawned), and last a login shell's `command -v` (`loginShellWhich`
198
+ * - slowest, memoized, asked only when the first two came up empty).
199
+ *
200
+ * On POSIX a `bin` that already names a place (it contains a separator) is
201
+ * `PM_<AGENT>_BIN` set to an explicit path, handed back unchanged and
202
+ * unchecked. Windows answers with the spawn's own Path/PATHEXT search
203
+ * (`findWindowsCommand`): `knownBinDirs` returns nothing there and
204
+ * `loginShellWhich` needs `$SHELL`, which a Windows service does not set. */
205
+ export async function resolveAgentBinary(bin, { platform = process.platform, env = process.env, exists = existsSync, execImpl = execFile } = {}) {
206
+ const onWindows = platform === "win32" ? findWindowsCommand(bin, { env, exists }) : undefined;
207
+ if (onWindows !== undefined) return { found: onWindows !== null, path: onWindows ?? bin, via: onWindows ? "path" : "none" };
208
+ if (/[\\/]/.test(bin)) return { found: true, path: bin, via: "unchanged" };
209
+ for (const dir of ownPathDirs({ env })) {
210
+ const candidate = posix.join(dir, bin);
211
+ if (exists(candidate)) return { found: true, path: candidate, via: "path" };
212
+ }
213
+ for (const dir of knownBinDirs({ platform, env })) {
214
+ const candidate = posix.join(dir, bin);
215
+ if (exists(candidate)) return { found: true, path: candidate, via: "known-dir" };
216
+ }
217
+ const shellPath = await loginShellWhich(bin, { env, execImpl, exists });
218
+ if (shellPath && exists(shellPath)) return { found: true, path: shellPath, via: "login-shell" };
219
+ return { found: false, path: bin, via: "none" };
220
+ }
221
+
222
+ /** The default runner of a probe: `<bin> --version`, never throwing.
223
+ *
224
+ * `platform`, `env`, `exists`, `readFile` and `execImpl` are injected so the
225
+ * `.cmd` resolution is provable on macOS. On Windows, npm installs `claude`
226
+ * and `opencode` as `.cmd` shims; `runnableCommand` resolves their Node entry
227
+ * points without exposing request arguments to cmd.exe.
228
+ *
229
+ * A PROBE IS THE ONLY THING THAT RETRIES (2026-09-12 review, F3). This ran
230
+ * interactive commands too, through a `stdio` mode, and the `claude` PATH-miss
231
+ * retry below ran for those as well: a `claude mcp login glytch` the person
232
+ * DECLINED in their browser exits non-zero, which this read as "not found on
233
+ * PATH" and re-ran through `~/.local/bin/claude` - a second browser window
234
+ * for a consent that was just refused. `--version` is safe to run twice and a
235
+ * login verb is not, so the interactive path is `runInteractive` below, which
236
+ * resolves its command once and runs it once. This function no longer takes
237
+ * `stdio` at all.
238
+ *
239
+ * For `claude` specifically, a PATH miss is retried against
240
+ * `nativeClaudeCandidates` before this reports not-found - see that function
241
+ * for why a native install can be right there and still lose the first
242
+ * lookup. */
243
+ export const execProbe = (
244
+ bin,
245
+ args,
246
+ {
247
+ timeoutMs = PROBE_TIMEOUT_MS,
248
+ cwd,
249
+ platform = process.platform,
250
+ env = process.env,
251
+ exists,
252
+ readFile,
253
+ execImpl = execFile,
254
+ } = {},
255
+ ) => {
256
+ const checkExists = exists ?? existsSync;
257
+ const run = (target) =>
258
+ new Promise((resolve) => {
259
+ let resolved;
260
+ try {
261
+ resolved = runnableCommand(target, args, { platform, env, ...(exists ? { exists } : {}), ...(readFile ? { readFile } : {}) });
262
+ } catch (error) {
263
+ resolve({ ok: false, stdout: "", stderr: String(error?.message ?? error), code: null });
264
+ return;
265
+ }
266
+ const options = { env, timeout: timeoutMs, windowsHide: true, shell: resolved.shell, ...(cwd ? { cwd } : {}) };
267
+ execImpl(resolved.file, resolved.args, options, (error, stdout, stderr) => {
268
+ // `code` is part of the shape both runners answer in. execFile's error
269
+ // carries the exit status as a number when the child ran and failed,
270
+ // and a string like "ENOENT" when it never started - only the number
271
+ // is an exit code, so anything else is reported as null.
272
+ if (error) {
273
+ resolve({
274
+ ok: false,
275
+ stdout: "",
276
+ stderr: String(stderr ?? ""),
277
+ code: typeof error.code === "number" ? error.code : null,
278
+ });
279
+ } else {
280
+ resolve({ ok: true, stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), code: 0 });
281
+ }
282
+ });
283
+ });
284
+ return (async () => {
285
+ const first = await run(bin);
286
+ if (first.ok || bin !== "claude") return first;
287
+ for (const candidate of nativeClaudeCandidates({ platform, env })) {
288
+ if (!checkExists(candidate)) continue;
289
+ const result = await run(candidate);
290
+ if (result.ok) return result;
291
+ }
292
+ return first;
293
+ })();
294
+ };
295
+
296
+ /** How much of a failed interactive verb's stderr is kept: the LAST 64KB.
297
+ * Only the last line is ever reported (init-connect's `lastStderrLine`), and a
298
+ * verb that decides to stream progress at a terminal it thinks it has could
299
+ * otherwise grow this string without a ceiling for as long as the person
300
+ * leaves the browser open. The tail is the half that carries the failure. */
301
+ export const STDERR_TAIL_LIMIT = 64 * 1024;
302
+
303
+ /** RUN ONE COMMAND, ONCE, WITH THE PERSON'S OWN TERMINAL UNDER IT.
304
+ *
305
+ * This is what `init` drives an agent's OAuth verb with (brief 31, Move 2,
306
+ * 2026-09-12): `claude mcp login glytch` refuses a pipe with "stdin isn't a
307
+ * terminal" (measured on 2.1.269), so stdin and stdout are inherited - the
308
+ * flow can run and print its URL - and stderr stays piped so a failure still
309
+ * has a last line to report.
310
+ *
311
+ * WHY NOT `execFile`: it does not honour `stdio` at all. It builds the child's
312
+ * spawn options from a fixed field list (cwd, env, gid, uid, shell, signal,
313
+ * windowsHide, windowsVerbatimArguments, timeout, maxBuffer, killSignal) so it
314
+ * can always pipe all three streams itself and buffer them for its callback; a
315
+ * `stdio` option handed to it is silently dropped and the child never sees the
316
+ * terminal (confirmed 2026-09-12: a probe run that way under `script -q` still
317
+ * reported the child's `stdin.isTTY` as `undefined`). `spawn` is the primitive
318
+ * that honours it.
319
+ *
320
+ * WHY IT IS ITS OWN FUNCTION (2026-09-12 review, F3): this used to be a mode
321
+ * of `execProbe`, and inherited that function's `claude` PATH-miss retry. A
322
+ * declined consent exits non-zero, so a person who said no in the browser got
323
+ * a SECOND browser window from `~/.local/bin/claude`. The command is resolved
324
+ * once here, through the same `runnableCommand` every other spawn in this
325
+ * package uses, and run exactly once - a verb with a person in it gets one
326
+ * attempt, always.
327
+ *
328
+ * Answers in the same shape a probe does, `{ok, stdout, stderr, code}`;
329
+ * `stdout` is always "" because with stdio inherited there was never anything
330
+ * captured to return. `timeoutMs` is the caller's to name - the 5s probe
331
+ * timeout is for `--version`, and this waits on someone finding their password
332
+ * manager - and is left off entirely when it is not given. */
333
+ export const runInteractive = (
334
+ bin,
335
+ args,
336
+ {
337
+ timeoutMs,
338
+ cwd,
339
+ platform = process.platform,
340
+ env = process.env,
341
+ exists,
342
+ readFile,
343
+ stdio = ["inherit", "inherit", "pipe"],
344
+ spawnImpl = spawn,
345
+ } = {},
346
+ ) =>
347
+ new Promise((resolve) => {
348
+ let resolved;
349
+ try {
350
+ resolved = runnableCommand(bin, args, { platform, env, ...(exists ? { exists } : {}), ...(readFile ? { readFile } : {}) });
351
+ } catch (error) {
352
+ resolve({ ok: false, stdout: "", stderr: String(error?.message ?? error), code: null });
353
+ return;
354
+ }
355
+ const options = {
356
+ windowsHide: true,
357
+ shell: resolved.shell,
358
+ stdio,
359
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
360
+ ...(cwd ? { cwd } : {}),
361
+ };
362
+ let stderr = "";
363
+ let settled = false;
364
+ const settle = (result) => {
365
+ if (settled) return;
366
+ settled = true;
367
+ resolve(result);
368
+ };
369
+ const child = spawnImpl(resolved.file, resolved.args, options);
370
+ if (child.stderr) {
371
+ child.stderr.on("data", (chunk) => {
372
+ stderr += String(chunk);
373
+ if (stderr.length > STDERR_TAIL_LIMIT) stderr = stderr.slice(stderr.length - STDERR_TAIL_LIMIT);
374
+ });
375
+ }
376
+ child.on("error", (error) =>
377
+ settle({ ok: false, stdout: "", stderr: stderr || String(error?.message ?? error), code: null }),
378
+ );
379
+ child.on("close", (code) => settle({ ok: code === 0, stdout: "", stderr, code }));
380
+ });
381
+
382
+ /** Claude Code's login marker ON THE PLATFORMS THAT KEEP ONE AS A FILE.
383
+ *
384
+ * `CLAUDE_CONFIG_DIR` moves the whole config directory, and the file with it
385
+ * ("Claude Code keeps the `.credentials.json` file under that directory
386
+ * instead" - code.claude.com/docs/en/authentication#credential-management),
387
+ * so it is honoured here rather than assuming `~/.claude`.
388
+ *
389
+ * Its PRESENCE is the whole check. Reading its CONTENTS would mean this
390
+ * package handling someone's credentials, which it will not do. */
391
+ export const claudeCredentialsPath = (env = process.env) => {
392
+ const configDir = env.CLAUDE_CONFIG_DIR?.trim();
393
+ if (configDir) return join(configDir, ".credentials.json");
394
+ return join(env.HOME?.trim() || homedir(), ".claude", ".credentials.json");
395
+ };
396
+
397
+ /** The macOS Keychain item Claude Code writes its login into.
398
+ *
399
+ * THE FILE PROBE ALONE WENT WRONG ON THIS MAC (2026-09-12, 2.1.269): there is
400
+ * no `~/.claude/.credentials.json`, the machine IS logged in, and `init`
401
+ * announced "Claude Code found 2.1.269 - NOT logged in" and told him to run
402
+ * `claude` once and log in. The credential is in the login Keychain.
403
+ *
404
+ * WHAT THE DOCS SAY, PER PLATFORM (code.claude.com/docs/en, read 2026-09-12,
405
+ * pages current through v2.1.265 so they cover 2.1.269; no page names a
406
+ * version at which the locations changed):
407
+ *
408
+ * macOS the encrypted login Keychain, with the FILE as a documented
409
+ * fallback for when the Keychain cannot be written (a locked
410
+ * keychain over SSH) - which is why this probe tries the keychain
411
+ * and then still falls through to the file rather than instead
412
+ * of it.
413
+ * Linux `~/.claude/.credentials.json`, mode 0600. Not a legacy path:
414
+ * it is the documented primary store.
415
+ * Windows `%USERPROFILE%\\.claude\\.credentials.json`. NOT the Windows
416
+ * Credential Manager - the docs say "protected by file
417
+ * permissions on Windows and Linux", and the whole docs corpus
418
+ * has no mention of Credential Manager, DPAPI, libsecret or
419
+ * gnome-keyring for this.
420
+ * WSL not addressed anywhere in the docs. It is Linux, so it takes
421
+ * the Linux path here by implication, not by a documented rule.
422
+ *
423
+ * So the file probe stays correct everywhere except macOS, and on macOS it is
424
+ * the second question rather than the only one.
425
+ *
426
+ * THE SERVICE NAME IS MEASURED, NOT DOCUMENTED. Nothing in the docs names the
427
+ * keychain item; this string was confirmed by running
428
+ * `security find-generic-password -s "Claude Code-credentials"` on his Mac
429
+ * against Claude Code 2.1.269 and getting exit 0. The docs do say
430
+ * `CLAUDE_CONFIG_DIR` "keys the macOS Keychain entry to that directory too",
431
+ * so a non-default config dir has an entry under some other name whose shape
432
+ * is not published - that case falls through to the file probe, which follows
433
+ * CLAUDE_CONFIG_DIR correctly, and at worst under-reports a login. An
434
+ * under-reported login is a yellow line telling someone to log in again; an
435
+ * over-reported one is a session that dies on spawn. */
436
+ export const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
437
+
438
+ /** Is Claude Code logged in on this machine?
439
+ *
440
+ * EXIT CODE ONLY. `find-generic-password` is run WITHOUT `-w` and WITHOUT
441
+ * `-g`, the two flags that would make it emit the secret (to stdout and to
442
+ * stderr respectively). What comes back is the item's attributes, which are
443
+ * discarded unread - this package looks at `result.ok` and nothing else, the
444
+ * same promise `claudeCredentialsPath` makes about the file. */
445
+ export async function claudeLoggedIn({
446
+ exec = execProbe,
447
+ env = process.env,
448
+ platform = process.platform,
449
+ credentialsPath,
450
+ fileExists,
451
+ } = {}) {
452
+ if (platform === "darwin") {
453
+ const found = await exec("security", ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE], { env });
454
+ if (found?.ok === true) return true;
455
+ }
456
+ return (fileExists ?? exists)(credentialsPath ?? claudeCredentialsPath(env));
457
+ }
458
+
459
+ const exists = async (path) => {
460
+ try {
461
+ await stat(path);
462
+ return true;
463
+ } catch {
464
+ return false;
465
+ }
466
+ };
467
+
468
+ /** Pull a version number out of whatever the binary printed. Claude Code says
469
+ * "2.1.4 (Claude Code)", opencode says "0.9.1", and a future one will say
470
+ * something else again - so the first dotted number wins and the rest is
471
+ * ignored. Undefined when there is nothing that looks like one: found is
472
+ * still true, because the binary answered. */
473
+ export function versionFrom(text) {
474
+ const match = /\b(\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?)\b/.exec(String(text ?? ""));
475
+ return match ? match[1] : undefined;
476
+ }
477
+
478
+ /** CLI syntax evidence, never a model request. Only explicit model IDs with
479
+ * documented effort support are advertised; aliases/default can be overridden.
480
+ * Checked with Claude Code 2.1.312 and code.claude.com/docs/en/model-config
481
+ * on 2026-10-28 (#3210). These are capabilities, not an availability catalog. */
482
+ export async function probeClaudeEffort(bin, { exec = execProbe, env = process.env, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
483
+ const help = await exec(bin, ["--help"], { env, timeoutMs });
484
+ if (!help?.ok) return {};
485
+ const match = /--effort\s+<level>[\s\S]*?\((low,[^)]*)\)/.exec(help.stdout ?? "");
486
+ if (!match) return {};
487
+ const levels = match[1].split(",").map((s) => s.trim());
488
+ const models = Object.create(null);
489
+ for (const model of ["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-5", "claude-sonnet-5", "claude-fable-5", "claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"]) {
490
+ models[model] = ["low", "medium", "high", ...(model.endsWith("4-6") ? [] : ["xhigh"]), "max"].filter((level) => levels.includes(level));
491
+ // Documented 1M variants share the base model's effort support. Advertise
492
+ // the exact request key so both the picker and runner preserve its suffix.
493
+ if (model === "claude-opus-4-6" || model === "claude-sonnet-4-6") models[`${model}[1m]`] = models[model];
494
+ }
495
+ return models;
496
+ }
497
+
498
+ /** Probe every engine. Returns the `agents` block stage 2 writes into the
499
+ * mark file:
500
+ *
501
+ * { claude: {found, version?, loggedIn}, opencode: {found, version?},
502
+ * codex: {found} }
503
+ *
504
+ * `exec` is injected so the tests cover every branch without a binary being
505
+ * installed, and so a caller can probe with its own timeout. */
506
+ export async function detectAgents({
507
+ exec = execProbe,
508
+ env = process.env,
509
+ bins = PROBE_BINS,
510
+ credentialsPath,
511
+ platform = process.platform,
512
+ fileExists,
513
+ timeoutMs = PROBE_TIMEOUT_MS,
514
+ codexDiscover = exec === execProbe ? discoverCodex : null,
515
+ } = {}) {
516
+ const agents = {};
517
+ for (const spec of bins) {
518
+ const bin = env[`PM_${spec.key.toUpperCase()}_BIN`]?.trim() || spec.bin;
519
+ const result = await exec(bin, ["--version"], { timeoutMs, env });
520
+ const found = result?.ok === true;
521
+ const version = found ? versionFrom(result.stdout || result.stderr) : undefined;
522
+ agents[spec.key] = { found, ...(version ? { version } : {}) };
523
+ }
524
+ // Claude exposes a local marker - a keychain item on macOS, a file
525
+ // elsewhere; Codex is queried through app-server below.
526
+ if (agents.claude) {
527
+ if (agents.claude.found) {
528
+ const effortModels = await probeClaudeEffort(env.PM_CLAUDE_BIN?.trim() || bins.find((b) => b.key === "claude")?.bin || "claude", { exec, env, timeoutMs });
529
+ if (Object.keys(effortModels).length) agents.claude.effortModels = effortModels;
530
+ }
531
+ agents.claude.loggedIn = await claudeLoggedIn({ exec, env, platform, credentialsPath, fileExists });
532
+ }
533
+ if (agents.codex?.found && codexDiscover) {
534
+ try {
535
+ Object.assign(agents.codex, await codexDiscover({ bin: env.PM_CODEX_BIN?.trim() || "codex", env, timeoutMs }));
536
+ } catch { /* version detection remains valid when model discovery fails */ }
537
+ }
538
+ return agents;
539
+ }
540
+
541
+ /** The detection table `viewable init` prints, one line per engine. */
542
+ export function detectionLines(agents, bins = PROBE_BINS) {
543
+ return bins.map((spec) => {
544
+ const found = agents?.[spec.key];
545
+ if (!found?.found) return ` ${spec.label.padEnd(12)} not installed on your computer`;
546
+ const version = found.version ? ` ${found.version}` : "";
547
+ const login =
548
+ found.loggedIn === true ? " - logged in" : found.loggedIn === false ? " - NOT logged in" : "";
549
+ return ` ${spec.label.padEnd(12)} found${version}${login}`;
550
+ });
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // THE DAEMON'S COPY OF THE ANSWER, refreshed on a clock of its own.
555
+ //
556
+ // The mark file is written on the LIVENESS BEAT - every ten seconds, from a
557
+ // timer whose whole job is to say the runner is still here. Three child
558
+ // processes per beat would be absurd, and worse, a beat that waits on
559
+ // `claude --version` on a cold disk is a beat the app is entitled to call
560
+ // stopped. So the probe answers from MEMORY and refreshes in the background,
561
+ // exactly like lib/update.mjs's daily npm check and for the same reason.
562
+ //
563
+ // FIVE MINUTES is the brief's number. It is not a compromise between "fresh"
564
+ // and "cheap": installing a coding agent is a thing a person does once, and
565
+ // the only case that matters is the one where they do it WITH the app open
566
+ // and want the grey dot to go green without restarting anything. Five minutes
567
+ // is short enough for that to feel like the machine noticed.
568
+ //
569
+ // A PROBE FAILURE IS `found: false`, NEVER A MISSING KEY AND NEVER A THROW -
570
+ // `detectAgents` above already guarantees the first two (execProbe resolves
571
+ // on error), and the try/catch here guarantees the third, so a machine where
572
+ // spawning a child fails outright keeps its liveness beat.
573
+
574
+ /** How often the daemon re-asks what is installed. */
575
+ export const PROBE_INTERVAL_MS = 5 * 60_000;
576
+
577
+ /**
578
+ * The cached probe the daemon's beat reads.
579
+ *
580
+ * `mark()` is SYNCHRONOUS and cheap, because the beat calls it: it returns
581
+ * what is known now and starts a refresh in the background when one is due.
582
+ * Before the first answer it returns `{}` - no `agents` key at all rather
583
+ * than a block of falses, because "not asked yet" and "not installed" are
584
+ * different facts and the app's dots must not claim the second while the
585
+ * first is true. `once()` is how a caller waits for a real answer; the daemon
586
+ * awaits it before the first folder is watched, so in practice the first mark
587
+ * a person ever sees already carries the block.
588
+ */
589
+ export function makeAgentProbe({
590
+ detect = detectAgents,
591
+ env = process.env,
592
+ intervalMs = PROBE_INTERVAL_MS,
593
+ now = () => Date.now(),
594
+ log = () => {},
595
+ } = {}) {
596
+ // null rather than 0 for the same reason update.mjs uses null: with an
597
+ // injected clock starting at zero, a zero here would read as "checked at
598
+ // time zero" and the first refresh would never fire.
599
+ let checkedAt = null;
600
+ let known = null;
601
+ let running = false;
602
+ const run = async () => {
603
+ running = true;
604
+ checkedAt = now();
605
+ try {
606
+ known = await detect({ env });
607
+ } catch (error) {
608
+ // Never take the beat down for a probe. An empty block is honest here:
609
+ // nothing was learned, so nothing is claimed.
610
+ log(`agent probe failed: ${error instanceof Error ? error.message : String(error)}`);
611
+ } finally {
612
+ running = false;
613
+ }
614
+ };
615
+ return {
616
+ /** What is known now, without asking anything. */
617
+ state: () => (known === null ? null : { ...known }),
618
+ /** What goes into the mark file, plus a refresh when one is due. */
619
+ mark() {
620
+ if (!running && (checkedAt === null || now() - checkedAt >= intervalMs)) void run();
621
+ return known === null ? {} : { agents: { ...known } };
622
+ },
623
+ /** Await one probe. The daemon's start, `doctor`, and the tests use this. */
624
+ async once() {
625
+ await run();
626
+ return known;
627
+ },
628
+ };
629
+ }