@byok-sdk/client 0.3.0 → 0.4.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.
Files changed (41) hide show
  1. package/README.md +14 -1
  2. package/dist/adapters/claude/claude-adapter.d.ts +4 -20
  3. package/dist/adapters/claude/events.d.ts +3 -0
  4. package/dist/adapters/claude/process-client.d.ts +15 -1
  5. package/dist/adapters/codex/codex-adapter.d.ts +4 -16
  6. package/dist/adapters/codex/process-runner.d.ts +9 -1
  7. package/dist/adapters/index.d.ts +3 -1
  8. package/dist/adapters/index.js +1057 -261
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/adapters/pi/pi-adapter.d.ts +3 -16
  11. package/dist/adapters/pi/resolve-bin.d.ts +1 -1
  12. package/dist/adapters/pi/rpc-client.d.ts +15 -1
  13. package/dist/adapters/process-tree.d.ts +60 -0
  14. package/dist/adapters/taskkill-pid-set.d.ts +34 -0
  15. package/dist/bin/audit-log.d.ts +12 -0
  16. package/dist/bin/byok-agent.js +1452 -507
  17. package/dist/bin/byok-agent.js.map +1 -1
  18. package/dist/bin/byok-approval-mcp.js.map +1 -1
  19. package/dist/bin/commands/workspaces.d.ts +11 -0
  20. package/dist/bin/format.d.ts +13 -0
  21. package/dist/bin/runtime-probe.d.ts +1 -1
  22. package/dist/bin/tasks-view.d.ts +13 -0
  23. package/dist/daemon/approvals.d.ts +2 -2
  24. package/dist/daemon/connection-manager.d.ts +15 -17
  25. package/dist/daemon/control-server.d.ts +18 -1
  26. package/dist/daemon/create-daemon.d.ts +2 -2
  27. package/dist/daemon/daemon-owner.d.ts +4 -2
  28. package/dist/daemon/environment.d.ts +9 -9
  29. package/dist/daemon/git-workspace.d.ts +21 -0
  30. package/dist/daemon/long-poll-transport.d.ts +6 -0
  31. package/dist/daemon/observer.d.ts +13 -0
  32. package/dist/daemon/presence-publisher.d.ts +29 -0
  33. package/dist/daemon/runtime-capabilities.d.ts +1 -1
  34. package/dist/daemon/task-runner.d.ts +34 -40
  35. package/dist/daemon/ws-transport.d.ts +3 -1
  36. package/dist/index.d.ts +4 -2
  37. package/dist/index.js +1413 -455
  38. package/dist/index.js.map +1 -1
  39. package/dist/runtime-failure.d.ts +64 -0
  40. package/dist/types.d.ts +100 -73
  41. package/package.json +14 -14
@@ -1,4 +1,4 @@
1
- import { execFile, spawn, spawnSync } from 'child_process';
1
+ import { execFile, spawn } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { promises, existsSync, readFileSync, realpathSync } from 'fs';
4
4
  import path3 from 'path';
@@ -6,9 +6,80 @@ import { fileURLToPath } from 'url';
6
6
  import os from 'os';
7
7
  import 'readline';
8
8
 
9
- // src/adapters/pi/pi-adapter.ts
9
+ // src/runtime-failure.ts
10
+ var RUNTIME_EXECUTION_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeExecutionFailure/v1");
11
+ var RUNTIME_DISPOSAL_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeDisposalFailure/v1");
12
+ var RuntimeDisposalFailure = class extends Error {
13
+ stage;
14
+ constructor(input, options) {
15
+ if (!isRuntimeDisposalStage(input.stage) || typeof input.reason !== "string" || input.reason.length === 0) {
16
+ throw new TypeError("invalid RuntimeDisposalFailure input");
17
+ }
18
+ super(input.reason, options);
19
+ this.name = "RuntimeDisposalFailure";
20
+ this.stage = input.stage;
21
+ Object.defineProperty(this, RUNTIME_DISPOSAL_FAILURE_BRAND, { value: true });
22
+ Object.freeze(this);
23
+ }
24
+ };
25
+ function isRuntimeDisposalStage(value) {
26
+ return value === "signal" || value === "quiescence" || value === "cleanup";
27
+ }
28
+ var RuntimeExecutionFailure = class extends Error {
29
+ phase;
30
+ category;
31
+ retry;
32
+ constructor(input, options) {
33
+ if (!isRuntimeFailurePhase(input.phase) || !isRuntimeFailureCategory(input.category) || !isRuntimeRetryDisposition(input.retry) || typeof input.reason !== "string" || input.reason.length === 0) {
34
+ throw new TypeError("invalid RuntimeExecutionFailure input");
35
+ }
36
+ super(input.reason, options);
37
+ this.name = "RuntimeExecutionFailure";
38
+ this.phase = input.phase;
39
+ this.category = input.category;
40
+ this.retry = input.retry;
41
+ Object.defineProperty(this, RUNTIME_EXECUTION_FAILURE_BRAND, { value: true });
42
+ Object.freeze(this);
43
+ }
44
+ };
45
+ function isRuntimeFailurePhase(value) {
46
+ return value === "start" || value === "run";
47
+ }
48
+ function isRuntimeFailureCategory(value) {
49
+ return value === "semantic" || value === "infrastructure" || value === "authority";
50
+ }
51
+ function isRuntimeRetryDisposition(value) {
52
+ return value === "retryable" || value === "non-retryable";
53
+ }
54
+ function isRuntimeExecutionFailure(value) {
55
+ if (typeof value !== "object" || value === null) return false;
56
+ const candidate = value;
57
+ return candidate[RUNTIME_EXECUTION_FAILURE_BRAND] === true && isRuntimeFailurePhase(candidate.phase) && isRuntimeFailureCategory(candidate.category) && isRuntimeRetryDisposition(candidate.retry) && typeof candidate.message === "string" && candidate.message.length > 0;
58
+ }
10
59
 
11
60
  // src/types.ts
61
+ function frozenStrings(values) {
62
+ return values === void 0 ? void 0 : Object.freeze([...values]);
63
+ }
64
+ function freezeRuntimeAdapterDescriptor(descriptor) {
65
+ const baseNames = frozenStrings(descriptor.environmentRequirements.baseNames);
66
+ const credentialNames = frozenStrings(descriptor.environmentRequirements.credentialNames);
67
+ return Object.freeze({
68
+ id: descriptor.id,
69
+ supportsDispatchSelection: descriptor.supportsDispatchSelection === true,
70
+ capabilities: Object.freeze({
71
+ steer: descriptor.capabilities.steer === true,
72
+ resume: descriptor.capabilities.resume === true,
73
+ approvalInteractive: descriptor.capabilities.approvalInteractive === true,
74
+ ...descriptor.capabilities.mcpToolsets === void 0 ? {} : { mcpToolsets: descriptor.capabilities.mcpToolsets === true },
75
+ permissionModes: Object.freeze([...descriptor.capabilities.permissionModes])
76
+ }),
77
+ environmentRequirements: Object.freeze({
78
+ ...baseNames === void 0 ? {} : { baseNames },
79
+ ...credentialNames === void 0 ? {} : { credentialNames }
80
+ })
81
+ });
82
+ }
12
83
  var PolicyUnsupportedError = class extends Error {
13
84
  constructor(message) {
14
85
  super(message);
@@ -16,7 +87,7 @@ var PolicyUnsupportedError = class extends Error {
16
87
  }
17
88
  };
18
89
  var SteerUnsupportedError = class extends Error {
19
- /** The `RuntimeAdapter.id` that cannot steer (e.g. `claude`, `codex`). */
90
+ /** The `RuntimeAdapter.descriptor.id` that cannot steer (e.g. `claude`, `codex`). */
20
91
  runtimeId;
21
92
  constructor(runtimeId, message) {
22
93
  super(message);
@@ -57,12 +128,12 @@ function resolvePiBin() {
57
128
  }
58
129
  } catch (cause) {
59
130
  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`,
131
+ `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`,
61
132
  { cause }
62
133
  );
63
134
  }
64
135
  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`
136
+ `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`
66
137
  );
67
138
  }
68
139
 
@@ -268,6 +339,266 @@ var AsyncQueue = class {
268
339
  }
269
340
  };
270
341
 
342
+ // src/adapters/taskkill-pid-set.ts
343
+ var INTEGER_PATTERN = /\d+/g;
344
+ function isCandidatePid(value) {
345
+ return Number.isSafeInteger(value) && value > 0;
346
+ }
347
+ function walkTaskkillPidSet(text, rootPid, excludedPids = []) {
348
+ const excluded = new Set(excludedPids);
349
+ const accepted = /* @__PURE__ */ new Set();
350
+ if (isCandidatePid(rootPid)) accepted.add(rootPid);
351
+ const lines = text.split(/\r?\n/).map((line) => {
352
+ const pids = [];
353
+ for (const match of line.matchAll(INTEGER_PATTERN)) {
354
+ const pid = Number(match[0]);
355
+ if (isCandidatePid(pid) && !excluded.has(pid)) pids.push(pid);
356
+ }
357
+ return pids;
358
+ });
359
+ let changed = true;
360
+ while (changed) {
361
+ changed = false;
362
+ for (const pids of lines) {
363
+ if (!pids.some((pid) => accepted.has(pid))) continue;
364
+ for (const pid of pids) {
365
+ if (accepted.has(pid)) continue;
366
+ accepted.add(pid);
367
+ changed = true;
368
+ }
369
+ }
370
+ }
371
+ return accepted;
372
+ }
373
+
374
+ // src/adapters/process-tree.ts
375
+ var DEFAULT_TERM_GRACE_MS = 750;
376
+ var DEFAULT_KILL_GRACE_MS = 2e3;
377
+ var POLL_MS = 20;
378
+ var terminationState = /* @__PURE__ */ new WeakMap();
379
+ function stateFor(child) {
380
+ const existing = terminationState.get(child);
381
+ if (existing) return existing;
382
+ const created = { requested: false, acceptedPids: /* @__PURE__ */ new Set() };
383
+ terminationState.set(child, created);
384
+ return created;
385
+ }
386
+ function defaultKill(pid, signal) {
387
+ process.kill(pid, signal);
388
+ }
389
+ function withOwnedProcessTree(options) {
390
+ return {
391
+ ...options,
392
+ ...process.platform === "win32" ? { windowsHide: true } : { detached: true }
393
+ };
394
+ }
395
+ function positivePid(child, label) {
396
+ const pid = child.pid;
397
+ if (pid === void 0) return void 0;
398
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
399
+ throw new RuntimeDisposalFailure({
400
+ stage: "signal",
401
+ reason: `${label} runtime process has an unsafe owned pid`
402
+ });
403
+ }
404
+ return pid;
405
+ }
406
+ function groupExists(pid, label, kill) {
407
+ try {
408
+ kill(-pid, 0);
409
+ return true;
410
+ } catch (cause) {
411
+ const code = cause.code;
412
+ if (code === "ESRCH") return false;
413
+ if (code === "EPERM") return true;
414
+ throw new RuntimeDisposalFailure({
415
+ stage: "quiescence",
416
+ reason: `${label} runtime process-group state could not be verified`
417
+ }, { cause });
418
+ }
419
+ }
420
+ function processExists(pid, label, kill) {
421
+ try {
422
+ kill(pid, 0);
423
+ return true;
424
+ } catch (cause) {
425
+ const code = cause.code;
426
+ if (code === "ESRCH") return false;
427
+ if (code === "EPERM") return true;
428
+ throw new RuntimeDisposalFailure({
429
+ stage: "quiescence",
430
+ reason: `${label} runtime process ${pid} state could not be verified`
431
+ }, { cause });
432
+ }
433
+ }
434
+ function signalGroup(pid, signal, label, kill) {
435
+ try {
436
+ kill(-pid, signal);
437
+ } catch (cause) {
438
+ const code = cause.code;
439
+ if (code === "ESRCH" || code === "EPERM") return;
440
+ throw new RuntimeDisposalFailure({
441
+ stage: "signal",
442
+ reason: `${label} runtime process group ${pid} could not receive ${signal} (${code ?? "unknown"})`
443
+ }, { cause });
444
+ }
445
+ }
446
+ async function runTaskkill(pid, options) {
447
+ const spawnFn = options.spawnFn ?? spawn;
448
+ return new Promise((resolve, reject) => {
449
+ const signalFailure = (cause) => {
450
+ reject(new RuntimeDisposalFailure({
451
+ stage: "signal",
452
+ reason: `${options.label} runtime process tree termination could not be requested`
453
+ }, { cause }));
454
+ };
455
+ let taskkill;
456
+ try {
457
+ taskkill = spawnFn("taskkill", ["/PID", String(pid), "/T", "/F"], {
458
+ windowsHide: true,
459
+ stdio: ["ignore", "pipe", "pipe"]
460
+ });
461
+ } catch (cause) {
462
+ signalFailure(cause);
463
+ return;
464
+ }
465
+ const chunks = [];
466
+ taskkill.stdout?.on("data", (chunk) => chunks.push(chunk));
467
+ taskkill.stderr?.on("data", (chunk) => chunks.push(chunk));
468
+ taskkill.once("error", signalFailure);
469
+ taskkill.once("close", () => resolve(Buffer.concat(chunks).toString("latin1")));
470
+ });
471
+ }
472
+ function liveAcceptedPids(accepted, label, kill) {
473
+ const live = [];
474
+ for (const pid of accepted) {
475
+ if (processExists(pid, label, kill)) live.push(pid);
476
+ }
477
+ return live;
478
+ }
479
+ async function waitUntil(predicate, timeoutMs) {
480
+ const deadline = Date.now() + timeoutMs;
481
+ while (predicate()) {
482
+ if (Date.now() >= deadline) return false;
483
+ await new Promise((resolve) => {
484
+ setTimeout(resolve, POLL_MS);
485
+ });
486
+ }
487
+ return true;
488
+ }
489
+ async function waitWithDeadline(promise, timeoutMs) {
490
+ return new Promise((resolve) => {
491
+ let settled = false;
492
+ const timer = setTimeout(() => {
493
+ if (!settled) {
494
+ settled = true;
495
+ resolve(false);
496
+ }
497
+ }, timeoutMs);
498
+ void promise.then(
499
+ () => {
500
+ if (!settled) {
501
+ settled = true;
502
+ clearTimeout(timer);
503
+ resolve(true);
504
+ }
505
+ },
506
+ () => {
507
+ if (!settled) {
508
+ settled = true;
509
+ clearTimeout(timer);
510
+ resolve(false);
511
+ }
512
+ }
513
+ );
514
+ });
515
+ }
516
+ async function requestOwnedProcessTreeTermination(options) {
517
+ const platform = options.platform ?? process.platform;
518
+ if (platform !== "win32" && options.isClosed()) return;
519
+ const pid = positivePid(options.child, options.label);
520
+ if (pid === void 0) return;
521
+ if (platform === "win32") {
522
+ const output = await runTaskkill(pid, options);
523
+ const state = stateFor(options.child);
524
+ for (const walked of walkTaskkillPidSet(output, pid, [process.pid])) state.acceptedPids.add(walked);
525
+ state.requested = true;
526
+ return;
527
+ }
528
+ signalGroup(pid, "SIGTERM", options.label, options.killFn ?? defaultKill);
529
+ stateFor(options.child).requested = true;
530
+ }
531
+ async function disposeOwnedProcessTree(options) {
532
+ const pid = positivePid(options.child, options.label);
533
+ const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
534
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
535
+ const platform = options.platform ?? process.platform;
536
+ const kill = options.killFn ?? defaultKill;
537
+ if (pid === void 0) {
538
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
539
+ throw new RuntimeDisposalFailure({
540
+ stage: "quiescence",
541
+ reason: `${options.label} runtime process did not settle after spawn failure`
542
+ });
543
+ }
544
+ if (platform === "win32") {
545
+ if (!terminationState.get(options.child)?.requested) {
546
+ await requestOwnedProcessTreeTermination(options);
547
+ }
548
+ const accepted = terminationState.get(options.child)?.acceptedPids ?? /* @__PURE__ */ new Set();
549
+ const deadline = Date.now() + killGraceMs;
550
+ const resweepAt = Date.now() + Math.floor(killGraceMs / 2);
551
+ let reswept = false;
552
+ let live = liveAcceptedPids(accepted, options.label, kill);
553
+ while (live.length > 0) {
554
+ if (Date.now() >= deadline) {
555
+ throw new RuntimeDisposalFailure({
556
+ stage: "quiescence",
557
+ reason: `${options.label} runtime process tree did not quiesce: ${live.length} of ${accepted.size} walked process ids were still alive at the disposal deadline`
558
+ });
559
+ }
560
+ if (!reswept && Date.now() >= resweepAt) {
561
+ reswept = true;
562
+ for (const livePid of live) {
563
+ for (const walked of walkTaskkillPidSet(await runTaskkill(livePid, options), livePid, [process.pid])) {
564
+ accepted.add(walked);
565
+ }
566
+ }
567
+ }
568
+ await new Promise((resolve) => {
569
+ setTimeout(resolve, POLL_MS);
570
+ });
571
+ live = liveAcceptedPids(accepted, options.label, kill);
572
+ }
573
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
574
+ throw new RuntimeDisposalFailure({
575
+ stage: "quiescence",
576
+ reason: `${options.label} runtime root did not emit close after its process tree quiesced`
577
+ });
578
+ }
579
+ return;
580
+ }
581
+ if (groupExists(pid, options.label, kill) && !terminationState.get(options.child)?.requested) {
582
+ signalGroup(pid, "SIGTERM", options.label, kill);
583
+ stateFor(options.child).requested = true;
584
+ }
585
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), termGraceMs)) {
586
+ signalGroup(pid, "SIGKILL", options.label, kill);
587
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), killGraceMs)) {
588
+ throw new RuntimeDisposalFailure({
589
+ stage: "quiescence",
590
+ reason: `${options.label} runtime process group remained live after SIGKILL`
591
+ });
592
+ }
593
+ }
594
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
595
+ throw new RuntimeDisposalFailure({
596
+ stage: "quiescence",
597
+ reason: `${options.label} runtime root did not emit close after its process group exited`
598
+ });
599
+ }
600
+ }
601
+
271
602
  // src/adapters/pi/rpc-client.ts
272
603
  var STDERR_RING_CAPACITY = 20;
273
604
  var DIALOG_UI_METHODS = /* @__PURE__ */ new Set(["select", "confirm", "input", "editor"]);
@@ -279,16 +610,22 @@ var PiRpcClient = class {
279
610
  eventQueue = new AsyncQueue();
280
611
  closed = false;
281
612
  exitError;
613
+ closedPromise;
614
+ resolveClosed;
615
+ disposalAttempt;
282
616
  /** 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
617
  stderrRing = [];
284
618
  /** 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
619
  unmappedFrameCounts = /* @__PURE__ */ new Map();
286
620
  constructor(options) {
287
621
  const spawnFn = options.spawnFn ?? spawn;
288
- this.child = spawnFn(options.command, options.args, {
622
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
289
623
  cwd: options.cwd,
290
624
  env: options.env,
291
625
  stdio: ["pipe", "pipe", "pipe"]
626
+ }));
627
+ this.closedPromise = new Promise((resolve) => {
628
+ this.resolveClosed = resolve;
292
629
  });
293
630
  this.child.stdout.setEncoding("utf8");
294
631
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -323,6 +660,10 @@ var PiRpcClient = class {
323
660
  get events() {
324
661
  return this.eventQueue;
325
662
  }
663
+ /** Local transport diagnostic retained when the process closes; consumers must classify it explicitly. */
664
+ get terminalError() {
665
+ return this.exitError;
666
+ }
326
667
  /**
327
668
  * Record a pi RPC message `type` that `PiSession` (pi-adapter.ts) decided
328
669
  * has no `AgentEvent` mapping and isn't routine bookkeeping (see
@@ -341,15 +682,37 @@ var PiRpcClient = class {
341
682
  );
342
683
  }
343
684
  }
344
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes pi itself spawned (e.g. bash). */
685
+ /**
686
+ * Immediate process-tree termination request. `dispose()` is the settlement
687
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
688
+ * terminator. A request that could not be spawned is left unrecorded, so
689
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
690
+ * swallowing it here loses nothing.
691
+ */
345
692
  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");
693
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
694
+ });
695
+ }
696
+ waitClosed() {
697
+ return this.closedPromise;
698
+ }
699
+ dispose() {
700
+ if (!this.disposalAttempt) {
701
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
702
+ this.disposalAttempt = attempt.catch((error) => {
703
+ this.disposalAttempt = void 0;
704
+ throw error;
705
+ });
352
706
  }
707
+ return this.disposalAttempt;
708
+ }
709
+ processTreeOptions() {
710
+ return {
711
+ child: this.child,
712
+ waitClosed: () => this.closedPromise,
713
+ isClosed: () => this.closed,
714
+ label: "pi"
715
+ };
353
716
  }
354
717
  onData(chunk) {
355
718
  this.buffer += chunk;
@@ -435,6 +798,7 @@ var PiRpcClient = class {
435
798
  if (this.closed) return;
436
799
  this.closed = true;
437
800
  this.exitError = err;
801
+ this.resolveClosed();
438
802
  for (const [, waiter] of this.pending) waiter.reject(err);
439
803
  this.pending.clear();
440
804
  this.eventQueue.end();
@@ -505,8 +869,17 @@ var PiAdapter = class {
505
869
  this.options = options;
506
870
  }
507
871
  options;
508
- id = "pi";
509
- supportsDispatchSelection = true;
872
+ descriptor = freezeRuntimeAdapterDescriptor({
873
+ id: "pi",
874
+ supportsDispatchSelection: true,
875
+ capabilities: {
876
+ steer: true,
877
+ resume: true,
878
+ approvalInteractive: false,
879
+ permissionModes: ["auto", "readonly"]
880
+ },
881
+ environmentRequirements: { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES }
882
+ });
510
883
  async detect() {
511
884
  try {
512
885
  const bin = this.resolveBin();
@@ -518,49 +891,26 @@ var PiAdapter = class {
518
891
  return { present: false };
519
892
  }
520
893
  }
521
- capabilities() {
522
- return { steer: true, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
523
- }
524
- /**
525
- * M5: pi authenticates to its ~30 supported providers via env-var API
526
- * keys — `detect()`'s own `authPresent` probe above checks this identical
527
- * list — so these MUST keep flowing into pi's spawned process or pi auth
528
- * breaks entirely. `KNOWN_PROVIDER_ENV_VARS` above is the single source
529
- * of truth, reused here rather than duplicated. No `baseNames`: nothing
530
- * in this adapter or `rpc-client.ts` reads a pi-specific config-discovery
531
- * variable beyond the platform baseline (`daemon/environment.ts`).
532
- */
533
- environmentRequirements() {
534
- return { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES };
535
- }
536
- async start(task, ctx) {
537
- if (typeof task.instruction !== "string") {
538
- throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
539
- }
540
- const mapping = mapPermissionPolicyToPiArgs(ctx.policy);
894
+ async prepare(input) {
895
+ const mapping = mapPermissionPolicyToPiArgs(input.policy);
541
896
  if (!mapping.ok) {
542
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
897
+ return { kind: "reject", reason: mapping.reason ?? "policy rejected by pi adapter", retryable: false };
543
898
  }
544
899
  const bin = this.resolveBin();
545
- const resumeSessionId = task.sessionRef;
546
- const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
547
- const selection = task.dispatchSelection;
900
+ const selection = input.offer.dispatchSelection;
901
+ const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
548
902
  let command = bin.command;
549
- let args = piArgs;
550
- if (selection !== void 0) {
551
- if (selection.lane !== "byok" || selection.runtimeId !== "pi") {
552
- throw new PolicyUnsupportedError(
553
- `pi adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
554
- );
903
+ let launcherArgs;
904
+ if (pinnedSelection !== void 0) {
905
+ if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
906
+ return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
555
907
  }
556
908
  const launcher = this.options.byokLauncher;
557
909
  if (launcher === void 0) {
558
- throw new PolicyUnsupportedError(
559
- "pi BYOK selection requires a configured credential-custody launcher"
560
- );
910
+ return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
561
911
  }
562
912
  command = launcher.command;
563
- args = [
913
+ launcherArgs = [
564
914
  ...launcher.args ?? [],
565
915
  "--pi-bin",
566
916
  bin.command,
@@ -570,62 +920,136 @@ var PiAdapter = class {
570
920
  launcher.sessionDir,
571
921
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
572
922
  "--provider",
573
- selection.providerId,
923
+ pinnedSelection.providerId,
574
924
  "--model",
575
- selection.modelId,
576
- "--",
577
- ...piArgs
925
+ pinnedSelection.modelId
578
926
  ];
579
927
  }
580
- const rpc = new PiRpcClient({
581
- command,
582
- args,
583
- cwd: ctx.workspaceDir,
584
- env: selection === void 0 ? ctx.env : withoutProviderCredentials(ctx.env),
585
- spawnFn: this.options.spawnFn
586
- });
587
- const response = await rpc.send({ type: "prompt", message: task.instruction });
588
- if (response.success === false) {
589
- rpc.kill();
590
- throw new Error(typeof response.error === "string" ? response.error : "pi rejected the initial prompt");
591
- }
592
- let sessionRef;
593
- if (resumeSessionId) {
594
- sessionRef = resumeSessionId;
595
- } else {
596
- try {
597
- sessionRef = await resolveFreshSessionId(rpc);
598
- } catch (err) {
599
- rpc.kill();
600
- throw err;
928
+ return {
929
+ kind: "prepared",
930
+ operation: {
931
+ start: async (startInput) => {
932
+ const manifestSelection = startInput.manifest.dispatchSelection;
933
+ if (!sameDispatchSelection(manifestSelection, pinnedSelection)) {
934
+ throw new RuntimeExecutionFailure({
935
+ phase: "start",
936
+ category: "authority",
937
+ retry: "non-retryable",
938
+ reason: "prepared pi operation received a manifest with different runtime selection"
939
+ });
940
+ }
941
+ if (typeof startInput.instruction !== "string") {
942
+ throw new RuntimeExecutionFailure({
943
+ phase: "start",
944
+ category: "authority",
945
+ retry: "non-retryable",
946
+ reason: "prepared pi operation requires a resolved string instruction"
947
+ });
948
+ }
949
+ const resumeSessionId = startInput.manifest.sessionRef;
950
+ const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
951
+ const args = launcherArgs === void 0 ? piArgs : [...launcherArgs, "--", ...piArgs];
952
+ let rpc;
953
+ try {
954
+ rpc = new PiRpcClient({
955
+ command,
956
+ args,
957
+ cwd: startInput.manifest.workspace.workspaceDir,
958
+ env: manifestSelection === void 0 ? startInput.env : withoutProviderCredentials(startInput.env),
959
+ spawnFn: this.options.spawnFn
960
+ });
961
+ } catch (cause) {
962
+ throw new RuntimeExecutionFailure({
963
+ phase: "start",
964
+ category: "infrastructure",
965
+ retry: "retryable",
966
+ reason: "pi runtime process could not be spawned"
967
+ }, { cause });
968
+ }
969
+ let response;
970
+ try {
971
+ response = await rpc.send({ type: "prompt", message: startInput.instruction });
972
+ } catch (cause) {
973
+ rpc.kill();
974
+ throw new RuntimeExecutionFailure({
975
+ phase: "start",
976
+ category: "infrastructure",
977
+ retry: "retryable",
978
+ reason: `pi initial prompt transport failed: ${errorMessage(cause)}`
979
+ }, { cause });
980
+ }
981
+ if (response.success === false) {
982
+ rpc.kill();
983
+ throw new RuntimeExecutionFailure({
984
+ phase: "start",
985
+ category: "semantic",
986
+ retry: "non-retryable",
987
+ reason: typeof response.error === "string" ? response.error : "pi rejected the initial prompt"
988
+ });
989
+ }
990
+ let sessionRef;
991
+ try {
992
+ sessionRef = await resolveAuthoritativeSessionId(rpc);
993
+ } catch (err) {
994
+ rpc.kill();
995
+ throw err;
996
+ }
997
+ if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
998
+ rpc.kill();
999
+ throw new RuntimeExecutionFailure({
1000
+ phase: "start",
1001
+ category: "authority",
1002
+ retry: "non-retryable",
1003
+ reason: "pi resumed a different authoritative session than requested"
1004
+ });
1005
+ }
1006
+ return new PiSession(sessionRef, rpc, manifestSelection);
1007
+ }
601
1008
  }
602
- }
603
- return new PiSession(sessionRef, rpc, selection);
1009
+ };
604
1010
  }
605
1011
  resolveBin() {
606
1012
  return (this.options.resolveBin ?? resolvePiBin)();
607
1013
  }
608
1014
  };
609
- async function resolveFreshSessionId(rpc) {
1015
+ function sameDispatchSelection(left, right) {
1016
+ if (left === void 0 || right === void 0) return left === right;
1017
+ return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
1018
+ }
1019
+ async function resolveAuthoritativeSessionId(rpc) {
610
1020
  let state;
611
1021
  try {
612
1022
  state = await rpc.send({ type: "get_state" });
613
1023
  } catch (err) {
614
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${errorMessage(err)}`, {
1024
+ if (isRuntimeExecutionFailure(err)) throw err;
1025
+ throw new RuntimeExecutionFailure({
1026
+ phase: "start",
1027
+ category: "infrastructure",
1028
+ retry: "retryable",
1029
+ reason: `pi transport ended before yielding an authoritative session id: ${errorMessage(err)}`
1030
+ }, {
615
1031
  cause: err
616
1032
  });
617
1033
  }
618
1034
  if (state.success === false) {
619
1035
  const reason = typeof state.error === "string" ? state.error : "get_state reported failure";
620
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${reason}`);
1036
+ throw new RuntimeExecutionFailure({
1037
+ phase: "start",
1038
+ category: "authority",
1039
+ retry: "non-retryable",
1040
+ reason: `pi did not yield an authoritative session id: ${reason}`
1041
+ });
621
1042
  }
622
1043
  const data = state.data;
623
1044
  if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
624
1045
  return data.sessionId;
625
1046
  }
626
- throw new Error(
627
- "pi did not yield an authoritative session id (get_state succeeded but reported no sessionId) \u2014 cannot mint a resumable session"
628
- );
1047
+ throw new RuntimeExecutionFailure({
1048
+ phase: "start",
1049
+ category: "authority",
1050
+ retry: "non-retryable",
1051
+ reason: "pi get_state reported no authoritative session id"
1052
+ });
629
1053
  }
630
1054
  var PiSession = class {
631
1055
  constructor(sessionRef, rpc, selection) {
@@ -641,12 +1065,40 @@ var PiSession = class {
641
1065
  return {
642
1066
  [Symbol.asyncIterator]() {
643
1067
  const inner = rpc.events[Symbol.asyncIterator]();
1068
+ let terminalFailure;
644
1069
  return {
645
1070
  async next() {
646
1071
  for (; ; ) {
647
- const { value, done } = await inner.next();
648
- if (done) return { value: void 0, done: true };
1072
+ if (terminalFailure) throw terminalFailure;
1073
+ let result;
1074
+ try {
1075
+ result = await inner.next();
1076
+ } catch (cause) {
1077
+ throw new RuntimeExecutionFailure({
1078
+ phase: "run",
1079
+ category: "infrastructure",
1080
+ retry: "retryable",
1081
+ reason: "pi runtime event transport failed"
1082
+ }, { cause });
1083
+ }
1084
+ const { value, done } = result;
1085
+ if (done) {
1086
+ throw new RuntimeExecutionFailure({
1087
+ phase: "run",
1088
+ category: "infrastructure",
1089
+ retry: "retryable",
1090
+ reason: "pi runtime process ended before agent_settled"
1091
+ }, { cause: rpc.terminalError });
1092
+ }
649
1093
  const mapped = mapPiMessageToAgentEvent(value);
1094
+ if (value.type === "auto_retry_end" && value.success === false) {
1095
+ terminalFailure = new RuntimeExecutionFailure({
1096
+ phase: "run",
1097
+ category: "semantic",
1098
+ retry: "non-retryable",
1099
+ reason: "pi exhausted its native retry policy"
1100
+ });
1101
+ }
650
1102
  if (mapped) return { value: mapped, done: false };
651
1103
  if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
652
1104
  rpc.recordUnmappedFrame(value.type);
@@ -676,7 +1128,7 @@ var PiSession = class {
676
1128
  await this.rpc.send({ type: "abort" });
677
1129
  }
678
1130
  async close() {
679
- this.rpc.kill();
1131
+ await this.rpc.dispose();
680
1132
  }
681
1133
  async resolveApproval() {
682
1134
  throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
@@ -888,7 +1340,15 @@ function mapResult(msg) {
888
1340
  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`
889
1341
  );
890
1342
  const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
891
- return { events };
1343
+ return {
1344
+ events,
1345
+ terminalFailure: new RuntimeExecutionFailure({
1346
+ phase: "run",
1347
+ category: msg.is_error === true ? "semantic" : "authority",
1348
+ retry: "non-retryable",
1349
+ reason: msg.is_error === true ? "claude reported terminal task failure" : "claude emitted a malformed terminal result frame"
1350
+ })
1351
+ };
892
1352
  }
893
1353
  var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
894
1354
  function truncateResultDiagnostic(text) {
@@ -940,16 +1400,22 @@ var ClaudeProcessClient = class {
940
1400
  eventQueue = new AsyncQueue();
941
1401
  closed = false;
942
1402
  exitError;
1403
+ closedPromise;
1404
+ resolveClosed;
1405
+ disposalAttempt;
943
1406
  stderrRing = [];
944
1407
  unmappedFrameCounts = /* @__PURE__ */ new Map();
945
1408
  sessionId;
946
1409
  initWaiter;
947
1410
  constructor(options) {
948
1411
  const spawnFn = options.spawnFn ?? spawn;
949
- this.child = spawnFn(options.command, options.args, {
1412
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
950
1413
  cwd: options.cwd,
951
1414
  env: options.env,
952
1415
  stdio: ["pipe", "pipe", "pipe"]
1416
+ }));
1417
+ this.closedPromise = new Promise((resolve) => {
1418
+ this.resolveClosed = resolve;
953
1419
  });
954
1420
  this.child.stdout.setEncoding("utf8");
955
1421
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1005,6 +1471,10 @@ var ClaudeProcessClient = class {
1005
1471
  get events() {
1006
1472
  return this.eventQueue;
1007
1473
  }
1474
+ /** Local transport diagnostic retained when the process closes; consumers classify it at the session boundary. */
1475
+ get terminalError() {
1476
+ return this.exitError;
1477
+ }
1008
1478
  /**
1009
1479
  * Record a claude stream-json frame/subtype/content-block label that
1010
1480
  * `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
@@ -1024,15 +1494,37 @@ var ClaudeProcessClient = class {
1024
1494
  );
1025
1495
  }
1026
1496
  }
1027
- /** 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). */
1497
+ /**
1498
+ * Immediate process-tree termination request. `dispose()` is the settlement
1499
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
1500
+ * terminator. A request that could not be spawned is left unrecorded, so
1501
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
1502
+ * swallowing it here loses nothing.
1503
+ */
1028
1504
  kill() {
1029
- if (this.closed) return;
1030
- const pid = this.child.pid;
1031
- if (process.platform === "win32" && pid !== void 0) {
1032
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1033
- } else {
1034
- this.child.kill("SIGTERM");
1505
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
1506
+ });
1507
+ }
1508
+ waitClosed() {
1509
+ return this.closedPromise;
1510
+ }
1511
+ dispose() {
1512
+ if (!this.disposalAttempt) {
1513
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
1514
+ this.disposalAttempt = attempt.catch((error) => {
1515
+ this.disposalAttempt = void 0;
1516
+ throw error;
1517
+ });
1035
1518
  }
1519
+ return this.disposalAttempt;
1520
+ }
1521
+ processTreeOptions() {
1522
+ return {
1523
+ child: this.child,
1524
+ waitClosed: () => this.closedPromise,
1525
+ isClosed: () => this.closed,
1526
+ label: "claude"
1527
+ };
1036
1528
  }
1037
1529
  onData(chunk) {
1038
1530
  this.buffer += chunk;
@@ -1083,6 +1575,7 @@ var ClaudeProcessClient = class {
1083
1575
  if (this.closed) return;
1084
1576
  this.closed = true;
1085
1577
  this.exitError = err;
1578
+ this.resolveClosed();
1086
1579
  this.initWaiter?.reject(err);
1087
1580
  this.initWaiter = void 0;
1088
1581
  this.eventQueue.end();
@@ -1094,18 +1587,37 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1094
1587
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1095
1588
  var execFileAsync2 = promisify(execFile);
1096
1589
  var DETECT_TIMEOUT_MS2 = 5e3;
1590
+ function errorMessage2(err) {
1591
+ return err instanceof Error ? err.message : String(err);
1592
+ }
1097
1593
  async function cleanupMcpConfigDir(dir) {
1098
1594
  if (!dir) return;
1099
- await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1100
- });
1595
+ try {
1596
+ await promises.rm(dir, { recursive: true, force: true });
1597
+ } catch (cause) {
1598
+ throw new RuntimeDisposalFailure({
1599
+ stage: "cleanup",
1600
+ reason: "claude task-scoped MCP configuration could not be removed"
1601
+ }, { cause });
1602
+ }
1101
1603
  }
1102
1604
  var ClaudeAdapter = class {
1103
1605
  constructor(options = {}) {
1104
1606
  this.options = options;
1105
1607
  }
1106
1608
  options;
1107
- supportsDispatchSelection = true;
1108
- id = "claude";
1609
+ descriptor = freezeRuntimeAdapterDescriptor({
1610
+ id: "claude",
1611
+ supportsDispatchSelection: true,
1612
+ capabilities: {
1613
+ steer: false,
1614
+ resume: true,
1615
+ approvalInteractive: true,
1616
+ mcpToolsets: true,
1617
+ permissionModes: ["auto", "readonly", "plan", "confirm"]
1618
+ },
1619
+ environmentRequirements: { credentialNames: [] }
1620
+ });
1109
1621
  async detect() {
1110
1622
  const bin = this.resolveBin();
1111
1623
  try {
@@ -1117,54 +1629,73 @@ var ClaudeAdapter = class {
1117
1629
  return { present: false };
1118
1630
  }
1119
1631
  }
1120
- capabilities() {
1632
+ async prepare(input) {
1633
+ const mapping = mapPermissionPolicyToClaudeArgs(input.policy);
1634
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by claude adapter", retryable: false };
1635
+ let modelId;
1636
+ try {
1637
+ modelId = subscriptionModel(input.offer.dispatchSelection, "claude");
1638
+ } catch (error) {
1639
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
1640
+ }
1641
+ if (mapping.needsApprovalMcp && Object.prototype.hasOwnProperty.call(input.mcpServers ?? {}, APPROVAL_MCP_SERVER_NAME)) {
1642
+ return { kind: "reject", reason: `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`, retryable: false };
1643
+ }
1644
+ let bin;
1645
+ try {
1646
+ bin = this.resolveBin();
1647
+ } catch (error) {
1648
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
1649
+ }
1650
+ let approvalMcpBin;
1651
+ if (mapping.needsApprovalMcp) {
1652
+ try {
1653
+ approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1654
+ } catch (error) {
1655
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
1656
+ }
1657
+ }
1121
1658
  return {
1122
- steer: false,
1123
- resume: true,
1124
- approvalInteractive: true,
1125
- mcpToolsets: true,
1126
- permissionModes: ["auto", "readonly", "plan", "confirm"]
1659
+ kind: "prepared",
1660
+ operation: {
1661
+ start: (startInput) => this.startPrepared(startInput, mapping, modelId, bin, approvalMcpBin)
1662
+ }
1127
1663
  };
1128
1664
  }
1129
- /**
1130
- * M5: deliberate product-boundary decision, not an oversight — byok's
1131
- * current ToS posture for claude is login-state-only (`claude auth
1132
- * login`'s own OAuth session — see `probeAuthPresent` below), so this
1133
- * adapter declares NO credential env vars at all; env-based API-key
1134
- * passthrough for claude is a separate, still-pending product decision.
1135
- * A product that genuinely needs it can opt in locally per-device via
1136
- * `DaemonConfig.runtimeEnvironment.claude.allow` (`create-daemon.ts`).
1137
- * `baseNames` is empty too: nothing in this adapter reads a
1138
- * claude-specific config-discovery variable (e.g. `CLAUDE_CONFIG_DIR`)
1139
- * today — if a future version of this adapter starts reading one, it
1140
- * belongs here, not left to rely on the platform baseline alone.
1141
- */
1142
- environmentRequirements() {
1143
- return { credentialNames: [] };
1144
- }
1145
- async start(task, ctx) {
1146
- if (typeof task.instruction !== "string") {
1147
- throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1148
- }
1149
- const mapping = mapPermissionPolicyToClaudeArgs(ctx.policy);
1150
- if (!mapping.ok) {
1151
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
1665
+ async startPrepared(startInput, initialMapping, modelId, bin, approvalMcpBin) {
1666
+ if (!initialMapping.ok) throw new RuntimeExecutionFailure({
1667
+ phase: "start",
1668
+ category: "authority",
1669
+ retry: "non-retryable",
1670
+ reason: "prepared claude permission mapping was invalid"
1671
+ });
1672
+ if (typeof startInput.instruction !== "string") {
1673
+ throw new RuntimeExecutionFailure({
1674
+ phase: "start",
1675
+ category: "authority",
1676
+ retry: "non-retryable",
1677
+ reason: "prepared claude operation requires a resolved string instruction"
1678
+ });
1152
1679
  }
1153
- const modelId = subscriptionModel(task, "claude");
1680
+ const mapping = { ...initialMapping, args: [...initialMapping.args] };
1154
1681
  let mcpConfigDir;
1155
- const taskMcpServers = ctx.mcpServers ?? {};
1682
+ const taskMcpServers = startInput.mcpServers ?? {};
1156
1683
  const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
1157
1684
  if (mapping.needsApprovalMcp) {
1158
- if (!ctx.approvalChannel) {
1159
- throw new PolicyUnsupportedError(
1160
- 'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1161
- );
1162
- }
1163
- if (Object.prototype.hasOwnProperty.call(taskMcpServers, APPROVAL_MCP_SERVER_NAME)) {
1164
- throw new PolicyUnsupportedError(
1165
- `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`
1166
- );
1685
+ if (!startInput.approvalChannel) {
1686
+ throw new RuntimeExecutionFailure({
1687
+ phase: "start",
1688
+ category: "authority",
1689
+ retry: "non-retryable",
1690
+ reason: 'claude adapter requires policy.mode "confirm" to be started with an approval channel'
1691
+ });
1167
1692
  }
1693
+ if (!approvalMcpBin) throw new RuntimeExecutionFailure({
1694
+ phase: "start",
1695
+ category: "authority",
1696
+ retry: "non-retryable",
1697
+ reason: "prepared claude approval MCP binary was not resolved"
1698
+ });
1168
1699
  }
1169
1700
  if (needsMcpConfig) {
1170
1701
  mcpConfigDir = await promises.mkdtemp(path3.join(os.tmpdir(), "byok-mcp-"));
@@ -1173,12 +1704,23 @@ var ClaudeAdapter = class {
1173
1704
  const mcpConfigPath = path3.join(mcpConfigDir, "mcp-config.json");
1174
1705
  const mcpServers = { ...taskMcpServers };
1175
1706
  if (mapping.needsApprovalMcp) {
1176
- const approvalChannel = ctx.approvalChannel;
1177
- if (!approvalChannel) throw new Error("unreachable: approval channel checked above");
1178
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1707
+ const approvalChannel = startInput.approvalChannel;
1708
+ if (!approvalChannel) throw new RuntimeExecutionFailure({
1709
+ phase: "start",
1710
+ category: "authority",
1711
+ retry: "non-retryable",
1712
+ reason: "prepared claude approval channel was not available"
1713
+ });
1714
+ const preparedApprovalMcpBin = approvalMcpBin;
1715
+ if (!preparedApprovalMcpBin) throw new RuntimeExecutionFailure({
1716
+ phase: "start",
1717
+ category: "authority",
1718
+ retry: "non-retryable",
1719
+ reason: "prepared claude approval MCP binary was not resolved"
1720
+ });
1179
1721
  mcpServers[APPROVAL_MCP_SERVER_NAME] = {
1180
- command: approvalMcpBin.command,
1181
- args: approvalMcpBin.args,
1722
+ command: preparedApprovalMcpBin.command,
1723
+ args: preparedApprovalMcpBin.args,
1182
1724
  env: {
1183
1725
  BYOK_STORE_DIR: approvalChannel.storeDir,
1184
1726
  BYOK_PRODUCT_ID: approvalChannel.productId,
@@ -1198,8 +1740,26 @@ var ClaudeAdapter = class {
1198
1740
  "--strict-mcp-config"
1199
1741
  ];
1200
1742
  }
1201
- const bin = this.resolveBin();
1202
- const resumeSessionId = task.sessionRef;
1743
+ const resumeSessionId = startInput.manifest.sessionRef;
1744
+ let manifestModelId;
1745
+ try {
1746
+ manifestModelId = subscriptionModel(startInput.manifest.dispatchSelection, "claude");
1747
+ } catch (cause) {
1748
+ throw new RuntimeExecutionFailure({
1749
+ phase: "start",
1750
+ category: "authority",
1751
+ retry: "non-retryable",
1752
+ reason: "prepared claude operation received an invalid runtime selection manifest"
1753
+ }, { cause });
1754
+ }
1755
+ if (manifestModelId !== modelId) {
1756
+ throw new RuntimeExecutionFailure({
1757
+ phase: "start",
1758
+ category: "authority",
1759
+ retry: "non-retryable",
1760
+ reason: "prepared claude operation received a manifest with different runtime selection"
1761
+ });
1762
+ }
1203
1763
  const args = [
1204
1764
  "-p",
1205
1765
  "--input-format",
@@ -1211,40 +1771,71 @@ var ClaudeAdapter = class {
1211
1771
  // "Error: When using --print, --output-format=stream-json requires
1212
1772
  // --verbose", before spawning any model call.
1213
1773
  "--verbose",
1214
- ...modelId ? ["--model", modelId] : [],
1774
+ ...manifestModelId ? ["--model", manifestModelId] : [],
1215
1775
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1216
1776
  ...mapping.args
1217
1777
  ];
1218
- const client = new ClaudeProcessClient({
1219
- command: bin.command,
1220
- args,
1221
- cwd: ctx.workspaceDir,
1222
- env: withoutProviderCredentials(ctx.env),
1223
- spawnFn: this.options.spawnFn
1224
- });
1225
- client.writeUserMessage(task.instruction);
1778
+ let client;
1779
+ try {
1780
+ client = new ClaudeProcessClient({
1781
+ command: bin.command,
1782
+ args,
1783
+ cwd: startInput.manifest.workspace.workspaceDir,
1784
+ env: withoutProviderCredentials(startInput.env),
1785
+ spawnFn: this.options.spawnFn
1786
+ });
1787
+ } catch (cause) {
1788
+ await cleanupMcpConfigDir(mcpConfigDir);
1789
+ throw new RuntimeExecutionFailure({
1790
+ phase: "start",
1791
+ category: "infrastructure",
1792
+ retry: "retryable",
1793
+ reason: "claude runtime process could not be spawned"
1794
+ }, { cause });
1795
+ }
1796
+ try {
1797
+ client.writeUserMessage(startInput.instruction);
1798
+ } catch (cause) {
1799
+ client.kill();
1800
+ await cleanupMcpConfigDir(mcpConfigDir);
1801
+ throw new RuntimeExecutionFailure({
1802
+ phase: "start",
1803
+ category: "infrastructure",
1804
+ retry: "retryable",
1805
+ reason: "claude initial instruction transport failed"
1806
+ }, { cause });
1807
+ }
1226
1808
  let sessionRef;
1227
1809
  try {
1228
1810
  sessionRef = await client.waitForInit();
1229
1811
  } catch (err) {
1230
1812
  client.kill();
1231
1813
  await cleanupMcpConfigDir(mcpConfigDir);
1232
- throw err;
1814
+ if (isRuntimeExecutionFailure(err)) throw err;
1815
+ throw new RuntimeExecutionFailure({
1816
+ phase: "start",
1817
+ category: "infrastructure",
1818
+ retry: "retryable",
1819
+ reason: `claude exited before yielding an authoritative session id: ${errorMessage2(err)}`
1820
+ }, { cause: err });
1233
1821
  }
1234
1822
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1235
1823
  client.kill();
1236
1824
  await cleanupMcpConfigDir(mcpConfigDir);
1237
- throw new Error(
1238
- `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1239
- );
1825
+ throw new RuntimeExecutionFailure({
1826
+ phase: "start",
1827
+ category: "authority",
1828
+ retry: "non-retryable",
1829
+ reason: `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef})`
1830
+ });
1240
1831
  }
1241
1832
  return new ClaudeSession(
1242
1833
  sessionRef,
1243
1834
  client,
1244
- ctx.workspaceDir,
1245
- ctx.approvalChannel,
1835
+ startInput.manifest.workspace.workspaceDir,
1836
+ startInput.approvalChannel,
1246
1837
  mcpConfigDir,
1247
- modelId
1838
+ manifestModelId
1248
1839
  );
1249
1840
  }
1250
1841
  /**
@@ -1279,8 +1870,7 @@ var ClaudeAdapter = class {
1279
1870
  return (this.options.resolveBin ?? resolveClaudeBin)();
1280
1871
  }
1281
1872
  };
1282
- function subscriptionModel(task, runtimeId) {
1283
- const selection = task.dispatchSelection;
1873
+ function subscriptionModel(selection, runtimeId) {
1284
1874
  if (selection === void 0) return void 0;
1285
1875
  if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
1286
1876
  throw new PolicyUnsupportedError(
@@ -1305,6 +1895,7 @@ var ClaudeSession = class {
1305
1895
  mcpConfigDir;
1306
1896
  modelId;
1307
1897
  correlation = createToolUseCorrelation();
1898
+ closeAttempt;
1308
1899
  get events() {
1309
1900
  const client = this.client;
1310
1901
  const correlation = this.correlation;
@@ -1313,17 +1904,40 @@ var ClaudeSession = class {
1313
1904
  [Symbol.asyncIterator]() {
1314
1905
  const inner = client.events[Symbol.asyncIterator]();
1315
1906
  let pending = [];
1907
+ let terminalFailure;
1316
1908
  let turnSettled = false;
1317
1909
  return {
1318
1910
  async next() {
1319
1911
  for (; ; ) {
1320
1912
  const buffered = pending.shift();
1321
1913
  if (buffered) return { value: buffered, done: false };
1322
- if (turnSettled) return { value: void 0, done: true };
1323
- const { value, done } = await inner.next();
1324
- if (done) return { value: void 0, done: true };
1914
+ if (turnSettled) {
1915
+ if (terminalFailure) throw terminalFailure;
1916
+ return { value: void 0, done: true };
1917
+ }
1918
+ let raw;
1919
+ try {
1920
+ raw = await inner.next();
1921
+ } catch (cause) {
1922
+ throw new RuntimeExecutionFailure({
1923
+ phase: "run",
1924
+ category: "infrastructure",
1925
+ retry: "retryable",
1926
+ reason: "claude runtime event transport failed"
1927
+ }, { cause });
1928
+ }
1929
+ const { value, done } = raw;
1930
+ if (done) {
1931
+ throw new RuntimeExecutionFailure({
1932
+ phase: "run",
1933
+ category: "infrastructure",
1934
+ retry: "retryable",
1935
+ reason: "claude runtime process ended before a terminal result frame"
1936
+ }, { cause: client.terminalError });
1937
+ }
1325
1938
  if (value.type === "result") turnSettled = true;
1326
1939
  const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
1940
+ terminalFailure = mapped.terminalFailure ?? terminalFailure;
1327
1941
  if (mapped.unmappedLabel) {
1328
1942
  client.recordUnmappedFrame(mapped.unmappedLabel);
1329
1943
  }
@@ -1354,7 +1968,7 @@ var ClaudeSession = class {
1354
1968
  if (typeof task.instruction !== "string") {
1355
1969
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1356
1970
  }
1357
- const requestedModel = subscriptionModel(task, "claude");
1971
+ const requestedModel = subscriptionModel(task.dispatchSelection, "claude");
1358
1972
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
1359
1973
  throw new PolicyUnsupportedError(
1360
1974
  `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -1378,8 +1992,17 @@ var ClaudeSession = class {
1378
1992
  this.client.kill();
1379
1993
  }
1380
1994
  async close() {
1381
- this.client.kill();
1382
- await cleanupMcpConfigDir(this.mcpConfigDir);
1995
+ if (!this.closeAttempt) {
1996
+ const attempt = (async () => {
1997
+ await this.client.dispose();
1998
+ await cleanupMcpConfigDir(this.mcpConfigDir);
1999
+ })();
2000
+ this.closeAttempt = attempt.catch((error) => {
2001
+ this.closeAttempt = void 0;
2002
+ throw error;
2003
+ });
2004
+ }
2005
+ await this.closeAttempt;
1383
2006
  }
1384
2007
  /**
1385
2008
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -1596,14 +2219,15 @@ var CodexProcessRunner = class {
1596
2219
  exitSignal = null;
1597
2220
  closedPromise;
1598
2221
  resolveClosed;
2222
+ disposalAttempt;
1599
2223
  constructor(options) {
1600
2224
  this.onEvent = options.onEvent;
1601
2225
  const spawnFn = options.spawnFn ?? spawn;
1602
- this.child = spawnFn(options.command, options.args, {
2226
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
1603
2227
  cwd: options.cwd,
1604
2228
  env: options.env,
1605
2229
  stdio: ["ignore", "pipe", "pipe"]
1606
- });
2230
+ }));
1607
2231
  this.closedPromise = new Promise((resolve) => {
1608
2232
  this.resolveClosed = resolve;
1609
2233
  });
@@ -1633,7 +2257,7 @@ var CodexProcessRunner = class {
1633
2257
  return this.closed;
1634
2258
  }
1635
2259
  /**
1636
- * Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
2260
+ * Immediate tree termination request. SIGTERM on POSIX: SIGINT was empirically confirmed
1637
2261
  * to be silently ignored by `codex exec` (a real, direct test — a 60s
1638
2262
  * shell `sleep` ran to full, unaffected completion despite SIGINT sent at
1639
2263
  * t=4s) — a genuine, evidence-based correction to this task's own initial
@@ -1644,15 +2268,33 @@ var CodexProcessRunner = class {
1644
2268
  * cleanly resumable afterward via `codex exec resume` (no corruption from
1645
2269
  * killing mid-turn). `taskkill /T /F` on Windows, mirroring
1646
2270
  * `../pi/rpc-client.ts`'s own cross-platform convention.
2271
+ *
2272
+ * Fire-and-forget by design: an interrupt must not block on a terminator,
2273
+ * and `dispose()` is the settlement receipt. A request that could not be
2274
+ * spawned is left unrecorded, so `dispose()` re-issues it and raises the
2275
+ * typed `stage:'signal'` failure — swallowing it here loses nothing.
1647
2276
  */
1648
2277
  kill() {
1649
- if (this.closed) return;
1650
- const pid = this.child.pid;
1651
- if (process.platform === "win32" && pid !== void 0) {
1652
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1653
- } else {
1654
- this.child.kill("SIGTERM");
2278
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
2279
+ });
2280
+ }
2281
+ dispose() {
2282
+ if (!this.disposalAttempt) {
2283
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2284
+ this.disposalAttempt = attempt.catch((error) => {
2285
+ this.disposalAttempt = void 0;
2286
+ throw error;
2287
+ });
1655
2288
  }
2289
+ return this.disposalAttempt;
2290
+ }
2291
+ processTreeOptions() {
2292
+ return {
2293
+ child: this.child,
2294
+ waitClosed: () => this.closedPromise,
2295
+ isClosed: () => this.closed,
2296
+ label: "codex"
2297
+ };
1656
2298
  }
1657
2299
  /** 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. */
1658
2300
  buildExitError(context) {
@@ -1703,8 +2345,17 @@ var CodexAdapter = class {
1703
2345
  this.options = options;
1704
2346
  }
1705
2347
  options;
1706
- supportsDispatchSelection = true;
1707
- id = "codex";
2348
+ descriptor = freezeRuntimeAdapterDescriptor({
2349
+ id: "codex",
2350
+ supportsDispatchSelection: true,
2351
+ capabilities: {
2352
+ steer: false,
2353
+ resume: true,
2354
+ approvalInteractive: false,
2355
+ permissionModes: ["auto", "readonly"]
2356
+ },
2357
+ environmentRequirements: { credentialNames: [] }
2358
+ });
1708
2359
  async detect() {
1709
2360
  const bin = this.resolveBin();
1710
2361
  try {
@@ -1753,61 +2404,100 @@ ${result.stderr}`);
1753
2404
  ${withStreams.stderr ?? ""}`);
1754
2405
  }
1755
2406
  }
1756
- capabilities() {
1757
- return { steer: false, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
1758
- }
1759
- /**
1760
- * M5: same deliberate posture as the claude adapter (see its own doc
1761
- * comment) — codex authenticates via its own `codex login`-managed
1762
- * ChatGPT OAuth session (`probeAuthPresent` above), not an env var, so
1763
- * there is no credential env var this adapter needs forwarded; env-based
1764
- * API-key passthrough remains a separate, pending product decision. No
1765
- * `baseNames` either: nothing in this adapter reads a codex-specific
1766
- * config-discovery variable (e.g. `CODEX_HOME`) today.
1767
- */
1768
- environmentRequirements() {
1769
- return { credentialNames: [] };
1770
- }
1771
- async start(task, ctx) {
1772
- if (typeof task.instruction !== "string") {
1773
- throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2407
+ async prepare(input) {
2408
+ const mapping = mapPermissionPolicyToCodexArgs(input.policy);
2409
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by codex adapter", retryable: false };
2410
+ let modelId;
2411
+ try {
2412
+ modelId = subscriptionModel2(input.offer.dispatchSelection);
2413
+ } catch (error) {
2414
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
1774
2415
  }
1775
- const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
1776
- if (!mapping.ok) {
1777
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2416
+ let command;
2417
+ try {
2418
+ command = this.resolveBin().command;
2419
+ } catch (error) {
2420
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2421
+ }
2422
+ return {
2423
+ kind: "prepared",
2424
+ operation: {
2425
+ start: (startInput) => this.startPrepared(startInput, mapping.args, modelId, command)
2426
+ }
2427
+ };
2428
+ }
2429
+ async startPrepared(startInput, policyArgs, modelId, command) {
2430
+ if (typeof startInput.instruction !== "string") {
2431
+ throw new RuntimeExecutionFailure({
2432
+ phase: "start",
2433
+ category: "authority",
2434
+ retry: "non-retryable",
2435
+ reason: "prepared codex operation requires a resolved string instruction"
2436
+ });
1778
2437
  }
1779
- const modelId = subscriptionModel2(task);
1780
- const bin = this.resolveBin();
1781
2438
  const queue = new AsyncQueue();
2439
+ const terminal = {};
1782
2440
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
1783
- const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
1784
- const runtimeEnv = withoutProviderCredentials(ctx.env);
2441
+ let workspaceDir;
2442
+ try {
2443
+ workspaceDir = await resolveRealWorkspaceDir(startInput.manifest.workspace.workspaceDir);
2444
+ } catch (cause) {
2445
+ throw new RuntimeExecutionFailure({
2446
+ phase: "start",
2447
+ category: "infrastructure",
2448
+ retry: "retryable",
2449
+ reason: "codex runtime workspace could not be resolved"
2450
+ }, { cause });
2451
+ }
2452
+ const runtimeEnv = withoutProviderCredentials(startInput.env);
2453
+ let manifestModelId;
2454
+ try {
2455
+ manifestModelId = subscriptionModel2(startInput.manifest.dispatchSelection);
2456
+ } catch (cause) {
2457
+ throw new RuntimeExecutionFailure({
2458
+ phase: "start",
2459
+ category: "authority",
2460
+ retry: "non-retryable",
2461
+ reason: "prepared codex operation received an invalid runtime selection manifest"
2462
+ }, { cause });
2463
+ }
2464
+ if (manifestModelId !== modelId) {
2465
+ throw new RuntimeExecutionFailure({
2466
+ phase: "start",
2467
+ category: "authority",
2468
+ retry: "non-retryable",
2469
+ reason: "prepared codex operation received a manifest with different runtime selection"
2470
+ });
2471
+ }
1785
2472
  const { sessionRef, runner } = await runCodexTurn({
1786
- command: bin.command,
1787
- resumeRef: task.sessionRef,
1788
- instruction: task.instruction,
1789
- modelId,
1790
- policyArgs: mapping.args,
1791
- cwd: ctx.workspaceDir,
2473
+ command,
2474
+ resumeRef: startInput.manifest.sessionRef,
2475
+ instruction: startInput.instruction,
2476
+ modelId: manifestModelId,
2477
+ policyArgs: [...policyArgs],
2478
+ cwd: startInput.manifest.workspace.workspaceDir,
1792
2479
  env: runtimeEnv,
1793
2480
  spawnFn: this.options.spawnFn,
1794
2481
  workspaceDir,
1795
2482
  queue,
1796
2483
  recordUnmapped,
1797
- expectedSessionRef: task.sessionRef,
1798
- preparedGit: ctx.gitWorkspace !== void 0
2484
+ expectedSessionRef: startInput.manifest.sessionRef,
2485
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
2486
+ failurePhase: "start",
2487
+ terminal
1799
2488
  });
1800
2489
  return new CodexSession({
1801
2490
  sessionRef,
1802
- command: bin.command,
2491
+ command,
1803
2492
  workspaceDir,
1804
- env: ctx.env,
2493
+ env: startInput.env,
1805
2494
  spawnFn: this.options.spawnFn,
1806
2495
  queue,
1807
2496
  recordUnmapped,
1808
2497
  initialRunner: runner,
1809
- preparedGit: ctx.gitWorkspace !== void 0,
1810
- modelId
2498
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
2499
+ modelId: manifestModelId,
2500
+ terminal
1811
2501
  });
1812
2502
  }
1813
2503
  resolveBin() {
@@ -1855,37 +2545,69 @@ async function runCodexTurn(params) {
1855
2545
  rejectFirstLine = reject;
1856
2546
  });
1857
2547
  let turnEnded = false;
1858
- const runner = new CodexProcessRunner({
1859
- command: params.command,
1860
- args: argv,
1861
- cwd: params.cwd,
1862
- env: params.env,
1863
- spawnFn: params.spawnFn,
1864
- onEvent: (evt) => {
1865
- if (!firstLineSettled) {
1866
- firstLineSettled = true;
1867
- if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
1868
- resolveFirstLine(evt.thread_id);
1869
- } else {
1870
- rejectFirstLine(
1871
- new Error(`codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`)
1872
- );
2548
+ let runner;
2549
+ try {
2550
+ runner = new CodexProcessRunner({
2551
+ command: params.command,
2552
+ args: argv,
2553
+ cwd: params.cwd,
2554
+ env: params.env,
2555
+ spawnFn: params.spawnFn,
2556
+ onEvent: (evt) => {
2557
+ if (!firstLineSettled) {
2558
+ firstLineSettled = true;
2559
+ if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
2560
+ resolveFirstLine(evt.thread_id);
2561
+ } else {
2562
+ rejectFirstLine(
2563
+ new RuntimeExecutionFailure({
2564
+ phase: params.failurePhase,
2565
+ category: "authority",
2566
+ retry: "non-retryable",
2567
+ reason: `codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`
2568
+ })
2569
+ );
2570
+ }
2571
+ return;
2572
+ }
2573
+ const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
2574
+ for (const agentEvent of mapped) {
2575
+ if (agentEvent.type === "turn_end") turnEnded = true;
2576
+ params.queue.push(agentEvent);
2577
+ }
2578
+ if (evt.type === "turn.failed") {
2579
+ params.terminal.failure = new RuntimeExecutionFailure({
2580
+ phase: "run",
2581
+ category: "semantic",
2582
+ retry: "non-retryable",
2583
+ reason: "codex reported terminal task failure"
2584
+ });
2585
+ params.queue.end();
2586
+ }
2587
+ if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
2588
+ params.recordUnmapped(unmappedFrameKey(evt));
1873
2589
  }
1874
- return;
1875
- }
1876
- const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
1877
- for (const agentEvent of mapped) {
1878
- if (agentEvent.type === "turn_end") turnEnded = true;
1879
- params.queue.push(agentEvent);
1880
- }
1881
- if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
1882
- params.recordUnmapped(unmappedFrameKey(evt));
1883
2590
  }
1884
- }
1885
- });
2591
+ });
2592
+ } catch (cause) {
2593
+ throw new RuntimeExecutionFailure({
2594
+ phase: params.failurePhase,
2595
+ category: "infrastructure",
2596
+ retry: "retryable",
2597
+ reason: "codex runtime process could not be spawned"
2598
+ }, { cause });
2599
+ }
2600
+ params.onRunnerCreated?.(runner);
1886
2601
  void runner.waitClosed().then(() => {
1887
- if (turnEnded) return;
1888
- params.queue.push({ type: "error", message: runner.buildExitError("codex exited without completing the turn").message });
2602
+ if (turnEnded || params.terminal.failure) return;
2603
+ const cause = runner.buildExitError("codex exited without completing the turn");
2604
+ params.terminal.failure = new RuntimeExecutionFailure({
2605
+ phase: "run",
2606
+ category: "infrastructure",
2607
+ retry: "retryable",
2608
+ reason: cause.message
2609
+ }, { cause });
2610
+ params.queue.push({ type: "error", message: cause.message });
1889
2611
  params.queue.end();
1890
2612
  });
1891
2613
  let sessionRef;
@@ -1909,7 +2631,13 @@ async function runCodexTurn(params) {
1909
2631
  void runner.waitClosed().then(() => {
1910
2632
  if (!settled) {
1911
2633
  settled = true;
1912
- reject(runner.buildExitError("codex exited before yielding an authoritative thread id"));
2634
+ const cause = runner.buildExitError("codex exited before yielding an authoritative thread id");
2635
+ reject(new RuntimeExecutionFailure({
2636
+ phase: params.failurePhase,
2637
+ category: "infrastructure",
2638
+ retry: "retryable",
2639
+ reason: cause.message
2640
+ }, { cause }));
1913
2641
  }
1914
2642
  });
1915
2643
  });
@@ -1919,9 +2647,12 @@ async function runCodexTurn(params) {
1919
2647
  }
1920
2648
  if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
1921
2649
  runner.kill();
1922
- throw new Error(
1923
- `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)`
1924
- );
2650
+ throw new RuntimeExecutionFailure({
2651
+ phase: params.failurePhase,
2652
+ category: "authority",
2653
+ retry: "non-retryable",
2654
+ reason: `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef})`
2655
+ });
1925
2656
  }
1926
2657
  return { sessionRef, runner };
1927
2658
  }
@@ -1948,8 +2679,12 @@ var CodexSession = class {
1948
2679
  recordUnmapped;
1949
2680
  preparedGit;
1950
2681
  modelId;
2682
+ terminal;
1951
2683
  currentRunner;
2684
+ ownedRunners = /* @__PURE__ */ new Set();
2685
+ followUpAttempts = /* @__PURE__ */ new Set();
1952
2686
  closed = false;
2687
+ closeAttempt;
1953
2688
  constructor(options) {
1954
2689
  this.sessionRef = options.sessionRef;
1955
2690
  this.command = options.command;
@@ -1960,11 +2695,36 @@ var CodexSession = class {
1960
2695
  this.recordUnmapped = options.recordUnmapped;
1961
2696
  this.preparedGit = options.preparedGit;
1962
2697
  this.modelId = options.modelId;
2698
+ this.terminal = options.terminal;
1963
2699
  this.currentRunner = options.initialRunner;
2700
+ this.ownedRunners.add(options.initialRunner);
1964
2701
  void this.forgetRunnerOnceClosed(options.initialRunner);
1965
2702
  }
1966
2703
  get events() {
1967
- return this.queue;
2704
+ const queue = this.queue;
2705
+ const session = this;
2706
+ return {
2707
+ [Symbol.asyncIterator]() {
2708
+ const inner = queue[Symbol.asyncIterator]();
2709
+ return {
2710
+ async next() {
2711
+ let result;
2712
+ try {
2713
+ result = await inner.next();
2714
+ } catch (cause) {
2715
+ throw new RuntimeExecutionFailure({
2716
+ phase: "run",
2717
+ category: "infrastructure",
2718
+ retry: "retryable",
2719
+ reason: "codex runtime event transport failed"
2720
+ }, { cause });
2721
+ }
2722
+ if (result.done && session.terminal.failure) throw session.terminal.failure;
2723
+ return result;
2724
+ }
2725
+ };
2726
+ }
2727
+ };
1968
2728
  }
1969
2729
  async forgetRunnerOnceClosed(runner) {
1970
2730
  await runner.waitClosed();
@@ -2009,7 +2769,14 @@ var CodexSession = class {
2009
2769
  * stale id even after codex had moved on) — it just can now only ever be
2010
2770
  * the SAME id this call asked to resume, never a silently-different one.
2011
2771
  */
2012
- async followUp(task) {
2772
+ followUp(task) {
2773
+ const attempt = this.runFollowUp(task);
2774
+ this.followUpAttempts.add(attempt);
2775
+ void attempt.finally(() => this.followUpAttempts.delete(attempt)).catch(() => {
2776
+ });
2777
+ return attempt;
2778
+ }
2779
+ async runFollowUp(task) {
2013
2780
  if (typeof task.instruction !== "string") {
2014
2781
  throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2015
2782
  }
@@ -2020,7 +2787,7 @@ var CodexSession = class {
2020
2787
  if (!mapping.ok) {
2021
2788
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2022
2789
  }
2023
- const requestedModel = subscriptionModel2(task);
2790
+ const requestedModel = subscriptionModel2(task.dispatchSelection);
2024
2791
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2025
2792
  throw new PolicyUnsupportedError(
2026
2793
  `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2030,6 +2797,7 @@ var CodexSession = class {
2030
2797
  const resumeRef = this.sessionRef;
2031
2798
  let sessionRef;
2032
2799
  let runner;
2800
+ const terminal = {};
2033
2801
  try {
2034
2802
  ({ sessionRef, runner } = await runCodexTurn({
2035
2803
  command: this.command,
@@ -2044,25 +2812,54 @@ var CodexSession = class {
2044
2812
  queue: this.queue,
2045
2813
  recordUnmapped: this.recordUnmapped,
2046
2814
  expectedSessionRef: resumeRef,
2047
- preparedGit: this.preparedGit
2815
+ preparedGit: this.preparedGit,
2816
+ failurePhase: "run",
2817
+ terminal,
2818
+ onRunnerCreated: (created) => {
2819
+ this.ownedRunners.add(created);
2820
+ void this.forgetRunnerOnceClosed(created);
2821
+ }
2048
2822
  }));
2049
2823
  } catch (err) {
2050
2824
  this.queue.end();
2825
+ this.terminal = {
2826
+ failure: isRuntimeExecutionFailure(err) ? err : new RuntimeExecutionFailure({
2827
+ phase: "run",
2828
+ category: "authority",
2829
+ retry: "non-retryable",
2830
+ reason: "codex follow-up violated the runtime adapter contract"
2831
+ }, { cause: err })
2832
+ };
2051
2833
  throw err;
2052
2834
  }
2835
+ if (this.closed) {
2836
+ await runner.dispose();
2837
+ throw new Error("codex session closed while follow-up was starting");
2838
+ }
2839
+ this.terminal = terminal;
2053
2840
  this.sessionRef = sessionRef;
2054
2841
  this.currentRunner = runner;
2055
- void this.forgetRunnerOnceClosed(runner);
2056
2842
  }
2057
2843
  /** 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. */
2058
2844
  async interrupt() {
2059
2845
  this.currentRunner?.kill();
2060
2846
  }
2061
2847
  async close() {
2062
- if (this.closed) return;
2063
- this.closed = true;
2064
- this.currentRunner?.kill();
2065
- this.queue.end();
2848
+ if (!this.closeAttempt) {
2849
+ this.closed = true;
2850
+ this.queue.end();
2851
+ const attempt = (async () => {
2852
+ const runners = [...this.ownedRunners];
2853
+ await Promise.all(runners.map((runner) => runner.dispose()));
2854
+ for (const runner of runners) this.ownedRunners.delete(runner);
2855
+ await Promise.allSettled([...this.followUpAttempts]);
2856
+ })();
2857
+ this.closeAttempt = attempt.catch((error) => {
2858
+ this.closeAttempt = void 0;
2859
+ throw error;
2860
+ });
2861
+ }
2862
+ await this.closeAttempt;
2066
2863
  }
2067
2864
  /**
2068
2865
  * `codex exec` has no in-band channel to inject text into an already-
@@ -2098,8 +2895,7 @@ var CodexSession = class {
2098
2895
  );
2099
2896
  }
2100
2897
  };
2101
- function subscriptionModel2(task) {
2102
- const selection = task.dispatchSelection;
2898
+ function subscriptionModel2(selection) {
2103
2899
  if (selection === void 0) return void 0;
2104
2900
  if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
2105
2901
  throw new PolicyUnsupportedError(
@@ -2109,6 +2905,6 @@ function subscriptionModel2(task) {
2109
2905
  return selection.modelId;
2110
2906
  }
2111
2907
 
2112
- export { ClaudeAdapter, CodexAdapter, PI_PACKAGE_NAME, PiAdapter };
2908
+ export { ClaudeAdapter, CodexAdapter, PI_PACKAGE_NAME, PiAdapter, RuntimeDisposalFailure, RuntimeExecutionFailure };
2113
2909
  //# sourceMappingURL=index.js.map
2114
2910
  //# sourceMappingURL=index.js.map