@peakflo/agent-harness 0.2.8 → 0.2.9-rc.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.
@@ -0,0 +1,812 @@
1
+ "use strict";
2
+ /**
3
+ * HARNESS DISCOVERY — which coding harnesses are installed on this machine.
4
+ *
5
+ * Modelled on 20x, where the harness code came from:
6
+ * - `src/main/agent-installer/detect.js` finds the binaries (`claude`,
7
+ * `codex`, `cursor-agent`, `opencode`, `pi`) with `--version`, after it
8
+ * puts the well-known install directories on PATH;
9
+ * - `src/renderer/src/types/index.ts` holds the static model lists the 20x
10
+ * agent form shows for Claude Code, Codex and Cursor.
11
+ *
12
+ * WHAT THIS IS NOT. It is not the list of backends this runner can run.
13
+ * `capabilities.backends` stays that list and the control plane routes on it.
14
+ * An entry here says "this CLI is on the machine", and `runnable` says whether
15
+ * the runner can also drive it. Detection never implies turn support.
16
+ *
17
+ * RULES THIS MODULE KEEPS:
18
+ *
19
+ * 1. NEVER THROWS, NEVER REJECTS. One broken CLI must not hide the others, so
20
+ * every harness is probed on its own, inside its own try/catch and its own
21
+ * deadline.
22
+ * 2. TIME-LIMITED. Each command has a timeout and each harness has a total
23
+ * deadline. A CLI that hangs is killed and reported as timed out.
24
+ * 3. NO SECRETS LEAVE THIS FILE. Sign-in is decided from an exit code, one
25
+ * boolean of a status reply, or the existence of an environment variable or
26
+ * a file. No status text is kept. Every `reason` is a constant from
27
+ * `REASONS` below, never something a CLI printed. Model ids and names are
28
+ * the only CLI text that is reported, and each one is checked first.
29
+ * 4. NO GLOBAL SIDE EFFECTS. `process.env.PATH` is not changed; the search path
30
+ * is local and is handed to each child process.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.KNOWN_HARNESSES = exports.CURSOR_STATIC_MODELS = exports.CODEX_STATIC_MODELS = exports.CLAUDE_STATIC_MODELS = exports.REASONS = exports.DISCOVERY_CONCURRENCY = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = void 0;
34
+ exports.discoverySearchPath = discoverySearchPath;
35
+ exports.discoveryChildEnv = discoveryChildEnv;
36
+ exports.sanitizeHarnessModels = sanitizeHarnessModels;
37
+ exports.parseVersion = parseVersion;
38
+ exports.saysSignedOut = saysSignedOut;
39
+ exports.parseCodexModelCache = parseCodexModelCache;
40
+ exports.parseCodexDefaultModel = parseCodexDefaultModel;
41
+ exports.parseCursorModels = parseCursorModels;
42
+ exports.parseOpencodeModels = parseOpencodeModels;
43
+ exports.discoverHarnesses = discoverHarnesses;
44
+ const node_child_process_1 = require("node:child_process");
45
+ const node_fs_1 = require("node:fs");
46
+ const node_os_1 = require("node:os");
47
+ const node_path_1 = require("node:path");
48
+ /**
49
+ * One command: `--version`, a status check, or a model listing.
50
+ *
51
+ * MEASURED, NOT GUESSED. On the Mac where the first release candidate was
52
+ * tried, with two runners and other work going on, `cursor-agent --version`
53
+ * took 9.4 s cold and 3.7 s warm ALONE, and 6-10 s when five harnesses were
54
+ * probed at once; `opencode --version` took 3-5 s alone and 7-9 s beside the
55
+ * others. The first limit was 8 s, so both lost on a busy machine and were
56
+ * shown as broken. The limit is now well above the slowest start seen — and
57
+ * the pass no longer starts every CLI at once (`DISCOVERY_CONCURRENCY`),
58
+ * which is what made each of them slow.
59
+ */
60
+ exports.DISCOVERY_COMMAND_TIMEOUT_MS = 20_000;
61
+ /**
62
+ * The budget for finding out a harness's STATE: its version and whether it is
63
+ * signed in. The model listing is NOT inside this budget — it has one command
64
+ * timeout of its own and can never change the state (see `probeHarness`).
65
+ */
66
+ exports.DISCOVERY_HARNESS_TIMEOUT_MS = 45_000;
67
+ /**
68
+ * How many harnesses are probed at the same time. Each harness runs its
69
+ * commands one after another, so this is also the most CLI processes
70
+ * discovery ever has running. Two, not five: these CLIs are heavy to start
71
+ * (most are bundled Node programs), and five cold starts at once roughly
72
+ * doubled the start time of each on the test machine.
73
+ */
74
+ exports.DISCOVERY_CONCURRENCY = 2;
75
+ const MAX_MODELS_PER_HARNESS = 200;
76
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
77
+ /**
78
+ * Every sentence a `reason` can be. A closed set on purpose: text printed by a
79
+ * CLI can hold an account email or a key, and a constant cannot.
80
+ */
81
+ exports.REASONS = {
82
+ notInstalled: 'Not installed on this machine.',
83
+ /*
84
+ * The three "timed out" sentences describe a CHECK that did not finish, not
85
+ * the harness. Each goes out with `transient: true`, and the runner checks
86
+ * again shortly.
87
+ */
88
+ versionTimedOut: 'Installed. The version check timed out and will be tried again.',
89
+ versionFailed: 'The version check failed.',
90
+ discoveryTimedOut: 'Installed. The check timed out and will be tried again.',
91
+ discoveryFailed: 'Discovery failed.',
92
+ signInTimedOut: 'Installed. The sign-in check timed out and will be tried again.',
93
+ signInUnknown: 'Sign-in could not be confirmed.',
94
+ codexSignIn: 'Not signed in. Run `codex login` on this machine.',
95
+ claudeSignIn: 'Not signed in. Run `claude auth login` on this machine.',
96
+ cursorSignIn: 'Not signed in. Run `cursor-agent login` on this machine.'
97
+ };
98
+ /* -------------------------------------------------------------------------- */
99
+ /* Static model lists — copied from 20x `src/renderer/src/types/index.ts` */
100
+ /* -------------------------------------------------------------------------- */
101
+ /** 20x `CLAUDE_MODELS`. Claude Code has no command that lists models. */
102
+ exports.CLAUDE_STATIC_MODELS = [
103
+ { id: 'claude-fable-5-1', name: 'Claude Fable 5.1' },
104
+ { id: 'claude-fable-5', name: 'Claude Fable 5' },
105
+ { id: 'claude-opus-5-5', name: 'Claude Opus 5.5' },
106
+ { id: 'claude-opus-5', name: 'Claude Opus 5' },
107
+ { id: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5' },
108
+ { id: 'claude-sonnet-5', name: 'Claude Sonnet 5' },
109
+ { id: 'claude-opus-4-8', name: 'Claude Opus 4.8' },
110
+ { id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
111
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
112
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
113
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
114
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
115
+ { id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
116
+ { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet (Oct)' },
117
+ { id: 'claude-3-5-sonnet-20240620', name: 'Claude 3.5 Sonnet (Jun)' }
118
+ ];
119
+ /** 20x `CODEX_MODELS`. Used only when Codex's own model cache cannot be read. */
120
+ exports.CODEX_STATIC_MODELS = [
121
+ { id: 'gpt-6-astra', name: 'GPT-6 Astra' },
122
+ { id: 'gpt-6.1-sol', name: 'GPT-6.1 Sol' },
123
+ { id: 'gpt-6-sol', name: 'GPT-6 Sol' },
124
+ { id: 'gpt-6-luna', name: 'GPT-6 Luna' },
125
+ { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' },
126
+ { id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra' },
127
+ { id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna' },
128
+ { id: 'gpt-5.5', name: 'GPT-5.5' },
129
+ { id: 'gpt-5.4', name: 'GPT-5.4' },
130
+ { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' },
131
+ { id: 'gpt-5.3-codex-spark', name: 'GPT-5.3 Codex Spark' }
132
+ ];
133
+ /** 20x `CURSOR_MODELS`. Used only when `cursor-agent models` gives nothing. */
134
+ exports.CURSOR_STATIC_MODELS = [
135
+ { id: 'composer-2.5', name: 'Composer 2.5' },
136
+ { id: 'grok-4.5', name: 'Grok 4.5' }
137
+ ];
138
+ /**
139
+ * Where to look for a harness binary: PATH first, then the directories the
140
+ * harness installers use. The well-known directories come from 20x
141
+ * `detect.js` (`ensureAgentPaths` and `getPiCommandCandidates`). The directory
142
+ * of the running `node` is added because a runner started by launchd or
143
+ * systemd has a short PATH, and an npm-installed CLI sits beside that `node`.
144
+ */
145
+ function discoverySearchPath(env, home, platform = process.platform) {
146
+ /*
147
+ * ABSOLUTE ENTRIES ONLY. A relative entry (`.`, `node_modules/.bin`) means
148
+ * "whatever is in the directory the runner was started from", and a file
149
+ * named `codex` there is not the user's Codex.
150
+ */
151
+ const fromEnv = (env.PATH ?? env.Path ?? '')
152
+ .split(node_path_1.delimiter)
153
+ .filter((entry) => entry !== '' && (0, node_path_1.isAbsolute)(entry));
154
+ const known = platform === 'win32'
155
+ ? [
156
+ (0, node_path_1.join)(home, 'AppData', 'Roaming', 'npm'),
157
+ (0, node_path_1.join)(home, '.local', 'bin'),
158
+ (0, node_path_1.join)(home, '.opencode', 'bin')
159
+ ]
160
+ : [
161
+ (0, node_path_1.join)(home, '.local', 'bin'),
162
+ (0, node_path_1.join)(home, '.opencode', 'bin'),
163
+ '/opt/homebrew/bin',
164
+ '/usr/local/bin',
165
+ (0, node_path_1.join)(home, '.npm-global', 'bin'),
166
+ (0, node_path_1.join)(home, '.volta', 'bin'),
167
+ (0, node_path_1.join)(home, '.bun', 'bin')
168
+ ];
169
+ return [...new Set([...fromEnv, ...known, (0, node_path_1.dirname)(process.execPath)])];
170
+ }
171
+ function isExecutableFile(path) {
172
+ try {
173
+ if (!(0, node_fs_1.statSync)(path).isFile())
174
+ return false;
175
+ (0, node_fs_1.accessSync)(path, node_fs_1.constants.X_OK);
176
+ return true;
177
+ }
178
+ catch {
179
+ return false;
180
+ }
181
+ }
182
+ function defaultResolveCommand(command, searchPath, env) {
183
+ // npm installs `.cmd` wrappers on Windows, so the bare name is not a file.
184
+ const extensions = process.platform === 'win32'
185
+ ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
186
+ : [''];
187
+ for (const dir of searchPath) {
188
+ for (const extension of extensions) {
189
+ const candidate = (0, node_path_1.join)(dir, `${command}${extension}`);
190
+ if (isExecutableFile(candidate))
191
+ return candidate;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+ function defaultExec(command, args, timeoutMs, env) {
197
+ return new Promise((resolve) => {
198
+ let settled = false;
199
+ let timer = null;
200
+ const finish = (result) => {
201
+ if (settled)
202
+ return;
203
+ settled = true;
204
+ if (timer)
205
+ clearTimeout(timer);
206
+ resolve(result);
207
+ };
208
+ try {
209
+ const isWindows = process.platform === 'win32';
210
+ const child = (0, node_child_process_1.execFile)(
211
+ // `shell: true` is what resolves `.cmd` wrappers on Windows (20x
212
+ // detect.js). With a shell the path must be quoted by hand.
213
+ isWindows ? `"${command}"` : command, args, {
214
+ timeout: timeoutMs,
215
+ killSignal: 'SIGKILL',
216
+ shell: isWindows,
217
+ windowsHide: true,
218
+ maxBuffer: MAX_OUTPUT_BYTES,
219
+ env: { ...env, NO_COLOR: '1', TERM: 'dumb' }
220
+ }, (error, stdout, stderr) => {
221
+ const failure = error;
222
+ finish({
223
+ code: failure
224
+ ? typeof failure.code === 'number'
225
+ ? failure.code
226
+ : null
227
+ : 0,
228
+ stdout: String(stdout ?? ''),
229
+ stderr: String(stderr ?? ''),
230
+ timedOut: failure?.killed === true
231
+ });
232
+ });
233
+ // A CLI that waits for input must see end-of-file, not a terminal.
234
+ child.stdin?.end();
235
+ child.on('error', () => undefined);
236
+ /*
237
+ * THE BACKSTOP. `execFile` calls back only when the output pipes close,
238
+ * and a killed CLI can leave a child of its own holding them open. This
239
+ * timer answers anyway, so a hanging harness costs its timeout and no
240
+ * more.
241
+ */
242
+ timer = setTimeout(() => {
243
+ try {
244
+ child.kill('SIGKILL');
245
+ }
246
+ catch {
247
+ // Already gone.
248
+ }
249
+ finish({ code: null, stdout: '', stderr: '', timedOut: true });
250
+ }, timeoutMs + 250);
251
+ timer.unref();
252
+ }
253
+ catch {
254
+ // execFile can throw synchronously (EACCES on a non-executable path).
255
+ finish({ code: null, stdout: '', stderr: '', timedOut: false });
256
+ }
257
+ });
258
+ }
259
+ /**
260
+ * The environment a harness CLI is started with.
261
+ *
262
+ * EVERY `PF_*` VARIABLE IS REMOVED. Those are this runner's own settings and
263
+ * include `PF_ENROLMENT_TOKEN`; a third-party CLI has no use for them and
264
+ * must not be handed them. The rest is kept, because each CLI finds its own
265
+ * login through it (`HOME`, `CODEX_HOME`, `ANTHROPIC_API_KEY`, ...).
266
+ */
267
+ function discoveryChildEnv(env, searchPath) {
268
+ const child = {};
269
+ for (const [key, value] of Object.entries(env)) {
270
+ if (!/^PF_/i.test(key))
271
+ child[key] = value;
272
+ }
273
+ // `Path` is how Windows spells it; one PATH only, the search path.
274
+ delete child.Path;
275
+ child.PATH = searchPath.join(node_path_1.delimiter);
276
+ return child;
277
+ }
278
+ function resolveDeps(overrides = {}) {
279
+ const env = overrides.env ?? process.env;
280
+ const home = overrides.home ?? (0, node_os_1.homedir)();
281
+ const searchPath = discoverySearchPath(env, home);
282
+ const childEnv = discoveryChildEnv(env, searchPath);
283
+ return {
284
+ exec: (command, args, timeoutMs) => defaultExec(command, args, timeoutMs, childEnv),
285
+ resolveCommand: (command) => defaultResolveCommand(command, searchPath, env),
286
+ realpath: (path) => (0, node_fs_1.realpathSync)(path),
287
+ readFile: (path) => {
288
+ try {
289
+ return (0, node_fs_1.readFileSync)(path, 'utf8');
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ },
295
+ exists: (path) => (0, node_fs_1.existsSync)(path),
296
+ now: () => new Date(),
297
+ ...overrides,
298
+ env,
299
+ home
300
+ };
301
+ }
302
+ /* -------------------------------------------------------------------------- */
303
+ /* Cleaning what a CLI printed */
304
+ /* -------------------------------------------------------------------------- */
305
+ // eslint-disable-next-line no-control-regex
306
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
307
+ // eslint-disable-next-line no-control-regex
308
+ const CONTROL = /[\u0000-\u001f\u007f\u200b-\u200d\ufeff]/g;
309
+ const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/;
310
+ /** An email, a bearer value, a JWT, or a key with a well-known prefix. */
311
+ const SECRET_LIKE = /[^\s@]+@[^\s@]+\.[^\s@]+|\bBearer\s|\beyJ[\w-]{10,}|\b(?:sk|pk|rk)-[\w-]{8,}|\bgh[pousr]_\w{8,}|\bxox[abprs]-[\w-]{8,}|\bAKIA[A-Z0-9]{12,}/i;
312
+ function cleanLine(text) {
313
+ return text.replace(ANSI, '').replace(CONTROL, '').trim();
314
+ }
315
+ /**
316
+ * Keep only entries that look like a model. Anything that looks like a secret
317
+ * is dropped whole: a model list has no reason to hold one, so the safe
318
+ * reading of such a line is that it is not a model.
319
+ */
320
+ function sanitizeHarnessModels(models) {
321
+ const out = [];
322
+ const seen = new Set();
323
+ for (const entry of models) {
324
+ if (out.length >= MAX_MODELS_PER_HARNESS)
325
+ break;
326
+ if (typeof entry?.id !== 'string')
327
+ continue;
328
+ const id = cleanLine(entry.id);
329
+ if (!MODEL_ID.test(id) || SECRET_LIKE.test(id) || seen.has(id))
330
+ continue;
331
+ const rawName = typeof entry.name === 'string' ? cleanLine(entry.name) : '';
332
+ const name = rawName && !SECRET_LIKE.test(rawName) ? rawName : id;
333
+ seen.add(id);
334
+ out.push({
335
+ id,
336
+ name: name.slice(0, 120),
337
+ ...(entry.default === true ? { default: true } : {})
338
+ });
339
+ }
340
+ return out;
341
+ }
342
+ /** Mark exactly one model as the default: `preferred` when listed, else the first. */
343
+ function withDefault(models, preferred) {
344
+ if (models.length === 0)
345
+ return models;
346
+ const chosen = (preferred && models.find((model) => model.id === preferred)?.id) ||
347
+ models.find((model) => model.default)?.id ||
348
+ models[0].id;
349
+ return models.map(({ id, name }) => id === chosen ? { id, name, default: true } : { id, name });
350
+ }
351
+ /** The first version-like token, as 20x `detect.js` reads it. */
352
+ function parseVersion(output) {
353
+ const match = cleanLine(output).match(/(\d+\.\d+[\w.+-]*)/);
354
+ return match ? match[1].slice(0, 64) : null;
355
+ }
356
+ const SIGN_IN_TIMED_OUT = {
357
+ usable: false,
358
+ reason: exports.REASONS.signInTimedOut,
359
+ transient: true
360
+ };
361
+ function hasEnv(env, names) {
362
+ return names.some((name) => Boolean(env[name]));
363
+ }
364
+ /** An on/off variable that is switched ON. Present is not enough. */
365
+ function hasFlag(env, names) {
366
+ return names.some((name) => /^(1|true|yes|on)$/i.test((env[name] ?? '').trim()));
367
+ }
368
+ /**
369
+ * A CLI said it is NOT signed in. Tested BEFORE any positive pattern, because
370
+ * "You are not currently logged in" also contains "logged in".
371
+ */
372
+ const SIGNED_OUT = /\bnot\b[^.\n]{0,40}\b(?:logged|signed)[\s-]?in\b|\bnot\s+authenticated\b|\bunauthenticated\b|\blogged\s+out\b|\bsigned\s+out\b|\blog\s?in\s+required\b|\bplease\s+(?:log|sign)\s?in\b|\bno\s+credentials\b/i;
373
+ const SIGNED_IN = /\b(?:logged|signed)\s+in\b|\bauthenticated\b/i;
374
+ function saysSignedOut(text) {
375
+ return SIGNED_OUT.test(text);
376
+ }
377
+ function parseJsonObject(text) {
378
+ if (!text)
379
+ return null;
380
+ try {
381
+ const parsed = JSON.parse(text);
382
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
383
+ ? parsed
384
+ : null;
385
+ }
386
+ catch {
387
+ return null;
388
+ }
389
+ }
390
+ /* ---- Codex --------------------------------------------------------------- */
391
+ function codexHome(deps) {
392
+ return deps.env.CODEX_HOME || (0, node_path_1.join)(deps.home, '.codex');
393
+ }
394
+ /**
395
+ * Codex keeps the models its account can use in `models_cache.json`. This is a
396
+ * model cache, not a credential file; only `slug`, `display_name`,
397
+ * `visibility` and `priority` are read from it.
398
+ */
399
+ function parseCodexModelCache(text) {
400
+ const cache = parseJsonObject(text);
401
+ const list = Array.isArray(cache?.models) ? cache.models : [];
402
+ const listed = list
403
+ .filter((entry) => Boolean(entry) &&
404
+ typeof entry === 'object' &&
405
+ typeof entry.slug === 'string' &&
406
+ // 'hide' marks internal models the Codex picker does not show either.
407
+ (entry.visibility ?? 'list') === 'list')
408
+ .sort((a, b) => (typeof a.priority === 'number'
409
+ ? a.priority
410
+ : Number.MAX_SAFE_INTEGER) -
411
+ (typeof b.priority === 'number' ? b.priority : Number.MAX_SAFE_INTEGER));
412
+ return sanitizeHarnessModels(listed.map((entry) => ({ id: entry.slug, name: entry.display_name })));
413
+ }
414
+ /** The top-level `model = "..."` of `config.toml`. Nothing else is read. */
415
+ function parseCodexDefaultModel(text) {
416
+ if (!text)
417
+ return null;
418
+ for (const line of text.split('\n')) {
419
+ const trimmed = line.trim();
420
+ // A table header ends the top level; keys below it belong to the table.
421
+ if (trimmed.startsWith('['))
422
+ return null;
423
+ const match = trimmed.match(/^model\s*=\s*["']([^"']{1,128})["']/);
424
+ if (match)
425
+ return match[1];
426
+ }
427
+ return null;
428
+ }
429
+ const CODEX = {
430
+ name: 'codex',
431
+ label: 'Codex',
432
+ commands: ['codex'],
433
+ async checkAuth({ deps, run }) {
434
+ // 20x `codex-adapter.ts` `checkHealth` accepts these two keys.
435
+ if (hasEnv(deps.env, ['OPENAI_API_KEY', 'CODEX_API_KEY'])) {
436
+ return { usable: true };
437
+ }
438
+ // Exit code only. The reply names the sign-in method, which is not needed.
439
+ const status = await run(['login', 'status']);
440
+ if (status.timedOut)
441
+ return SIGN_IN_TIMED_OUT;
442
+ // The exit code decides; the text is only tested for a plain "not
443
+ // logged in", in case a version exits 0 while saying so. Nothing is kept.
444
+ return status.code === 0 &&
445
+ !saysSignedOut(`${status.stdout}\n${status.stderr}`)
446
+ ? { usable: true }
447
+ : { usable: false, reason: exports.REASONS.codexSignIn };
448
+ },
449
+ async listModels({ deps }) {
450
+ const home = codexHome(deps);
451
+ const preferred = parseCodexDefaultModel(deps.readFile((0, node_path_1.join)(home, 'config.toml')));
452
+ const cached = parseCodexModelCache(deps.readFile((0, node_path_1.join)(home, 'models_cache.json')));
453
+ if (cached.length > 0) {
454
+ return { models: withDefault(cached, preferred), source: 'config' };
455
+ }
456
+ return {
457
+ models: withDefault([...exports.CODEX_STATIC_MODELS], preferred),
458
+ source: 'static'
459
+ };
460
+ }
461
+ };
462
+ /* ---- Claude Code --------------------------------------------------------- */
463
+ function claudeHome(deps) {
464
+ return deps.env.CLAUDE_CONFIG_DIR || (0, node_path_1.join)(deps.home, '.claude');
465
+ }
466
+ const CLAUDE = {
467
+ name: 'claude-code',
468
+ label: 'Claude Code',
469
+ commands: ['claude'],
470
+ async checkAuth({ deps, run }) {
471
+ if (hasEnv(deps.env, [
472
+ 'ANTHROPIC_API_KEY',
473
+ 'ANTHROPIC_AUTH_TOKEN',
474
+ 'CLAUDE_CODE_OAUTH_TOKEN'
475
+ ]) ||
476
+ // These two are on/off switches, so `=0` and `=false` mean OFF.
477
+ hasFlag(deps.env, ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX'])) {
478
+ return { usable: true };
479
+ }
480
+ const status = await run(['auth', 'status']);
481
+ if (status.timedOut)
482
+ return SIGN_IN_TIMED_OUT;
483
+ /*
484
+ * The reply is JSON that also holds the account email and organisation.
485
+ * ONE boolean is read from it and the rest is dropped here.
486
+ */
487
+ const loggedIn = parseJsonObject(status.stdout)?.loggedIn;
488
+ if (loggedIn === true)
489
+ return { usable: true };
490
+ if (loggedIn === false)
491
+ return { usable: false, reason: exports.REASONS.claudeSignIn };
492
+ if (saysSignedOut(`${status.stdout}\n${status.stderr}`)) {
493
+ return { usable: false, reason: exports.REASONS.claudeSignIn };
494
+ }
495
+ // Exit 0 with a reply that cannot be read is NOT taken as signed in:
496
+ // without the boolean there is nothing to base "yes" on.
497
+ // An older CLI has no `auth status`. On Linux the login is a file; only
498
+ // its existence is checked. On macOS it is in the Keychain, which is not
499
+ // read, so the honest answer there is "could not confirm".
500
+ if (deps.exists((0, node_path_1.join)(claudeHome(deps), '.credentials.json'))) {
501
+ return { usable: true };
502
+ }
503
+ return { usable: false, reason: exports.REASONS.signInUnknown };
504
+ },
505
+ async listModels({ deps }) {
506
+ // Only the `model` key is read. The file can also hold an `env` block.
507
+ const configured = parseJsonObject(deps.readFile((0, node_path_1.join)(claudeHome(deps), 'settings.json')))?.model;
508
+ return {
509
+ models: withDefault([...exports.CLAUDE_STATIC_MODELS], typeof configured === 'string' ? configured : null),
510
+ source: 'static'
511
+ };
512
+ }
513
+ };
514
+ /* ---- Cursor -------------------------------------------------------------- */
515
+ /**
516
+ * `cursor-agent models` prints one `id - Name` line per model and marks the
517
+ * default with `(current, default)` or `(default)`.
518
+ */
519
+ function parseCursorModels(output) {
520
+ const parsed = [];
521
+ for (const raw of output.split('\n')) {
522
+ const match = cleanLine(raw).match(/^(\S+)\s+-\s+(.+)$/);
523
+ if (!match)
524
+ continue;
525
+ const marker = match[2].match(/\s*\(([^()]*)\)\s*$/);
526
+ const isMarker = marker ? /\b(current|default)\b/i.test(marker[1]) : false;
527
+ parsed.push({
528
+ id: match[1],
529
+ name: isMarker ? match[2].slice(0, marker.index).trim() : match[2],
530
+ ...(isMarker && /\bdefault\b/i.test(marker[1]) ? { default: true } : {})
531
+ });
532
+ }
533
+ return sanitizeHarnessModels(parsed);
534
+ }
535
+ const CURSOR = {
536
+ name: 'cursor',
537
+ label: 'Cursor',
538
+ // 20x drives `cursor-agent` (`agent-manager.ts`: `cursor-agent acp`). Newer
539
+ // installers also add a plain `agent` command.
540
+ commands: ['cursor-agent', 'agent'],
541
+ /*
542
+ * `agent` IS A NAME OTHER TOOLS USE TOO. On the machine this was written on,
543
+ * `agent` is a different vendor's CLI. So `agent` counts as Cursor only when
544
+ * the file it resolves to lives in a Cursor directory.
545
+ */
546
+ accept(command, path, deps) {
547
+ if (command !== 'agent')
548
+ return true;
549
+ try {
550
+ // A path SEGMENT, not any mention: `~/work/cursor-notes/bin/agent` is
551
+ // not Cursor. The installer puts the real file under
552
+ // `~/.local/share/cursor-agent/` and older ones under `~/.cursor/`.
553
+ return /[\\/](?:cursor-agent|\.cursor)[\\/]/i.test(deps.realpath(path));
554
+ }
555
+ catch {
556
+ return false;
557
+ }
558
+ },
559
+ async checkAuth({ deps, run }) {
560
+ if (hasEnv(deps.env, ['CURSOR_API_KEY']))
561
+ return { usable: true };
562
+ const status = await run(['status']);
563
+ if (status.timedOut)
564
+ return SIGN_IN_TIMED_OUT;
565
+ // The reply names the account. It is tested here and never stored.
566
+ const text = `${status.stdout}\n${status.stderr}`;
567
+ // The negative test comes first and wins.
568
+ if (saysSignedOut(text)) {
569
+ return { usable: false, reason: exports.REASONS.cursorSignIn };
570
+ }
571
+ if (status.code === 0 && SIGNED_IN.test(text)) {
572
+ return { usable: true };
573
+ }
574
+ return { usable: false, reason: exports.REASONS.signInUnknown };
575
+ },
576
+ async listModels({ run }, usable) {
577
+ // The listing needs a signed-in account, so it is not tried without one.
578
+ if (usable) {
579
+ const listed = await run(['models']);
580
+ const models = listed.code === 0 ? parseCursorModels(listed.stdout) : [];
581
+ if (models.length > 0) {
582
+ return { models: withDefault(models), source: 'cli' };
583
+ }
584
+ }
585
+ return { models: withDefault([...exports.CURSOR_STATIC_MODELS]), source: 'static' };
586
+ }
587
+ };
588
+ /* ---- Pi ------------------------------------------------------------------ */
589
+ const PI = {
590
+ name: 'pi',
591
+ label: 'Pi',
592
+ commands: ['pi'],
593
+ // Pi needs no login of its own here: each turn brings the Peakflo model
594
+ // gateway and a key for it.
595
+ async checkAuth() {
596
+ return { usable: true };
597
+ },
598
+ // The runner already probes Pi's models for `backends[].models`
599
+ // (model-probe.ts). The caller copies that list in, so Pi is not started
600
+ // twice.
601
+ async listModels() {
602
+ return null;
603
+ }
604
+ };
605
+ /* ---- OpenCode ------------------------------------------------------------ */
606
+ /** `opencode models` prints one `provider/model` id per line. */
607
+ function parseOpencodeModels(output) {
608
+ return sanitizeHarnessModels(output
609
+ .split('\n')
610
+ .map((line) => cleanLine(line))
611
+ .filter((line) => /^[^\s/]+\/\S+$/.test(line))
612
+ .map((id) => ({ id, name: id })));
613
+ }
614
+ const OPENCODE = {
615
+ name: 'opencode',
616
+ label: 'OpenCode',
617
+ commands: ['opencode'],
618
+ // OpenCode has no status command and serves some models with no login.
619
+ async checkAuth() {
620
+ return { usable: true };
621
+ },
622
+ async listModels({ run }) {
623
+ const listed = await run(['models']);
624
+ const models = listed.code === 0 ? parseOpencodeModels(listed.stdout) : [];
625
+ return models.length > 0 ? { models, source: 'cli' } : null;
626
+ }
627
+ };
628
+ /** Every harness this build knows how to look for, in display order. */
629
+ exports.KNOWN_HARNESSES = [
630
+ CODEX,
631
+ CLAUDE,
632
+ CURSOR,
633
+ PI,
634
+ OPENCODE
635
+ ].map(({ name, label }) => ({ name, label }));
636
+ const SPECS = [CODEX, CLAUDE, CURSOR, PI, OPENCODE];
637
+ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired, progress) {
638
+ let path = null;
639
+ for (const command of spec.commands) {
640
+ const found = deps.resolveCommand(command);
641
+ if (found && (spec.accept?.(command, found, deps) ?? true)) {
642
+ path = found;
643
+ break;
644
+ }
645
+ }
646
+ if (!path)
647
+ return;
648
+ entry.installed = true;
649
+ entry.version = null;
650
+ delete entry.reason;
651
+ const resolved = path;
652
+ const context = {
653
+ path: resolved,
654
+ deps,
655
+ /*
656
+ * NOTHING IS STARTED AFTER THE STATE DEADLINE. The report has already
657
+ * been sent without this harness by then, so a sign-in check started now
658
+ * would be a process nobody is waiting for.
659
+ */
660
+ run: (args) => isExpired()
661
+ ? Promise.resolve({
662
+ code: null,
663
+ stdout: '',
664
+ stderr: '',
665
+ timedOut: true
666
+ })
667
+ : deps.exec(resolved, args, commandTimeoutMs)
668
+ };
669
+ const version = await context.run(['--version']);
670
+ if (version.timedOut) {
671
+ // The binary EXISTS and did not answer in time. That is a slow machine,
672
+ // not a broken harness, so it is reported as "will be tried again".
673
+ entry.reason = exports.REASONS.versionTimedOut;
674
+ entry.transient = true;
675
+ return;
676
+ }
677
+ if (version.code !== 0) {
678
+ entry.reason = exports.REASONS.versionFailed;
679
+ return;
680
+ }
681
+ // 20x reads stdout and stderr together: some CLIs print the version on stderr.
682
+ // A version that cannot be parsed stays null and the harness is still reported.
683
+ entry.version = parseVersion(`${version.stdout}\n${version.stderr}`);
684
+ const auth = await spec.checkAuth(context);
685
+ entry.usable = auth.usable;
686
+ if (!auth.usable && auth.reason)
687
+ entry.reason = auth.reason;
688
+ if (!auth.usable && auth.transient)
689
+ entry.transient = true;
690
+ /*
691
+ * THE STATE IS SETTLED HERE, and the state deadline stops counting.
692
+ *
693
+ * The model listing below is extra information. It gets one command timeout
694
+ * of its own and its result can only ADD models: a listing that is slow or
695
+ * fails leaves `usable` exactly as the sign-in check found it. Before this,
696
+ * the listing shared one 20 s budget with the two checks above, and a slow
697
+ * `cursor-agent models` or `opencode models` turned a signed-in harness
698
+ * into "timed out".
699
+ */
700
+ progress.stateKnown = true;
701
+ progress.onStateKnown?.();
702
+ try {
703
+ const listed = await spec.listModels(context, auth.usable);
704
+ if (listed && listed.models.length > 0) {
705
+ entry.models = listed.models;
706
+ entry.modelSource = listed.source;
707
+ }
708
+ }
709
+ catch {
710
+ // No models. The state above stands.
711
+ }
712
+ }
713
+ /** Run `task` for every item, at most `limit` at a time, keeping the order. */
714
+ async function mapWithLimit(items, limit, task) {
715
+ const results = new Array(items.length);
716
+ let next = 0;
717
+ const worker = async () => {
718
+ while (next < items.length) {
719
+ const index = next++;
720
+ results[index] = await task(items[index]);
721
+ }
722
+ };
723
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
724
+ return results;
725
+ }
726
+ /**
727
+ * Look for every known harness. ALWAYS resolves, with one entry per known
728
+ * harness in a fixed order — a harness that is missing is reported as
729
+ * `installed: false`, so the reader can tell "not installed" from "this runner
730
+ * does not report harnesses". With `only`, just those harnesses are probed and
731
+ * returned.
732
+ */
733
+ async function discoverHarnesses(options = {}) {
734
+ const commandTimeoutMs = options.commandTimeoutMs ?? exports.DISCOVERY_COMMAND_TIMEOUT_MS;
735
+ const harnessTimeoutMs = options.harnessTimeoutMs ?? exports.DISCOVERY_HARNESS_TIMEOUT_MS;
736
+ const runnable = new Set(options.runnableBackends ?? []);
737
+ const only = options.only ? new Set(options.only) : null;
738
+ const specs = only ? SPECS.filter((spec) => only.has(spec.name)) : SPECS;
739
+ let deps = null;
740
+ let detectedAt = new Date().toISOString();
741
+ try {
742
+ deps = resolveDeps(options.deps);
743
+ detectedAt = deps.now().toISOString();
744
+ }
745
+ catch {
746
+ // `os.homedir()` throws on some locked-down hosts. Report every harness
747
+ // as not found rather than fail the runner.
748
+ }
749
+ const resolvedDeps = deps;
750
+ return mapWithLimit(specs, options.concurrency ?? exports.DISCOVERY_CONCURRENCY, async (spec) => {
751
+ /*
752
+ * The entry is filled in as the probe goes, so a deadline or a throw
753
+ * part-way still reports what was already learnt.
754
+ */
755
+ const entry = {
756
+ name: spec.name,
757
+ label: spec.label,
758
+ installed: false,
759
+ usable: false,
760
+ reason: exports.REASONS.notInstalled,
761
+ runnable: false,
762
+ detectedAt
763
+ };
764
+ if (!resolvedDeps) {
765
+ entry.reason = exports.REASONS.discoveryFailed;
766
+ return entry;
767
+ }
768
+ let timer = null;
769
+ let expired = false;
770
+ const progress = { stateKnown: false };
771
+ const deadline = new Promise((resolve) => {
772
+ timer = setTimeout(() => {
773
+ expired = true;
774
+ resolve('expired');
775
+ }, harnessTimeoutMs);
776
+ timer.unref();
777
+ // The deadline is for the STATE only. Once that is known the model
778
+ // listing runs on its own command timeout.
779
+ progress.onStateKnown = () => {
780
+ if (timer)
781
+ clearTimeout(timer);
782
+ timer = null;
783
+ };
784
+ });
785
+ const work = probeHarness(spec, entry, resolvedDeps, commandTimeoutMs, () => expired, progress).then(() => 'done', () => 'failed');
786
+ const outcome = await Promise.race([work, deadline]);
787
+ if (timer)
788
+ clearTimeout(timer);
789
+ // A copy, so a probe still running after its deadline cannot change
790
+ // what was reported.
791
+ const report = { ...entry };
792
+ if (outcome === 'expired') {
793
+ // Found, and the checks did not finish in time: "will be tried again".
794
+ report.usable = false;
795
+ report.reason = exports.REASONS.discoveryTimedOut;
796
+ if (report.installed)
797
+ report.transient = true;
798
+ delete report.models;
799
+ delete report.modelSource;
800
+ }
801
+ else if (outcome === 'failed' && !progress.stateKnown) {
802
+ report.usable = false;
803
+ report.reason = exports.REASONS.discoveryFailed;
804
+ delete report.transient;
805
+ delete report.models;
806
+ delete report.modelSource;
807
+ }
808
+ // Runnable needs both: the runner drives it AND the binary is there.
809
+ report.runnable = report.installed && runnable.has(spec.name);
810
+ return report;
811
+ });
812
+ }