@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.
@@ -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