@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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * THE NODE.JS FLOOR, CHECKED AT START.
3
+ *
4
+ * `package.json` says `engines.node: ">=22.19"`. npm treats that as advice: on
5
+ * an older Node it prints EBADENGINE and installs anyway, and `npx` then starts
6
+ * the runner as if nothing was wrong. So the floor was a line in a manifest
7
+ * that nothing enforced.
8
+ *
9
+ * WHERE THE FLOOR COMES FROM. Not from this package's own code — that needs
10
+ * `node:sqlite` without a flag (Node 22.13). It comes from Pi, the one backend
11
+ * this runner runs turns with: `@earendil-works/pi-coding-agent` declares
12
+ * `engines.node >=22.19.0`, and Pi's launcher is `#!/usr/bin/env node`, so Pi
13
+ * normally runs on the same Node as the runner. Pi 1.1.0 was seen to start on
14
+ * 22.14 (`--version`, `--list-models`), and nobody has shown that a full turn
15
+ * works there. An upstream requirement that is not proven false is kept.
16
+ *
17
+ * WHAT HAPPENS BELOW THE FLOOR. A desktop or self-hosted runner refuses to
18
+ * start, with one line that says what to do. `PF_ALLOW_UNSUPPORTED_NODE=1`
19
+ * starts it anyway, with a one-line warning, for someone who knows their
20
+ * setup works. A SANDBOX only warns: its Node is whatever the image was built
21
+ * with, nobody is at a terminal to read the line, and a refusal there would
22
+ * take a cloud runner out of service for a requirement of a tool the image
23
+ * already carries.
24
+ *
25
+ * PURE: no process state is read here, so every branch can be tested.
26
+ */
27
+ /** Parse `22.19`, `v22.19.0`, `>=22.19` into numbers. Null when it is none of those. */
28
+ export declare function parseNodeVersion(text: string): number[] | null;
29
+ export declare function isNodeVersionAtLeast(current: string, floor: string): boolean;
30
+ export type NodeFloorVerdict = {
31
+ action: 'start';
32
+ } | {
33
+ action: 'warn';
34
+ message: string;
35
+ } | {
36
+ action: 'refuse';
37
+ message: string;
38
+ };
39
+ export declare function checkNodeFloor(input: {
40
+ /** `process.versions.node`, e.g. `22.14.0`. */
41
+ current: string;
42
+ /** `engines.node` of this package, e.g. `>=22.19`. Null when unreadable. */
43
+ floor: string | null;
44
+ runtime: string | undefined;
45
+ env: NodeJS.ProcessEnv;
46
+ }): NodeFloorVerdict;
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * THE NODE.JS FLOOR, CHECKED AT START.
4
+ *
5
+ * `package.json` says `engines.node: ">=22.19"`. npm treats that as advice: on
6
+ * an older Node it prints EBADENGINE and installs anyway, and `npx` then starts
7
+ * the runner as if nothing was wrong. So the floor was a line in a manifest
8
+ * that nothing enforced.
9
+ *
10
+ * WHERE THE FLOOR COMES FROM. Not from this package's own code — that needs
11
+ * `node:sqlite` without a flag (Node 22.13). It comes from Pi, the one backend
12
+ * this runner runs turns with: `@earendil-works/pi-coding-agent` declares
13
+ * `engines.node >=22.19.0`, and Pi's launcher is `#!/usr/bin/env node`, so Pi
14
+ * normally runs on the same Node as the runner. Pi 1.1.0 was seen to start on
15
+ * 22.14 (`--version`, `--list-models`), and nobody has shown that a full turn
16
+ * works there. An upstream requirement that is not proven false is kept.
17
+ *
18
+ * WHAT HAPPENS BELOW THE FLOOR. A desktop or self-hosted runner refuses to
19
+ * start, with one line that says what to do. `PF_ALLOW_UNSUPPORTED_NODE=1`
20
+ * starts it anyway, with a one-line warning, for someone who knows their
21
+ * setup works. A SANDBOX only warns: its Node is whatever the image was built
22
+ * with, nobody is at a terminal to read the line, and a refusal there would
23
+ * take a cloud runner out of service for a requirement of a tool the image
24
+ * already carries.
25
+ *
26
+ * PURE: no process state is read here, so every branch can be tested.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.parseNodeVersion = parseNodeVersion;
30
+ exports.isNodeVersionAtLeast = isNodeVersionAtLeast;
31
+ exports.checkNodeFloor = checkNodeFloor;
32
+ /** Parse `22.19`, `v22.19.0`, `>=22.19` into numbers. Null when it is none of those. */
33
+ function parseNodeVersion(text) {
34
+ const match = text.trim().match(/^(?:>=\s*)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
35
+ if (!match)
36
+ return null;
37
+ return [match[1], match[2] ?? '0', match[3] ?? '0'].map(Number);
38
+ }
39
+ function isNodeVersionAtLeast(current, floor) {
40
+ const have = parseNodeVersion(current);
41
+ const need = parseNodeVersion(floor);
42
+ // A floor or a version that cannot be read is not a reason to refuse.
43
+ if (!have || !need)
44
+ return true;
45
+ for (let index = 0; index < 3; index++) {
46
+ if (have[index] > need[index])
47
+ return true;
48
+ if (have[index] < need[index])
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+ function checkNodeFloor(input) {
54
+ if (!input.floor || isNodeVersionAtLeast(input.current, input.floor)) {
55
+ return { action: 'start' };
56
+ }
57
+ const need = (parseNodeVersion(input.floor) ?? []).slice(0, 2).join('.');
58
+ const problem = `pf-agent-harness needs Node.js ${need} or newer; this is Node.js ${input.current}.`;
59
+ const allowed = /^(1|true|yes|on)$/i.test((input.env.PF_ALLOW_UNSUPPORTED_NODE ?? '').trim());
60
+ if (allowed) {
61
+ return {
62
+ action: 'warn',
63
+ message: `${problem} Starting anyway because PF_ALLOW_UNSUPPORTED_NODE is set.`
64
+ };
65
+ }
66
+ if (input.runtime === 'sandbox') {
67
+ return {
68
+ action: 'warn',
69
+ message: `${problem} Starting anyway in a sandbox.`
70
+ };
71
+ }
72
+ return {
73
+ action: 'refuse',
74
+ message: `${problem} Switch to a newer Node.js (for example \`nvm install 22 && nvm use 22\`) and start again.`
75
+ };
76
+ }
@@ -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,
@@ -27,6 +27,7 @@
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.MemoryConversationStore = exports.SqliteConversationStore = void 0;
29
29
  exports.openConversationStore = openConversationStore;
30
+ const sqlite_store_1 = require("./sqlite-store");
30
31
  const SCHEMA = `
31
32
  CREATE TABLE IF NOT EXISTS model_messages (
32
33
  session_id TEXT NOT NULL,
@@ -292,6 +293,7 @@ function openConversationStore(path) {
292
293
  };
293
294
  }
294
295
  try {
296
+ (0, sqlite_store_1.ensureStoreDirectory)(path);
295
297
  const db = new sqlite.DatabaseSync(path);
296
298
  return { store: new SqliteConversationStore(db), durable: true };
297
299
  }
@@ -106,6 +106,23 @@ export interface OpenStoreResult {
106
106
  /** Why durability was unavailable. Reported so a degraded fleet is visible. */
107
107
  reason?: string;
108
108
  }
109
+ /**
110
+ * Make the directory a store file goes in, when it is not there yet.
111
+ *
112
+ * sqlite creates the FILE and never the directory: a `--workspace-root` that
113
+ * does not exist yet made every open fail with "unable to open database
114
+ * file", and the runner then ran in memory and reported itself degraded —
115
+ * for a path it was about to create anyway on the first session. Private
116
+ * mode, because the stores hold transcripts and the model's conversation.
117
+ *
118
+ * A directory that ALREADY EXISTS is left exactly as it is, its mode included:
119
+ * `mkdir` with `recursive` does not touch one, and changing the permissions
120
+ * of a directory somebody chose is not this function's call.
121
+ *
122
+ * Throws when the directory cannot be made; the caller turns that into the
123
+ * same visible memory fallback as any other open failure.
124
+ */
125
+ export declare function ensureStoreDirectory(path: string): void;
109
126
  /**
110
127
  * Open the durable store, or say plainly why it could not be opened.
111
128
  *
@@ -32,7 +32,10 @@
32
32
  */
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
34
  exports.MemoryTranscriptStore = exports.SqliteTranscriptStore = void 0;
35
+ exports.ensureStoreDirectory = ensureStoreDirectory;
35
36
  exports.openTranscriptStore = openTranscriptStore;
37
+ const node_fs_1 = require("node:fs");
38
+ const node_path_1 = require("node:path");
36
39
  const SCHEMA = `
37
40
  CREATE TABLE IF NOT EXISTS transcript_parts (
38
41
  session_id TEXT NOT NULL,
@@ -309,6 +312,28 @@ class MemoryTranscriptStore {
309
312
  }
310
313
  }
311
314
  exports.MemoryTranscriptStore = MemoryTranscriptStore;
315
+ /**
316
+ * Make the directory a store file goes in, when it is not there yet.
317
+ *
318
+ * sqlite creates the FILE and never the directory: a `--workspace-root` that
319
+ * does not exist yet made every open fail with "unable to open database
320
+ * file", and the runner then ran in memory and reported itself degraded —
321
+ * for a path it was about to create anyway on the first session. Private
322
+ * mode, because the stores hold transcripts and the model's conversation.
323
+ *
324
+ * A directory that ALREADY EXISTS is left exactly as it is, its mode included:
325
+ * `mkdir` with `recursive` does not touch one, and changing the permissions
326
+ * of a directory somebody chose is not this function's call.
327
+ *
328
+ * Throws when the directory cannot be made; the caller turns that into the
329
+ * same visible memory fallback as any other open failure.
330
+ */
331
+ function ensureStoreDirectory(path) {
332
+ // sqlite's in-memory and temporary databases have no directory.
333
+ if (path === ':memory:' || path === '')
334
+ return;
335
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
336
+ }
312
337
  /**
313
338
  * Open the durable store, or say plainly why it could not be opened.
314
339
  *
@@ -333,6 +358,7 @@ function openTranscriptStore(path) {
333
358
  };
334
359
  }
335
360
  try {
361
+ ensureStoreDirectory(path);
336
362
  const db = new sqlite.DatabaseSync(path);
337
363
  return { store: new SqliteTranscriptStore(db), durable: true };
338
364
  }
package/dist/types.d.ts CHANGED
@@ -283,8 +283,22 @@ export interface RunnerHarness {
283
283
  * account name or a token.
284
284
  */
285
285
  reason?: string;
286
+ /**
287
+ * The check of this harness DID NOT FINISH (it timed out), so `usable:
288
+ * false` here is not a statement about the harness. The runner checks again
289
+ * shortly. Only ever sent with `installed: true, usable: false`. A reader
290
+ * that does not know this field shows the entry as not usable with its
291
+ * reason, which says the same thing in words.
292
+ */
293
+ transient?: boolean;
286
294
  /** This runner can run a turn with it: it is also listed in `backends`. */
287
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;
288
302
  models?: RunnerHarnessModel[];
289
303
  modelSource?: RunnerHarnessModelSource;
290
304
  /** ISO time of the discovery pass that first found this state. */
@@ -0,0 +1,77 @@
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 is not installed. Run `npm install -g @anthropic-ai/claude-agent-sdk` on this machine.";
51
+ readonly sandbox: "A cloud sandbox runs Pi only.";
52
+ };
53
+ /**
54
+ * Directories to look for the SDK in, besides this package's own
55
+ * `node_modules`.
56
+ *
57
+ * WHY IT IS NOT SIMPLY A DEPENDENCY. In 20x the SDK ships inside the app. Here
58
+ * that would add its platform package — about 190 MB, a copy of the Claude
59
+ * binary — to EVERY install of the runner, the cloud sandbox included, for a
60
+ * harness most installs never use. The adapter already points the SDK at the
61
+ * `claude` the user installed, so only the SDK's own JavaScript is needed, and
62
+ * a machine that wants Claude Code installs it once, globally.
63
+ */
64
+ export declare function claudeSdkSearchRoots(env?: NodeJS.ProcessEnv, execPath?: string): string[];
65
+ /**
66
+ * Where the SDK can be loaded from, or null. Synchronous and cheap (a few
67
+ * file reads), so it can be asked on every capability report.
68
+ *
69
+ * `ownTree` is this package's own resolution, replaced in tests — this
70
+ * repository has the SDK as a dev dependency, so "not installed" could not be
71
+ * tested otherwise.
72
+ */
73
+ export declare function resolveClaudeSdk(roots?: readonly string[], ownTree?: () => string): string | null;
74
+ /** Import the SDK from wherever `resolveClaudeSdk` finds it. Throws when absent. */
75
+ export declare function importClaudeSdk(): Promise<unknown>;
76
+ /** Whether this runner can build an adapter for a vendor backend right now. */
77
+ export declare function vendorBackendBlocker(backend: string, runtime: string): string | null;
@@ -0,0 +1,167 @@
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.claudeSdkSearchRoots = claudeSdkSearchRoots;
29
+ exports.resolveClaudeSdk = resolveClaudeSdk;
30
+ exports.importClaudeSdk = importClaudeSdk;
31
+ exports.vendorBackendBlocker = vendorBackendBlocker;
32
+ const node_fs_1 = require("node:fs");
33
+ const node_module_1 = require("node:module");
34
+ const node_path_1 = require("node:path");
35
+ const node_url_1 = require("node:url");
36
+ exports.VENDOR_BACKENDS = ['codex', 'claude-code', 'cursor'];
37
+ function isVendorBackend(name) {
38
+ return exports.VENDOR_BACKENDS.includes(name);
39
+ }
40
+ /**
41
+ * The model id a vendor adapter is given, or undefined for "the harness's own
42
+ * default".
43
+ *
44
+ * An agent's model is stored as `{ provider, model }`. For a vendor harness the
45
+ * picker stores the HARNESS NAME as the provider (`codex` / `gpt-6-sol`), so a
46
+ * model that belongs to this harness is recognised by `provider === backend`
47
+ * and its bare id is what the adapter gets.
48
+ *
49
+ * ANY OTHER PROVIDER IS NOT PASSED ON. An agent switched from Pi to Codex can
50
+ * still carry `peakflo/gpt-4.1`; handing `gpt-4.1` to Codex would be a guess
51
+ * that the two vendors mean the same thing by a name. The harness then runs
52
+ * its own default model, which is what its CLI would do.
53
+ */
54
+ function vendorModelId(model, backend) {
55
+ const name = (model.model ?? '').trim();
56
+ const provider = (model.provider ?? '').trim();
57
+ return name && provider === backend ? name : undefined;
58
+ }
59
+ /* -------------------------------------------------------------------------- */
60
+ /* The Claude Agent SDK */
61
+ /* -------------------------------------------------------------------------- */
62
+ exports.CLAUDE_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk';
63
+ /**
64
+ * Why a harness that is installed and signed in still cannot be run by this
65
+ * runner. Fixed sentences, like every other reason in the report.
66
+ */
67
+ exports.NOT_RUNNABLE_REASONS = {
68
+ claudeSdkMissing: 'The Claude Agent SDK is not installed. Run `npm install -g @anthropic-ai/claude-agent-sdk` on this machine.',
69
+ sandbox: 'A cloud sandbox runs Pi only.'
70
+ };
71
+ /**
72
+ * Directories to look for the SDK in, besides this package's own
73
+ * `node_modules`.
74
+ *
75
+ * WHY IT IS NOT SIMPLY A DEPENDENCY. In 20x the SDK ships inside the app. Here
76
+ * that would add its platform package — about 190 MB, a copy of the Claude
77
+ * binary — to EVERY install of the runner, the cloud sandbox included, for a
78
+ * harness most installs never use. The adapter already points the SDK at the
79
+ * `claude` the user installed, so only the SDK's own JavaScript is needed, and
80
+ * a machine that wants Claude Code installs it once, globally.
81
+ */
82
+ function claudeSdkSearchRoots(env = process.env, execPath = process.execPath) {
83
+ const roots = [
84
+ env.PF_CLAUDE_AGENT_SDK_DIR,
85
+ // `npm install -g` beside the running node: <prefix>/lib/node_modules
86
+ (0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.dirname)(execPath)), 'lib', 'node_modules'),
87
+ // Windows: <prefix>\node_modules
88
+ (0, node_path_1.join)((0, node_path_1.dirname)(execPath), 'node_modules'),
89
+ '/opt/homebrew/lib/node_modules',
90
+ '/usr/local/lib/node_modules'
91
+ ];
92
+ return [...new Set(roots.filter((root) => Boolean(root)))];
93
+ }
94
+ /** The file a package directory loads as, read from its own package.json. */
95
+ function packageEntry(directory) {
96
+ try {
97
+ const manifest = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(directory, 'package.json'), 'utf8'));
98
+ const root = manifest.exports && typeof manifest.exports === 'object'
99
+ ? (manifest.exports['.'] ??
100
+ manifest.exports)
101
+ : manifest.exports;
102
+ const fromExports = typeof root === 'string'
103
+ ? root
104
+ : root && typeof root === 'object'
105
+ ? (root.import ??
106
+ root.default)
107
+ : undefined;
108
+ const entry = [
109
+ fromExports,
110
+ manifest.module,
111
+ manifest.main,
112
+ 'index.js'
113
+ ].find((candidate) => typeof candidate === 'string');
114
+ const file = entry ? (0, node_path_1.join)(directory, entry) : null;
115
+ return file && (0, node_fs_1.existsSync)(file) ? file : null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /**
122
+ * Where the SDK can be loaded from, or null. Synchronous and cheap (a few
123
+ * file reads), so it can be asked on every capability report.
124
+ *
125
+ * `ownTree` is this package's own resolution, replaced in tests — this
126
+ * repository has the SDK as a dev dependency, so "not installed" could not be
127
+ * tested otherwise.
128
+ */
129
+ function resolveClaudeSdk(roots = claudeSdkSearchRoots(), ownTree = () => (0, node_module_1.createRequire)(__filename).resolve(exports.CLAUDE_SDK_PACKAGE)) {
130
+ try {
131
+ return ownTree();
132
+ }
133
+ catch {
134
+ // Not in this package's own tree.
135
+ }
136
+ for (const root of roots) {
137
+ const entry = packageEntry((0, node_path_1.join)(root, exports.CLAUDE_SDK_PACKAGE));
138
+ if (entry)
139
+ return entry;
140
+ }
141
+ return null;
142
+ }
143
+ /** Import the SDK from wherever `resolveClaudeSdk` finds it. Throws when absent. */
144
+ async function importClaudeSdk() {
145
+ const resolved = resolveClaudeSdk();
146
+ if (!resolved) {
147
+ throw new Error(exports.NOT_RUNNABLE_REASONS.claudeSdkMissing);
148
+ }
149
+ /*
150
+ * `new Function` keeps this a REAL dynamic import in the compiled CommonJS
151
+ * output. Written as `import(x)`, TypeScript turns it into `require(x)`,
152
+ * and the SDK is an ES module that `require` cannot load on every Node.
153
+ */
154
+ const dynamicImport = new Function('specifier', 'return import(specifier)');
155
+ return dynamicImport((0, node_url_1.pathToFileURL)(resolved).href);
156
+ }
157
+ /** Whether this runner can build an adapter for a vendor backend right now. */
158
+ function vendorBackendBlocker(backend, runtime) {
159
+ if (!isVendorBackend(backend))
160
+ return null;
161
+ if (runtime === 'sandbox')
162
+ return exports.NOT_RUNNABLE_REASONS.sandbox;
163
+ if (backend === 'claude-code' && !resolveClaudeSdk()) {
164
+ return exports.NOT_RUNNABLE_REASONS.claudeSdkMissing;
165
+ }
166
+ return null;
167
+ }
package/dist/version.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** The version of THIS package, as installed. */
2
2
  export declare function getHarnessVersion(): string;
3
+ /** `engines.node` of THIS package (e.g. `>=22.19`), or null when unreadable. */
4
+ export declare function getNodeFloor(): string | null;
3
5
  /**
4
6
  * The version of the SANDBOX TOOLS layer (gh, @peculiar/x509, ...) currently
5
7
  * installed, or null when nothing was ever synced — a self-hosted runner or