@peakflo/agent-harness 0.2.9-rc.2 → 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 +18 -3
- package/dist/adapter-resolver.d.ts +61 -0
- package/dist/adapter-resolver.js +243 -0
- package/dist/adapters/claude-code-adapter.js +5 -1
- package/dist/cli.js +2 -22
- package/dist/harness.d.ts +24 -0
- package/dist/harness.js +57 -2
- package/dist/runtime/session-runner.d.ts +27 -1
- package/dist/runtime/session-runner.js +66 -3
- package/dist/types.d.ts +6 -0
- package/dist/vendor-backends.d.ts +77 -0
- package/dist/vendor-backends.js +167 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,9 +46,24 @@ 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
52
67
|
|
|
53
68
|
**When it looks.** Once after start, without delaying registration, and then
|
|
54
69
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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,51 @@ 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
|
+
capabilities.backends.push({
|
|
1039
|
+
name: harness.name,
|
|
1040
|
+
version: harness.version ?? null,
|
|
1041
|
+
...(harness.models && harness.models.length > 0
|
|
1042
|
+
? { models: harness.models.map(({ id, name }) => ({ id, name })) }
|
|
1043
|
+
: {})
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
return blocked;
|
|
1047
|
+
}
|
|
993
1048
|
harnessDiscoveryEnabled() {
|
|
994
1049
|
return (this.options.runtime !== 'sandbox' &&
|
|
995
1050
|
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(
|
|
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(
|
|
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),
|
|
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:
|
|
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,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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.9-rc.
|
|
3
|
+
"version": "0.2.9-rc.3",
|
|
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",
|