@byok-sdk/client 0.1.0 → 0.1.1
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 +9 -0
- package/dist/adapters/codex/resolve-bin.d.ts +3 -4
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +1921 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/pi/resolve-bin.d.ts +12 -22
- package/dist/bin/byok-agent.js +91 -117
- package/dist/bin/byok-agent.js.map +1 -1
- package/dist/index.js +72 -97
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
|
@@ -0,0 +1,1921 @@
|
|
|
1
|
+
import { execFile, spawn, spawnSync } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { promises, realpathSync } from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path2 from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import 'readline';
|
|
8
|
+
|
|
9
|
+
// src/adapters/pi/pi-adapter.ts
|
|
10
|
+
|
|
11
|
+
// src/types.ts
|
|
12
|
+
var PolicyUnsupportedError = class extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "PolicyUnsupportedError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var SteerUnsupportedError = class extends Error {
|
|
19
|
+
/** The `RuntimeAdapter.id` that cannot steer (e.g. `claude`, `codex`). */
|
|
20
|
+
runtimeId;
|
|
21
|
+
constructor(runtimeId, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "SteerUnsupportedError";
|
|
24
|
+
this.runtimeId = runtimeId;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/adapters/pi/resolve-bin.ts
|
|
29
|
+
var PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
|
30
|
+
function resolvePiBin() {
|
|
31
|
+
const override = process.env.BYOK_PI_BIN;
|
|
32
|
+
if (override) {
|
|
33
|
+
return { command: override, source: "path" };
|
|
34
|
+
}
|
|
35
|
+
return { command: "pi", source: "path" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/adapters/pi/permission-mapping.ts
|
|
39
|
+
var READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
40
|
+
var DEFAULT_ACTIVE_TOOLS = ["read", "bash", "edit", "write"];
|
|
41
|
+
function mapPermissionPolicyToPiArgs(policy) {
|
|
42
|
+
if (policy.network === false) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
args: [],
|
|
46
|
+
reason: "policy requires network:false, which the pi adapter cannot enforce (pi has no network sandbox)"
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (policy.mode === "confirm" || policy.mode === "plan") {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
args: [],
|
|
53
|
+
reason: `pi adapter cannot express permission mode "${policy.mode}" (no built-in per-call approval gate or plan-only mode without a custom extension)`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const denyTools = policy.denyTools ?? [];
|
|
57
|
+
if (policy.mode === "readonly") {
|
|
58
|
+
const base = policy.allowTools ? policy.allowTools.filter((tool) => READONLY_TOOLS.includes(tool)) : [...READONLY_TOOLS];
|
|
59
|
+
const effective = subtractDenied(base, denyTools);
|
|
60
|
+
return { ok: true, args: effective.length === 0 ? ["--no-tools"] : ["--tools", effective.join(",")] };
|
|
61
|
+
}
|
|
62
|
+
if (denyTools.length > 0) {
|
|
63
|
+
const base = policy.allowTools && policy.allowTools.length > 0 ? policy.allowTools : [...DEFAULT_ACTIVE_TOOLS];
|
|
64
|
+
const effective = subtractDenied(base, denyTools);
|
|
65
|
+
return { ok: true, args: effective.length === 0 ? ["--no-tools"] : ["--tools", effective.join(",")] };
|
|
66
|
+
}
|
|
67
|
+
if (policy.allowTools && policy.allowTools.length > 0) {
|
|
68
|
+
return { ok: true, args: ["--tools", policy.allowTools.join(",")] };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, args: [] };
|
|
71
|
+
}
|
|
72
|
+
function subtractDenied(tools, denyTools) {
|
|
73
|
+
const denied = new Set(denyTools);
|
|
74
|
+
return tools.filter((tool) => !denied.has(tool));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/adapters/pi/events.ts
|
|
78
|
+
function mapPiMessageToAgentEvent(msg) {
|
|
79
|
+
switch (msg.type) {
|
|
80
|
+
case "message_update": {
|
|
81
|
+
const delta = msg.assistantMessageEvent;
|
|
82
|
+
if (delta?.type === "text_delta" && typeof delta.delta === "string") {
|
|
83
|
+
return { type: "progress", text: delta.delta };
|
|
84
|
+
}
|
|
85
|
+
return void 0;
|
|
86
|
+
}
|
|
87
|
+
case "tool_execution_start": {
|
|
88
|
+
if (typeof msg.toolName !== "string") return void 0;
|
|
89
|
+
return { type: "tool_use", tool: msg.toolName, input: msg.args };
|
|
90
|
+
}
|
|
91
|
+
case "tool_execution_end": {
|
|
92
|
+
if (typeof msg.toolName !== "string") return void 0;
|
|
93
|
+
return {
|
|
94
|
+
type: "tool_result",
|
|
95
|
+
tool: msg.toolName,
|
|
96
|
+
output: { result: msg.result, isError: msg.isError === true }
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
case "agent_end":
|
|
100
|
+
return { type: "turn_end" };
|
|
101
|
+
/**
|
|
102
|
+
* `artifact` is NOT a real pi RPC message — pi's own `write` tool only
|
|
103
|
+
* ever surfaces as `tool_execution_start`/`tool_execution_end` (see
|
|
104
|
+
* `docs/tools/write.js` in the installed package), and neither carries
|
|
105
|
+
* enough on its own at the `_end` message (no `path`) for this stateless,
|
|
106
|
+
* one-message-at-a-time mapper to correlate back to a written file
|
|
107
|
+
* without introducing per-toolCallId state. This case exists purely so
|
|
108
|
+
* the `fake-pi.mjs` test/e2e fixture (M1-4 blob-path acceptance run) has
|
|
109
|
+
* a way to simulate "the runtime wrote a file and is reporting it as an
|
|
110
|
+
* artifact" — real pi emits nothing today that reaches this branch, so
|
|
111
|
+
* production traffic through this adapter never takes it. Revisit if a
|
|
112
|
+
* future pi release adds a native artifact-producing message, or if this
|
|
113
|
+
* needs correlating to a real `write` tool call.
|
|
114
|
+
*/
|
|
115
|
+
case "artifact": {
|
|
116
|
+
if (typeof msg.name !== "string" || typeof msg.contentType !== "string") return void 0;
|
|
117
|
+
return { type: "artifact", name: msg.name, contentType: msg.contentType };
|
|
118
|
+
}
|
|
119
|
+
case "extension_error":
|
|
120
|
+
return { type: "error", message: typeof msg.error === "string" ? msg.error : "pi extension error" };
|
|
121
|
+
case "auto_retry_end":
|
|
122
|
+
if (msg.success === false) {
|
|
123
|
+
return {
|
|
124
|
+
type: "error",
|
|
125
|
+
message: typeof msg.finalError === "string" ? msg.finalError : "pi auto-retry exhausted"
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return void 0;
|
|
129
|
+
// Routine pi session/turn/streaming bookkeeping with no `AgentEvent`
|
|
130
|
+
// equivalent — see `ROUTINE_PI_EVENT_TYPES` below, which mirrors this
|
|
131
|
+
// list so `PiSession`'s unmapped-frame accounting (rpc-client.ts's
|
|
132
|
+
// `recordUnmappedFrame`) can tell "known, expected, silently ignored"
|
|
133
|
+
// apart from "genuinely never seen before" (falls to `default` below).
|
|
134
|
+
case "agent_start":
|
|
135
|
+
case "turn_start":
|
|
136
|
+
case "turn_end":
|
|
137
|
+
// pi's own per-LLM-turn boundary, not ours — see `agent_end` above
|
|
138
|
+
case "message_start":
|
|
139
|
+
case "message_end":
|
|
140
|
+
case "tool_execution_update":
|
|
141
|
+
case "queue_update":
|
|
142
|
+
case "compaction_start":
|
|
143
|
+
case "compaction_end":
|
|
144
|
+
case "auto_retry_start":
|
|
145
|
+
case "session_info_changed":
|
|
146
|
+
case "thinking_level_changed":
|
|
147
|
+
return void 0;
|
|
148
|
+
default:
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
var ROUTINE_PI_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
153
|
+
"agent_start",
|
|
154
|
+
"turn_start",
|
|
155
|
+
"turn_end",
|
|
156
|
+
"message_start",
|
|
157
|
+
"message_end",
|
|
158
|
+
"tool_execution_update",
|
|
159
|
+
"queue_update",
|
|
160
|
+
"compaction_start",
|
|
161
|
+
"compaction_end",
|
|
162
|
+
"auto_retry_start",
|
|
163
|
+
"session_info_changed",
|
|
164
|
+
"thinking_level_changed"
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
// src/util/async-queue.ts
|
|
168
|
+
var AsyncQueueOverflowError = class extends Error {
|
|
169
|
+
constructor(capacity) {
|
|
170
|
+
super(
|
|
171
|
+
`AsyncQueue exceeded its capacity of ${capacity} buffered item(s) \u2014 failing fast rather than growing unbounded, silently dropping events, or blocking push()`
|
|
172
|
+
);
|
|
173
|
+
this.capacity = capacity;
|
|
174
|
+
this.name = "AsyncQueueOverflowError";
|
|
175
|
+
}
|
|
176
|
+
capacity;
|
|
177
|
+
};
|
|
178
|
+
var DEFAULT_ASYNC_QUEUE_CAPACITY = 1e4;
|
|
179
|
+
var AsyncQueue = class {
|
|
180
|
+
constructor(capacity = DEFAULT_ASYNC_QUEUE_CAPACITY) {
|
|
181
|
+
this.capacity = capacity;
|
|
182
|
+
}
|
|
183
|
+
capacity;
|
|
184
|
+
buffered = [];
|
|
185
|
+
waiters = [];
|
|
186
|
+
ended = false;
|
|
187
|
+
failure;
|
|
188
|
+
push(item) {
|
|
189
|
+
if (this.ended || this.failure) return;
|
|
190
|
+
const waiter = this.waiters.shift();
|
|
191
|
+
if (waiter) {
|
|
192
|
+
waiter.resolve({ value: item, done: false });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (this.buffered.length >= this.capacity) {
|
|
196
|
+
this.fail(new AsyncQueueOverflowError(this.capacity));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.buffered.push(item);
|
|
200
|
+
}
|
|
201
|
+
end() {
|
|
202
|
+
if (this.ended || this.failure) return;
|
|
203
|
+
this.ended = true;
|
|
204
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
205
|
+
waiter.resolve({ value: void 0, done: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Transitions the queue into its permanent overflow state: rejects every currently-pending waiter and, from then on, every `next()` call (checked ahead of `ended` — see the class doc comment on why the error must win). */
|
|
209
|
+
fail(error) {
|
|
210
|
+
if (this.ended || this.failure) return;
|
|
211
|
+
this.failure = error;
|
|
212
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
213
|
+
waiter.reject(error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
[Symbol.asyncIterator]() {
|
|
217
|
+
return {
|
|
218
|
+
next: () => {
|
|
219
|
+
if (this.failure) {
|
|
220
|
+
return Promise.reject(this.failure);
|
|
221
|
+
}
|
|
222
|
+
if (this.buffered.length > 0) {
|
|
223
|
+
return Promise.resolve({ value: this.buffered.shift(), done: false });
|
|
224
|
+
}
|
|
225
|
+
if (this.ended) {
|
|
226
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
227
|
+
}
|
|
228
|
+
return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/adapters/pi/rpc-client.ts
|
|
235
|
+
var STDERR_RING_CAPACITY = 20;
|
|
236
|
+
var DIALOG_UI_METHODS = /* @__PURE__ */ new Set(["select", "confirm", "input", "editor"]);
|
|
237
|
+
var PiRpcClient = class {
|
|
238
|
+
child;
|
|
239
|
+
buffer = "";
|
|
240
|
+
nextId = 1;
|
|
241
|
+
pending = /* @__PURE__ */ new Map();
|
|
242
|
+
eventQueue = new AsyncQueue();
|
|
243
|
+
closed = false;
|
|
244
|
+
exitError;
|
|
245
|
+
/** Bounded tail of recent stderr lines — pi discarded this entirely before (nothing ever read `child.stderr`), which is exactly why finding #1 (`Error: Unknown option: --session-id`, exit 1) had to be root-caused by hand instead of reading it off a thrown error. See `buildExitError`. */
|
|
246
|
+
stderrRing = [];
|
|
247
|
+
/** Count of pi RPC message types `PiSession` (pi-adapter.ts) has told us have no `AgentEvent` mapping and aren't routine bookkeeping — see `recordUnmappedFrame`. */
|
|
248
|
+
unmappedFrameCounts = /* @__PURE__ */ new Map();
|
|
249
|
+
constructor(options) {
|
|
250
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
251
|
+
this.child = spawnFn(options.command, options.args, {
|
|
252
|
+
cwd: options.cwd,
|
|
253
|
+
env: options.env,
|
|
254
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
255
|
+
});
|
|
256
|
+
this.child.stdout.setEncoding("utf8");
|
|
257
|
+
this.child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
258
|
+
this.child.stderr.setEncoding("utf8");
|
|
259
|
+
this.child.stderr.on("data", (chunk) => this.onStderr(chunk));
|
|
260
|
+
this.child.on("close", (code, signal) => {
|
|
261
|
+
this.onClosed(this.buildExitError(code, signal));
|
|
262
|
+
});
|
|
263
|
+
this.child.on("error", (err) => {
|
|
264
|
+
this.onClosed(err);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/** Send a command, resolved with its correlated `response` message. */
|
|
268
|
+
send(command) {
|
|
269
|
+
if (this.closed) {
|
|
270
|
+
return Promise.reject(this.exitError ?? new Error("pi process is closed"));
|
|
271
|
+
}
|
|
272
|
+
const id = command.id ?? `req-${this.nextId++}`;
|
|
273
|
+
const full = { ...command, id };
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
this.pending.set(id, { resolve, reject });
|
|
276
|
+
this.child.stdin.write(`${JSON.stringify(full)}
|
|
277
|
+
`, (err) => {
|
|
278
|
+
if (err) {
|
|
279
|
+
this.pending.delete(id);
|
|
280
|
+
reject(err);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** Every non-response, non-`extension_ui_request` line — the latter is answered directly by this client (see `respondToExtensionUiRequest`) and never enqueued. */
|
|
286
|
+
get events() {
|
|
287
|
+
return this.eventQueue;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Record a pi RPC message `type` that `PiSession` (pi-adapter.ts) decided
|
|
291
|
+
* has no `AgentEvent` mapping and isn't routine bookkeeping (see
|
|
292
|
+
* `events.ts`'s `ROUTINE_PI_EVENT_TYPES`) — i.e. genuinely unexpected
|
|
293
|
+
* traffic. Logs once per distinct type (not per occurrence, so a
|
|
294
|
+
* repeating unmapped type can't spam stdout); the running tally is also
|
|
295
|
+
* folded into this client's exit-time error message (`buildExitError`) so
|
|
296
|
+
* a post-mortem on a failed/hung task has it without needing separate log
|
|
297
|
+
* scraping. This is the exact mechanism that would have turned this
|
|
298
|
+
* task's root-cause hang (`agent_end` arriving with no mapping) into a
|
|
299
|
+
* one-line, immediate warning instead of a silent stall.
|
|
300
|
+
*/
|
|
301
|
+
recordUnmappedFrame(type) {
|
|
302
|
+
const next = (this.unmappedFrameCounts.get(type) ?? 0) + 1;
|
|
303
|
+
this.unmappedFrameCounts.set(type, next);
|
|
304
|
+
if (next === 1) {
|
|
305
|
+
console.warn(
|
|
306
|
+
`[byok/pi-adapter] pi emitted a frame type with no AgentEvent mapping: "${type}" (further occurrences of this type won't be logged individually)`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes pi itself spawned (e.g. bash). */
|
|
311
|
+
kill() {
|
|
312
|
+
if (this.closed) return;
|
|
313
|
+
const pid = this.child.pid;
|
|
314
|
+
if (process.platform === "win32" && pid !== void 0) {
|
|
315
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
|
|
316
|
+
} else {
|
|
317
|
+
this.child.kill("SIGTERM");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
onData(chunk) {
|
|
321
|
+
this.buffer += chunk;
|
|
322
|
+
let newlineIndex = this.buffer.indexOf("\n");
|
|
323
|
+
while (newlineIndex !== -1) {
|
|
324
|
+
let line = this.buffer.slice(0, newlineIndex);
|
|
325
|
+
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
326
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
327
|
+
if (line.length > 0) this.onLine(line);
|
|
328
|
+
newlineIndex = this.buffer.indexOf("\n");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
onLine(line) {
|
|
332
|
+
let msg;
|
|
333
|
+
try {
|
|
334
|
+
msg = JSON.parse(line);
|
|
335
|
+
} catch {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (msg.type === "response" && typeof msg.id === "string" && this.pending.has(msg.id)) {
|
|
339
|
+
const waiter = this.pending.get(msg.id);
|
|
340
|
+
this.pending.delete(msg.id);
|
|
341
|
+
waiter?.resolve(msg);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (msg.type === "extension_ui_request" && typeof msg.id === "string" && typeof msg.method === "string") {
|
|
345
|
+
this.respondToExtensionUiRequest(msg.id, msg.method);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
this.eventQueue.push(msg);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Answer pi's extension-UI blocking protocol headlessly (rpc.md's
|
|
352
|
+
* "Extension UI Protocol"). Fail-closed policy, stated explicitly because
|
|
353
|
+
* it's a security-relevant default, not an incidental one: this NEVER
|
|
354
|
+
* approves or picks a value on the caller's behalf — every dialog method
|
|
355
|
+
* (`select`/`confirm`/`input`/`editor`) gets `{cancelled: true}`, the one
|
|
356
|
+
* response shape rpc.md documents as valid for all four uniformly
|
|
357
|
+
* ("Dismiss any dialog method... the extension receives `undefined` (for
|
|
358
|
+
* select/input/editor) or `false` (for confirm)"). An extension asking
|
|
359
|
+
* e.g. `confirm("Delete everything?")` gets a firm decline, never a
|
|
360
|
+
* guessed approval — this adapter has no human in the loop to ask, and
|
|
361
|
+
* silently approving would defeat any extension that uses these dialogs
|
|
362
|
+
* specifically as a permission gate. Fire-and-forget methods
|
|
363
|
+
* (`notify`/`setStatus`/`setWidget`/`setTitle`/`set_editor_text`) get no
|
|
364
|
+
* reply at all — sending one would itself violate rpc.md ("Responses are
|
|
365
|
+
* sent for dialog methods only").
|
|
366
|
+
*/
|
|
367
|
+
respondToExtensionUiRequest(id, method) {
|
|
368
|
+
if (!DIALOG_UI_METHODS.has(method)) return;
|
|
369
|
+
this.child.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, cancelled: true })}
|
|
370
|
+
`);
|
|
371
|
+
}
|
|
372
|
+
onStderr(chunk) {
|
|
373
|
+
for (const rawLine of chunk.split("\n")) {
|
|
374
|
+
const line = rawLine.trim();
|
|
375
|
+
if (line.length === 0) continue;
|
|
376
|
+
this.stderrRing.push(line);
|
|
377
|
+
if (this.stderrRing.length > STDERR_RING_CAPACITY) this.stderrRing.shift();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* finding #1 ("bad flag → instant exit", e.g. the `--session-id`/
|
|
382
|
+
* `--exclude-tools` bugs this task fixes): the daemon used to report only
|
|
383
|
+
* `pi process exited (code=1, signal=null)` — accurate but useless for
|
|
384
|
+
* diagnosing *why* without re-running pi by hand with a raw JSONL logger,
|
|
385
|
+
* exactly as this task's own root-cause investigation had to. Folding in
|
|
386
|
+
* the stderr tail and any unmapped-frame tally makes that self-diagnosing
|
|
387
|
+
* from the thrown error alone.
|
|
388
|
+
*/
|
|
389
|
+
buildExitError(code, signal) {
|
|
390
|
+
const parts = [`pi process exited (code=${code}, signal=${signal})`];
|
|
391
|
+
if (this.stderrRing.length > 0) {
|
|
392
|
+
parts.push(`stderr: ${this.stderrRing.join(" | ")}`);
|
|
393
|
+
}
|
|
394
|
+
if (this.unmappedFrameCounts.size > 0) {
|
|
395
|
+
const summary = [...this.unmappedFrameCounts.entries()].map(([type, count]) => `${type}\xD7${count}`).join(", ");
|
|
396
|
+
parts.push(`unmapped frame types seen: ${summary}`);
|
|
397
|
+
}
|
|
398
|
+
return new Error(parts.join("; "));
|
|
399
|
+
}
|
|
400
|
+
onClosed(err) {
|
|
401
|
+
if (this.closed) return;
|
|
402
|
+
this.closed = true;
|
|
403
|
+
this.exitError = err;
|
|
404
|
+
for (const [, waiter] of this.pending) waiter.reject(err);
|
|
405
|
+
this.pending.clear();
|
|
406
|
+
this.eventQueue.end();
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// src/adapters/pi/pi-adapter.ts
|
|
411
|
+
var execFileAsync = promisify(execFile);
|
|
412
|
+
var DETECT_TIMEOUT_MS = 5e3;
|
|
413
|
+
function errorMessage(err) {
|
|
414
|
+
return err instanceof Error ? err.message : String(err);
|
|
415
|
+
}
|
|
416
|
+
var KNOWN_PROVIDER_ENV_VARS = [
|
|
417
|
+
"ANTHROPIC_API_KEY",
|
|
418
|
+
"ANTHROPIC_OAUTH_TOKEN",
|
|
419
|
+
"OPENAI_API_KEY",
|
|
420
|
+
"GEMINI_API_KEY",
|
|
421
|
+
"AZURE_OPENAI_API_KEY",
|
|
422
|
+
"DEEPSEEK_API_KEY",
|
|
423
|
+
"GROQ_API_KEY",
|
|
424
|
+
"MISTRAL_API_KEY",
|
|
425
|
+
"OPENROUTER_API_KEY",
|
|
426
|
+
"XAI_API_KEY",
|
|
427
|
+
// Confirmed against the installed pi's own docs/providers.md ("ZAI |
|
|
428
|
+
// `ZAI_API_KEY` | `zai`") and exercised live against real GLM traffic
|
|
429
|
+
// during this task's acceptance run — omitting it made `authPresent`
|
|
430
|
+
// silently false for a perfectly valid, working z.ai/GLM setup.
|
|
431
|
+
"ZAI_API_KEY"
|
|
432
|
+
];
|
|
433
|
+
var PiAdapter = class {
|
|
434
|
+
constructor(options = {}) {
|
|
435
|
+
this.options = options;
|
|
436
|
+
}
|
|
437
|
+
options;
|
|
438
|
+
id = "pi";
|
|
439
|
+
async detect() {
|
|
440
|
+
const bin = this.resolveBin();
|
|
441
|
+
try {
|
|
442
|
+
const { stdout, stderr } = await execFileAsync(bin.command, ["--version"], { timeout: DETECT_TIMEOUT_MS });
|
|
443
|
+
const version = stdout.trim() || stderr.trim();
|
|
444
|
+
const authPresent = KNOWN_PROVIDER_ENV_VARS.some((name) => process.env[name] !== void 0);
|
|
445
|
+
return { present: true, version, authPresent };
|
|
446
|
+
} catch {
|
|
447
|
+
return { present: false };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
capabilities() {
|
|
451
|
+
return { steer: true, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* M5: pi authenticates to its ~30 supported providers via env-var API
|
|
455
|
+
* keys — `detect()`'s own `authPresent` probe above checks this identical
|
|
456
|
+
* list — so these MUST keep flowing into pi's spawned process or pi auth
|
|
457
|
+
* breaks entirely. `KNOWN_PROVIDER_ENV_VARS` above is the single source
|
|
458
|
+
* of truth, reused here rather than duplicated. No `baseNames`: nothing
|
|
459
|
+
* in this adapter or `rpc-client.ts` reads a pi-specific config-discovery
|
|
460
|
+
* variable beyond the platform baseline (`daemon/environment.ts`).
|
|
461
|
+
*/
|
|
462
|
+
environmentRequirements() {
|
|
463
|
+
return { credentialNames: KNOWN_PROVIDER_ENV_VARS };
|
|
464
|
+
}
|
|
465
|
+
async start(task, ctx) {
|
|
466
|
+
if (typeof task.instruction !== "string") {
|
|
467
|
+
throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
|
|
468
|
+
}
|
|
469
|
+
const mapping = mapPermissionPolicyToPiArgs(ctx.policy);
|
|
470
|
+
if (!mapping.ok) {
|
|
471
|
+
throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
|
|
472
|
+
}
|
|
473
|
+
const bin = this.resolveBin();
|
|
474
|
+
const resumeSessionId = task.sessionRef;
|
|
475
|
+
const args = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
|
|
476
|
+
const rpc = new PiRpcClient({
|
|
477
|
+
command: bin.command,
|
|
478
|
+
args,
|
|
479
|
+
cwd: ctx.workspaceDir,
|
|
480
|
+
env: ctx.env,
|
|
481
|
+
spawnFn: this.options.spawnFn
|
|
482
|
+
});
|
|
483
|
+
const response = await rpc.send({ type: "prompt", message: task.instruction });
|
|
484
|
+
if (response.success === false) {
|
|
485
|
+
rpc.kill();
|
|
486
|
+
throw new Error(typeof response.error === "string" ? response.error : "pi rejected the initial prompt");
|
|
487
|
+
}
|
|
488
|
+
let sessionRef;
|
|
489
|
+
if (resumeSessionId) {
|
|
490
|
+
sessionRef = resumeSessionId;
|
|
491
|
+
} else {
|
|
492
|
+
try {
|
|
493
|
+
sessionRef = await resolveFreshSessionId(rpc);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
rpc.kill();
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return new PiSession(sessionRef, rpc);
|
|
500
|
+
}
|
|
501
|
+
resolveBin() {
|
|
502
|
+
return (this.options.resolveBin ?? resolvePiBin)();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
async function resolveFreshSessionId(rpc) {
|
|
506
|
+
let state;
|
|
507
|
+
try {
|
|
508
|
+
state = await rpc.send({ type: "get_state" });
|
|
509
|
+
} catch (err) {
|
|
510
|
+
throw new Error(`pi did not yield an authoritative session id (get_state failed): ${errorMessage(err)}`, {
|
|
511
|
+
cause: err
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
if (state.success === false) {
|
|
515
|
+
const reason = typeof state.error === "string" ? state.error : "get_state reported failure";
|
|
516
|
+
throw new Error(`pi did not yield an authoritative session id (get_state failed): ${reason}`);
|
|
517
|
+
}
|
|
518
|
+
const data = state.data;
|
|
519
|
+
if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
|
|
520
|
+
return data.sessionId;
|
|
521
|
+
}
|
|
522
|
+
throw new Error(
|
|
523
|
+
"pi did not yield an authoritative session id (get_state succeeded but reported no sessionId) \u2014 cannot mint a resumable session"
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
var PiSession = class {
|
|
527
|
+
constructor(sessionRef, rpc) {
|
|
528
|
+
this.sessionRef = sessionRef;
|
|
529
|
+
this.rpc = rpc;
|
|
530
|
+
}
|
|
531
|
+
sessionRef;
|
|
532
|
+
rpc;
|
|
533
|
+
get events() {
|
|
534
|
+
const rpc = this.rpc;
|
|
535
|
+
return {
|
|
536
|
+
[Symbol.asyncIterator]() {
|
|
537
|
+
const inner = rpc.events[Symbol.asyncIterator]();
|
|
538
|
+
return {
|
|
539
|
+
async next() {
|
|
540
|
+
for (; ; ) {
|
|
541
|
+
const { value, done } = await inner.next();
|
|
542
|
+
if (done) return { value: void 0, done: true };
|
|
543
|
+
const mapped = mapPiMessageToAgentEvent(value);
|
|
544
|
+
if (mapped) return { value: mapped, done: false };
|
|
545
|
+
if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
|
|
546
|
+
rpc.recordUnmappedFrame(value.type);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
async steer(text) {
|
|
555
|
+
await this.rpc.send({ type: "steer", message: text });
|
|
556
|
+
}
|
|
557
|
+
async followUp(task) {
|
|
558
|
+
if (typeof task.instruction !== "string") {
|
|
559
|
+
throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
|
|
560
|
+
}
|
|
561
|
+
await this.rpc.send({ type: "prompt", message: task.instruction, streamingBehavior: "followUp" });
|
|
562
|
+
}
|
|
563
|
+
async interrupt() {
|
|
564
|
+
await this.rpc.send({ type: "abort" });
|
|
565
|
+
}
|
|
566
|
+
async close() {
|
|
567
|
+
this.rpc.kill();
|
|
568
|
+
}
|
|
569
|
+
async resolveApproval() {
|
|
570
|
+
throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
// src/adapters/claude/resolve-bin.ts
|
|
575
|
+
function resolveClaudeBin() {
|
|
576
|
+
const override = process.env.BYOK_CLAUDE_BIN;
|
|
577
|
+
if (override) {
|
|
578
|
+
return { command: override, source: "env" };
|
|
579
|
+
}
|
|
580
|
+
return { command: "claude", source: "path" };
|
|
581
|
+
}
|
|
582
|
+
function resolveApprovalMcpBin() {
|
|
583
|
+
const override = process.env.BYOK_APPROVAL_MCP_BIN;
|
|
584
|
+
if (override) {
|
|
585
|
+
return { command: override, args: [], source: "env" };
|
|
586
|
+
}
|
|
587
|
+
const distBin = path2.join(path2.dirname(fileURLToPath(import.meta.url)), "bin", "byok-approval-mcp.js");
|
|
588
|
+
return { command: process.execPath, args: [distBin], source: "dist" };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/adapters/claude/permission-mapping.ts
|
|
592
|
+
var READONLY_TOOLS2 = ["Read", "Glob", "Grep"];
|
|
593
|
+
function mapPermissionPolicyToClaudeArgs(policy) {
|
|
594
|
+
if (policy.network === false) {
|
|
595
|
+
return {
|
|
596
|
+
ok: false,
|
|
597
|
+
args: [],
|
|
598
|
+
reason: "policy requires network:false, which the claude adapter cannot enforce (claude has no network sandbox for its Bash tool)"
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const denyTools = policy.denyTools ?? [];
|
|
602
|
+
if (policy.mode === "confirm") {
|
|
603
|
+
if (denyTools.length > 0) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
args: [],
|
|
607
|
+
reason: `claude adapter cannot reliably enforce denyTools ([${denyTools.join(", ")}]) under confirm mode: its only trustworthy tool-restriction mechanism (--tools) replaces the whole active set rather than subtracting from it, and this adapter has no reliable way to learn a target installation's full default tool set to compute "default minus denied" (this dev machine's own installed build exposes a non-vanilla, bespoke tool list) \u2014 refusing rather than risking a denial that silently doesn't hold`
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
const confirmArgs = ["--permission-mode", "default"];
|
|
611
|
+
if (policy.allowTools && policy.allowTools.length > 0) {
|
|
612
|
+
confirmArgs.push("--tools", policy.allowTools.join(","));
|
|
613
|
+
}
|
|
614
|
+
return { ok: true, args: confirmArgs, needsApprovalMcp: true };
|
|
615
|
+
}
|
|
616
|
+
if (policy.mode === "readonly") {
|
|
617
|
+
const base = policy.allowTools ? policy.allowTools.filter((tool) => READONLY_TOOLS2.includes(tool)) : [...READONLY_TOOLS2];
|
|
618
|
+
const effective = subtractDenied2(base, denyTools);
|
|
619
|
+
return { ok: true, args: ["--permission-mode", "default", "--tools", effective.join(",")] };
|
|
620
|
+
}
|
|
621
|
+
if (denyTools.length > 0) {
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
args: [],
|
|
625
|
+
reason: `claude adapter cannot reliably enforce denyTools ([${denyTools.join(", ")}]): its only trustworthy tool-restriction mechanism (--tools) replaces the whole active set rather than subtracting from it, and this adapter has no reliable way to learn a target installation's full default tool set to compute "default minus denied" (this dev machine's own installed build exposes a non-vanilla, bespoke tool list) \u2014 refusing rather than risking a denial that silently doesn't hold`
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
const permissionMode = policy.mode === "plan" ? "plan" : "acceptEdits";
|
|
629
|
+
const args = ["--permission-mode", permissionMode];
|
|
630
|
+
if (policy.allowTools && policy.allowTools.length > 0) {
|
|
631
|
+
args.push("--tools", policy.allowTools.join(","));
|
|
632
|
+
}
|
|
633
|
+
return { ok: true, args };
|
|
634
|
+
}
|
|
635
|
+
function subtractDenied2(tools, denyTools) {
|
|
636
|
+
const denied = new Set(denyTools);
|
|
637
|
+
return tools.filter((tool) => !denied.has(tool));
|
|
638
|
+
}
|
|
639
|
+
function createToolUseCorrelation() {
|
|
640
|
+
return { toolNameByUseId: /* @__PURE__ */ new Map() };
|
|
641
|
+
}
|
|
642
|
+
var ROUTINE_CLAUDE_SYSTEM_SUBTYPES = /* @__PURE__ */ new Set([
|
|
643
|
+
"init",
|
|
644
|
+
"hook_started",
|
|
645
|
+
"hook_response",
|
|
646
|
+
"thinking_tokens"
|
|
647
|
+
]);
|
|
648
|
+
var FILE_WRITING_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
|
|
649
|
+
var EXTENSION_CONTENT_TYPES = {
|
|
650
|
+
".txt": "text/plain",
|
|
651
|
+
".md": "text/markdown",
|
|
652
|
+
".json": "application/json",
|
|
653
|
+
".js": "text/javascript",
|
|
654
|
+
".mjs": "text/javascript",
|
|
655
|
+
".cjs": "text/javascript",
|
|
656
|
+
".ts": "text/plain",
|
|
657
|
+
".tsx": "text/plain",
|
|
658
|
+
".jsx": "text/plain",
|
|
659
|
+
".html": "text/html",
|
|
660
|
+
".css": "text/css",
|
|
661
|
+
".csv": "text/csv",
|
|
662
|
+
".py": "text/x-python",
|
|
663
|
+
".yaml": "application/yaml",
|
|
664
|
+
".yml": "application/yaml"
|
|
665
|
+
};
|
|
666
|
+
function guessContentType(filePath) {
|
|
667
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
668
|
+
return EXTENSION_CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
669
|
+
}
|
|
670
|
+
function mapAssistant(msg, correlation) {
|
|
671
|
+
const message = msg.message;
|
|
672
|
+
const content = message?.content;
|
|
673
|
+
if (!Array.isArray(content)) return { events: [] };
|
|
674
|
+
const events = [];
|
|
675
|
+
let unmappedLabel;
|
|
676
|
+
for (const raw of content) {
|
|
677
|
+
const block = raw;
|
|
678
|
+
switch (block.type) {
|
|
679
|
+
case "text":
|
|
680
|
+
if (typeof block.text === "string") {
|
|
681
|
+
events.push({ type: "progress", text: block.text });
|
|
682
|
+
}
|
|
683
|
+
break;
|
|
684
|
+
case "tool_use":
|
|
685
|
+
if (typeof block.id === "string" && typeof block.name === "string") {
|
|
686
|
+
correlation.toolNameByUseId.set(block.id, block.name);
|
|
687
|
+
events.push({ type: "tool_use", tool: block.name, input: block.input });
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
// Deliberately NOT mapped to `progress` — mirrors pi's own choice to
|
|
691
|
+
// ignore `thinking_delta` sub-events (see `../pi/events.ts`). Two
|
|
692
|
+
// independent reasons: (1) the daemon's `task-runner.ts` folds every
|
|
693
|
+
// `progress` event's text into `task.complete.summary` verbatim —
|
|
694
|
+
// surfacing raw model reasoning there would leak internal
|
|
695
|
+
// chain-of-thought to whatever SaaS embeds this SDK; (2) on several
|
|
696
|
+
// current-generation models this content is empty by design anyway
|
|
697
|
+
// (`display: "omitted"` is the default — see the claude-api skill's
|
|
698
|
+
// "Thinking & Effort" reference) even though it was NOT empty in this
|
|
699
|
+
// task's own live captures against `claude-haiku-4-5`. `redacted_thinking`
|
|
700
|
+
// is a documented Anthropic Messages API block type never observed
|
|
701
|
+
// live here; treated identically for the same reasoning.
|
|
702
|
+
case "thinking":
|
|
703
|
+
case "redacted_thinking":
|
|
704
|
+
break;
|
|
705
|
+
default:
|
|
706
|
+
unmappedLabel = unmappedLabel ?? `assistant-block:${String(block.type)}`;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return { events, unmappedLabel };
|
|
710
|
+
}
|
|
711
|
+
function mapUser(msg, correlation, options) {
|
|
712
|
+
const message = msg.message;
|
|
713
|
+
const content = message?.content;
|
|
714
|
+
if (!Array.isArray(content)) return { events: [] };
|
|
715
|
+
const events = [];
|
|
716
|
+
let unmappedLabel;
|
|
717
|
+
for (const raw of content) {
|
|
718
|
+
const block = raw;
|
|
719
|
+
if (block.type !== "tool_result") {
|
|
720
|
+
unmappedLabel = unmappedLabel ?? `user-block:${String(block.type)}`;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : void 0;
|
|
724
|
+
const tool = toolUseId && correlation.toolNameByUseId.get(toolUseId) || "unknown";
|
|
725
|
+
const isError = block.is_error === true;
|
|
726
|
+
events.push({ type: "tool_result", tool, output: { content: block.content, isError } });
|
|
727
|
+
if (!isError && FILE_WRITING_TOOLS.has(tool)) {
|
|
728
|
+
const artifact = tryBuildArtifactEvent(msg, options.workspaceDir);
|
|
729
|
+
if (artifact) events.push(artifact);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return { events, unmappedLabel };
|
|
733
|
+
}
|
|
734
|
+
function tryBuildArtifactEvent(msg, workspaceDir) {
|
|
735
|
+
const toolUseResult = msg.tool_use_result;
|
|
736
|
+
const filePath = toolUseResult && typeof toolUseResult.filePath === "string" ? toolUseResult.filePath : void 0;
|
|
737
|
+
if (!filePath) return void 0;
|
|
738
|
+
const realWorkspaceDir = tryRealpath(workspaceDir) ?? workspaceDir;
|
|
739
|
+
const fileDir = path2.dirname(filePath);
|
|
740
|
+
const realFileDir = tryRealpath(fileDir) ?? fileDir;
|
|
741
|
+
const realFilePath = path2.join(realFileDir, path2.basename(filePath));
|
|
742
|
+
const relative = path2.relative(realWorkspaceDir, realFilePath);
|
|
743
|
+
if (relative === "" || relative.startsWith("..") || path2.isAbsolute(relative)) {
|
|
744
|
+
return void 0;
|
|
745
|
+
}
|
|
746
|
+
return { type: "artifact", name: relative, contentType: guessContentType(filePath) };
|
|
747
|
+
}
|
|
748
|
+
function tryRealpath(candidate) {
|
|
749
|
+
try {
|
|
750
|
+
return realpathSync(candidate);
|
|
751
|
+
} catch {
|
|
752
|
+
return void 0;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function mapResult(msg) {
|
|
756
|
+
const usageEvent = extractClaudeUsageEvent(msg.usage);
|
|
757
|
+
if (msg.is_error === false) {
|
|
758
|
+
const events2 = usageEvent ? [usageEvent, { type: "turn_end" }] : [{ type: "turn_end" }];
|
|
759
|
+
return { events: events2 };
|
|
760
|
+
}
|
|
761
|
+
const errors = Array.isArray(msg.errors) ? msg.errors.filter((e) => typeof e === "string") : [];
|
|
762
|
+
const diagnostic = errors.length > 0 ? errors.join("; ") : typeof msg.result === "string" && msg.result.length > 0 ? msg.result : void 0;
|
|
763
|
+
const message = msg.is_error === true ? diagnostic ?? "claude reported an error result" : (
|
|
764
|
+
// M4 hardening: a missing/invalid `is_error` is already fail-closed
|
|
765
|
+
// (treated as failure, never inferred as success — see above), but
|
|
766
|
+
// this branch used to report ONLY the generic fallback string, even
|
|
767
|
+
// when the frame actually carried a perfectly usable `result`/
|
|
768
|
+
// `errors` payload (real diagnostic content is only read above when
|
|
769
|
+
// `is_error === true` EXACTLY, so a malformed-but-otherwise-populated
|
|
770
|
+
// frame silently lost it). Appending `diagnostic` here (when
|
|
771
|
+
// present) preserves the fail-closed verdict — this is still always
|
|
772
|
+
// an `error` AgentEvent — while no longer discarding whatever the
|
|
773
|
+
// frame actually said. Truncated defensively: this is an
|
|
774
|
+
// unexpected/malformed frame shape, not the normal error path, so
|
|
775
|
+
// the content could in principle be arbitrarily large.
|
|
776
|
+
diagnostic ? `claude result frame had a missing/invalid is_error flag (got ${JSON.stringify(msg.is_error)}) \u2014 treating as failure, fail-closed; diagnostic content on the frame: ${truncateResultDiagnostic(diagnostic)}` : `claude result frame had a missing/invalid is_error flag (got ${JSON.stringify(msg.is_error)}) \u2014 treating as failure, fail-closed`
|
|
777
|
+
);
|
|
778
|
+
const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
|
|
779
|
+
return { events };
|
|
780
|
+
}
|
|
781
|
+
var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
|
|
782
|
+
function truncateResultDiagnostic(text) {
|
|
783
|
+
return text.length > RESULT_DIAGNOSTIC_MAX_CHARS ? `${text.slice(0, RESULT_DIAGNOSTIC_MAX_CHARS)}\u2026 [truncated]` : text;
|
|
784
|
+
}
|
|
785
|
+
function extractClaudeUsageEvent(rawUsage) {
|
|
786
|
+
if (!rawUsage || typeof rawUsage !== "object") return void 0;
|
|
787
|
+
const usage = rawUsage;
|
|
788
|
+
const inputTokens = toNonNegativeInt(usage.input_tokens);
|
|
789
|
+
const cachedInputTokens = toNonNegativeInt(usage.cache_read_input_tokens);
|
|
790
|
+
const outputTokens = toNonNegativeInt(usage.output_tokens);
|
|
791
|
+
if (inputTokens === void 0 && cachedInputTokens === void 0 && outputTokens === void 0) {
|
|
792
|
+
return void 0;
|
|
793
|
+
}
|
|
794
|
+
const event = { type: "usage" };
|
|
795
|
+
if (inputTokens !== void 0) event.inputTokens = inputTokens;
|
|
796
|
+
if (cachedInputTokens !== void 0) event.cachedInputTokens = cachedInputTokens;
|
|
797
|
+
if (outputTokens !== void 0) event.outputTokens = outputTokens;
|
|
798
|
+
return event;
|
|
799
|
+
}
|
|
800
|
+
function toNonNegativeInt(value) {
|
|
801
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
|
|
802
|
+
}
|
|
803
|
+
function mapClaudeMessageToAgentEvents(msg, correlation, options) {
|
|
804
|
+
switch (msg.type) {
|
|
805
|
+
case "assistant":
|
|
806
|
+
return mapAssistant(msg, correlation);
|
|
807
|
+
case "user":
|
|
808
|
+
return mapUser(msg, correlation, options);
|
|
809
|
+
case "result":
|
|
810
|
+
return mapResult(msg);
|
|
811
|
+
case "system": {
|
|
812
|
+
const subtype = typeof msg.subtype === "string" ? msg.subtype : void 0;
|
|
813
|
+
if (subtype && ROUTINE_CLAUDE_SYSTEM_SUBTYPES.has(subtype)) return { events: [] };
|
|
814
|
+
return { events: [], unmappedLabel: `system:${subtype ?? "unknown"}` };
|
|
815
|
+
}
|
|
816
|
+
// Per-turn rate-limit-window bookkeeping, unconditionally emitted
|
|
817
|
+
// alongside every result — no `AgentEvent` equivalent, routine.
|
|
818
|
+
case "rate_limit_event":
|
|
819
|
+
return { events: [] };
|
|
820
|
+
default:
|
|
821
|
+
return { events: [], unmappedLabel: `top-level:${String(msg.type)}` };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
var STDERR_RING_CAPACITY2 = 20;
|
|
825
|
+
var ClaudeProcessClient = class {
|
|
826
|
+
child;
|
|
827
|
+
buffer = "";
|
|
828
|
+
eventQueue = new AsyncQueue();
|
|
829
|
+
closed = false;
|
|
830
|
+
exitError;
|
|
831
|
+
stderrRing = [];
|
|
832
|
+
unmappedFrameCounts = /* @__PURE__ */ new Map();
|
|
833
|
+
sessionId;
|
|
834
|
+
initWaiter;
|
|
835
|
+
constructor(options) {
|
|
836
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
837
|
+
this.child = spawnFn(options.command, options.args, {
|
|
838
|
+
cwd: options.cwd,
|
|
839
|
+
env: options.env,
|
|
840
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
841
|
+
});
|
|
842
|
+
this.child.stdout.setEncoding("utf8");
|
|
843
|
+
this.child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
844
|
+
this.child.stderr.setEncoding("utf8");
|
|
845
|
+
this.child.stderr.on("data", (chunk) => this.onStderr(chunk));
|
|
846
|
+
this.child.on("close", (code, signal) => {
|
|
847
|
+
this.onClosed(this.buildExitError(code, signal));
|
|
848
|
+
});
|
|
849
|
+
this.child.on("error", (err) => {
|
|
850
|
+
this.onClosed(err);
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Write a new user turn onto stdin (`--input-format stream-json`'s wire
|
|
855
|
+
* shape: `{"type":"user","message":{"role":"user","content":[{"type":
|
|
856
|
+
* "text","text":...}]}}`). Used identically for the very first turn
|
|
857
|
+
* (`ClaudeAdapter.start()`) and any later same-session turn
|
|
858
|
+
* (`ClaudeSession.followUp()`) — empirically confirmed live that claude
|
|
859
|
+
* keeps a `--input-format stream-json` process alive across multiple
|
|
860
|
+
* sequential turns on ONE persistent process/session (same `session_id`
|
|
861
|
+
* reported on each turn's own `system/init` and `result` frames), only
|
|
862
|
+
* exiting when stdin is closed or the process is killed. This is the
|
|
863
|
+
* mechanism `followUp()` relies on instead of spawning a fresh
|
|
864
|
+
* `--resume`'d process per follow-up.
|
|
865
|
+
*/
|
|
866
|
+
writeUserMessage(text) {
|
|
867
|
+
if (this.closed) {
|
|
868
|
+
throw this.exitError ?? new Error("claude process is closed");
|
|
869
|
+
}
|
|
870
|
+
const line = `${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })}
|
|
871
|
+
`;
|
|
872
|
+
this.child.stdin.write(line, (err) => {
|
|
873
|
+
if (err) {
|
|
874
|
+
console.error(`[byok/claude-adapter] failed to write to claude stdin: ${err.message}`);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Resolves with claude's own `session_id` once its `system/init` frame
|
|
880
|
+
* arrives (see this class's doc comment for why this exists at all).
|
|
881
|
+
* Idempotent: once resolved, further calls resolve immediately with the
|
|
882
|
+
* same id; if the process already closed before init ever arrived,
|
|
883
|
+
* every call rejects with that same exit error.
|
|
884
|
+
*/
|
|
885
|
+
waitForInit() {
|
|
886
|
+
if (this.sessionId !== void 0) return Promise.resolve(this.sessionId);
|
|
887
|
+
if (this.closed) return Promise.reject(this.exitError ?? new Error("claude process is closed"));
|
|
888
|
+
return new Promise((resolve, reject) => {
|
|
889
|
+
this.initWaiter = { resolve, reject };
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
/** Every parsed stream-json line — `system/init` is consumed internally (see `waitForInit`) but is also forwarded here like any other frame, so routine-frame accounting in `ClaudeSession`'s mapper stays uniform. */
|
|
893
|
+
get events() {
|
|
894
|
+
return this.eventQueue;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Record a claude stream-json frame/subtype/content-block label that
|
|
898
|
+
* `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
|
|
899
|
+
* no `AgentEvent` mapping and isn't routine bookkeeping (see
|
|
900
|
+
* `events.ts`'s `MapClaudeMessageResult.unmappedLabel` doc comment) —
|
|
901
|
+
* i.e. genuinely unexpected traffic. Mirrors pi's
|
|
902
|
+
* `PiRpcClient.recordUnmappedFrame` exactly: logs once per distinct
|
|
903
|
+
* label, folds the running tally into a later exit error for a
|
|
904
|
+
* post-mortem without separate log scraping.
|
|
905
|
+
*/
|
|
906
|
+
recordUnmappedFrame(label) {
|
|
907
|
+
const next = (this.unmappedFrameCounts.get(label) ?? 0) + 1;
|
|
908
|
+
this.unmappedFrameCounts.set(label, next);
|
|
909
|
+
if (next === 1) {
|
|
910
|
+
console.warn(
|
|
911
|
+
`[byok/claude-adapter] claude emitted a frame with no AgentEvent mapping: "${label}" (further occurrences of this label won't be logged individually)`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes claude itself spawned (e.g. Bash) — mirrors pi's cross-platform `kill()` exactly. Empirically confirmed on this (POSIX) machine: a running claude process exits cleanly within ~1s of SIGTERM (observed exit code 143 = 128+SIGTERM, i.e. claude catches and handles the signal itself rather than needing a harder kill). */
|
|
916
|
+
kill() {
|
|
917
|
+
if (this.closed) return;
|
|
918
|
+
const pid = this.child.pid;
|
|
919
|
+
if (process.platform === "win32" && pid !== void 0) {
|
|
920
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
|
|
921
|
+
} else {
|
|
922
|
+
this.child.kill("SIGTERM");
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
onData(chunk) {
|
|
926
|
+
this.buffer += chunk;
|
|
927
|
+
let newlineIndex = this.buffer.indexOf("\n");
|
|
928
|
+
while (newlineIndex !== -1) {
|
|
929
|
+
let line = this.buffer.slice(0, newlineIndex);
|
|
930
|
+
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
931
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
932
|
+
if (line.length > 0) this.onLine(line);
|
|
933
|
+
newlineIndex = this.buffer.indexOf("\n");
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
onLine(line) {
|
|
937
|
+
let msg;
|
|
938
|
+
try {
|
|
939
|
+
msg = JSON.parse(line);
|
|
940
|
+
} catch {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (this.sessionId === void 0 && msg.type === "system" && msg.subtype === "init" && typeof msg.session_id === "string" && msg.session_id.length > 0) {
|
|
944
|
+
this.sessionId = msg.session_id;
|
|
945
|
+
this.initWaiter?.resolve(this.sessionId);
|
|
946
|
+
this.initWaiter = void 0;
|
|
947
|
+
}
|
|
948
|
+
this.eventQueue.push(msg);
|
|
949
|
+
}
|
|
950
|
+
onStderr(chunk) {
|
|
951
|
+
for (const rawLine of chunk.split("\n")) {
|
|
952
|
+
const line = rawLine.trim();
|
|
953
|
+
if (line.length === 0) continue;
|
|
954
|
+
this.stderrRing.push(line);
|
|
955
|
+
if (this.stderrRing.length > STDERR_RING_CAPACITY2) this.stderrRing.shift();
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/** Mirrors pi's `buildExitError` exactly — stderr tail + unmapped-frame tally folded into one self-diagnosing message. */
|
|
959
|
+
buildExitError(code, signal) {
|
|
960
|
+
const parts = [`claude process exited (code=${code}, signal=${signal})`];
|
|
961
|
+
if (this.stderrRing.length > 0) {
|
|
962
|
+
parts.push(`stderr: ${this.stderrRing.join(" | ")}`);
|
|
963
|
+
}
|
|
964
|
+
if (this.unmappedFrameCounts.size > 0) {
|
|
965
|
+
const summary = [...this.unmappedFrameCounts.entries()].map(([label, count]) => `${label}\xD7${count}`).join(", ");
|
|
966
|
+
parts.push(`unmapped frame labels seen: ${summary}`);
|
|
967
|
+
}
|
|
968
|
+
return new Error(parts.join("; "));
|
|
969
|
+
}
|
|
970
|
+
onClosed(err) {
|
|
971
|
+
if (this.closed) return;
|
|
972
|
+
this.closed = true;
|
|
973
|
+
this.exitError = err;
|
|
974
|
+
this.initWaiter?.reject(err);
|
|
975
|
+
this.initWaiter = void 0;
|
|
976
|
+
this.eventQueue.end();
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
var APPROVAL_TOOL_NAME = "approval_prompt";
|
|
980
|
+
|
|
981
|
+
// src/adapters/claude/claude-adapter.ts
|
|
982
|
+
var APPROVAL_MCP_SERVER_NAME = "byokapproval";
|
|
983
|
+
var execFileAsync2 = promisify(execFile);
|
|
984
|
+
var DETECT_TIMEOUT_MS2 = 5e3;
|
|
985
|
+
async function cleanupApprovalMcpConfigDir(dir) {
|
|
986
|
+
if (!dir) return;
|
|
987
|
+
await promises.rm(dir, { recursive: true, force: true }).catch(() => {
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
var ClaudeAdapter = class {
|
|
991
|
+
constructor(options = {}) {
|
|
992
|
+
this.options = options;
|
|
993
|
+
}
|
|
994
|
+
options;
|
|
995
|
+
id = "claude";
|
|
996
|
+
async detect() {
|
|
997
|
+
const bin = this.resolveBin();
|
|
998
|
+
try {
|
|
999
|
+
const { stdout } = await execFileAsync2(bin.command, ["--version"], { timeout: DETECT_TIMEOUT_MS2 });
|
|
1000
|
+
const version = stdout.trim();
|
|
1001
|
+
const authPresent = await this.probeAuthPresent(bin.command);
|
|
1002
|
+
return { present: true, version, authPresent };
|
|
1003
|
+
} catch {
|
|
1004
|
+
return { present: false };
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
capabilities() {
|
|
1008
|
+
return { steer: false, resume: true, approvalInteractive: true, permissionModes: ["auto", "readonly", "plan", "confirm"] };
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* M5: deliberate product-boundary decision, not an oversight — byok's
|
|
1012
|
+
* current ToS posture for claude is login-state-only (`claude auth
|
|
1013
|
+
* login`'s own OAuth session — see `probeAuthPresent` below), so this
|
|
1014
|
+
* adapter declares NO credential env vars at all; env-based API-key
|
|
1015
|
+
* passthrough for claude is a separate, still-pending product decision.
|
|
1016
|
+
* A product that genuinely needs it can opt in locally per-device via
|
|
1017
|
+
* `DaemonConfig.runtimeEnvironment.claude.allow` (`create-daemon.ts`).
|
|
1018
|
+
* `baseNames` is empty too: nothing in this adapter reads a
|
|
1019
|
+
* claude-specific config-discovery variable (e.g. `CLAUDE_CONFIG_DIR`)
|
|
1020
|
+
* today — if a future version of this adapter starts reading one, it
|
|
1021
|
+
* belongs here, not left to rely on the platform baseline alone.
|
|
1022
|
+
*/
|
|
1023
|
+
environmentRequirements() {
|
|
1024
|
+
return { credentialNames: [] };
|
|
1025
|
+
}
|
|
1026
|
+
async start(task, ctx) {
|
|
1027
|
+
if (typeof task.instruction !== "string") {
|
|
1028
|
+
throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
|
|
1029
|
+
}
|
|
1030
|
+
const mapping = mapPermissionPolicyToClaudeArgs(ctx.policy);
|
|
1031
|
+
if (!mapping.ok) {
|
|
1032
|
+
throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
|
|
1033
|
+
}
|
|
1034
|
+
let approvalMcpConfigDir;
|
|
1035
|
+
if (mapping.needsApprovalMcp) {
|
|
1036
|
+
if (!ctx.approvalChannel) {
|
|
1037
|
+
throw new PolicyUnsupportedError(
|
|
1038
|
+
'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
|
|
1042
|
+
approvalMcpConfigDir = await promises.mkdtemp(path2.join(os.tmpdir(), "byok-approval-mcp-"));
|
|
1043
|
+
await promises.chmod(approvalMcpConfigDir, 448).catch(() => {
|
|
1044
|
+
});
|
|
1045
|
+
const mcpConfigPath = path2.join(approvalMcpConfigDir, "mcp-config.json");
|
|
1046
|
+
const mcpConfig = {
|
|
1047
|
+
mcpServers: {
|
|
1048
|
+
[APPROVAL_MCP_SERVER_NAME]: {
|
|
1049
|
+
command: approvalMcpBin.command,
|
|
1050
|
+
args: approvalMcpBin.args,
|
|
1051
|
+
env: {
|
|
1052
|
+
BYOK_STORE_DIR: ctx.approvalChannel.storeDir,
|
|
1053
|
+
BYOK_PRODUCT_ID: ctx.approvalChannel.productId,
|
|
1054
|
+
BYOK_TASK_ID: ctx.approvalChannel.taskId,
|
|
1055
|
+
BYOK_APPROVAL_TIMEOUT_MS: String(ctx.approvalChannel.timeoutMs)
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
await promises.writeFile(mcpConfigPath, JSON.stringify(mcpConfig), { mode: 384 });
|
|
1061
|
+
mapping.args = [
|
|
1062
|
+
...mapping.args,
|
|
1063
|
+
"--permission-prompt-tool",
|
|
1064
|
+
`mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`,
|
|
1065
|
+
"--mcp-config",
|
|
1066
|
+
mcpConfigPath,
|
|
1067
|
+
// Never let this task's confirm-mode run pick up some OTHER MCP
|
|
1068
|
+
// server from ambient project/user config — the approval channel is
|
|
1069
|
+
// the only MCP server this invocation should ever see.
|
|
1070
|
+
"--strict-mcp-config"
|
|
1071
|
+
];
|
|
1072
|
+
}
|
|
1073
|
+
const bin = this.resolveBin();
|
|
1074
|
+
const resumeSessionId = task.sessionRef;
|
|
1075
|
+
const args = [
|
|
1076
|
+
"-p",
|
|
1077
|
+
"--input-format",
|
|
1078
|
+
"stream-json",
|
|
1079
|
+
"--output-format",
|
|
1080
|
+
"stream-json",
|
|
1081
|
+
// REQUIRED alongside `--output-format stream-json` in `--print` mode —
|
|
1082
|
+
// empirically confirmed: omitting this exits 1 immediately with
|
|
1083
|
+
// "Error: When using --print, --output-format=stream-json requires
|
|
1084
|
+
// --verbose", before spawning any model call.
|
|
1085
|
+
"--verbose",
|
|
1086
|
+
...resumeSessionId ? ["--resume", resumeSessionId] : [],
|
|
1087
|
+
...mapping.args
|
|
1088
|
+
];
|
|
1089
|
+
const client = new ClaudeProcessClient({
|
|
1090
|
+
command: bin.command,
|
|
1091
|
+
args,
|
|
1092
|
+
cwd: ctx.workspaceDir,
|
|
1093
|
+
env: ctx.env,
|
|
1094
|
+
spawnFn: this.options.spawnFn
|
|
1095
|
+
});
|
|
1096
|
+
client.writeUserMessage(task.instruction);
|
|
1097
|
+
let sessionRef;
|
|
1098
|
+
try {
|
|
1099
|
+
sessionRef = await client.waitForInit();
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
client.kill();
|
|
1102
|
+
await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
|
|
1103
|
+
throw err;
|
|
1104
|
+
}
|
|
1105
|
+
if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
|
|
1106
|
+
client.kill();
|
|
1107
|
+
await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
return new ClaudeSession(sessionRef, client, ctx.workspaceDir, ctx.approvalChannel, approvalMcpConfigDir);
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* `claude auth status --json` is claude's OWN non-secret login-state
|
|
1116
|
+
* signal (see the credential-isolation rule on `RuntimeAdapter` in
|
|
1117
|
+
* `../../types.ts`) — empirically confirmed live on this logged-in
|
|
1118
|
+
* machine to report `{"loggedIn":true,"authMethod":"claude.ai",
|
|
1119
|
+
* "apiProvider":"firstParty","email":"...","orgId":"...","orgName":"...",
|
|
1120
|
+
* "subscriptionType":"max"}`, with no token/key material anywhere in it.
|
|
1121
|
+
* This spawns the binary and parses ONLY its own reported status — it
|
|
1122
|
+
* never reads `~/.claude` or any credential file itself, matching pi's
|
|
1123
|
+
* `authPresent` computation being limited to environment-variable
|
|
1124
|
+
* *names* (`../pi/pi-adapter.ts`'s `KNOWN_PROVIDER_ENV_VARS`), just via
|
|
1125
|
+
* claude's own equivalent non-secret probe instead (claude's auth is
|
|
1126
|
+
* OAuth-session-based via `claude auth login`, not primarily an env var,
|
|
1127
|
+
* so pi's env-var-presence approach doesn't apply here the same way).
|
|
1128
|
+
* A failed/unparseable probe (binary present but not logged in, a future
|
|
1129
|
+
* claude release changing this output shape, etc.) fails closed to
|
|
1130
|
+
* `false` — this never affects `present`, which is solely about whether
|
|
1131
|
+
* `--version` itself succeeded.
|
|
1132
|
+
*/
|
|
1133
|
+
async probeAuthPresent(command) {
|
|
1134
|
+
try {
|
|
1135
|
+
const { stdout } = await execFileAsync2(command, ["auth", "status", "--json"], { timeout: DETECT_TIMEOUT_MS2 });
|
|
1136
|
+
const parsed = JSON.parse(stdout);
|
|
1137
|
+
return parsed.loggedIn === true;
|
|
1138
|
+
} catch {
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
resolveBin() {
|
|
1143
|
+
return (this.options.resolveBin ?? resolveClaudeBin)();
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
var ClaudeSession = class {
|
|
1147
|
+
constructor(sessionRef, client, workspaceDir, approvalChannel, approvalMcpConfigDir) {
|
|
1148
|
+
this.sessionRef = sessionRef;
|
|
1149
|
+
this.client = client;
|
|
1150
|
+
this.workspaceDir = workspaceDir;
|
|
1151
|
+
this.approvalChannel = approvalChannel;
|
|
1152
|
+
this.approvalMcpConfigDir = approvalMcpConfigDir;
|
|
1153
|
+
}
|
|
1154
|
+
sessionRef;
|
|
1155
|
+
client;
|
|
1156
|
+
workspaceDir;
|
|
1157
|
+
approvalChannel;
|
|
1158
|
+
approvalMcpConfigDir;
|
|
1159
|
+
correlation = createToolUseCorrelation();
|
|
1160
|
+
get events() {
|
|
1161
|
+
const client = this.client;
|
|
1162
|
+
const correlation = this.correlation;
|
|
1163
|
+
const workspaceDir = this.workspaceDir;
|
|
1164
|
+
return {
|
|
1165
|
+
[Symbol.asyncIterator]() {
|
|
1166
|
+
const inner = client.events[Symbol.asyncIterator]();
|
|
1167
|
+
let pending = [];
|
|
1168
|
+
let turnSettled = false;
|
|
1169
|
+
return {
|
|
1170
|
+
async next() {
|
|
1171
|
+
for (; ; ) {
|
|
1172
|
+
const buffered = pending.shift();
|
|
1173
|
+
if (buffered) return { value: buffered, done: false };
|
|
1174
|
+
if (turnSettled) return { value: void 0, done: true };
|
|
1175
|
+
const { value, done } = await inner.next();
|
|
1176
|
+
if (done) return { value: void 0, done: true };
|
|
1177
|
+
if (value.type === "result") turnSettled = true;
|
|
1178
|
+
const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
|
|
1179
|
+
if (mapped.unmappedLabel) {
|
|
1180
|
+
client.recordUnmappedFrame(mapped.unmappedLabel);
|
|
1181
|
+
}
|
|
1182
|
+
if (mapped.events.length > 0) {
|
|
1183
|
+
pending = mapped.events;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Not supported — see the class-level doc comment on `ClaudeAdapter` for
|
|
1193
|
+
* the full empirical basis (a message written to stdin mid-turn was
|
|
1194
|
+
* proven to queue as a follow-up turn, not redirect the running one).
|
|
1195
|
+
* `capabilities().steer` reports `false` for exactly this reason; this
|
|
1196
|
+
* throws rather than silently behaving like `followUp()` under the
|
|
1197
|
+
* `steer()` name, which would promise live redirection it cannot deliver.
|
|
1198
|
+
*/
|
|
1199
|
+
async steer() {
|
|
1200
|
+
throw new SteerUnsupportedError(
|
|
1201
|
+
"claude",
|
|
1202
|
+
"claude adapter does not support mid-turn steering: writing to claude's stdin while a turn is in flight queues as a separate subsequent turn rather than redirecting the running one (empirically confirmed) \u2014 see capabilities().steer"
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
async followUp(task) {
|
|
1206
|
+
if (typeof task.instruction !== "string") {
|
|
1207
|
+
throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
|
|
1208
|
+
}
|
|
1209
|
+
this.client.writeUserMessage(task.instruction);
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Real claude has no distinct "abort but stay alive" primitive the way
|
|
1213
|
+
* pi's RPC mode does (`{type:'abort'}`, after which pi keeps running and
|
|
1214
|
+
* stays queryable) — nothing in `claude --help` exposes one, and this
|
|
1215
|
+
* task's probes found none. SIGTERM (via `ClaudeProcessClient.kill()`)
|
|
1216
|
+
* is the only verified way to stop an in-flight turn, so `interrupt()`
|
|
1217
|
+
* and `close()` both resolve to the same underlying action here. This is
|
|
1218
|
+
* consistent with how they are actually used together: `task-runner.ts`'s
|
|
1219
|
+
* `handleCancel` always calls `interrupt()` immediately followed by
|
|
1220
|
+
* `close()` on the same task, never `interrupt()` alone expecting the
|
|
1221
|
+
* session to remain usable afterward.
|
|
1222
|
+
*/
|
|
1223
|
+
async interrupt() {
|
|
1224
|
+
this.client.kill();
|
|
1225
|
+
}
|
|
1226
|
+
async close() {
|
|
1227
|
+
this.client.kill();
|
|
1228
|
+
await cleanupApprovalMcpConfigDir(this.approvalMcpConfigDir);
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* M4 Phase 3: routes into the out-of-band approval channel `start()`
|
|
1232
|
+
* threaded through from `TaskContext.approvalChannel` — see that type's
|
|
1233
|
+
* own doc comment (`../../types.ts`) for the full design, and
|
|
1234
|
+
* `permission-mapping.ts`'s `confirm`-mode doc comment for the empirical
|
|
1235
|
+
* basis. `approved`/`reason` map directly onto `ApprovalChannel.resolve`'s
|
|
1236
|
+
* own parameters, which in turn resolve the SAME `ApprovalRegistry` entry
|
|
1237
|
+
* `bin/byok-approval-mcp.ts`'s pending `approvals.request` control call is
|
|
1238
|
+
* awaiting — answering that call is what lets claude's own blocked
|
|
1239
|
+
* `tools/call` (and therefore the paused turn) proceed.
|
|
1240
|
+
*
|
|
1241
|
+
* Still throws when no channel is present — every adapter/session that
|
|
1242
|
+
* ISN'T running under `confirm` mode (the overwhelming majority) has
|
|
1243
|
+
* nothing to resolve, and a caller receiving `task.approve`/`task.reject`
|
|
1244
|
+
* for one of those implies something upstream expected approval support
|
|
1245
|
+
* that isn't there, exactly as this method's doc comment always said.
|
|
1246
|
+
* `ApprovalChannel.resolve` itself throws the equally-descriptive "no
|
|
1247
|
+
* pending approval" error for the narrower case (confirm mode, but nothing
|
|
1248
|
+
* currently pending) — this method doesn't need its own separate check for
|
|
1249
|
+
* that.
|
|
1250
|
+
*/
|
|
1251
|
+
async resolveApproval(approved, reason) {
|
|
1252
|
+
if (!this.approvalChannel) {
|
|
1253
|
+
throw new Error(
|
|
1254
|
+
'claude adapter has no approval channel for this session (not running under policy.mode "confirm") \u2014 under every other mode, claude resolves every permission decision synchronously (auto-denied under a restrictive --permission-mode, auto-granted under a permissive one) before this adapter ever sees the corresponding frame, so there is nothing to resume later'
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
await this.approvalChannel.resolve(approved, reason);
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
// src/adapters/codex/resolve-bin.ts
|
|
1262
|
+
function resolveCodexBin() {
|
|
1263
|
+
const override = process.env.BYOK_CODEX_BIN;
|
|
1264
|
+
if (override) {
|
|
1265
|
+
return { command: override, source: "env" };
|
|
1266
|
+
}
|
|
1267
|
+
return { command: "codex", source: "path" };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// src/adapters/codex/permission-mapping.ts
|
|
1271
|
+
function mapPermissionPolicyToCodexArgs(policy) {
|
|
1272
|
+
if (policy.mode === "confirm" || policy.mode === "plan") {
|
|
1273
|
+
return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
args: [],
|
|
1276
|
+
reason: `codex adapter cannot express permission mode "${policy.mode}" (codex exec has no interactive approval channel \u2014 sandbox-denied actions resolve internally with no needs_approval-equivalent wire signal, and -a/--ask-for-approval is rejected outright by codex exec's real arg parser despite being documented in --help)`
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
if (policy.network === true) {
|
|
1280
|
+
return {
|
|
1281
|
+
ok: false,
|
|
1282
|
+
args: [],
|
|
1283
|
+
reason: "codex adapter cannot guarantee network:true (empirically, -c sandbox_workspace_write.network_access=true did not restore real network access on the installed codex build \u2014 see this file's module doc comment) \u2014 never silently proceeds without the requested grant"
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
if (policy.allowTools && policy.allowTools.length > 0 || policy.denyTools && policy.denyTools.length > 0) {
|
|
1287
|
+
return {
|
|
1288
|
+
ok: false,
|
|
1289
|
+
args: [],
|
|
1290
|
+
reason: "codex adapter cannot express allowTools/denyTools (codex exec has no verified per-tool allow/deny surface, only the coarse sandbox_mode) \u2014 never silently ignores a requested tool restriction"
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
const sandboxMode = policy.mode === "readonly" ? "read-only" : "workspace-write";
|
|
1294
|
+
return { ok: true, args: ["-c", `sandbox_mode=${sandboxMode}`, "-c", "approval_policy=never"] };
|
|
1295
|
+
}
|
|
1296
|
+
function mapCodexEventToAgentEvents(evt, workspaceDir) {
|
|
1297
|
+
switch (evt.type) {
|
|
1298
|
+
case "turn.completed": {
|
|
1299
|
+
const usageEvent = extractCodexUsageEvent(evt.usage);
|
|
1300
|
+
return usageEvent ? [usageEvent, { type: "turn_end" }] : [{ type: "turn_end" }];
|
|
1301
|
+
}
|
|
1302
|
+
case "turn.failed":
|
|
1303
|
+
return [{ type: "error", message: extractErrorMessage(evt.error) ?? "codex turn failed" }];
|
|
1304
|
+
case "error":
|
|
1305
|
+
return [{ type: "error", message: typeof evt.message === "string" ? evt.message : "codex reported an error" }];
|
|
1306
|
+
case "item.started":
|
|
1307
|
+
return mapItem(evt.item, "started", workspaceDir);
|
|
1308
|
+
case "item.completed":
|
|
1309
|
+
return mapItem(evt.item, "completed", workspaceDir);
|
|
1310
|
+
// `thread.started`/`turn.started` carry no AgentEvent-mappable content —
|
|
1311
|
+
// see the module doc comment above for why `thread.started` is handled
|
|
1312
|
+
// upstream instead. Both are also listed in ROUTINE_CODEX_EVENT_TYPES.
|
|
1313
|
+
case "thread.started":
|
|
1314
|
+
case "turn.started":
|
|
1315
|
+
return [];
|
|
1316
|
+
default:
|
|
1317
|
+
return [];
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function extractCodexUsageEvent(rawUsage) {
|
|
1321
|
+
if (!rawUsage || typeof rawUsage !== "object") return void 0;
|
|
1322
|
+
const usage = rawUsage;
|
|
1323
|
+
const inputTokens = toNonNegativeInt2(usage.input_tokens);
|
|
1324
|
+
const cachedInputTokens = toNonNegativeInt2(usage.cached_input_tokens);
|
|
1325
|
+
const outputTokens = toNonNegativeInt2(usage.output_tokens);
|
|
1326
|
+
const reasoningTokens = toNonNegativeInt2(usage.reasoning_output_tokens);
|
|
1327
|
+
if (inputTokens === void 0 && cachedInputTokens === void 0 && outputTokens === void 0 && reasoningTokens === void 0) {
|
|
1328
|
+
return void 0;
|
|
1329
|
+
}
|
|
1330
|
+
const event = { type: "usage" };
|
|
1331
|
+
if (inputTokens !== void 0) event.inputTokens = inputTokens;
|
|
1332
|
+
if (cachedInputTokens !== void 0) event.cachedInputTokens = cachedInputTokens;
|
|
1333
|
+
if (outputTokens !== void 0) event.outputTokens = outputTokens;
|
|
1334
|
+
if (reasoningTokens !== void 0) event.reasoningTokens = reasoningTokens;
|
|
1335
|
+
return event;
|
|
1336
|
+
}
|
|
1337
|
+
function toNonNegativeInt2(value) {
|
|
1338
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
|
|
1339
|
+
}
|
|
1340
|
+
function mapItem(rawItem, phase, workspaceDir) {
|
|
1341
|
+
if (!rawItem || typeof rawItem !== "object") return [];
|
|
1342
|
+
const item = rawItem;
|
|
1343
|
+
const itemType = item.type;
|
|
1344
|
+
switch (itemType) {
|
|
1345
|
+
case "agent_message": {
|
|
1346
|
+
if (phase !== "completed") return [];
|
|
1347
|
+
return typeof item.text === "string" ? [{ type: "progress", text: item.text }] : [];
|
|
1348
|
+
}
|
|
1349
|
+
case "command_execution": {
|
|
1350
|
+
const command = typeof item.command === "string" ? item.command : void 0;
|
|
1351
|
+
if (phase === "started") {
|
|
1352
|
+
return command !== void 0 ? [{ type: "tool_use", tool: "command_execution", input: { command } }] : [];
|
|
1353
|
+
}
|
|
1354
|
+
return [
|
|
1355
|
+
{
|
|
1356
|
+
type: "tool_result",
|
|
1357
|
+
tool: "command_execution",
|
|
1358
|
+
output: {
|
|
1359
|
+
command,
|
|
1360
|
+
aggregatedOutput: item.aggregated_output,
|
|
1361
|
+
exitCode: item.exit_code,
|
|
1362
|
+
status: item.status
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
];
|
|
1366
|
+
}
|
|
1367
|
+
case "file_change": {
|
|
1368
|
+
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
1369
|
+
if (phase === "started") {
|
|
1370
|
+
return [{ type: "tool_use", tool: "file_change", input: { changes } }];
|
|
1371
|
+
}
|
|
1372
|
+
return [
|
|
1373
|
+
{ type: "tool_result", tool: "file_change", output: { changes, status: item.status } },
|
|
1374
|
+
...extractArtifactEvents(changes, workspaceDir)
|
|
1375
|
+
];
|
|
1376
|
+
}
|
|
1377
|
+
case "error": {
|
|
1378
|
+
return typeof item.message === "string" ? [{ type: "error", message: item.message }] : [];
|
|
1379
|
+
}
|
|
1380
|
+
default:
|
|
1381
|
+
return [];
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function extractArtifactEvents(changes, workspaceDir) {
|
|
1385
|
+
const events = [];
|
|
1386
|
+
for (const rawChange of changes) {
|
|
1387
|
+
if (!rawChange || typeof rawChange !== "object") continue;
|
|
1388
|
+
const change = rawChange;
|
|
1389
|
+
const absolutePath = typeof change.path === "string" ? change.path : void 0;
|
|
1390
|
+
const kind = typeof change.kind === "string" ? change.kind : void 0;
|
|
1391
|
+
if (!absolutePath || kind === "delete") continue;
|
|
1392
|
+
const relative = path2.relative(workspaceDir, absolutePath);
|
|
1393
|
+
if (relative.length === 0 || relative.startsWith("..") || path2.isAbsolute(relative)) continue;
|
|
1394
|
+
events.push({ type: "artifact", name: relative, contentType: guessContentType2(relative) });
|
|
1395
|
+
}
|
|
1396
|
+
return events;
|
|
1397
|
+
}
|
|
1398
|
+
var CONTENT_TYPE_BY_EXTENSION = {
|
|
1399
|
+
".txt": "text/plain",
|
|
1400
|
+
".md": "text/markdown",
|
|
1401
|
+
".json": "application/json",
|
|
1402
|
+
".js": "text/javascript",
|
|
1403
|
+
".mjs": "text/javascript",
|
|
1404
|
+
".cjs": "text/javascript",
|
|
1405
|
+
".ts": "text/plain",
|
|
1406
|
+
".html": "text/html",
|
|
1407
|
+
".css": "text/css",
|
|
1408
|
+
".py": "text/x-python",
|
|
1409
|
+
".yml": "application/yaml",
|
|
1410
|
+
".yaml": "application/yaml",
|
|
1411
|
+
".csv": "text/csv"
|
|
1412
|
+
};
|
|
1413
|
+
function guessContentType2(relativePath) {
|
|
1414
|
+
return CONTENT_TYPE_BY_EXTENSION[path2.extname(relativePath).toLowerCase()] ?? "application/octet-stream";
|
|
1415
|
+
}
|
|
1416
|
+
function extractErrorMessage(rawError) {
|
|
1417
|
+
if (typeof rawError === "string") return rawError;
|
|
1418
|
+
if (!rawError || typeof rawError !== "object") return void 0;
|
|
1419
|
+
const message = rawError.message;
|
|
1420
|
+
return typeof message === "string" ? message : void 0;
|
|
1421
|
+
}
|
|
1422
|
+
var ROUTINE_CODEX_EVENT_TYPES = /* @__PURE__ */ new Set(["thread.started", "turn.started"]);
|
|
1423
|
+
function isRoutineCodexEvent(evt) {
|
|
1424
|
+
return ROUTINE_CODEX_EVENT_TYPES.has(evt.type);
|
|
1425
|
+
}
|
|
1426
|
+
function unmappedFrameKey(evt) {
|
|
1427
|
+
if (evt.type === "item.started" || evt.type === "item.completed") {
|
|
1428
|
+
const item = evt.item;
|
|
1429
|
+
const itemType = item && typeof item === "object" && typeof item.type === "string" ? item.type : "unknown";
|
|
1430
|
+
return `${evt.type}:${String(itemType)}`;
|
|
1431
|
+
}
|
|
1432
|
+
return evt.type;
|
|
1433
|
+
}
|
|
1434
|
+
var STDERR_RING_CAPACITY3 = 20;
|
|
1435
|
+
var CodexProcessRunner = class {
|
|
1436
|
+
child;
|
|
1437
|
+
onEvent;
|
|
1438
|
+
buffer = "";
|
|
1439
|
+
stderrRing = [];
|
|
1440
|
+
closed = false;
|
|
1441
|
+
exitCode = null;
|
|
1442
|
+
exitSignal = null;
|
|
1443
|
+
closedPromise;
|
|
1444
|
+
resolveClosed;
|
|
1445
|
+
constructor(options) {
|
|
1446
|
+
this.onEvent = options.onEvent;
|
|
1447
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
1448
|
+
this.child = spawnFn(options.command, options.args, {
|
|
1449
|
+
cwd: options.cwd,
|
|
1450
|
+
env: options.env,
|
|
1451
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1452
|
+
});
|
|
1453
|
+
this.closedPromise = new Promise((resolve) => {
|
|
1454
|
+
this.resolveClosed = resolve;
|
|
1455
|
+
});
|
|
1456
|
+
this.child.stdout.setEncoding("utf8");
|
|
1457
|
+
this.child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
1458
|
+
this.child.stderr.setEncoding("utf8");
|
|
1459
|
+
this.child.stderr.on("data", (chunk) => this.onStderr(chunk));
|
|
1460
|
+
this.child.on("close", (code, signal) => {
|
|
1461
|
+
this.exitCode = code;
|
|
1462
|
+
this.exitSignal = signal;
|
|
1463
|
+
this.finishClosing();
|
|
1464
|
+
});
|
|
1465
|
+
this.child.on("error", () => {
|
|
1466
|
+
this.finishClosing();
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
finishClosing() {
|
|
1470
|
+
if (this.closed) return;
|
|
1471
|
+
this.closed = true;
|
|
1472
|
+
this.resolveClosed();
|
|
1473
|
+
}
|
|
1474
|
+
/** Resolves once the child process has fully exited (both exit and stdio-flush guaranteed — see the `close` listener above). Never rejects. */
|
|
1475
|
+
waitClosed() {
|
|
1476
|
+
return this.closedPromise;
|
|
1477
|
+
}
|
|
1478
|
+
get isClosed() {
|
|
1479
|
+
return this.closed;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
|
|
1483
|
+
* to be silently ignored by `codex exec` (a real, direct test — a 60s
|
|
1484
|
+
* shell `sleep` ran to full, unaffected completion despite SIGINT sent at
|
|
1485
|
+
* t=4s) — a genuine, evidence-based correction to this task's own initial
|
|
1486
|
+
* assumption ("interrupt: SIGINT — POSIX here"). SIGTERM was separately
|
|
1487
|
+
* confirmed to terminate the process immediately (exit code 143) with no
|
|
1488
|
+
* orphaned child processes left behind (the shell command it was running
|
|
1489
|
+
* died with it), and — critically — the underlying codex thread remained
|
|
1490
|
+
* cleanly resumable afterward via `codex exec resume` (no corruption from
|
|
1491
|
+
* killing mid-turn). `taskkill /T /F` on Windows, mirroring
|
|
1492
|
+
* `../pi/rpc-client.ts`'s own cross-platform convention.
|
|
1493
|
+
*/
|
|
1494
|
+
kill() {
|
|
1495
|
+
if (this.closed) return;
|
|
1496
|
+
const pid = this.child.pid;
|
|
1497
|
+
if (process.platform === "win32" && pid !== void 0) {
|
|
1498
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
|
|
1499
|
+
} else {
|
|
1500
|
+
this.child.kill("SIGTERM");
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
/** Builds a descriptive error folding in the exit code/signal and the stderr tail — mirrors `PiRpcClient.buildExitError`'s reasoning: a post-mortem on a failed start/resume should never need separately re-running codex by hand with a raw JSONL logger to learn why. */
|
|
1504
|
+
buildExitError(context) {
|
|
1505
|
+
const parts = [`${context} (exit code=${this.exitCode}, signal=${this.exitSignal})`];
|
|
1506
|
+
if (this.stderrRing.length > 0) {
|
|
1507
|
+
parts.push(`stderr: ${this.stderrRing.join(" | ")}`);
|
|
1508
|
+
}
|
|
1509
|
+
return new Error(parts.join("; "));
|
|
1510
|
+
}
|
|
1511
|
+
onData(chunk) {
|
|
1512
|
+
this.buffer += chunk;
|
|
1513
|
+
let newlineIndex = this.buffer.indexOf("\n");
|
|
1514
|
+
while (newlineIndex !== -1) {
|
|
1515
|
+
let line = this.buffer.slice(0, newlineIndex);
|
|
1516
|
+
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
1517
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
1518
|
+
if (line.length > 0) this.parseLine(line);
|
|
1519
|
+
newlineIndex = this.buffer.indexOf("\n");
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
parseLine(line) {
|
|
1523
|
+
let parsed;
|
|
1524
|
+
try {
|
|
1525
|
+
parsed = JSON.parse(line);
|
|
1526
|
+
} catch {
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.type !== "string") {
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
this.onEvent(parsed);
|
|
1533
|
+
}
|
|
1534
|
+
onStderr(chunk) {
|
|
1535
|
+
for (const rawLine of chunk.split("\n")) {
|
|
1536
|
+
const line = rawLine.trim();
|
|
1537
|
+
if (line.length === 0) continue;
|
|
1538
|
+
this.stderrRing.push(line);
|
|
1539
|
+
if (this.stderrRing.length > STDERR_RING_CAPACITY3) this.stderrRing.shift();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
// src/adapters/codex/codex-adapter.ts
|
|
1545
|
+
var execFileAsync3 = promisify(execFile);
|
|
1546
|
+
var DETECT_TIMEOUT_MS3 = 5e3;
|
|
1547
|
+
var CodexAdapter = class {
|
|
1548
|
+
constructor(options = {}) {
|
|
1549
|
+
this.options = options;
|
|
1550
|
+
}
|
|
1551
|
+
options;
|
|
1552
|
+
id = "codex";
|
|
1553
|
+
async detect() {
|
|
1554
|
+
const bin = this.resolveBin();
|
|
1555
|
+
try {
|
|
1556
|
+
const versionResult = await execFileAsync3(bin.command, ["--version"], { timeout: DETECT_TIMEOUT_MS3 });
|
|
1557
|
+
const version = versionResult.stdout.trim() || versionResult.stderr.trim();
|
|
1558
|
+
const authPresent = await this.probeAuthPresent(bin);
|
|
1559
|
+
return { present: true, version, authPresent };
|
|
1560
|
+
} catch {
|
|
1561
|
+
return { present: false };
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* `authPresent` without ever reading `~/.codex/auth.json` (credential-
|
|
1566
|
+
* isolation rule, `../../types.ts`): spawns codex's OWN `login status`
|
|
1567
|
+
* subcommand and interprets its human-readable report — the exact
|
|
1568
|
+
* "non-secret signal" this adapter is required to use, and cleaner than
|
|
1569
|
+
* pi's env-var-name check since codex's real credential model (on the
|
|
1570
|
+
* reference machine) is a ChatGPT OAuth session, not an env var.
|
|
1571
|
+
*
|
|
1572
|
+
* Two independently-verified channel gotchas apply here, the "pi lesson"
|
|
1573
|
+
* yet again:
|
|
1574
|
+
* - `codex login status`'s human-readable "Logged in using ChatGPT"
|
|
1575
|
+
* message prints on STDERR, not stdout (the opposite-channel
|
|
1576
|
+
* counterpart of pi's own `--version`-goes-to-stderr surprise) — both
|
|
1577
|
+
* streams are checked here for exactly that reason.
|
|
1578
|
+
* - The NOT-logged-in message/exit-code shape was deliberately never
|
|
1579
|
+
* empirically tested: this machine has a real, live ChatGPT login, and
|
|
1580
|
+
* running `codex logout` to observe the negative case would have
|
|
1581
|
+
* broken that login for the rest of this session/machine. The match
|
|
1582
|
+
* below is intentionally conservative (`/logged in (using|with)/i`,
|
|
1583
|
+
* not a bare `"logged in"` substring) specifically because a bare
|
|
1584
|
+
* substring check would false-positive on a plausible negative message
|
|
1585
|
+
* like "Not logged in" (itself containing the substring "logged in").
|
|
1586
|
+
* This is a documented, known gap — flagged for M2-c / a follow-up
|
|
1587
|
+
* empirical pass on a logged-out machine, not asserted as verified.
|
|
1588
|
+
*/
|
|
1589
|
+
async probeAuthPresent(bin) {
|
|
1590
|
+
try {
|
|
1591
|
+
const result = await execFileAsync3(bin.command, ["login", "status"], { timeout: DETECT_TIMEOUT_MS3 });
|
|
1592
|
+
return /logged in (using|with)/i.test(`${result.stdout}
|
|
1593
|
+
${result.stderr}`);
|
|
1594
|
+
} catch (err) {
|
|
1595
|
+
const withStreams = err;
|
|
1596
|
+
return /logged in (using|with)/i.test(`${withStreams.stdout ?? ""}
|
|
1597
|
+
${withStreams.stderr ?? ""}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
capabilities() {
|
|
1601
|
+
return { steer: false, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* M5: same deliberate posture as the claude adapter (see its own doc
|
|
1605
|
+
* comment) — codex authenticates via its own `codex login`-managed
|
|
1606
|
+
* ChatGPT OAuth session (`probeAuthPresent` above), not an env var, so
|
|
1607
|
+
* there is no credential env var this adapter needs forwarded; env-based
|
|
1608
|
+
* API-key passthrough remains a separate, pending product decision. No
|
|
1609
|
+
* `baseNames` either: nothing in this adapter reads a codex-specific
|
|
1610
|
+
* config-discovery variable (e.g. `CODEX_HOME`) today.
|
|
1611
|
+
*/
|
|
1612
|
+
environmentRequirements() {
|
|
1613
|
+
return { credentialNames: [] };
|
|
1614
|
+
}
|
|
1615
|
+
async start(task, ctx) {
|
|
1616
|
+
if (typeof task.instruction !== "string") {
|
|
1617
|
+
throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
|
|
1618
|
+
}
|
|
1619
|
+
const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
|
|
1620
|
+
if (!mapping.ok) {
|
|
1621
|
+
throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
|
|
1622
|
+
}
|
|
1623
|
+
const bin = this.resolveBin();
|
|
1624
|
+
const queue = new AsyncQueue();
|
|
1625
|
+
const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
|
|
1626
|
+
const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
|
|
1627
|
+
const { sessionRef, runner } = await runCodexTurn({
|
|
1628
|
+
command: bin.command,
|
|
1629
|
+
resumeRef: task.sessionRef,
|
|
1630
|
+
instruction: task.instruction,
|
|
1631
|
+
policyArgs: mapping.args,
|
|
1632
|
+
cwd: ctx.workspaceDir,
|
|
1633
|
+
env: ctx.env,
|
|
1634
|
+
spawnFn: this.options.spawnFn,
|
|
1635
|
+
workspaceDir,
|
|
1636
|
+
queue,
|
|
1637
|
+
recordUnmapped,
|
|
1638
|
+
expectedSessionRef: task.sessionRef,
|
|
1639
|
+
preparedGit: ctx.gitWorkspace !== void 0
|
|
1640
|
+
});
|
|
1641
|
+
return new CodexSession({
|
|
1642
|
+
sessionRef,
|
|
1643
|
+
command: bin.command,
|
|
1644
|
+
workspaceDir,
|
|
1645
|
+
env: ctx.env,
|
|
1646
|
+
spawnFn: this.options.spawnFn,
|
|
1647
|
+
queue,
|
|
1648
|
+
recordUnmapped,
|
|
1649
|
+
initialRunner: runner,
|
|
1650
|
+
preparedGit: ctx.gitWorkspace !== void 0
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
resolveBin() {
|
|
1654
|
+
return (this.options.resolveBin ?? resolveCodexBin)();
|
|
1655
|
+
}
|
|
1656
|
+
};
|
|
1657
|
+
async function resolveRealWorkspaceDir(workspaceDir) {
|
|
1658
|
+
return promises.realpath(workspaceDir).catch(() => workspaceDir);
|
|
1659
|
+
}
|
|
1660
|
+
function makeUnmappedFrameRecorder(counts) {
|
|
1661
|
+
return (key) => {
|
|
1662
|
+
const next = (counts.get(key) ?? 0) + 1;
|
|
1663
|
+
counts.set(key, next);
|
|
1664
|
+
if (next === 1) {
|
|
1665
|
+
console.warn(
|
|
1666
|
+
`[byok/codex-adapter] codex emitted a frame with no AgentEvent mapping: "${key}" (further occurrences of this type won't be logged individually)`
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
function buildArgv(resumeRef, policyArgs, instruction, preparedGit = false) {
|
|
1672
|
+
const base = resumeRef !== void 0 ? ["exec", "resume", resumeRef] : ["exec"];
|
|
1673
|
+
return [...base, "--json", ...preparedGit ? [] : ["--skip-git-repo-check"], ...policyArgs, instruction];
|
|
1674
|
+
}
|
|
1675
|
+
async function runCodexTurn(params) {
|
|
1676
|
+
const argv = buildArgv(params.resumeRef, params.policyArgs, params.instruction, params.preparedGit);
|
|
1677
|
+
let firstLineSettled = false;
|
|
1678
|
+
let resolveFirstLine;
|
|
1679
|
+
let rejectFirstLine;
|
|
1680
|
+
const firstLine = new Promise((resolve, reject) => {
|
|
1681
|
+
resolveFirstLine = resolve;
|
|
1682
|
+
rejectFirstLine = reject;
|
|
1683
|
+
});
|
|
1684
|
+
let turnEnded = false;
|
|
1685
|
+
const runner = new CodexProcessRunner({
|
|
1686
|
+
command: params.command,
|
|
1687
|
+
args: argv,
|
|
1688
|
+
cwd: params.cwd,
|
|
1689
|
+
env: params.env,
|
|
1690
|
+
spawnFn: params.spawnFn,
|
|
1691
|
+
onEvent: (evt) => {
|
|
1692
|
+
if (!firstLineSettled) {
|
|
1693
|
+
firstLineSettled = true;
|
|
1694
|
+
if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
|
|
1695
|
+
resolveFirstLine(evt.thread_id);
|
|
1696
|
+
} else {
|
|
1697
|
+
rejectFirstLine(
|
|
1698
|
+
new Error(`codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`)
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
|
|
1704
|
+
for (const agentEvent of mapped) {
|
|
1705
|
+
if (agentEvent.type === "turn_end") turnEnded = true;
|
|
1706
|
+
params.queue.push(agentEvent);
|
|
1707
|
+
}
|
|
1708
|
+
if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
|
|
1709
|
+
params.recordUnmapped(unmappedFrameKey(evt));
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
void runner.waitClosed().then(() => {
|
|
1714
|
+
if (turnEnded) return;
|
|
1715
|
+
params.queue.push({ type: "error", message: runner.buildExitError("codex exited without completing the turn").message });
|
|
1716
|
+
params.queue.end();
|
|
1717
|
+
});
|
|
1718
|
+
let sessionRef;
|
|
1719
|
+
try {
|
|
1720
|
+
sessionRef = await new Promise((resolve, reject) => {
|
|
1721
|
+
let settled = false;
|
|
1722
|
+
firstLine.then(
|
|
1723
|
+
(ref) => {
|
|
1724
|
+
if (!settled) {
|
|
1725
|
+
settled = true;
|
|
1726
|
+
resolve(ref);
|
|
1727
|
+
}
|
|
1728
|
+
},
|
|
1729
|
+
(err) => {
|
|
1730
|
+
if (!settled) {
|
|
1731
|
+
settled = true;
|
|
1732
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
);
|
|
1736
|
+
void runner.waitClosed().then(() => {
|
|
1737
|
+
if (!settled) {
|
|
1738
|
+
settled = true;
|
|
1739
|
+
reject(runner.buildExitError("codex exited before yielding an authoritative thread id"));
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
});
|
|
1743
|
+
} catch (err) {
|
|
1744
|
+
runner.kill();
|
|
1745
|
+
throw err;
|
|
1746
|
+
}
|
|
1747
|
+
if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
|
|
1748
|
+
runner.kill();
|
|
1749
|
+
throw new Error(
|
|
1750
|
+
`codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
return { sessionRef, runner };
|
|
1754
|
+
}
|
|
1755
|
+
var CodexSession = class {
|
|
1756
|
+
/**
|
|
1757
|
+
* NOT `readonly` (cross-model review finding): `followUp()` below
|
|
1758
|
+
* re-assigns this from the runtime's own CONFIRMED reflected id on each
|
|
1759
|
+
* resume — see its own doc comment. Silently keeping the value captured at
|
|
1760
|
+
* construction time was the original bug (the NEW id `runCodexTurn`
|
|
1761
|
+
* returns was discarded, so every later `followUp()` kept resuming the
|
|
1762
|
+
* OLD, potentially stale, id); that stays fixed here. A LATER cross-model
|
|
1763
|
+
* re-review found the fix above was itself incomplete: `followUp()` now
|
|
1764
|
+
* also verifies the reflected id matches what it asked to resume BEFORE
|
|
1765
|
+
* this field is touched — a mismatch throws (fail-closed) instead of ever
|
|
1766
|
+
* reaching this assignment, so `sessionRef` only ever advances to an id
|
|
1767
|
+
* codex has proven it actually resumed, never one it silently swapped in.
|
|
1768
|
+
*/
|
|
1769
|
+
sessionRef;
|
|
1770
|
+
command;
|
|
1771
|
+
workspaceDir;
|
|
1772
|
+
env;
|
|
1773
|
+
spawnFn;
|
|
1774
|
+
queue;
|
|
1775
|
+
recordUnmapped;
|
|
1776
|
+
preparedGit;
|
|
1777
|
+
currentRunner;
|
|
1778
|
+
closed = false;
|
|
1779
|
+
constructor(options) {
|
|
1780
|
+
this.sessionRef = options.sessionRef;
|
|
1781
|
+
this.command = options.command;
|
|
1782
|
+
this.workspaceDir = options.workspaceDir;
|
|
1783
|
+
this.env = options.env;
|
|
1784
|
+
this.spawnFn = options.spawnFn;
|
|
1785
|
+
this.queue = options.queue;
|
|
1786
|
+
this.recordUnmapped = options.recordUnmapped;
|
|
1787
|
+
this.preparedGit = options.preparedGit;
|
|
1788
|
+
this.currentRunner = options.initialRunner;
|
|
1789
|
+
void this.forgetRunnerOnceClosed(options.initialRunner);
|
|
1790
|
+
}
|
|
1791
|
+
get events() {
|
|
1792
|
+
return this.queue;
|
|
1793
|
+
}
|
|
1794
|
+
async forgetRunnerOnceClosed(runner) {
|
|
1795
|
+
await runner.waitClosed();
|
|
1796
|
+
if (this.currentRunner === runner) this.currentRunner = void 0;
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* New turn, same session, via the real resume mechanism (`codex exec
|
|
1800
|
+
* resume <sessionRef> ...`). Maps `task.policy` FRESH on every call — a
|
|
1801
|
+
* deliberate, evidence-based divergence from pi's own `followUp()`
|
|
1802
|
+
* (`../pi/pi-adapter.ts`), which ignores `task.policy` entirely and relies
|
|
1803
|
+
* on argv baked in once at `start()`. That's safe for pi only because pi
|
|
1804
|
+
* reuses one already-running RPC process for its whole session lifetime,
|
|
1805
|
+
* so there is nothing to re-apply. codex has no such invariant: a resume
|
|
1806
|
+
* spawns a BRAND NEW process, and empirically that new process does NOT
|
|
1807
|
+
* inherit the sandbox mode the session originally started with (see this
|
|
1808
|
+
* file's module doc comment) — omitting a fresh, explicit mapping here
|
|
1809
|
+
* would silently run the follow-up turn under this machine's ambient codex
|
|
1810
|
+
* config default instead of the policy this specific follow-up was
|
|
1811
|
+
* offered under, exactly the silent-widen failure mode this adapter exists
|
|
1812
|
+
* to prevent.
|
|
1813
|
+
*
|
|
1814
|
+
* Cross-model RE-review finding (this corrects the previous wave's own
|
|
1815
|
+
* reasoning, quoted below for context): `expectedSessionRef` IS now passed
|
|
1816
|
+
* to `runCodexTurn`, set to the exact id this call asked to resume
|
|
1817
|
+
* (`resumeRef`, captured up front before the call). The previous version
|
|
1818
|
+
* of this method deliberately omitted it — "this resume targets OUR OWN
|
|
1819
|
+
* previously-recorded `sessionRef`, not an externally supplied server
|
|
1820
|
+
* expectation crossing a trust boundary... whatever id codex reports back
|
|
1821
|
+
* for THIS resume is unambiguously the current, authoritative id for this
|
|
1822
|
+
* session" — but that reasoning let a resume-A/reports-B mismatch silently
|
|
1823
|
+
* MIGRATE this session's identity instead of failing closed: codex has no
|
|
1824
|
+
* documented contract for re-keying a thread on resume, so a mismatch here
|
|
1825
|
+
* must be treated as an error, exactly like `start()`'s own
|
|
1826
|
+
* `task.sessionRef` comparison (see `runCodexTurn`'s doc comment on that
|
|
1827
|
+
* check) — never silently adopted. `runCodexTurn` kills the (failed) new
|
|
1828
|
+
* runner and throws on a mismatch, BEFORE `sessionRef`/`currentRunner`
|
|
1829
|
+
* below are ever touched — so a thrown mismatch leaves this session in its
|
|
1830
|
+
* previous, still-good state rather than partially migrated. The
|
|
1831
|
+
* `sessionRef` return value is still captured and re-assigned below (the
|
|
1832
|
+
* ORIGINAL bug this fixes, one wave further back: the return value used to
|
|
1833
|
+
* be discarded entirely, so every later `followUp()` kept resuming a
|
|
1834
|
+
* stale id even after codex had moved on) — it just can now only ever be
|
|
1835
|
+
* the SAME id this call asked to resume, never a silently-different one.
|
|
1836
|
+
*/
|
|
1837
|
+
async followUp(task) {
|
|
1838
|
+
if (typeof task.instruction !== "string") {
|
|
1839
|
+
throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
|
|
1840
|
+
}
|
|
1841
|
+
if (this.closed) {
|
|
1842
|
+
throw new Error("cannot follow up on a closed codex session");
|
|
1843
|
+
}
|
|
1844
|
+
const mapping = mapPermissionPolicyToCodexArgs(task.policy);
|
|
1845
|
+
if (!mapping.ok) {
|
|
1846
|
+
throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
|
|
1847
|
+
}
|
|
1848
|
+
const resumeRef = this.sessionRef;
|
|
1849
|
+
let sessionRef;
|
|
1850
|
+
let runner;
|
|
1851
|
+
try {
|
|
1852
|
+
({ sessionRef, runner } = await runCodexTurn({
|
|
1853
|
+
command: this.command,
|
|
1854
|
+
resumeRef,
|
|
1855
|
+
instruction: task.instruction,
|
|
1856
|
+
policyArgs: mapping.args,
|
|
1857
|
+
cwd: this.workspaceDir,
|
|
1858
|
+
env: this.env,
|
|
1859
|
+
spawnFn: this.spawnFn,
|
|
1860
|
+
workspaceDir: this.workspaceDir,
|
|
1861
|
+
queue: this.queue,
|
|
1862
|
+
recordUnmapped: this.recordUnmapped,
|
|
1863
|
+
expectedSessionRef: resumeRef,
|
|
1864
|
+
preparedGit: this.preparedGit
|
|
1865
|
+
}));
|
|
1866
|
+
} catch (err) {
|
|
1867
|
+
this.queue.end();
|
|
1868
|
+
throw err;
|
|
1869
|
+
}
|
|
1870
|
+
this.sessionRef = sessionRef;
|
|
1871
|
+
this.currentRunner = runner;
|
|
1872
|
+
void this.forgetRunnerOnceClosed(runner);
|
|
1873
|
+
}
|
|
1874
|
+
/** Best-effort abort of the current turn. SIGTERM's the currently-running child, if any — see `process-runner.ts`'s `kill()` doc comment for why SIGTERM (not SIGINT) and why this is safe: the underlying codex thread survives and stays resumable, confirmed empirically. A no-op when no turn is currently in flight. */
|
|
1875
|
+
async interrupt() {
|
|
1876
|
+
this.currentRunner?.kill();
|
|
1877
|
+
}
|
|
1878
|
+
async close() {
|
|
1879
|
+
if (this.closed) return;
|
|
1880
|
+
this.closed = true;
|
|
1881
|
+
this.currentRunner?.kill();
|
|
1882
|
+
this.queue.end();
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* `codex exec` has no in-band channel to inject text into an already-
|
|
1886
|
+
* running turn — confirmed empirically, not assumed: there is no stdin
|
|
1887
|
+
* protocol (stdin is never even piped to the child — see
|
|
1888
|
+
* `process-runner.ts`'s module doc comment), SIGINT (a plausible
|
|
1889
|
+
* interrupt-and-redirect signal) is silently ignored outright, and `codex
|
|
1890
|
+
* exec resume` only ever starts a brand NEW turn strictly after the
|
|
1891
|
+
* current one has fully finished — it cannot inject into one still in
|
|
1892
|
+
* flight. Throws honestly rather than silently no-op-ing, matching this
|
|
1893
|
+
* task's explicit instruction and `capabilities().steer === false` above.
|
|
1894
|
+
*/
|
|
1895
|
+
async steer() {
|
|
1896
|
+
throw new SteerUnsupportedError(
|
|
1897
|
+
"codex",
|
|
1898
|
+
"codex adapter does not support steer: codex exec has no in-band channel to inject text into a running turn (no stdin protocol, SIGINT is ignored, and resume only starts a new turn after the current one finishes)"
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* `codex exec` never emits a `needs_approval`-equivalent event on the wire
|
|
1903
|
+
* — confirmed empirically (see `events.ts`'s module doc comment): a
|
|
1904
|
+
* sandbox-denied action resolves the approval decision internally with no
|
|
1905
|
+
* pause an external caller could ever observe or answer, regardless of
|
|
1906
|
+
* `approval_policy`. Since this session can therefore never have emitted a
|
|
1907
|
+
* `needs_approval` `AgentEvent` in the first place, a caller reaching this
|
|
1908
|
+
* method implies something upstream expected approval support that isn't
|
|
1909
|
+
* there — thrown as a descriptive error rather than a silent no-op,
|
|
1910
|
+
* mirroring `../pi/pi-adapter.ts`'s identical `resolveApproval`.
|
|
1911
|
+
*/
|
|
1912
|
+
async resolveApproval() {
|
|
1913
|
+
throw new Error(
|
|
1914
|
+
"codex adapter does not support approval resume: codex exec never emits a needs_approval-equivalent event (sandbox-denied actions resolve internally with no wire-visible pause)"
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
export { ClaudeAdapter, CodexAdapter, PI_PACKAGE_NAME, PiAdapter };
|
|
1920
|
+
//# sourceMappingURL=index.js.map
|
|
1921
|
+
//# sourceMappingURL=index.js.map
|