@byok-sdk/client 0.1.0 → 0.2.0

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