@byok-sdk/client 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +13 -0
  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 +9 -1
  5. package/dist/adapters/codex/codex-adapter.d.ts +4 -16
  6. package/dist/adapters/codex/process-runner.d.ts +4 -1
  7. package/dist/adapters/index.d.ts +3 -1
  8. package/dist/adapters/index.js +923 -258
  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/rpc-client.d.ts +9 -1
  12. package/dist/adapters/process-tree.d.ts +19 -0
  13. package/dist/bin/audit-log.d.ts +12 -0
  14. package/dist/bin/byok-agent.js +1293 -484
  15. package/dist/bin/byok-agent.js.map +1 -1
  16. package/dist/bin/byok-approval-mcp.js.map +1 -1
  17. package/dist/bin/commands/workspaces.d.ts +11 -0
  18. package/dist/bin/format.d.ts +13 -0
  19. package/dist/bin/runtime-probe.d.ts +1 -1
  20. package/dist/bin/tasks-view.d.ts +13 -0
  21. package/dist/daemon/approvals.d.ts +2 -2
  22. package/dist/daemon/connection-manager.d.ts +4 -2
  23. package/dist/daemon/control-server.d.ts +18 -1
  24. package/dist/daemon/create-daemon.d.ts +2 -2
  25. package/dist/daemon/daemon-owner.d.ts +4 -2
  26. package/dist/daemon/environment.d.ts +9 -9
  27. package/dist/daemon/git-workspace.d.ts +21 -0
  28. package/dist/daemon/observer.d.ts +13 -0
  29. package/dist/daemon/presence-publisher.d.ts +29 -0
  30. package/dist/daemon/runtime-capabilities.d.ts +1 -1
  31. package/dist/daemon/task-runner.d.ts +27 -34
  32. package/dist/daemon/ws-transport.d.ts +3 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.js +1254 -432
  35. package/dist/index.js.map +1 -1
  36. package/dist/runtime-failure.d.ts +64 -0
  37. package/dist/types.d.ts +100 -73
  38. package/package.json +4 -4
@@ -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);
@@ -267,6 +338,155 @@ var AsyncQueue = class {
267
338
  };
268
339
  }
269
340
  };
341
+ var DEFAULT_TERM_GRACE_MS = 750;
342
+ var DEFAULT_KILL_GRACE_MS = 2e3;
343
+ var POLL_MS = 20;
344
+ var terminationRequested = /* @__PURE__ */ new WeakSet();
345
+ var terminationRequestFailed = /* @__PURE__ */ new WeakSet();
346
+ function withOwnedProcessTree(options) {
347
+ return {
348
+ ...options,
349
+ ...process.platform === "win32" ? { windowsHide: true } : { detached: true }
350
+ };
351
+ }
352
+ function positivePid(child, label) {
353
+ const pid = child.pid;
354
+ if (pid === void 0) return void 0;
355
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
356
+ throw new RuntimeDisposalFailure({
357
+ stage: "signal",
358
+ reason: `${label} runtime process has an unsafe owned pid`
359
+ });
360
+ }
361
+ return pid;
362
+ }
363
+ function groupExists(pid, label) {
364
+ try {
365
+ process.kill(-pid, 0);
366
+ return true;
367
+ } catch (cause) {
368
+ const code = cause.code;
369
+ if (code === "ESRCH") return false;
370
+ if (code === "EPERM") return true;
371
+ throw new RuntimeDisposalFailure({
372
+ stage: "quiescence",
373
+ reason: `${label} runtime process-group state could not be verified`
374
+ }, { cause });
375
+ }
376
+ }
377
+ function signalGroup(pid, signal, label) {
378
+ try {
379
+ process.kill(-pid, signal);
380
+ } catch (cause) {
381
+ const code = cause.code;
382
+ if (code === "ESRCH" || code === "EPERM") return;
383
+ throw new RuntimeDisposalFailure({
384
+ stage: "signal",
385
+ reason: `${label} runtime process group ${pid} could not receive ${signal} (${code ?? "unknown"})`
386
+ }, { cause });
387
+ }
388
+ }
389
+ async function waitUntil(predicate, timeoutMs) {
390
+ const deadline = Date.now() + timeoutMs;
391
+ while (predicate()) {
392
+ if (Date.now() >= deadline) return false;
393
+ await new Promise((resolve) => {
394
+ setTimeout(resolve, POLL_MS);
395
+ });
396
+ }
397
+ return true;
398
+ }
399
+ async function waitWithDeadline(promise, timeoutMs) {
400
+ return new Promise((resolve) => {
401
+ let settled = false;
402
+ const timer = setTimeout(() => {
403
+ if (!settled) {
404
+ settled = true;
405
+ resolve(false);
406
+ }
407
+ }, timeoutMs);
408
+ void promise.then(
409
+ () => {
410
+ if (!settled) {
411
+ settled = true;
412
+ clearTimeout(timer);
413
+ resolve(true);
414
+ }
415
+ },
416
+ () => {
417
+ if (!settled) {
418
+ settled = true;
419
+ clearTimeout(timer);
420
+ resolve(false);
421
+ }
422
+ }
423
+ );
424
+ });
425
+ }
426
+ function requestOwnedProcessTreeTermination(options) {
427
+ if (options.isClosed()) return;
428
+ const pid = positivePid(options.child, options.label);
429
+ if (pid === void 0) return;
430
+ if (process.platform === "win32") {
431
+ const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
432
+ if (result.error) {
433
+ throw new RuntimeDisposalFailure({
434
+ stage: "signal",
435
+ reason: `${options.label} runtime process tree could not be terminated`
436
+ }, { cause: result.error });
437
+ }
438
+ terminationRequested.add(options.child);
439
+ if (result.status !== 0) terminationRequestFailed.add(options.child);
440
+ return;
441
+ }
442
+ signalGroup(pid, "SIGTERM", options.label);
443
+ terminationRequested.add(options.child);
444
+ }
445
+ async function disposeOwnedProcessTree(options) {
446
+ const pid = positivePid(options.child, options.label);
447
+ const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
448
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
449
+ if (pid === void 0) {
450
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
451
+ throw new RuntimeDisposalFailure({
452
+ stage: "quiescence",
453
+ reason: `${options.label} runtime process did not settle after spawn failure`
454
+ });
455
+ }
456
+ if (process.platform === "win32") {
457
+ if (!options.isClosed() && !terminationRequested.has(options.child)) requestOwnedProcessTreeTermination(options);
458
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
459
+ if (terminationRequestFailed.has(options.child)) {
460
+ throw new RuntimeDisposalFailure({
461
+ stage: "signal",
462
+ reason: `${options.label} runtime process tree could not be terminated`
463
+ });
464
+ }
465
+ throw new RuntimeDisposalFailure({
466
+ stage: "quiescence",
467
+ reason: `${options.label} runtime process tree did not close before the disposal deadline`
468
+ });
469
+ }
470
+ if (groupExists(pid, options.label) && !terminationRequested.has(options.child)) {
471
+ signalGroup(pid, "SIGTERM", options.label);
472
+ terminationRequested.add(options.child);
473
+ }
474
+ if (!await waitUntil(() => groupExists(pid, options.label), termGraceMs)) {
475
+ signalGroup(pid, "SIGKILL", options.label);
476
+ if (!await waitUntil(() => groupExists(pid, options.label), killGraceMs)) {
477
+ throw new RuntimeDisposalFailure({
478
+ stage: "quiescence",
479
+ reason: `${options.label} runtime process group remained live after SIGKILL`
480
+ });
481
+ }
482
+ }
483
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
484
+ throw new RuntimeDisposalFailure({
485
+ stage: "quiescence",
486
+ reason: `${options.label} runtime root did not emit close after its process group exited`
487
+ });
488
+ }
489
+ }
270
490
 
271
491
  // src/adapters/pi/rpc-client.ts
272
492
  var STDERR_RING_CAPACITY = 20;
@@ -279,16 +499,22 @@ var PiRpcClient = class {
279
499
  eventQueue = new AsyncQueue();
280
500
  closed = false;
281
501
  exitError;
502
+ closedPromise;
503
+ resolveClosed;
504
+ disposalAttempt;
282
505
  /** 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
506
  stderrRing = [];
284
507
  /** 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
508
  unmappedFrameCounts = /* @__PURE__ */ new Map();
286
509
  constructor(options) {
287
510
  const spawnFn = options.spawnFn ?? spawn;
288
- this.child = spawnFn(options.command, options.args, {
511
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
289
512
  cwd: options.cwd,
290
513
  env: options.env,
291
514
  stdio: ["pipe", "pipe", "pipe"]
515
+ }));
516
+ this.closedPromise = new Promise((resolve) => {
517
+ this.resolveClosed = resolve;
292
518
  });
293
519
  this.child.stdout.setEncoding("utf8");
294
520
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -323,6 +549,10 @@ var PiRpcClient = class {
323
549
  get events() {
324
550
  return this.eventQueue;
325
551
  }
552
+ /** Local transport diagnostic retained when the process closes; consumers must classify it explicitly. */
553
+ get terminalError() {
554
+ return this.exitError;
555
+ }
326
556
  /**
327
557
  * Record a pi RPC message `type` that `PiSession` (pi-adapter.ts) decided
328
558
  * has no `AgentEvent` mapping and isn't routine bookkeeping (see
@@ -341,15 +571,30 @@ var PiRpcClient = class {
341
571
  );
342
572
  }
343
573
  }
344
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes pi itself spawned (e.g. bash). */
574
+ /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
345
575
  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");
576
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
577
+ }
578
+ waitClosed() {
579
+ return this.closedPromise;
580
+ }
581
+ dispose() {
582
+ if (!this.disposalAttempt) {
583
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
584
+ this.disposalAttempt = attempt.catch((error) => {
585
+ this.disposalAttempt = void 0;
586
+ throw error;
587
+ });
352
588
  }
589
+ return this.disposalAttempt;
590
+ }
591
+ processTreeOptions() {
592
+ return {
593
+ child: this.child,
594
+ waitClosed: () => this.closedPromise,
595
+ isClosed: () => this.closed,
596
+ label: "pi"
597
+ };
353
598
  }
354
599
  onData(chunk) {
355
600
  this.buffer += chunk;
@@ -435,6 +680,7 @@ var PiRpcClient = class {
435
680
  if (this.closed) return;
436
681
  this.closed = true;
437
682
  this.exitError = err;
683
+ this.resolveClosed();
438
684
  for (const [, waiter] of this.pending) waiter.reject(err);
439
685
  this.pending.clear();
440
686
  this.eventQueue.end();
@@ -505,8 +751,17 @@ var PiAdapter = class {
505
751
  this.options = options;
506
752
  }
507
753
  options;
508
- id = "pi";
509
- supportsDispatchSelection = true;
754
+ descriptor = freezeRuntimeAdapterDescriptor({
755
+ id: "pi",
756
+ supportsDispatchSelection: true,
757
+ capabilities: {
758
+ steer: true,
759
+ resume: true,
760
+ approvalInteractive: false,
761
+ permissionModes: ["auto", "readonly"]
762
+ },
763
+ environmentRequirements: { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES }
764
+ });
510
765
  async detect() {
511
766
  try {
512
767
  const bin = this.resolveBin();
@@ -518,49 +773,26 @@ var PiAdapter = class {
518
773
  return { present: false };
519
774
  }
520
775
  }
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);
776
+ async prepare(input) {
777
+ const mapping = mapPermissionPolicyToPiArgs(input.policy);
541
778
  if (!mapping.ok) {
542
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
779
+ return { kind: "reject", reason: mapping.reason ?? "policy rejected by pi adapter", retryable: false };
543
780
  }
544
781
  const bin = this.resolveBin();
545
- const resumeSessionId = task.sessionRef;
546
- const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
547
- const selection = task.dispatchSelection;
782
+ const selection = input.offer.dispatchSelection;
783
+ const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
548
784
  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
- );
785
+ let launcherArgs;
786
+ if (pinnedSelection !== void 0) {
787
+ if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
788
+ return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
555
789
  }
556
790
  const launcher = this.options.byokLauncher;
557
791
  if (launcher === void 0) {
558
- throw new PolicyUnsupportedError(
559
- "pi BYOK selection requires a configured credential-custody launcher"
560
- );
792
+ return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
561
793
  }
562
794
  command = launcher.command;
563
- args = [
795
+ launcherArgs = [
564
796
  ...launcher.args ?? [],
565
797
  "--pi-bin",
566
798
  bin.command,
@@ -570,62 +802,136 @@ var PiAdapter = class {
570
802
  launcher.sessionDir,
571
803
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
572
804
  "--provider",
573
- selection.providerId,
805
+ pinnedSelection.providerId,
574
806
  "--model",
575
- selection.modelId,
576
- "--",
577
- ...piArgs
807
+ pinnedSelection.modelId
578
808
  ];
579
809
  }
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;
810
+ return {
811
+ kind: "prepared",
812
+ operation: {
813
+ start: async (startInput) => {
814
+ const manifestSelection = startInput.manifest.dispatchSelection;
815
+ if (!sameDispatchSelection(manifestSelection, pinnedSelection)) {
816
+ throw new RuntimeExecutionFailure({
817
+ phase: "start",
818
+ category: "authority",
819
+ retry: "non-retryable",
820
+ reason: "prepared pi operation received a manifest with different runtime selection"
821
+ });
822
+ }
823
+ if (typeof startInput.instruction !== "string") {
824
+ throw new RuntimeExecutionFailure({
825
+ phase: "start",
826
+ category: "authority",
827
+ retry: "non-retryable",
828
+ reason: "prepared pi operation requires a resolved string instruction"
829
+ });
830
+ }
831
+ const resumeSessionId = startInput.manifest.sessionRef;
832
+ const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
833
+ const args = launcherArgs === void 0 ? piArgs : [...launcherArgs, "--", ...piArgs];
834
+ let rpc;
835
+ try {
836
+ rpc = new PiRpcClient({
837
+ command,
838
+ args,
839
+ cwd: startInput.manifest.workspace.workspaceDir,
840
+ env: manifestSelection === void 0 ? startInput.env : withoutProviderCredentials(startInput.env),
841
+ spawnFn: this.options.spawnFn
842
+ });
843
+ } catch (cause) {
844
+ throw new RuntimeExecutionFailure({
845
+ phase: "start",
846
+ category: "infrastructure",
847
+ retry: "retryable",
848
+ reason: "pi runtime process could not be spawned"
849
+ }, { cause });
850
+ }
851
+ let response;
852
+ try {
853
+ response = await rpc.send({ type: "prompt", message: startInput.instruction });
854
+ } catch (cause) {
855
+ rpc.kill();
856
+ throw new RuntimeExecutionFailure({
857
+ phase: "start",
858
+ category: "infrastructure",
859
+ retry: "retryable",
860
+ reason: `pi initial prompt transport failed: ${errorMessage(cause)}`
861
+ }, { cause });
862
+ }
863
+ if (response.success === false) {
864
+ rpc.kill();
865
+ throw new RuntimeExecutionFailure({
866
+ phase: "start",
867
+ category: "semantic",
868
+ retry: "non-retryable",
869
+ reason: typeof response.error === "string" ? response.error : "pi rejected the initial prompt"
870
+ });
871
+ }
872
+ let sessionRef;
873
+ try {
874
+ sessionRef = await resolveAuthoritativeSessionId(rpc);
875
+ } catch (err) {
876
+ rpc.kill();
877
+ throw err;
878
+ }
879
+ if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
880
+ rpc.kill();
881
+ throw new RuntimeExecutionFailure({
882
+ phase: "start",
883
+ category: "authority",
884
+ retry: "non-retryable",
885
+ reason: "pi resumed a different authoritative session than requested"
886
+ });
887
+ }
888
+ return new PiSession(sessionRef, rpc, manifestSelection);
889
+ }
601
890
  }
602
- }
603
- return new PiSession(sessionRef, rpc, selection);
891
+ };
604
892
  }
605
893
  resolveBin() {
606
894
  return (this.options.resolveBin ?? resolvePiBin)();
607
895
  }
608
896
  };
609
- async function resolveFreshSessionId(rpc) {
897
+ function sameDispatchSelection(left, right) {
898
+ if (left === void 0 || right === void 0) return left === right;
899
+ return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
900
+ }
901
+ async function resolveAuthoritativeSessionId(rpc) {
610
902
  let state;
611
903
  try {
612
904
  state = await rpc.send({ type: "get_state" });
613
905
  } catch (err) {
614
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${errorMessage(err)}`, {
906
+ if (isRuntimeExecutionFailure(err)) throw err;
907
+ throw new RuntimeExecutionFailure({
908
+ phase: "start",
909
+ category: "infrastructure",
910
+ retry: "retryable",
911
+ reason: `pi transport ended before yielding an authoritative session id: ${errorMessage(err)}`
912
+ }, {
615
913
  cause: err
616
914
  });
617
915
  }
618
916
  if (state.success === false) {
619
917
  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}`);
918
+ throw new RuntimeExecutionFailure({
919
+ phase: "start",
920
+ category: "authority",
921
+ retry: "non-retryable",
922
+ reason: `pi did not yield an authoritative session id: ${reason}`
923
+ });
621
924
  }
622
925
  const data = state.data;
623
926
  if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
624
927
  return data.sessionId;
625
928
  }
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
- );
929
+ throw new RuntimeExecutionFailure({
930
+ phase: "start",
931
+ category: "authority",
932
+ retry: "non-retryable",
933
+ reason: "pi get_state reported no authoritative session id"
934
+ });
629
935
  }
630
936
  var PiSession = class {
631
937
  constructor(sessionRef, rpc, selection) {
@@ -641,12 +947,40 @@ var PiSession = class {
641
947
  return {
642
948
  [Symbol.asyncIterator]() {
643
949
  const inner = rpc.events[Symbol.asyncIterator]();
950
+ let terminalFailure;
644
951
  return {
645
952
  async next() {
646
953
  for (; ; ) {
647
- const { value, done } = await inner.next();
648
- if (done) return { value: void 0, done: true };
954
+ if (terminalFailure) throw terminalFailure;
955
+ let result;
956
+ try {
957
+ result = await inner.next();
958
+ } catch (cause) {
959
+ throw new RuntimeExecutionFailure({
960
+ phase: "run",
961
+ category: "infrastructure",
962
+ retry: "retryable",
963
+ reason: "pi runtime event transport failed"
964
+ }, { cause });
965
+ }
966
+ const { value, done } = result;
967
+ if (done) {
968
+ throw new RuntimeExecutionFailure({
969
+ phase: "run",
970
+ category: "infrastructure",
971
+ retry: "retryable",
972
+ reason: "pi runtime process ended before agent_settled"
973
+ }, { cause: rpc.terminalError });
974
+ }
649
975
  const mapped = mapPiMessageToAgentEvent(value);
976
+ if (value.type === "auto_retry_end" && value.success === false) {
977
+ terminalFailure = new RuntimeExecutionFailure({
978
+ phase: "run",
979
+ category: "semantic",
980
+ retry: "non-retryable",
981
+ reason: "pi exhausted its native retry policy"
982
+ });
983
+ }
650
984
  if (mapped) return { value: mapped, done: false };
651
985
  if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
652
986
  rpc.recordUnmappedFrame(value.type);
@@ -676,7 +1010,7 @@ var PiSession = class {
676
1010
  await this.rpc.send({ type: "abort" });
677
1011
  }
678
1012
  async close() {
679
- this.rpc.kill();
1013
+ await this.rpc.dispose();
680
1014
  }
681
1015
  async resolveApproval() {
682
1016
  throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
@@ -888,7 +1222,15 @@ function mapResult(msg) {
888
1222
  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
1223
  );
890
1224
  const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
891
- return { events };
1225
+ return {
1226
+ events,
1227
+ terminalFailure: new RuntimeExecutionFailure({
1228
+ phase: "run",
1229
+ category: msg.is_error === true ? "semantic" : "authority",
1230
+ retry: "non-retryable",
1231
+ reason: msg.is_error === true ? "claude reported terminal task failure" : "claude emitted a malformed terminal result frame"
1232
+ })
1233
+ };
892
1234
  }
893
1235
  var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
894
1236
  function truncateResultDiagnostic(text) {
@@ -940,16 +1282,22 @@ var ClaudeProcessClient = class {
940
1282
  eventQueue = new AsyncQueue();
941
1283
  closed = false;
942
1284
  exitError;
1285
+ closedPromise;
1286
+ resolveClosed;
1287
+ disposalAttempt;
943
1288
  stderrRing = [];
944
1289
  unmappedFrameCounts = /* @__PURE__ */ new Map();
945
1290
  sessionId;
946
1291
  initWaiter;
947
1292
  constructor(options) {
948
1293
  const spawnFn = options.spawnFn ?? spawn;
949
- this.child = spawnFn(options.command, options.args, {
1294
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
950
1295
  cwd: options.cwd,
951
1296
  env: options.env,
952
1297
  stdio: ["pipe", "pipe", "pipe"]
1298
+ }));
1299
+ this.closedPromise = new Promise((resolve) => {
1300
+ this.resolveClosed = resolve;
953
1301
  });
954
1302
  this.child.stdout.setEncoding("utf8");
955
1303
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1005,6 +1353,10 @@ var ClaudeProcessClient = class {
1005
1353
  get events() {
1006
1354
  return this.eventQueue;
1007
1355
  }
1356
+ /** Local transport diagnostic retained when the process closes; consumers classify it at the session boundary. */
1357
+ get terminalError() {
1358
+ return this.exitError;
1359
+ }
1008
1360
  /**
1009
1361
  * Record a claude stream-json frame/subtype/content-block label that
1010
1362
  * `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
@@ -1024,15 +1376,30 @@ var ClaudeProcessClient = class {
1024
1376
  );
1025
1377
  }
1026
1378
  }
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). */
1379
+ /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
1028
1380
  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");
1381
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
1382
+ }
1383
+ waitClosed() {
1384
+ return this.closedPromise;
1385
+ }
1386
+ dispose() {
1387
+ if (!this.disposalAttempt) {
1388
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
1389
+ this.disposalAttempt = attempt.catch((error) => {
1390
+ this.disposalAttempt = void 0;
1391
+ throw error;
1392
+ });
1035
1393
  }
1394
+ return this.disposalAttempt;
1395
+ }
1396
+ processTreeOptions() {
1397
+ return {
1398
+ child: this.child,
1399
+ waitClosed: () => this.closedPromise,
1400
+ isClosed: () => this.closed,
1401
+ label: "claude"
1402
+ };
1036
1403
  }
1037
1404
  onData(chunk) {
1038
1405
  this.buffer += chunk;
@@ -1083,6 +1450,7 @@ var ClaudeProcessClient = class {
1083
1450
  if (this.closed) return;
1084
1451
  this.closed = true;
1085
1452
  this.exitError = err;
1453
+ this.resolveClosed();
1086
1454
  this.initWaiter?.reject(err);
1087
1455
  this.initWaiter = void 0;
1088
1456
  this.eventQueue.end();
@@ -1094,18 +1462,37 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1094
1462
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1095
1463
  var execFileAsync2 = promisify(execFile);
1096
1464
  var DETECT_TIMEOUT_MS2 = 5e3;
1465
+ function errorMessage2(err) {
1466
+ return err instanceof Error ? err.message : String(err);
1467
+ }
1097
1468
  async function cleanupMcpConfigDir(dir) {
1098
1469
  if (!dir) return;
1099
- await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1100
- });
1470
+ try {
1471
+ await promises.rm(dir, { recursive: true, force: true });
1472
+ } catch (cause) {
1473
+ throw new RuntimeDisposalFailure({
1474
+ stage: "cleanup",
1475
+ reason: "claude task-scoped MCP configuration could not be removed"
1476
+ }, { cause });
1477
+ }
1101
1478
  }
1102
1479
  var ClaudeAdapter = class {
1103
1480
  constructor(options = {}) {
1104
1481
  this.options = options;
1105
1482
  }
1106
1483
  options;
1107
- supportsDispatchSelection = true;
1108
- id = "claude";
1484
+ descriptor = freezeRuntimeAdapterDescriptor({
1485
+ id: "claude",
1486
+ supportsDispatchSelection: true,
1487
+ capabilities: {
1488
+ steer: false,
1489
+ resume: true,
1490
+ approvalInteractive: true,
1491
+ mcpToolsets: true,
1492
+ permissionModes: ["auto", "readonly", "plan", "confirm"]
1493
+ },
1494
+ environmentRequirements: { credentialNames: [] }
1495
+ });
1109
1496
  async detect() {
1110
1497
  const bin = this.resolveBin();
1111
1498
  try {
@@ -1117,54 +1504,73 @@ var ClaudeAdapter = class {
1117
1504
  return { present: false };
1118
1505
  }
1119
1506
  }
1120
- capabilities() {
1507
+ async prepare(input) {
1508
+ const mapping = mapPermissionPolicyToClaudeArgs(input.policy);
1509
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by claude adapter", retryable: false };
1510
+ let modelId;
1511
+ try {
1512
+ modelId = subscriptionModel(input.offer.dispatchSelection, "claude");
1513
+ } catch (error) {
1514
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
1515
+ }
1516
+ if (mapping.needsApprovalMcp && Object.prototype.hasOwnProperty.call(input.mcpServers ?? {}, APPROVAL_MCP_SERVER_NAME)) {
1517
+ return { kind: "reject", reason: `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`, retryable: false };
1518
+ }
1519
+ let bin;
1520
+ try {
1521
+ bin = this.resolveBin();
1522
+ } catch (error) {
1523
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
1524
+ }
1525
+ let approvalMcpBin;
1526
+ if (mapping.needsApprovalMcp) {
1527
+ try {
1528
+ approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1529
+ } catch (error) {
1530
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
1531
+ }
1532
+ }
1121
1533
  return {
1122
- steer: false,
1123
- resume: true,
1124
- approvalInteractive: true,
1125
- mcpToolsets: true,
1126
- permissionModes: ["auto", "readonly", "plan", "confirm"]
1534
+ kind: "prepared",
1535
+ operation: {
1536
+ start: (startInput) => this.startPrepared(startInput, mapping, modelId, bin, approvalMcpBin)
1537
+ }
1127
1538
  };
1128
1539
  }
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");
1540
+ async startPrepared(startInput, initialMapping, modelId, bin, approvalMcpBin) {
1541
+ if (!initialMapping.ok) throw new RuntimeExecutionFailure({
1542
+ phase: "start",
1543
+ category: "authority",
1544
+ retry: "non-retryable",
1545
+ reason: "prepared claude permission mapping was invalid"
1546
+ });
1547
+ if (typeof startInput.instruction !== "string") {
1548
+ throw new RuntimeExecutionFailure({
1549
+ phase: "start",
1550
+ category: "authority",
1551
+ retry: "non-retryable",
1552
+ reason: "prepared claude operation requires a resolved string instruction"
1553
+ });
1152
1554
  }
1153
- const modelId = subscriptionModel(task, "claude");
1555
+ const mapping = { ...initialMapping, args: [...initialMapping.args] };
1154
1556
  let mcpConfigDir;
1155
- const taskMcpServers = ctx.mcpServers ?? {};
1557
+ const taskMcpServers = startInput.mcpServers ?? {};
1156
1558
  const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
1157
1559
  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
- );
1560
+ if (!startInput.approvalChannel) {
1561
+ throw new RuntimeExecutionFailure({
1562
+ phase: "start",
1563
+ category: "authority",
1564
+ retry: "non-retryable",
1565
+ reason: 'claude adapter requires policy.mode "confirm" to be started with an approval channel'
1566
+ });
1167
1567
  }
1568
+ if (!approvalMcpBin) throw new RuntimeExecutionFailure({
1569
+ phase: "start",
1570
+ category: "authority",
1571
+ retry: "non-retryable",
1572
+ reason: "prepared claude approval MCP binary was not resolved"
1573
+ });
1168
1574
  }
1169
1575
  if (needsMcpConfig) {
1170
1576
  mcpConfigDir = await promises.mkdtemp(path3.join(os.tmpdir(), "byok-mcp-"));
@@ -1173,12 +1579,23 @@ var ClaudeAdapter = class {
1173
1579
  const mcpConfigPath = path3.join(mcpConfigDir, "mcp-config.json");
1174
1580
  const mcpServers = { ...taskMcpServers };
1175
1581
  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)();
1582
+ const approvalChannel = startInput.approvalChannel;
1583
+ if (!approvalChannel) throw new RuntimeExecutionFailure({
1584
+ phase: "start",
1585
+ category: "authority",
1586
+ retry: "non-retryable",
1587
+ reason: "prepared claude approval channel was not available"
1588
+ });
1589
+ const preparedApprovalMcpBin = approvalMcpBin;
1590
+ if (!preparedApprovalMcpBin) throw new RuntimeExecutionFailure({
1591
+ phase: "start",
1592
+ category: "authority",
1593
+ retry: "non-retryable",
1594
+ reason: "prepared claude approval MCP binary was not resolved"
1595
+ });
1179
1596
  mcpServers[APPROVAL_MCP_SERVER_NAME] = {
1180
- command: approvalMcpBin.command,
1181
- args: approvalMcpBin.args,
1597
+ command: preparedApprovalMcpBin.command,
1598
+ args: preparedApprovalMcpBin.args,
1182
1599
  env: {
1183
1600
  BYOK_STORE_DIR: approvalChannel.storeDir,
1184
1601
  BYOK_PRODUCT_ID: approvalChannel.productId,
@@ -1198,8 +1615,26 @@ var ClaudeAdapter = class {
1198
1615
  "--strict-mcp-config"
1199
1616
  ];
1200
1617
  }
1201
- const bin = this.resolveBin();
1202
- const resumeSessionId = task.sessionRef;
1618
+ const resumeSessionId = startInput.manifest.sessionRef;
1619
+ let manifestModelId;
1620
+ try {
1621
+ manifestModelId = subscriptionModel(startInput.manifest.dispatchSelection, "claude");
1622
+ } catch (cause) {
1623
+ throw new RuntimeExecutionFailure({
1624
+ phase: "start",
1625
+ category: "authority",
1626
+ retry: "non-retryable",
1627
+ reason: "prepared claude operation received an invalid runtime selection manifest"
1628
+ }, { cause });
1629
+ }
1630
+ if (manifestModelId !== modelId) {
1631
+ throw new RuntimeExecutionFailure({
1632
+ phase: "start",
1633
+ category: "authority",
1634
+ retry: "non-retryable",
1635
+ reason: "prepared claude operation received a manifest with different runtime selection"
1636
+ });
1637
+ }
1203
1638
  const args = [
1204
1639
  "-p",
1205
1640
  "--input-format",
@@ -1211,40 +1646,71 @@ var ClaudeAdapter = class {
1211
1646
  // "Error: When using --print, --output-format=stream-json requires
1212
1647
  // --verbose", before spawning any model call.
1213
1648
  "--verbose",
1214
- ...modelId ? ["--model", modelId] : [],
1649
+ ...manifestModelId ? ["--model", manifestModelId] : [],
1215
1650
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1216
1651
  ...mapping.args
1217
1652
  ];
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);
1653
+ let client;
1654
+ try {
1655
+ client = new ClaudeProcessClient({
1656
+ command: bin.command,
1657
+ args,
1658
+ cwd: startInput.manifest.workspace.workspaceDir,
1659
+ env: withoutProviderCredentials(startInput.env),
1660
+ spawnFn: this.options.spawnFn
1661
+ });
1662
+ } catch (cause) {
1663
+ await cleanupMcpConfigDir(mcpConfigDir);
1664
+ throw new RuntimeExecutionFailure({
1665
+ phase: "start",
1666
+ category: "infrastructure",
1667
+ retry: "retryable",
1668
+ reason: "claude runtime process could not be spawned"
1669
+ }, { cause });
1670
+ }
1671
+ try {
1672
+ client.writeUserMessage(startInput.instruction);
1673
+ } catch (cause) {
1674
+ client.kill();
1675
+ await cleanupMcpConfigDir(mcpConfigDir);
1676
+ throw new RuntimeExecutionFailure({
1677
+ phase: "start",
1678
+ category: "infrastructure",
1679
+ retry: "retryable",
1680
+ reason: "claude initial instruction transport failed"
1681
+ }, { cause });
1682
+ }
1226
1683
  let sessionRef;
1227
1684
  try {
1228
1685
  sessionRef = await client.waitForInit();
1229
1686
  } catch (err) {
1230
1687
  client.kill();
1231
1688
  await cleanupMcpConfigDir(mcpConfigDir);
1232
- throw err;
1689
+ if (isRuntimeExecutionFailure(err)) throw err;
1690
+ throw new RuntimeExecutionFailure({
1691
+ phase: "start",
1692
+ category: "infrastructure",
1693
+ retry: "retryable",
1694
+ reason: `claude exited before yielding an authoritative session id: ${errorMessage2(err)}`
1695
+ }, { cause: err });
1233
1696
  }
1234
1697
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1235
1698
  client.kill();
1236
1699
  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
- );
1700
+ throw new RuntimeExecutionFailure({
1701
+ phase: "start",
1702
+ category: "authority",
1703
+ retry: "non-retryable",
1704
+ reason: `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef})`
1705
+ });
1240
1706
  }
1241
1707
  return new ClaudeSession(
1242
1708
  sessionRef,
1243
1709
  client,
1244
- ctx.workspaceDir,
1245
- ctx.approvalChannel,
1710
+ startInput.manifest.workspace.workspaceDir,
1711
+ startInput.approvalChannel,
1246
1712
  mcpConfigDir,
1247
- modelId
1713
+ manifestModelId
1248
1714
  );
1249
1715
  }
1250
1716
  /**
@@ -1279,8 +1745,7 @@ var ClaudeAdapter = class {
1279
1745
  return (this.options.resolveBin ?? resolveClaudeBin)();
1280
1746
  }
1281
1747
  };
1282
- function subscriptionModel(task, runtimeId) {
1283
- const selection = task.dispatchSelection;
1748
+ function subscriptionModel(selection, runtimeId) {
1284
1749
  if (selection === void 0) return void 0;
1285
1750
  if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
1286
1751
  throw new PolicyUnsupportedError(
@@ -1305,6 +1770,7 @@ var ClaudeSession = class {
1305
1770
  mcpConfigDir;
1306
1771
  modelId;
1307
1772
  correlation = createToolUseCorrelation();
1773
+ closeAttempt;
1308
1774
  get events() {
1309
1775
  const client = this.client;
1310
1776
  const correlation = this.correlation;
@@ -1313,17 +1779,40 @@ var ClaudeSession = class {
1313
1779
  [Symbol.asyncIterator]() {
1314
1780
  const inner = client.events[Symbol.asyncIterator]();
1315
1781
  let pending = [];
1782
+ let terminalFailure;
1316
1783
  let turnSettled = false;
1317
1784
  return {
1318
1785
  async next() {
1319
1786
  for (; ; ) {
1320
1787
  const buffered = pending.shift();
1321
1788
  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 };
1789
+ if (turnSettled) {
1790
+ if (terminalFailure) throw terminalFailure;
1791
+ return { value: void 0, done: true };
1792
+ }
1793
+ let raw;
1794
+ try {
1795
+ raw = await inner.next();
1796
+ } catch (cause) {
1797
+ throw new RuntimeExecutionFailure({
1798
+ phase: "run",
1799
+ category: "infrastructure",
1800
+ retry: "retryable",
1801
+ reason: "claude runtime event transport failed"
1802
+ }, { cause });
1803
+ }
1804
+ const { value, done } = raw;
1805
+ if (done) {
1806
+ throw new RuntimeExecutionFailure({
1807
+ phase: "run",
1808
+ category: "infrastructure",
1809
+ retry: "retryable",
1810
+ reason: "claude runtime process ended before a terminal result frame"
1811
+ }, { cause: client.terminalError });
1812
+ }
1325
1813
  if (value.type === "result") turnSettled = true;
1326
1814
  const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
1815
+ terminalFailure = mapped.terminalFailure ?? terminalFailure;
1327
1816
  if (mapped.unmappedLabel) {
1328
1817
  client.recordUnmappedFrame(mapped.unmappedLabel);
1329
1818
  }
@@ -1354,7 +1843,7 @@ var ClaudeSession = class {
1354
1843
  if (typeof task.instruction !== "string") {
1355
1844
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1356
1845
  }
1357
- const requestedModel = subscriptionModel(task, "claude");
1846
+ const requestedModel = subscriptionModel(task.dispatchSelection, "claude");
1358
1847
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
1359
1848
  throw new PolicyUnsupportedError(
1360
1849
  `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -1378,8 +1867,17 @@ var ClaudeSession = class {
1378
1867
  this.client.kill();
1379
1868
  }
1380
1869
  async close() {
1381
- this.client.kill();
1382
- await cleanupMcpConfigDir(this.mcpConfigDir);
1870
+ if (!this.closeAttempt) {
1871
+ const attempt = (async () => {
1872
+ await this.client.dispose();
1873
+ await cleanupMcpConfigDir(this.mcpConfigDir);
1874
+ })();
1875
+ this.closeAttempt = attempt.catch((error) => {
1876
+ this.closeAttempt = void 0;
1877
+ throw error;
1878
+ });
1879
+ }
1880
+ await this.closeAttempt;
1383
1881
  }
1384
1882
  /**
1385
1883
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -1596,14 +2094,15 @@ var CodexProcessRunner = class {
1596
2094
  exitSignal = null;
1597
2095
  closedPromise;
1598
2096
  resolveClosed;
2097
+ disposalAttempt;
1599
2098
  constructor(options) {
1600
2099
  this.onEvent = options.onEvent;
1601
2100
  const spawnFn = options.spawnFn ?? spawn;
1602
- this.child = spawnFn(options.command, options.args, {
2101
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
1603
2102
  cwd: options.cwd,
1604
2103
  env: options.env,
1605
2104
  stdio: ["ignore", "pipe", "pipe"]
1606
- });
2105
+ }));
1607
2106
  this.closedPromise = new Promise((resolve) => {
1608
2107
  this.resolveClosed = resolve;
1609
2108
  });
@@ -1633,7 +2132,7 @@ var CodexProcessRunner = class {
1633
2132
  return this.closed;
1634
2133
  }
1635
2134
  /**
1636
- * Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
2135
+ * Immediate tree termination request. SIGTERM on POSIX: SIGINT was empirically confirmed
1637
2136
  * to be silently ignored by `codex exec` (a real, direct test — a 60s
1638
2137
  * shell `sleep` ran to full, unaffected completion despite SIGINT sent at
1639
2138
  * t=4s) — a genuine, evidence-based correction to this task's own initial
@@ -1646,13 +2145,25 @@ var CodexProcessRunner = class {
1646
2145
  * `../pi/rpc-client.ts`'s own cross-platform convention.
1647
2146
  */
1648
2147
  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");
2148
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
2149
+ }
2150
+ dispose() {
2151
+ if (!this.disposalAttempt) {
2152
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2153
+ this.disposalAttempt = attempt.catch((error) => {
2154
+ this.disposalAttempt = void 0;
2155
+ throw error;
2156
+ });
1655
2157
  }
2158
+ return this.disposalAttempt;
2159
+ }
2160
+ processTreeOptions() {
2161
+ return {
2162
+ child: this.child,
2163
+ waitClosed: () => this.closedPromise,
2164
+ isClosed: () => this.closed,
2165
+ label: "codex"
2166
+ };
1656
2167
  }
1657
2168
  /** 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
2169
  buildExitError(context) {
@@ -1703,8 +2214,17 @@ var CodexAdapter = class {
1703
2214
  this.options = options;
1704
2215
  }
1705
2216
  options;
1706
- supportsDispatchSelection = true;
1707
- id = "codex";
2217
+ descriptor = freezeRuntimeAdapterDescriptor({
2218
+ id: "codex",
2219
+ supportsDispatchSelection: true,
2220
+ capabilities: {
2221
+ steer: false,
2222
+ resume: true,
2223
+ approvalInteractive: false,
2224
+ permissionModes: ["auto", "readonly"]
2225
+ },
2226
+ environmentRequirements: { credentialNames: [] }
2227
+ });
1708
2228
  async detect() {
1709
2229
  const bin = this.resolveBin();
1710
2230
  try {
@@ -1753,61 +2273,100 @@ ${result.stderr}`);
1753
2273
  ${withStreams.stderr ?? ""}`);
1754
2274
  }
1755
2275
  }
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)");
2276
+ async prepare(input) {
2277
+ const mapping = mapPermissionPolicyToCodexArgs(input.policy);
2278
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by codex adapter", retryable: false };
2279
+ let modelId;
2280
+ try {
2281
+ modelId = subscriptionModel2(input.offer.dispatchSelection);
2282
+ } catch (error) {
2283
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
1774
2284
  }
1775
- const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
1776
- if (!mapping.ok) {
1777
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2285
+ let command;
2286
+ try {
2287
+ command = this.resolveBin().command;
2288
+ } catch (error) {
2289
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2290
+ }
2291
+ return {
2292
+ kind: "prepared",
2293
+ operation: {
2294
+ start: (startInput) => this.startPrepared(startInput, mapping.args, modelId, command)
2295
+ }
2296
+ };
2297
+ }
2298
+ async startPrepared(startInput, policyArgs, modelId, command) {
2299
+ if (typeof startInput.instruction !== "string") {
2300
+ throw new RuntimeExecutionFailure({
2301
+ phase: "start",
2302
+ category: "authority",
2303
+ retry: "non-retryable",
2304
+ reason: "prepared codex operation requires a resolved string instruction"
2305
+ });
1778
2306
  }
1779
- const modelId = subscriptionModel2(task);
1780
- const bin = this.resolveBin();
1781
2307
  const queue = new AsyncQueue();
2308
+ const terminal = {};
1782
2309
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
1783
- const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
1784
- const runtimeEnv = withoutProviderCredentials(ctx.env);
2310
+ let workspaceDir;
2311
+ try {
2312
+ workspaceDir = await resolveRealWorkspaceDir(startInput.manifest.workspace.workspaceDir);
2313
+ } catch (cause) {
2314
+ throw new RuntimeExecutionFailure({
2315
+ phase: "start",
2316
+ category: "infrastructure",
2317
+ retry: "retryable",
2318
+ reason: "codex runtime workspace could not be resolved"
2319
+ }, { cause });
2320
+ }
2321
+ const runtimeEnv = withoutProviderCredentials(startInput.env);
2322
+ let manifestModelId;
2323
+ try {
2324
+ manifestModelId = subscriptionModel2(startInput.manifest.dispatchSelection);
2325
+ } catch (cause) {
2326
+ throw new RuntimeExecutionFailure({
2327
+ phase: "start",
2328
+ category: "authority",
2329
+ retry: "non-retryable",
2330
+ reason: "prepared codex operation received an invalid runtime selection manifest"
2331
+ }, { cause });
2332
+ }
2333
+ if (manifestModelId !== modelId) {
2334
+ throw new RuntimeExecutionFailure({
2335
+ phase: "start",
2336
+ category: "authority",
2337
+ retry: "non-retryable",
2338
+ reason: "prepared codex operation received a manifest with different runtime selection"
2339
+ });
2340
+ }
1785
2341
  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,
2342
+ command,
2343
+ resumeRef: startInput.manifest.sessionRef,
2344
+ instruction: startInput.instruction,
2345
+ modelId: manifestModelId,
2346
+ policyArgs: [...policyArgs],
2347
+ cwd: startInput.manifest.workspace.workspaceDir,
1792
2348
  env: runtimeEnv,
1793
2349
  spawnFn: this.options.spawnFn,
1794
2350
  workspaceDir,
1795
2351
  queue,
1796
2352
  recordUnmapped,
1797
- expectedSessionRef: task.sessionRef,
1798
- preparedGit: ctx.gitWorkspace !== void 0
2353
+ expectedSessionRef: startInput.manifest.sessionRef,
2354
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
2355
+ failurePhase: "start",
2356
+ terminal
1799
2357
  });
1800
2358
  return new CodexSession({
1801
2359
  sessionRef,
1802
- command: bin.command,
2360
+ command,
1803
2361
  workspaceDir,
1804
- env: ctx.env,
2362
+ env: startInput.env,
1805
2363
  spawnFn: this.options.spawnFn,
1806
2364
  queue,
1807
2365
  recordUnmapped,
1808
2366
  initialRunner: runner,
1809
- preparedGit: ctx.gitWorkspace !== void 0,
1810
- modelId
2367
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
2368
+ modelId: manifestModelId,
2369
+ terminal
1811
2370
  });
1812
2371
  }
1813
2372
  resolveBin() {
@@ -1855,37 +2414,69 @@ async function runCodexTurn(params) {
1855
2414
  rejectFirstLine = reject;
1856
2415
  });
1857
2416
  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
- );
2417
+ let runner;
2418
+ try {
2419
+ runner = new CodexProcessRunner({
2420
+ command: params.command,
2421
+ args: argv,
2422
+ cwd: params.cwd,
2423
+ env: params.env,
2424
+ spawnFn: params.spawnFn,
2425
+ onEvent: (evt) => {
2426
+ if (!firstLineSettled) {
2427
+ firstLineSettled = true;
2428
+ if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
2429
+ resolveFirstLine(evt.thread_id);
2430
+ } else {
2431
+ rejectFirstLine(
2432
+ new RuntimeExecutionFailure({
2433
+ phase: params.failurePhase,
2434
+ category: "authority",
2435
+ retry: "non-retryable",
2436
+ reason: `codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`
2437
+ })
2438
+ );
2439
+ }
2440
+ return;
2441
+ }
2442
+ const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
2443
+ for (const agentEvent of mapped) {
2444
+ if (agentEvent.type === "turn_end") turnEnded = true;
2445
+ params.queue.push(agentEvent);
2446
+ }
2447
+ if (evt.type === "turn.failed") {
2448
+ params.terminal.failure = new RuntimeExecutionFailure({
2449
+ phase: "run",
2450
+ category: "semantic",
2451
+ retry: "non-retryable",
2452
+ reason: "codex reported terminal task failure"
2453
+ });
2454
+ params.queue.end();
2455
+ }
2456
+ if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
2457
+ params.recordUnmapped(unmappedFrameKey(evt));
1873
2458
  }
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
2459
  }
1884
- }
1885
- });
2460
+ });
2461
+ } catch (cause) {
2462
+ throw new RuntimeExecutionFailure({
2463
+ phase: params.failurePhase,
2464
+ category: "infrastructure",
2465
+ retry: "retryable",
2466
+ reason: "codex runtime process could not be spawned"
2467
+ }, { cause });
2468
+ }
2469
+ params.onRunnerCreated?.(runner);
1886
2470
  void runner.waitClosed().then(() => {
1887
- if (turnEnded) return;
1888
- params.queue.push({ type: "error", message: runner.buildExitError("codex exited without completing the turn").message });
2471
+ if (turnEnded || params.terminal.failure) return;
2472
+ const cause = runner.buildExitError("codex exited without completing the turn");
2473
+ params.terminal.failure = new RuntimeExecutionFailure({
2474
+ phase: "run",
2475
+ category: "infrastructure",
2476
+ retry: "retryable",
2477
+ reason: cause.message
2478
+ }, { cause });
2479
+ params.queue.push({ type: "error", message: cause.message });
1889
2480
  params.queue.end();
1890
2481
  });
1891
2482
  let sessionRef;
@@ -1909,7 +2500,13 @@ async function runCodexTurn(params) {
1909
2500
  void runner.waitClosed().then(() => {
1910
2501
  if (!settled) {
1911
2502
  settled = true;
1912
- reject(runner.buildExitError("codex exited before yielding an authoritative thread id"));
2503
+ const cause = runner.buildExitError("codex exited before yielding an authoritative thread id");
2504
+ reject(new RuntimeExecutionFailure({
2505
+ phase: params.failurePhase,
2506
+ category: "infrastructure",
2507
+ retry: "retryable",
2508
+ reason: cause.message
2509
+ }, { cause }));
1913
2510
  }
1914
2511
  });
1915
2512
  });
@@ -1919,9 +2516,12 @@ async function runCodexTurn(params) {
1919
2516
  }
1920
2517
  if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
1921
2518
  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
- );
2519
+ throw new RuntimeExecutionFailure({
2520
+ phase: params.failurePhase,
2521
+ category: "authority",
2522
+ retry: "non-retryable",
2523
+ reason: `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef})`
2524
+ });
1925
2525
  }
1926
2526
  return { sessionRef, runner };
1927
2527
  }
@@ -1948,8 +2548,12 @@ var CodexSession = class {
1948
2548
  recordUnmapped;
1949
2549
  preparedGit;
1950
2550
  modelId;
2551
+ terminal;
1951
2552
  currentRunner;
2553
+ ownedRunners = /* @__PURE__ */ new Set();
2554
+ followUpAttempts = /* @__PURE__ */ new Set();
1952
2555
  closed = false;
2556
+ closeAttempt;
1953
2557
  constructor(options) {
1954
2558
  this.sessionRef = options.sessionRef;
1955
2559
  this.command = options.command;
@@ -1960,11 +2564,36 @@ var CodexSession = class {
1960
2564
  this.recordUnmapped = options.recordUnmapped;
1961
2565
  this.preparedGit = options.preparedGit;
1962
2566
  this.modelId = options.modelId;
2567
+ this.terminal = options.terminal;
1963
2568
  this.currentRunner = options.initialRunner;
2569
+ this.ownedRunners.add(options.initialRunner);
1964
2570
  void this.forgetRunnerOnceClosed(options.initialRunner);
1965
2571
  }
1966
2572
  get events() {
1967
- return this.queue;
2573
+ const queue = this.queue;
2574
+ const session = this;
2575
+ return {
2576
+ [Symbol.asyncIterator]() {
2577
+ const inner = queue[Symbol.asyncIterator]();
2578
+ return {
2579
+ async next() {
2580
+ let result;
2581
+ try {
2582
+ result = await inner.next();
2583
+ } catch (cause) {
2584
+ throw new RuntimeExecutionFailure({
2585
+ phase: "run",
2586
+ category: "infrastructure",
2587
+ retry: "retryable",
2588
+ reason: "codex runtime event transport failed"
2589
+ }, { cause });
2590
+ }
2591
+ if (result.done && session.terminal.failure) throw session.terminal.failure;
2592
+ return result;
2593
+ }
2594
+ };
2595
+ }
2596
+ };
1968
2597
  }
1969
2598
  async forgetRunnerOnceClosed(runner) {
1970
2599
  await runner.waitClosed();
@@ -2009,7 +2638,14 @@ var CodexSession = class {
2009
2638
  * stale id even after codex had moved on) — it just can now only ever be
2010
2639
  * the SAME id this call asked to resume, never a silently-different one.
2011
2640
  */
2012
- async followUp(task) {
2641
+ followUp(task) {
2642
+ const attempt = this.runFollowUp(task);
2643
+ this.followUpAttempts.add(attempt);
2644
+ void attempt.finally(() => this.followUpAttempts.delete(attempt)).catch(() => {
2645
+ });
2646
+ return attempt;
2647
+ }
2648
+ async runFollowUp(task) {
2013
2649
  if (typeof task.instruction !== "string") {
2014
2650
  throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2015
2651
  }
@@ -2020,7 +2656,7 @@ var CodexSession = class {
2020
2656
  if (!mapping.ok) {
2021
2657
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2022
2658
  }
2023
- const requestedModel = subscriptionModel2(task);
2659
+ const requestedModel = subscriptionModel2(task.dispatchSelection);
2024
2660
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2025
2661
  throw new PolicyUnsupportedError(
2026
2662
  `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2030,6 +2666,7 @@ var CodexSession = class {
2030
2666
  const resumeRef = this.sessionRef;
2031
2667
  let sessionRef;
2032
2668
  let runner;
2669
+ const terminal = {};
2033
2670
  try {
2034
2671
  ({ sessionRef, runner } = await runCodexTurn({
2035
2672
  command: this.command,
@@ -2044,25 +2681,54 @@ var CodexSession = class {
2044
2681
  queue: this.queue,
2045
2682
  recordUnmapped: this.recordUnmapped,
2046
2683
  expectedSessionRef: resumeRef,
2047
- preparedGit: this.preparedGit
2684
+ preparedGit: this.preparedGit,
2685
+ failurePhase: "run",
2686
+ terminal,
2687
+ onRunnerCreated: (created) => {
2688
+ this.ownedRunners.add(created);
2689
+ void this.forgetRunnerOnceClosed(created);
2690
+ }
2048
2691
  }));
2049
2692
  } catch (err) {
2050
2693
  this.queue.end();
2694
+ this.terminal = {
2695
+ failure: isRuntimeExecutionFailure(err) ? err : new RuntimeExecutionFailure({
2696
+ phase: "run",
2697
+ category: "authority",
2698
+ retry: "non-retryable",
2699
+ reason: "codex follow-up violated the runtime adapter contract"
2700
+ }, { cause: err })
2701
+ };
2051
2702
  throw err;
2052
2703
  }
2704
+ if (this.closed) {
2705
+ await runner.dispose();
2706
+ throw new Error("codex session closed while follow-up was starting");
2707
+ }
2708
+ this.terminal = terminal;
2053
2709
  this.sessionRef = sessionRef;
2054
2710
  this.currentRunner = runner;
2055
- void this.forgetRunnerOnceClosed(runner);
2056
2711
  }
2057
2712
  /** 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
2713
  async interrupt() {
2059
2714
  this.currentRunner?.kill();
2060
2715
  }
2061
2716
  async close() {
2062
- if (this.closed) return;
2063
- this.closed = true;
2064
- this.currentRunner?.kill();
2065
- this.queue.end();
2717
+ if (!this.closeAttempt) {
2718
+ this.closed = true;
2719
+ this.queue.end();
2720
+ const attempt = (async () => {
2721
+ const runners = [...this.ownedRunners];
2722
+ await Promise.all(runners.map((runner) => runner.dispose()));
2723
+ for (const runner of runners) this.ownedRunners.delete(runner);
2724
+ await Promise.allSettled([...this.followUpAttempts]);
2725
+ })();
2726
+ this.closeAttempt = attempt.catch((error) => {
2727
+ this.closeAttempt = void 0;
2728
+ throw error;
2729
+ });
2730
+ }
2731
+ await this.closeAttempt;
2066
2732
  }
2067
2733
  /**
2068
2734
  * `codex exec` has no in-band channel to inject text into an already-
@@ -2098,8 +2764,7 @@ var CodexSession = class {
2098
2764
  );
2099
2765
  }
2100
2766
  };
2101
- function subscriptionModel2(task) {
2102
- const selection = task.dispatchSelection;
2767
+ function subscriptionModel2(selection) {
2103
2768
  if (selection === void 0) return void 0;
2104
2769
  if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
2105
2770
  throw new PolicyUnsupportedError(
@@ -2109,6 +2774,6 @@ function subscriptionModel2(task) {
2109
2774
  return selection.modelId;
2110
2775
  }
2111
2776
 
2112
- export { ClaudeAdapter, CodexAdapter, PI_PACKAGE_NAME, PiAdapter };
2777
+ export { ClaudeAdapter, CodexAdapter, PI_PACKAGE_NAME, PiAdapter, RuntimeDisposalFailure, RuntimeExecutionFailure };
2113
2778
  //# sourceMappingURL=index.js.map
2114
2779
  //# sourceMappingURL=index.js.map