@peakflo/agent-harness 0.2.9-rc.2 → 0.2.9-rc.4

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
@@ -46,9 +46,26 @@ Claude Code (`claude`), Cursor (`cursor-agent`), Pi (`pi`) and OpenCode
46
46
  whether it is signed in, and the models it offers. Peakflo shows this list in
47
47
  the agent editor and in the chat details.
48
48
 
49
- Finding a harness does not mean the runner can use it. The runner runs turns
50
- with Pi only; the other harnesses are shown as "Detected, not yet runnable by
51
- 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
+ - What the runner brings and what it expects, the same split as the 20x app:
61
+ it brings the Claude Agent SDK (a dependency of this package, installed with
62
+ it); it expects the tools themselves — `codex`, `claude`, `cursor-agent` —
63
+ to be installed and signed in on the machine. The SDK includes a copy of the
64
+ Claude binary for your platform, which is most of this package's install
65
+ size (about 260 MB installed).
66
+ - Each turn starts the harness's own program and stops it when the turn ends.
67
+ The conversation continues from the session the harness saved on this
68
+ machine.
52
69
 
53
70
  **When it looks.** Once after start, without delaying registration, and then
54
71
  every five minutes while no turn is running. A harness you install or sign in
@@ -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
+ }
package/dist/cli.js CHANGED
@@ -34,7 +34,7 @@ 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");
@@ -88,27 +88,6 @@ PF_ALLOW_UNSUPPORTED_NODE=1 starts anyway, at your own risk.
88
88
  In a managed sandbox pf-bootstrap supplies these through the environment, so
89
89
  the enrolment token never appears in /proc/<pid>/cmdline.
90
90
  `;
91
- /**
92
- * Which adapter drives which backend.
93
- *
94
- * PI ONLY IN THIS BUILD, even though the legacy adapters remain exported for
95
- * extracted and are exported from the package. The others are wired by a HOST
96
- * that has verified their prerequisites: Claude Code needs
97
- * `@anthropic-ai/claude-agent-sdk` (an optional dependency, absent from the
98
- * sandbox image), and Codex and ACP need binaries the image does not ship.
99
- * Advertising a backend the host cannot actually run would make the control
100
- * plane route work to a runner that must then fail it.
101
- */
102
- function resolveAdapter(backend, ports) {
103
- if (backend === 'pi') {
104
- // The adapter reads the per-turn gateway holder directly. If the lease has
105
- // no model token SessionRunner refuses before this constructor is reached.
106
- return new adapters_1.PiAdapter(ports.modelGateway);
107
- }
108
- throw new Error(`Backend "${backend}" is not wired in this build. ` +
109
- 'Only "pi" is available; the other adapters are exported for a ' +
110
- 'host to wire once it has verified their prerequisites.');
111
- }
112
91
  /**
113
92
  * Read `--flag value` from argv. A tiny duplicate of the reader in options.ts,
114
93
  * because the login decision has to be made BEFORE `parseOptions` runs — that
@@ -204,6 +183,7 @@ async function main() {
204
183
  }
205
184
  const options = (0, options_1.parseOptions)(startArgs, process.env);
206
185
  const handedOffCredential = (0, credential_store_1.takeHandedOffCredential)(process.env);
186
+ const resolveAdapter = (0, adapter_resolver_1.createAdapterResolver)(options.runtime, options.workspaceRoot);
207
187
  const harness = new harness_1.Harness(options, {
208
188
  getAdapter: (backend, ports) => resolveAdapter(backend, ports),
209
189
  ...(handedOffCredential
package/dist/harness.d.ts CHANGED
@@ -123,6 +123,11 @@ export interface HarnessDeps {
123
123
  approvalRenewIntervalMs?: number;
124
124
  /** Injected in tests. */
125
125
  clientFactory?: (url: string) => ControlPlaneClient;
126
+ /**
127
+ * Why a vendor backend cannot be run here, or null. Injected in tests;
128
+ * defaults to `vendorBackendBlocker` (sandbox, or the Claude SDK missing).
129
+ */
130
+ vendorBackendBlocker?: (backend: string, runtime: string) => string | null;
126
131
  /** Injected in tests; defaults to the real `discoverHarnesses`. */
127
132
  discoverHarnesses?: (options: DiscoverHarnessesOptions) => Promise<RunnerHarness[]>;
128
133
  }
@@ -359,6 +364,25 @@ export declare class Harness {
359
364
  * the same reason — one source, so the two lists cannot disagree.
360
365
  */
361
366
  private applyHarnessReport;
367
+ /**
368
+ * Add Codex, Claude Code and Cursor to `backends` — the list work is routed
369
+ * on — for exactly as long as each is INSTALLED AND SIGNED IN.
370
+ *
371
+ * This is the gate that turns "detected" into "runnable", so it is strict:
372
+ *
373
+ * - only from a discovery report, which a sandbox never has;
374
+ * - only a harness whose check SETTLED as usable. A check that timed out
375
+ * with no earlier answer is not enough to take work on;
376
+ * - only when this runner was given adapters at all, and has what the
377
+ * adapter needs (Claude Code needs the Claude Agent SDK).
378
+ *
379
+ * Recomputed for every capability report from the cached discovery pass, so
380
+ * a harness that is signed out, or uninstalled, leaves `backends` with the
381
+ * report that follows the pass which noticed. Nothing is remembered here.
382
+ *
383
+ * Returns why each harness that is usable was still NOT added, for the UI.
384
+ */
385
+ private addVendorBackends;
362
386
  private harnessDiscoveryEnabled;
363
387
  /**
364
388
  * Whether to start a discovery pass on this heartbeat.
package/dist/harness.js CHANGED
@@ -22,6 +22,7 @@ const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
22
22
  const session_runner_1 = require("./runtime/session-runner");
23
23
  const conversation_store_1 = require("./store/conversation-store");
24
24
  const sqlite_store_1 = require("./store/sqlite-store");
25
+ const vendor_backends_1 = require("./vendor-backends");
25
26
  const version_1 = require("./version");
26
27
  /** Backoff after a lease poll fails, so an unreachable control plane is not hammered. */
27
28
  const LEASE_ERROR_BACKOFF_MS = 5_000;
@@ -763,7 +764,10 @@ class Harness {
763
764
  * `force` re-probes regardless of age (used at start-up). Never rejects.
764
765
  */
765
766
  async refreshModelCatalog(force = false) {
766
- const backends = this.capabilities?.backends ?? [];
767
+ // A vendor harness's models come from discovery, not from this probe:
768
+ // asking its adapter would start `codex app-server` or `cursor-agent acp`
769
+ // just to list models that are already known.
770
+ const backends = (this.capabilities?.backends ?? []).filter((backend) => !(0, vendor_backends_1.isVendorBackend)(backend.name));
767
771
  const build = this.deps.getAdapter;
768
772
  if (!build || backends.length === 0) {
769
773
  this.modelCatalog = null;
@@ -941,10 +945,16 @@ class Harness {
941
945
  if (!this.harnessReport) {
942
946
  return;
943
947
  }
948
+ const blocked = this.addVendorBackends(capabilities);
944
949
  const harnesses = this.harnessReport.map((harness) => {
945
950
  const backend = capabilities.backends.find((candidate) => candidate.name === harness.name);
946
951
  if (!backend) {
947
- return { ...harness, runnable: false };
952
+ const reason = blocked.get(harness.name);
953
+ return {
954
+ ...harness,
955
+ runnable: false,
956
+ ...(reason ? { notRunnableReason: reason } : {})
957
+ };
948
958
  }
949
959
  /*
950
960
  * A HARNESS THIS RUNNER RUNS IS NEVER SHOWN WORSE THAN `backends` SAYS.
@@ -990,6 +1000,55 @@ class Harness {
990
1000
  exports.HARNESS_DISCOVERY_FEATURE
991
1001
  ];
992
1002
  }
1003
+ /**
1004
+ * Add Codex, Claude Code and Cursor to `backends` — the list work is routed
1005
+ * on — for exactly as long as each is INSTALLED AND SIGNED IN.
1006
+ *
1007
+ * This is the gate that turns "detected" into "runnable", so it is strict:
1008
+ *
1009
+ * - only from a discovery report, which a sandbox never has;
1010
+ * - only a harness whose check SETTLED as usable. A check that timed out
1011
+ * with no earlier answer is not enough to take work on;
1012
+ * - only when this runner was given adapters at all, and has what the
1013
+ * adapter needs (Claude Code needs the Claude Agent SDK).
1014
+ *
1015
+ * Recomputed for every capability report from the cached discovery pass, so
1016
+ * a harness that is signed out, or uninstalled, leaves `backends` with the
1017
+ * report that follows the pass which noticed. Nothing is remembered here.
1018
+ *
1019
+ * Returns why each harness that is usable was still NOT added, for the UI.
1020
+ */
1021
+ addVendorBackends(capabilities) {
1022
+ const blocked = new Map();
1023
+ if (!this.harnessReport || !this.deps.getAdapter)
1024
+ return blocked;
1025
+ const blocker = this.deps.vendorBackendBlocker ?? vendor_backends_1.vendorBackendBlocker;
1026
+ for (const harness of this.harnessReport) {
1027
+ if (!(0, vendor_backends_1.isVendorBackend)(harness.name))
1028
+ continue;
1029
+ if (!harness.installed || !harness.usable)
1030
+ continue;
1031
+ if (capabilities.backends.some((b) => b.name === harness.name))
1032
+ continue;
1033
+ const reason = blocker(harness.name, this.options.runtime);
1034
+ if (reason) {
1035
+ blocked.set(harness.name, reason);
1036
+ continue;
1037
+ }
1038
+ /*
1039
+ * NAME AND VERSION ONLY. The harness's models are already in
1040
+ * `harnesses[]`, which is sent once and then by digest. Repeating them
1041
+ * here put them back into every heartbeat: about 15 KB each time on a
1042
+ * machine with Cursor's 200 models, seen on the first release candidate
1043
+ * that had this.
1044
+ */
1045
+ capabilities.backends.push({
1046
+ name: harness.name,
1047
+ version: harness.version ?? null
1048
+ });
1049
+ }
1050
+ return blocked;
1051
+ }
993
1052
  harnessDiscoveryEnabled() {
994
1053
  return (this.options.runtime !== 'sandbox' &&
995
1054
  this.options.harnessDiscovery !== false);
@@ -174,6 +174,32 @@ export declare function qualifiedModelId(model: {
174
174
  * claiming otherwise would send the reader to the wrong place.
175
175
  */
176
176
  export declare function describeProviderError(error: string, qualifiedModel: string | undefined): string;
177
+ /**
178
+ * A VENDOR HARNESS GETS NO GATEWAY AND NO GATEWAY KEY.
179
+ *
180
+ * Codex, Claude Code and Cursor run on the vendor sign-in that is already on
181
+ * the machine, as they do in 20x. The Peakflo model gateway has no part in
182
+ * such a turn, so everything about it is taken off the lease HERE, once,
183
+ * before anything reads it: no catalogue fetch with the key, no `peakflo`
184
+ * provider built, no gateway URL in the adapter's environment, and no key
185
+ * expiry watched.
186
+ *
187
+ * Done in the runner and not only on the control plane, because a control
188
+ * plane older than this change still sends both for every backend. The key
189
+ * must not reach a vendor CLI's process whatever the control plane's age.
190
+ */
191
+ export declare function withoutGatewayForVendorBackend(lease: LeaseResponse): LeaseResponse;
192
+ /**
193
+ * The model id as THIS backend understands it: `provider/model` for Pi, the
194
+ * vendor's own bare id for a vendor harness (see `vendorModelId`).
195
+ */
196
+ export declare function backendModelId(agent: {
197
+ backend: string;
198
+ model: {
199
+ provider?: string | null;
200
+ model?: string | null;
201
+ };
202
+ }): string | undefined;
177
203
  export declare function modelGatewayRefusal(lease: {
178
204
  modelToken?: string | null;
179
205
  session: {
@@ -338,7 +364,7 @@ export declare class SessionRunner {
338
364
  * control plane at all propagates, since that is the one thing the runner
339
365
  * cannot report through the control plane.
340
366
  */
341
- run(lease: LeaseResponse, signal: AbortSignal): Promise<RunTurnResult>;
367
+ run(leased: LeaseResponse, signal: AbortSignal): Promise<RunTurnResult>;
342
368
  /**
343
369
  * Does this polled part merely mirror the prompt the runner itself sent?
344
370
  *
@@ -23,6 +23,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.SessionRunner = void 0;
24
24
  exports.qualifiedModelId = qualifiedModelId;
25
25
  exports.describeProviderError = describeProviderError;
26
+ exports.withoutGatewayForVendorBackend = withoutGatewayForVendorBackend;
27
+ exports.backendModelId = backendModelId;
26
28
  exports.modelGatewayRefusal = modelGatewayRefusal;
27
29
  exports.permissionFingerprint = permissionFingerprint;
28
30
  exports.decodeQuestionAnswers = decodeQuestionAnswers;
@@ -30,6 +32,7 @@ exports.isApprovingOptionId = isApprovingOptionId;
30
32
  const node_crypto_1 = require("node:crypto");
31
33
  const model_gateway_1 = require("../adapters/model-gateway");
32
34
  const contract_1 = require("../contract");
35
+ const vendor_backends_1 = require("../vendor-backends");
33
36
  const attachments_1 = require("./attachments");
34
37
  const cold_start_steps_1 = require("./cold-start-steps");
35
38
  const conversation_1 = require("./conversation");
@@ -156,6 +159,43 @@ function describeProviderError(error, qualifiedModel) {
156
159
  `to ${provider}, or choose a ${model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID}/ model, ` +
157
160
  `which is credentialed for each turn.`);
158
161
  }
162
+ /**
163
+ * A VENDOR HARNESS GETS NO GATEWAY AND NO GATEWAY KEY.
164
+ *
165
+ * Codex, Claude Code and Cursor run on the vendor sign-in that is already on
166
+ * the machine, as they do in 20x. The Peakflo model gateway has no part in
167
+ * such a turn, so everything about it is taken off the lease HERE, once,
168
+ * before anything reads it: no catalogue fetch with the key, no `peakflo`
169
+ * provider built, no gateway URL in the adapter's environment, and no key
170
+ * expiry watched.
171
+ *
172
+ * Done in the runner and not only on the control plane, because a control
173
+ * plane older than this change still sends both for every backend. The key
174
+ * must not reach a vendor CLI's process whatever the control plane's age.
175
+ */
176
+ function withoutGatewayForVendorBackend(lease) {
177
+ const { agent } = lease.session;
178
+ if (!(0, vendor_backends_1.isVendorBackend)(agent.backend))
179
+ return lease;
180
+ return {
181
+ ...lease,
182
+ modelToken: '',
183
+ modelTokenExpiresAt: undefined,
184
+ session: {
185
+ ...lease.session,
186
+ agent: { ...agent, model: { ...agent.model, gatewayUrl: '' } }
187
+ }
188
+ };
189
+ }
190
+ /**
191
+ * The model id as THIS backend understands it: `provider/model` for Pi, the
192
+ * vendor's own bare id for a vendor harness (see `vendorModelId`).
193
+ */
194
+ function backendModelId(agent) {
195
+ return (0, vendor_backends_1.isVendorBackend)(agent.backend)
196
+ ? (0, vendor_backends_1.vendorModelId)(agent.model, agent.backend)
197
+ : qualifiedModelId(agent.model);
198
+ }
159
199
  function modelGatewayRefusal(lease) {
160
200
  const gatewayUrl = (lease.session.agent.model.gatewayUrl ?? '').trim();
161
201
  if (!gatewayUrl)
@@ -590,7 +630,8 @@ class SessionRunner {
590
630
  * control plane at all propagates, since that is the one thing the runner
591
631
  * cannot report through the control plane.
592
632
  */
593
- async run(lease, signal) {
633
+ async run(leased, signal) {
634
+ const lease = withoutGatewayForVendorBackend(leased);
594
635
  const { session } = lease;
595
636
  const logColdStart = (line) => {
596
637
  this.logger.log(line);
@@ -1109,6 +1150,28 @@ class SessionRunner {
1109
1150
  // THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
1110
1151
  // backend's own echo of it — see `drain`.
1111
1152
  sentPromptTexts, () => endFirstOutput(), () => endFirstIngest());
1153
+ /*
1154
+ * THE BACKEND MAY ONLY NOW KNOW ITS OWN SESSION ID.
1155
+ *
1156
+ * Claude Code is given a placeholder by `createSession` and learns the
1157
+ * real session id from the first message of the stream; the adapter
1158
+ * reports it on a part as `realSessionId`, and 20x re-keys the session
1159
+ * when it sees one (`agent-manager.ts`, "REKEYED ... reason=
1160
+ * adapter_provided_real_id"). The checkpoint must be that real id: the
1161
+ * placeholder names no saved session, and resuming it fails with
1162
+ * "session file does not exist" on every later turn.
1163
+ *
1164
+ * Within this turn the adapter answers to both ids, so only what is
1165
+ * SAVED changes. A backend with nothing to say keeps the id it had.
1166
+ */
1167
+ const realBackendSessionId = adapter.checkpointSessionId?.(backendSessionId);
1168
+ if (realBackendSessionId && realBackendSessionId !== backendSessionId) {
1169
+ backendSessionId = realBackendSessionId;
1170
+ this.deps.conversation?.setCheckpoint((0, conversation_1.conversationKey)(session), backendSessionId, session.agent.backend, {
1171
+ backendOwnerId: ownBackendInstanceId,
1172
+ permissionDecision: permissionFingerprint(session.agent.permissionMode)
1173
+ });
1174
+ }
1112
1175
  partsProduced = drained.parts;
1113
1176
  if (signal.aborted) {
1114
1177
  stopReason = 'aborted';
@@ -1157,7 +1220,7 @@ class SessionRunner {
1157
1220
  {
1158
1221
  id: `error-${(0, node_crypto_1.randomUUID)()}`,
1159
1222
  role: 'system',
1160
- content: describeProviderError(error ?? String(drained.providerError), qualifiedModelId(session.agent.model)),
1223
+ content: describeProviderError(error ?? String(drained.providerError), backendModelId(session.agent)),
1161
1224
  partType: 'error'
1162
1225
  }
1163
1226
  ]);
@@ -2463,7 +2526,7 @@ class SessionRunner {
2463
2526
  turnId: session.turnId,
2464
2527
  workspaceDir,
2465
2528
  devServerPolicy: session.agent.devServerPolicy,
2466
- model: qualifiedModelId(session.agent.model),
2529
+ model: backendModelId(session.agent),
2467
2530
  systemPrompt: session.agent.systemPrompt ?? undefined,
2468
2531
  skills: session.agent.skills.map((skill) => ({
2469
2532
  name: skill.name,
package/dist/types.d.ts CHANGED
@@ -293,6 +293,12 @@ export interface RunnerHarness {
293
293
  transient?: boolean;
294
294
  /** This runner can run a turn with it: it is also listed in `backends`. */
295
295
  runnable: boolean;
296
+ /**
297
+ * Why this runner cannot run a harness that is installed and signed in
298
+ * (for example the Claude Agent SDK is missing). A fixed sentence. Only
299
+ * sent with `usable: true, runnable: false`.
300
+ */
301
+ notRunnableReason?: string;
296
302
  models?: RunnerHarnessModel[];
297
303
  modelSource?: RunnerHarnessModelSource;
298
304
  /** ISO time of the discovery pass that first found this state. */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * THE VENDOR HARNESSES THIS RUNNER CAN RUN: Codex, Claude Code, Cursor.
3
+ *
4
+ * "Vendor" because each one talks to its own vendor with the sign-in that is
5
+ * already on the machine — exactly as in 20x, where the same adapters run
6
+ * with no gateway and no key from the app. That is what separates them from
7
+ * Pi, which is credentialed for every turn from the lease:
8
+ *
9
+ * Pi Codex / Claude Code / Cursor
10
+ * model credential gateway key in the lease the machine's own sign-in
11
+ * model id `provider/model` the vendor's own bare id
12
+ * where it runs every runtime desktop and self-hosted only
13
+ *
14
+ * WHICH ADAPTER, AS IN 20x `agent-manager.ts` `getAdapter`:
15
+ * codex -> CodexAppServerAdapter (`codex app-server --stdio`)
16
+ * claude-code -> ClaudeCodeAdapter (`@anthropic-ai/claude-agent-sdk`)
17
+ * cursor -> AcpAdapter('cursor') (`cursor-agent acp`)
18
+ *
19
+ * NEVER IN A SANDBOX. A cloud sandbox has no vendor sign-in and must not be
20
+ * given one: no tenant vendor credential is placed on an untrusted host. It
21
+ * stays Pi-only.
22
+ */
23
+ export declare const VENDOR_BACKENDS: readonly ["codex", "claude-code", "cursor"];
24
+ export type VendorBackend = (typeof VENDOR_BACKENDS)[number];
25
+ export declare function isVendorBackend(name: string): name is VendorBackend;
26
+ /**
27
+ * The model id a vendor adapter is given, or undefined for "the harness's own
28
+ * default".
29
+ *
30
+ * An agent's model is stored as `{ provider, model }`. For a vendor harness the
31
+ * picker stores the HARNESS NAME as the provider (`codex` / `gpt-6-sol`), so a
32
+ * model that belongs to this harness is recognised by `provider === backend`
33
+ * and its bare id is what the adapter gets.
34
+ *
35
+ * ANY OTHER PROVIDER IS NOT PASSED ON. An agent switched from Pi to Codex can
36
+ * still carry `peakflo/gpt-4.1`; handing `gpt-4.1` to Codex would be a guess
37
+ * that the two vendors mean the same thing by a name. The harness then runs
38
+ * its own default model, which is what its CLI would do.
39
+ */
40
+ export declare function vendorModelId(model: {
41
+ provider?: string | null;
42
+ model?: string | null;
43
+ }, backend: string): string | undefined;
44
+ export declare const CLAUDE_SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
45
+ /**
46
+ * Why a harness that is installed and signed in still cannot be run by this
47
+ * runner. Fixed sentences, like every other reason in the report.
48
+ */
49
+ export declare const NOT_RUNNABLE_REASONS: {
50
+ readonly claudeSdkMissing: "The Claude Agent SDK that ships with this runner could not be loaded. Reinstall the runner.";
51
+ readonly sandbox: "A cloud sandbox runs Pi only.";
52
+ };
53
+ /**
54
+ * Where the SDK is, or null.
55
+ *
56
+ * THE SDK IS A DEPENDENCY OF THIS PACKAGE, as it is of the 20x app: installing
57
+ * the runner installs it, and nothing else has to be done on the machine. So
58
+ * it is looked for in one place, this package's own dependency tree.
59
+ *
60
+ * Null is still possible and still handled, because "declared" is not
61
+ * "present": an install that was interrupted, pruned, or made with
62
+ * dependencies left out has no SDK. Claude Code is then reported as not
63
+ * runnable, with a reason, instead of being routed a turn it must fail.
64
+ *
65
+ * `ownTree` is replaced in tests, which could not otherwise see "missing".
66
+ */
67
+ export declare function resolveClaudeSdk(ownTree?: () => string): string | null;
68
+ /** Whether this runner can build an adapter for a vendor backend right now. */
69
+ export declare function vendorBackendBlocker(backend: string, runtime: string): string | null;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * THE VENDOR HARNESSES THIS RUNNER CAN RUN: Codex, Claude Code, Cursor.
4
+ *
5
+ * "Vendor" because each one talks to its own vendor with the sign-in that is
6
+ * already on the machine — exactly as in 20x, where the same adapters run
7
+ * with no gateway and no key from the app. That is what separates them from
8
+ * Pi, which is credentialed for every turn from the lease:
9
+ *
10
+ * Pi Codex / Claude Code / Cursor
11
+ * model credential gateway key in the lease the machine's own sign-in
12
+ * model id `provider/model` the vendor's own bare id
13
+ * where it runs every runtime desktop and self-hosted only
14
+ *
15
+ * WHICH ADAPTER, AS IN 20x `agent-manager.ts` `getAdapter`:
16
+ * codex -> CodexAppServerAdapter (`codex app-server --stdio`)
17
+ * claude-code -> ClaudeCodeAdapter (`@anthropic-ai/claude-agent-sdk`)
18
+ * cursor -> AcpAdapter('cursor') (`cursor-agent acp`)
19
+ *
20
+ * NEVER IN A SANDBOX. A cloud sandbox has no vendor sign-in and must not be
21
+ * given one: no tenant vendor credential is placed on an untrusted host. It
22
+ * stays Pi-only.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.NOT_RUNNABLE_REASONS = exports.CLAUDE_SDK_PACKAGE = exports.VENDOR_BACKENDS = void 0;
26
+ exports.isVendorBackend = isVendorBackend;
27
+ exports.vendorModelId = vendorModelId;
28
+ exports.resolveClaudeSdk = resolveClaudeSdk;
29
+ exports.vendorBackendBlocker = vendorBackendBlocker;
30
+ const node_module_1 = require("node:module");
31
+ exports.VENDOR_BACKENDS = ['codex', 'claude-code', 'cursor'];
32
+ function isVendorBackend(name) {
33
+ return exports.VENDOR_BACKENDS.includes(name);
34
+ }
35
+ /**
36
+ * The model id a vendor adapter is given, or undefined for "the harness's own
37
+ * default".
38
+ *
39
+ * An agent's model is stored as `{ provider, model }`. For a vendor harness the
40
+ * picker stores the HARNESS NAME as the provider (`codex` / `gpt-6-sol`), so a
41
+ * model that belongs to this harness is recognised by `provider === backend`
42
+ * and its bare id is what the adapter gets.
43
+ *
44
+ * ANY OTHER PROVIDER IS NOT PASSED ON. An agent switched from Pi to Codex can
45
+ * still carry `peakflo/gpt-4.1`; handing `gpt-4.1` to Codex would be a guess
46
+ * that the two vendors mean the same thing by a name. The harness then runs
47
+ * its own default model, which is what its CLI would do.
48
+ */
49
+ function vendorModelId(model, backend) {
50
+ const name = (model.model ?? '').trim();
51
+ const provider = (model.provider ?? '').trim();
52
+ return name && provider === backend ? name : undefined;
53
+ }
54
+ /* -------------------------------------------------------------------------- */
55
+ /* The Claude Agent SDK */
56
+ /* -------------------------------------------------------------------------- */
57
+ exports.CLAUDE_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk';
58
+ /**
59
+ * Why a harness that is installed and signed in still cannot be run by this
60
+ * runner. Fixed sentences, like every other reason in the report.
61
+ */
62
+ exports.NOT_RUNNABLE_REASONS = {
63
+ claudeSdkMissing: 'The Claude Agent SDK that ships with this runner could not be loaded. Reinstall the runner.',
64
+ sandbox: 'A cloud sandbox runs Pi only.'
65
+ };
66
+ /**
67
+ * Where the SDK is, or null.
68
+ *
69
+ * THE SDK IS A DEPENDENCY OF THIS PACKAGE, as it is of the 20x app: installing
70
+ * the runner installs it, and nothing else has to be done on the machine. So
71
+ * it is looked for in one place, this package's own dependency tree.
72
+ *
73
+ * Null is still possible and still handled, because "declared" is not
74
+ * "present": an install that was interrupted, pruned, or made with
75
+ * dependencies left out has no SDK. Claude Code is then reported as not
76
+ * runnable, with a reason, instead of being routed a turn it must fail.
77
+ *
78
+ * `ownTree` is replaced in tests, which could not otherwise see "missing".
79
+ */
80
+ function resolveClaudeSdk(ownTree = () => (0, node_module_1.createRequire)(__filename).resolve(exports.CLAUDE_SDK_PACKAGE)) {
81
+ try {
82
+ return ownTree();
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /** Whether this runner can build an adapter for a vendor backend right now. */
89
+ function vendorBackendBlocker(backend, runtime) {
90
+ if (!isVendorBackend(backend))
91
+ return null;
92
+ if (runtime === 'sandbox')
93
+ return exports.NOT_RUNNABLE_REASONS.sandbox;
94
+ if (backend === 'claude-code' && !resolveClaudeSdk()) {
95
+ return exports.NOT_RUNNABLE_REASONS.claudeSdkMissing;
96
+ }
97
+ return null;
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.9-rc.2",
3
+ "version": "0.2.9-rc.4",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -29,7 +29,6 @@
29
29
  "test": "jest"
30
30
  },
31
31
  "devDependencies": {
32
- "@anthropic-ai/claude-agent-sdk": "^0.3.195",
33
32
  "@opencode-ai/sdk": "^1.15.13",
34
33
  "@types/node": "^22.10.0",
35
34
  "tsx": "^4.19.3",
@@ -37,6 +36,7 @@
37
36
  "undici": "^7.21.0"
38
37
  },
39
38
  "dependencies": {
39
+ "@anthropic-ai/claude-agent-sdk": "0.3.258",
40
40
  "@modelcontextprotocol/client": "2.0.0",
41
41
  "@peculiar/x509": "^2.1.0",
42
42
  "reflect-metadata": "^0.2.2"