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.
- package/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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
|
+
}
|