@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.
- package/README.md +67 -1
- package/dist/cli.js +29 -0
- package/dist/harness-discovery.d.ts +173 -0
- package/dist/harness-discovery.js +812 -0
- package/dist/harness.d.ts +98 -1
- package/dist/harness.js +300 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +14 -2
- package/dist/node-floor.d.ts +46 -0
- package/dist/node-floor.js +76 -0
- package/dist/options.js +17 -1
- package/dist/store/conversation-store.js +2 -0
- package/dist/store/sqlite-store.d.ts +17 -0
- package/dist/store/sqlite-store.js +26 -0
- package/dist/types.d.ts +83 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|