@peakflo/agent-harness 0.2.9-rc.1 → 0.2.9-rc.3

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 CHANGED
@@ -4,7 +4,9 @@ Run a Peakflo coding agent on your machine.
4
4
 
5
5
  ## Quick start
6
6
 
7
- Use Node.js 22.19 or newer and install Git. Install the Pi coding agent once:
7
+ Use Node.js 22.19 or newer and install Git. The runner checks the Node.js
8
+ version when it starts and stops with one line if it is older (see
9
+ "Troubleshooting"). Install the Pi coding agent once:
8
10
 
9
11
  ```sh
10
12
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent
@@ -44,13 +46,36 @@ Claude Code (`claude`), Cursor (`cursor-agent`), Pi (`pi`) and OpenCode
44
46
  whether it is signed in, and the models it offers. Peakflo shows this list in
45
47
  the agent editor and in the chat details.
46
48
 
47
- Finding a harness does not mean the runner can use it. The runner runs turns
48
- with Pi only; the other harnesses are shown as "Detected, not yet runnable by
49
- this runner".
49
+ **Which ones the runner can run.** Pi always. On a desktop or self-hosted
50
+ machine also Codex, Claude Code and Cursor, each for as long as it is
51
+ installed and signed in on that machine — the runner uses that sign-in, as you
52
+ do when you run the tool yourself. An agent in Peakflo can then be given that
53
+ harness and one of its models. OpenCode is listed and cannot be run yet. A
54
+ cloud sandbox runs Pi only.
55
+
56
+ - These turns use your own Codex, Claude or Cursor account. They do not go
57
+ through the Peakflo model gateway and are not metered by Peakflo.
58
+ - When you sign out of a harness, or remove it, the runner stops taking work
59
+ for it after its next check.
60
+ - Claude Code also needs the Claude Agent SDK, which is not installed with the
61
+ runner: `npm install -g @anthropic-ai/claude-agent-sdk`. Without it Peakflo
62
+ shows Claude Code as "Detected, not runnable by this runner" with this
63
+ instruction.
64
+ - Each turn starts the harness's own program and stops it when the turn ends.
65
+ The conversation continues from the session the harness saved on this
66
+ machine.
50
67
 
51
68
  **When it looks.** Once after start, without delaying registration, and then
52
69
  every five minutes while no turn is running. A harness you install or sign in
53
- to later appears without a restart.
70
+ to later appears without a restart. At most two of these programs run at the
71
+ same time.
72
+
73
+ **A slow machine.** Some of these programs take several seconds to start. When
74
+ one does not answer in time, Peakflo shows it as "Installed — check timed out,
75
+ will retry", not as unavailable. The runner checks it again after 30 seconds,
76
+ then 60, then 120, and after that with the normal five-minute pass. A state
77
+ that was already known (signed in, or not signed in) is kept when a later
78
+ check times out.
54
79
 
55
80
  **What it runs.** To do this the runner starts those programs, as your user,
56
81
  with `--version` and a status or model-list command. It looks in the absolute
@@ -71,6 +96,27 @@ about what a third-party program prints.
71
96
  `PF_HARNESS_DISCOVERY=0`. The runner then starts none of these programs and
72
97
  reports no list. Running turns with Pi is not affected.
73
98
 
99
+ ## Troubleshooting
100
+
101
+ **`npx @peakflo/agent-harness` fails with a 404.** Your `~/.npmrc` maps the
102
+ `@peakflo` scope to another registry (for example GitHub Packages). Name the
103
+ public registry for this one command:
104
+
105
+ ```sh
106
+ npx --@peakflo:registry=https://registry.npmjs.org @peakflo/agent-harness@latest start
107
+ ```
108
+
109
+ **"pf-agent-harness needs Node.js 22.19 or newer".** Switch to a newer
110
+ Node.js, for example `nvm install 22 && nvm use 22`, and start again. npm only
111
+ prints an `EBADENGINE` warning for an older Node.js and installs anyway; the
112
+ runner is what stops. The requirement comes from Pi, which the runner starts
113
+ with the same Node.js. To start on an older Node.js at your own risk, set
114
+ `PF_ALLOW_UNSUPPORTED_NODE=1`.
115
+
116
+ **"Durable store unavailable, running in memory".** The runner could not
117
+ create or open its database under `--workspace-root`. It creates that
118
+ directory itself when it is missing, so check that the path is writable.
119
+
74
120
  ## Releasing
75
121
 
76
122
  `.github/scripts/agent-harness-release.mjs` publishes this package
@@ -0,0 +1,61 @@
1
+ import { type CodingAgentAdapter } from './adapters';
2
+ import type { AdapterPorts } from './harness';
3
+ /**
4
+ * Give a vendor adapter the one thing the runner asks of a backend at the end
5
+ * of a turn: `stopServer()`.
6
+ *
7
+ * ONE PROCESS PER TURN, as for Pi. In 20x an adapter lives as long as the app
8
+ * and its CLI process stays up between messages. Here every turn is a new
9
+ * lease with its own MCP token, the runner may be handed the same chat by a
10
+ * control plane that last ran it elsewhere, and nothing may be left running
11
+ * when no turn is. So the process a turn started is stopped when the turn
12
+ * ends, and the next turn resumes the vendor's own saved session by its
13
+ * checkpoint (Codex thread id, Claude session id, Cursor ACP session id) —
14
+ * the path these adapters already have for "the app was restarted".
15
+ *
16
+ * Leaving the process up is not merely wasteful: `codex app-server` keeps the
17
+ * thread open for writing, and the next turn's `thread/resume` from a second
18
+ * process is refused with "already has an active writer".
19
+ *
20
+ * The session ids are recorded here, from what `createSession` returned and
21
+ * `resumeSession` was given, rather than read out of the adapter's private
22
+ * state. `destroySession` is the adapters' own teardown and does not delete
23
+ * the vendor's saved session.
24
+ */
25
+ /** How long the end of a turn waits for one vendor session to stop. */
26
+ export declare const STOP_TIMEOUT_MS = 5000;
27
+ export declare function stopAtTurnEnd<T extends CodingAgentAdapter>(adapter: T, instanceId?: string | null, options?: {
28
+ /**
29
+ * The adapter numbers its parts from 1 in every process
30
+ * (`agent-response-1`). See `pollMessages` below.
31
+ */
32
+ partIdsRestartEveryProcess?: boolean;
33
+ }): T;
34
+ /**
35
+ * A random id for THIS runner's vendor sessions, written once into the
36
+ * runner's own state directory (never into a vendor's). Survives a restart;
37
+ * two runners never share one. Null when it cannot be written, which keeps
38
+ * the older behaviour (try to resume, fall back to replay).
39
+ */
40
+ export declare function vendorInstanceId(stateDir: string): string | null;
41
+ /**
42
+ * Which adapter drives which backend — the same table as 20x
43
+ * `agent-manager.ts` `getAdapter`.
44
+ *
45
+ * Pi on every runtime. Codex, Claude Code and Cursor on a desktop or
46
+ * self-hosted runner only: they use the vendor sign-in that is already on the
47
+ * machine, and a cloud sandbox has none and must not be given one.
48
+ *
49
+ * A NEW ADAPTER FOR EVERY TURN, for every backend, stopped when the turn
50
+ * ends (`stopAtTurnEnd`). This is where the runner differs from 20x, which
51
+ * keeps one adapter per harness for the life of the app.
52
+ *
53
+ * Advertising a backend the host cannot run makes the control plane route
54
+ * work to a runner that must then fail it, so `capabilities.backends` lists a
55
+ * vendor backend only while discovery says it is installed and signed in (see
56
+ * `Harness.vendorBackendsFor`). This function is the last line: it refuses
57
+ * anything that should not have been leased here.
58
+ */
59
+ export declare function createAdapterResolver(runtime: string,
60
+ /** The runner's own state directory (its workspace root). */
61
+ stateDir?: string): (backend: string, ports: AdapterPorts) => CodingAgentAdapter;
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STOP_TIMEOUT_MS = void 0;
4
+ exports.stopAtTurnEnd = stopAtTurnEnd;
5
+ exports.vendorInstanceId = vendorInstanceId;
6
+ exports.createAdapterResolver = createAdapterResolver;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const adapters_1 = require("./adapters");
11
+ const vendor_backends_1 = require("./vendor-backends");
12
+ /**
13
+ * Hand the vendor CLI the workspace by its REAL path.
14
+ *
15
+ * Claude Code files a session under a directory named after the real path of
16
+ * its working directory, and the adapter looks for that file under the path it
17
+ * was given. With a workspace root that goes through a symlink (on macOS
18
+ * `/tmp` is one, to `/private/tmp`) the two differ, the session file is "not
19
+ * found", and every turn replays instead of resuming. Seen for real while
20
+ * proving this change. Changed in place: the same config object is passed to
21
+ * every later call of the turn.
22
+ */
23
+ function setRealWorkspacePath(config) {
24
+ try {
25
+ config.workspaceDir = (0, node_fs_1.realpathSync)(config.workspaceDir);
26
+ }
27
+ catch {
28
+ // Not there yet, or unreadable: keep the path as given.
29
+ }
30
+ }
31
+ /**
32
+ * Give a vendor adapter the one thing the runner asks of a backend at the end
33
+ * of a turn: `stopServer()`.
34
+ *
35
+ * ONE PROCESS PER TURN, as for Pi. In 20x an adapter lives as long as the app
36
+ * and its CLI process stays up between messages. Here every turn is a new
37
+ * lease with its own MCP token, the runner may be handed the same chat by a
38
+ * control plane that last ran it elsewhere, and nothing may be left running
39
+ * when no turn is. So the process a turn started is stopped when the turn
40
+ * ends, and the next turn resumes the vendor's own saved session by its
41
+ * checkpoint (Codex thread id, Claude session id, Cursor ACP session id) —
42
+ * the path these adapters already have for "the app was restarted".
43
+ *
44
+ * Leaving the process up is not merely wasteful: `codex app-server` keeps the
45
+ * thread open for writing, and the next turn's `thread/resume` from a second
46
+ * process is refused with "already has an active writer".
47
+ *
48
+ * The session ids are recorded here, from what `createSession` returned and
49
+ * `resumeSession` was given, rather than read out of the adapter's private
50
+ * state. `destroySession` is the adapters' own teardown and does not delete
51
+ * the vendor's saved session.
52
+ */
53
+ /** How long the end of a turn waits for one vendor session to stop. */
54
+ exports.STOP_TIMEOUT_MS = 5_000;
55
+ function stopAtTurnEnd(adapter, instanceId, options = {}) {
56
+ if (instanceId) {
57
+ /*
58
+ * WHOSE CHECKPOINT IS IT. A vendor session lives on the machine that made
59
+ * it (`~/.codex`, `~/.claude`, Cursor's own store). A chat that is leased
60
+ * to another runner next carries a checkpoint that means nothing there;
61
+ * with this id the runner sees that at once and replays the conversation,
62
+ * instead of asking the vendor CLI for a session it never had.
63
+ */
64
+ adapter.backendInstanceId =
65
+ () => instanceId;
66
+ }
67
+ const started = new Map();
68
+ const realIds = new Map();
69
+ const createSession = adapter.createSession.bind(adapter);
70
+ const resumeSession = adapter.resumeSession.bind(adapter);
71
+ adapter.createSession = async (config) => {
72
+ setRealWorkspacePath(config);
73
+ const id = await createSession(config);
74
+ started.set(id, config);
75
+ return id;
76
+ };
77
+ adapter.resumeSession = async (sessionId, config) => {
78
+ // Recorded BEFORE the call: a resume that fails half-way may already have
79
+ // started the process.
80
+ setRealWorkspacePath(config);
81
+ started.set(sessionId, config);
82
+ return resumeSession(sessionId, config);
83
+ };
84
+ /*
85
+ * A PART WITH NO ROLE IS THE ASSISTANT'S — the rule 20x applies where it
86
+ * reads these adapters' parts (`agent-manager.ts`: `part.role || 'assistant'`).
87
+ * The Claude Code adapter sends the model's own text with no role at all and
88
+ * relies on that. The runner's projection reads a missing role as `system`,
89
+ * which is right for Pi and would show Claude's whole answer as a system
90
+ * notice. So the 20x default is applied here, for these adapters only.
91
+ */
92
+ /*
93
+ * PART IDS THAT START AGAIN IN EVERY PROCESS ARE MADE UNIQUE PER TURN.
94
+ *
95
+ * The ACP adapter names the answer of a turn `agent-response-<n>`, counting
96
+ * from 1 in each process. In 20x one process serves the whole conversation,
97
+ * so the numbers keep growing. Here each turn has its own process, so every
98
+ * turn's answer was `agent-response-1` and the transcript — which stores a
99
+ * part by its id — replaced the previous turn's answer with the new one.
100
+ * Seen for real with Cursor: two turns, one answer left.
101
+ *
102
+ * The id is prefixed with the turn id on its way out. The adapter keeps
103
+ * working in its own ids: it is given its own "already seen" sets, because
104
+ * the runner's hold the prefixed ids and the adapter compares against the
105
+ * ids it produces. After a resume the adapter has already dropped the
106
+ * replayed history from its buffer, so only this turn's parts come through.
107
+ */
108
+ const ownSeen = {
109
+ messages: new Set(),
110
+ parts: new Set(),
111
+ lengths: new Map()
112
+ };
113
+ const pollMessages = adapter.pollMessages.bind(adapter);
114
+ adapter.pollMessages = async (...args) => {
115
+ const scoped = options.partIdsRestartEveryProcess === true;
116
+ const turnId = args[4]?.turnId;
117
+ const parts = scoped
118
+ ? await pollMessages(args[0], ownSeen.messages, ownSeen.parts, ownSeen.lengths, args[4])
119
+ : await pollMessages(...args);
120
+ for (const part of parts) {
121
+ if (scoped && turnId && part.id)
122
+ part.id = `${turnId}:${part.id}`;
123
+ if (!part.role)
124
+ part.role = adapters_1.MessageRole.ASSISTANT;
125
+ // The id the vendor gave the session, when it differs from the one
126
+ // `createSession` returned. See `checkpointSessionId` below.
127
+ if (part.realSessionId)
128
+ realIds.set(args[0], part.realSessionId);
129
+ }
130
+ return parts;
131
+ };
132
+ /*
133
+ * The session id to SAVE for the next turn. Claude Code's real id arrives on
134
+ * a part of the stream; 20x re-keys its session when it sees one. The runner
135
+ * asks here at the end of the turn and saves the real id as the checkpoint.
136
+ */
137
+ adapter.checkpointSessionId = (sessionId) => realIds.get(sessionId);
138
+ adapter.stopServer =
139
+ async () => {
140
+ for (const [sessionId, config] of started) {
141
+ try {
142
+ // BOUNDED. This runs between a turn's last part and the release of
143
+ // its lease; a vendor CLI that will not die must not hold that up.
144
+ await Promise.race([
145
+ adapter.destroySession(sessionId, config),
146
+ new Promise((resolve) => {
147
+ const timer = setTimeout(resolve, exports.STOP_TIMEOUT_MS);
148
+ timer.unref();
149
+ })
150
+ ]);
151
+ }
152
+ catch {
153
+ // Teardown is best-effort; the next id still gets its turn.
154
+ }
155
+ }
156
+ started.clear();
157
+ };
158
+ return adapter;
159
+ }
160
+ /**
161
+ * A random id for THIS runner's vendor sessions, written once into the
162
+ * runner's own state directory (never into a vendor's). Survives a restart;
163
+ * two runners never share one. Null when it cannot be written, which keeps
164
+ * the older behaviour (try to resume, fall back to replay).
165
+ */
166
+ function vendorInstanceId(stateDir) {
167
+ const marker = (0, node_path_1.join)(stateDir, '.pf-vendor-instance-id');
168
+ try {
169
+ const existing = (0, node_fs_1.readFileSync)(marker, 'utf8').trim();
170
+ if (existing)
171
+ return existing;
172
+ }
173
+ catch {
174
+ // Not written yet.
175
+ }
176
+ try {
177
+ const id = (0, node_crypto_1.randomUUID)();
178
+ (0, node_fs_1.mkdirSync)(stateDir, { recursive: true, mode: 0o700 });
179
+ (0, node_fs_1.writeFileSync)(marker, `${id}\n`, { mode: 0o600, flag: 'wx' });
180
+ return id;
181
+ }
182
+ catch {
183
+ try {
184
+ // Lost a race with another start: read what the winner wrote.
185
+ return (0, node_fs_1.readFileSync)(marker, 'utf8').trim() || null;
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
191
+ }
192
+ /**
193
+ * Which adapter drives which backend — the same table as 20x
194
+ * `agent-manager.ts` `getAdapter`.
195
+ *
196
+ * Pi on every runtime. Codex, Claude Code and Cursor on a desktop or
197
+ * self-hosted runner only: they use the vendor sign-in that is already on the
198
+ * machine, and a cloud sandbox has none and must not be given one.
199
+ *
200
+ * A NEW ADAPTER FOR EVERY TURN, for every backend, stopped when the turn
201
+ * ends (`stopAtTurnEnd`). This is where the runner differs from 20x, which
202
+ * keeps one adapter per harness for the life of the app.
203
+ *
204
+ * Advertising a backend the host cannot run makes the control plane route
205
+ * work to a runner that must then fail it, so `capabilities.backends` lists a
206
+ * vendor backend only while discovery says it is installed and signed in (see
207
+ * `Harness.vendorBackendsFor`). This function is the last line: it refuses
208
+ * anything that should not have been leased here.
209
+ */
210
+ function createAdapterResolver(runtime,
211
+ /** The runner's own state directory (its workspace root). */
212
+ stateDir) {
213
+ const instanceId = stateDir ? vendorInstanceId(stateDir) : null;
214
+ const build = (backend) => {
215
+ switch (backend) {
216
+ case 'codex':
217
+ return new adapters_1.CodexAppServerAdapter();
218
+ case 'claude-code':
219
+ return new adapters_1.ClaudeCodeAdapter();
220
+ case 'cursor':
221
+ return new adapters_1.AcpAdapter('cursor');
222
+ default:
223
+ throw new Error(`No adapter for backend "${backend}".`);
224
+ }
225
+ };
226
+ return (backend, ports) => {
227
+ if (backend === 'pi') {
228
+ // The adapter reads the per-turn gateway holder directly. If the lease
229
+ // has no model token SessionRunner refuses before this is reached.
230
+ return new adapters_1.PiAdapter(ports.modelGateway);
231
+ }
232
+ if ((0, vendor_backends_1.isVendorBackend)(backend)) {
233
+ const blocker = (0, vendor_backends_1.vendorBackendBlocker)(backend, runtime);
234
+ if (blocker) {
235
+ throw new Error(`Backend "${backend}" cannot run here. ${blocker}`);
236
+ }
237
+ return stopAtTurnEnd(build(backend), instanceId ? `${backend}:${instanceId}` : null, { partIdsRestartEveryProcess: backend === 'cursor' });
238
+ }
239
+ throw new Error(`Backend "${backend}" is not wired in this build. ` +
240
+ 'Available: pi, and on a desktop or self-hosted runner codex, ' +
241
+ 'claude-code and cursor.');
242
+ };
243
+ }
@@ -46,6 +46,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
46
46
  exports.ClaudeCodeAdapter = exports.ClaudeSystemSubtype = void 0;
47
47
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
48
48
  const permission_mode_1 = require("../runtime/permission-mode");
49
+ const vendor_backends_1 = require("../vendor-backends");
49
50
  let ClaudeAgentSDK = null;
50
51
  /** Module-level cache for the resolved Claude executable path (persists across adapter instances) */
51
52
  let resolvedClaudeExecutablePath = null;
@@ -270,7 +271,10 @@ class ClaudeCodeAdapter {
270
271
  }
271
272
  async loadSDK() {
272
273
  try {
273
- ClaudeAgentSDK = await Promise.resolve().then(() => __importStar(require('@anthropic-ai/claude-agent-sdk')));
274
+ // CHANGED from 20x: the SDK is not bundled with the runner (it would add
275
+ // ~190 MB to every install), so it is also looked for in the global
276
+ // node_modules. See `vendor-backends.ts`.
277
+ ClaudeAgentSDK = (await (0, vendor_backends_1.importClaudeSdk)());
274
278
  console.log('[ClaudeCodeAdapter] SDK loaded successfully');
275
279
  }
276
280
  catch (error) {
package/dist/cli.js CHANGED
@@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- const adapters_1 = require("./adapters");
37
+ const adapter_resolver_1 = require("./adapter-resolver");
38
38
  const contract_1 = require("./contract");
39
39
  const credential_store_1 = require("./credential-store");
40
40
  const harness_1 = require("./harness");
41
+ const node_floor_1 = require("./node-floor");
41
42
  const options_1 = require("./options");
42
43
  const version_1 = require("./version");
43
44
  const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
@@ -81,30 +82,12 @@ Options:
81
82
  default for desktop/self-hosted; never runs
82
83
  in a sandbox.
83
84
 
85
+ Needs Node.js 22.19 or newer; start stops with one line on an older one.
86
+ PF_ALLOW_UNSUPPORTED_NODE=1 starts anyway, at your own risk.
87
+
84
88
  In a managed sandbox pf-bootstrap supplies these through the environment, so
85
89
  the enrolment token never appears in /proc/<pid>/cmdline.
86
90
  `;
87
- /**
88
- * Which adapter drives which backend.
89
- *
90
- * PI ONLY IN THIS BUILD, even though the legacy adapters remain exported for
91
- * extracted and are exported from the package. The others are wired by a HOST
92
- * that has verified their prerequisites: Claude Code needs
93
- * `@anthropic-ai/claude-agent-sdk` (an optional dependency, absent from the
94
- * sandbox image), and Codex and ACP need binaries the image does not ship.
95
- * Advertising a backend the host cannot actually run would make the control
96
- * plane route work to a runner that must then fail it.
97
- */
98
- function resolveAdapter(backend, ports) {
99
- if (backend === 'pi') {
100
- // The adapter reads the per-turn gateway holder directly. If the lease has
101
- // no model token SessionRunner refuses before this constructor is reached.
102
- return new adapters_1.PiAdapter(ports.modelGateway);
103
- }
104
- throw new Error(`Backend "${backend}" is not wired in this build. ` +
105
- 'Only "pi" is available; the other adapters are exported for a ' +
106
- 'host to wire once it has verified their prerequisites.');
107
- }
108
91
  /**
109
92
  * Read `--flag value` from argv. A tiny duplicate of the reader in options.ts,
110
93
  * because the login decision has to be made BEFORE `parseOptions` runs — that
@@ -144,6 +127,26 @@ async function main() {
144
127
  process.exitCode = 2;
145
128
  return;
146
129
  }
130
+ /*
131
+ * THE NODE.JS FLOOR — checked first, before the browser sign-in, so nobody
132
+ * signs in and picks an organisation only to be told to change Node. See
133
+ * `node-floor.ts` for where the floor comes from and why a sandbox only
134
+ * warns.
135
+ */
136
+ const nodeFloor = (0, node_floor_1.checkNodeFloor)({
137
+ current: process.versions.node,
138
+ floor: (0, version_1.getNodeFloor)(),
139
+ runtime: readOptionValue(argv.slice(1), 'runtime') ??
140
+ process.env.PF_RUNNER_RUNTIME,
141
+ env: process.env
142
+ });
143
+ if (nodeFloor.action === 'refuse') {
144
+ process.stderr.write(`${nodeFloor.message}\n`);
145
+ process.exit(1);
146
+ }
147
+ if (nodeFloor.action === 'warn') {
148
+ process.stderr.write(`Warning: ${nodeFloor.message}\n`);
149
+ }
147
150
  /*
148
151
  * NO TOKEN? OFFER TO SIGN IN, BUT ONLY WHERE THAT CAN POSSIBLY WORK.
149
152
  *
@@ -180,6 +183,7 @@ async function main() {
180
183
  }
181
184
  const options = (0, options_1.parseOptions)(startArgs, process.env);
182
185
  const handedOffCredential = (0, credential_store_1.takeHandedOffCredential)(process.env);
186
+ const resolveAdapter = (0, adapter_resolver_1.createAdapterResolver)(options.runtime, options.workspaceRoot);
183
187
  const harness = new harness_1.Harness(options, {
184
188
  getAdapter: (backend, ports) => resolveAdapter(backend, ports),
185
189
  ...(handedOffCredential
@@ -29,21 +29,44 @@
29
29
  * is local and is handed to each child process.
30
30
  */
31
31
  import type { RunnerHarness, RunnerHarnessModel } from './types';
32
- /** One command: `--version`, a status check, or a model listing. */
33
- export declare const DISCOVERY_COMMAND_TIMEOUT_MS = 8000;
34
- /** Everything for one harness. Harnesses run concurrently, so this is also the bound for the whole pass. */
35
- export declare const DISCOVERY_HARNESS_TIMEOUT_MS = 20000;
32
+ /**
33
+ * One command: `--version`, a status check, or a model listing.
34
+ *
35
+ * MEASURED, NOT GUESSED. On the Mac where the first release candidate was
36
+ * tried, with two runners and other work going on, `cursor-agent --version`
37
+ * took 9.4 s cold and 3.7 s warm ALONE, and 6-10 s when five harnesses were
38
+ * probed at once; `opencode --version` took 3-5 s alone and 7-9 s beside the
39
+ * others. The first limit was 8 s, so both lost on a busy machine and were
40
+ * shown as broken. The limit is now well above the slowest start seen — and
41
+ * the pass no longer starts every CLI at once (`DISCOVERY_CONCURRENCY`),
42
+ * which is what made each of them slow.
43
+ */
44
+ export declare const DISCOVERY_COMMAND_TIMEOUT_MS = 20000;
45
+ /**
46
+ * The budget for finding out a harness's STATE: its version and whether it is
47
+ * signed in. The model listing is NOT inside this budget — it has one command
48
+ * timeout of its own and can never change the state (see `probeHarness`).
49
+ */
50
+ export declare const DISCOVERY_HARNESS_TIMEOUT_MS = 45000;
51
+ /**
52
+ * How many harnesses are probed at the same time. Each harness runs its
53
+ * commands one after another, so this is also the most CLI processes
54
+ * discovery ever has running. Two, not five: these CLIs are heavy to start
55
+ * (most are bundled Node programs), and five cold starts at once roughly
56
+ * doubled the start time of each on the test machine.
57
+ */
58
+ export declare const DISCOVERY_CONCURRENCY = 2;
36
59
  /**
37
60
  * Every sentence a `reason` can be. A closed set on purpose: text printed by a
38
61
  * CLI can hold an account email or a key, and a constant cannot.
39
62
  */
40
63
  export declare const REASONS: {
41
64
  readonly notInstalled: "Not installed on this machine.";
42
- readonly versionTimedOut: "The version check timed out.";
65
+ readonly versionTimedOut: "Installed. The version check timed out and will be tried again.";
43
66
  readonly versionFailed: "The version check failed.";
44
- readonly discoveryTimedOut: "Discovery timed out.";
67
+ readonly discoveryTimedOut: "Installed. The check timed out and will be tried again.";
45
68
  readonly discoveryFailed: "Discovery failed.";
46
- readonly signInTimedOut: "The sign-in check timed out.";
69
+ readonly signInTimedOut: "Installed. The sign-in check timed out and will be tried again.";
47
70
  readonly signInUnknown: "Sign-in could not be confirmed.";
48
71
  readonly codexSignIn: "Not signed in. Run `codex login` on this machine.";
49
72
  readonly claudeSignIn: "Not signed in. Run `claude auth login` on this machine.";
@@ -81,6 +104,13 @@ export interface DiscoverHarnessesOptions {
81
104
  runnableBackends?: readonly string[];
82
105
  commandTimeoutMs?: number;
83
106
  harnessTimeoutMs?: number;
107
+ /** Harnesses probed at once. Defaults to `DISCOVERY_CONCURRENCY`. */
108
+ concurrency?: number;
109
+ /**
110
+ * Probe ONLY these harnesses and return only their entries. Used to check
111
+ * again the ones whose last check timed out, without starting the others.
112
+ */
113
+ only?: readonly string[];
84
114
  deps?: Partial<DiscoveryDeps>;
85
115
  }
86
116
  /**
@@ -137,6 +167,7 @@ export declare const KNOWN_HARNESSES: ReadonlyArray<{
137
167
  * Look for every known harness. ALWAYS resolves, with one entry per known
138
168
  * harness in a fixed order — a harness that is missing is reported as
139
169
  * `installed: false`, so the reader can tell "not installed" from "this runner
140
- * does not report harnesses".
170
+ * does not report harnesses". With `only`, just those harnesses are probed and
171
+ * returned.
141
172
  */
142
173
  export declare function discoverHarnesses(options?: DiscoverHarnessesOptions): Promise<RunnerHarness[]>;