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