@byok-sdk/client 0.2.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 (46) hide show
  1. package/README.md +58 -5
  2. package/dist/adapters/claude/claude-adapter.d.ts +6 -19
  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 -15
  6. package/dist/adapters/codex/process-runner.d.ts +4 -1
  7. package/dist/adapters/index.d.ts +4 -2
  8. package/dist/adapters/index.js +1081 -258
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/adapters/pi/pi-adapter.d.ts +24 -15
  11. package/dist/adapters/pi/rpc-client.d.ts +9 -1
  12. package/dist/adapters/process-tree.d.ts +19 -0
  13. package/dist/adapters/provider-credential-environment.d.ts +18 -0
  14. package/dist/bin/audit-log.d.ts +12 -0
  15. package/dist/bin/byok-agent.js +2686 -912
  16. package/dist/bin/byok-agent.js.map +1 -1
  17. package/dist/bin/byok-approval-mcp.js +2 -2
  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/assertion-client.d.ts +68 -0
  25. package/dist/daemon/capabilities-client.d.ts +48 -0
  26. package/dist/daemon/connection-manager.d.ts +4 -2
  27. package/dist/daemon/control-protocol.d.ts +81 -4
  28. package/dist/daemon/control-server.d.ts +18 -1
  29. package/dist/daemon/create-daemon.d.ts +171 -3
  30. package/dist/daemon/daemon-owner.d.ts +37 -0
  31. package/dist/daemon/device-assertion-signer.d.ts +41 -0
  32. package/dist/daemon/device-keys.d.ts +15 -13
  33. package/dist/daemon/environment.d.ts +9 -9
  34. package/dist/daemon/git-workspace.d.ts +21 -0
  35. package/dist/daemon/observer.d.ts +81 -3
  36. package/dist/daemon/presence-publisher.d.ts +98 -0
  37. package/dist/daemon/runtime-capabilities.d.ts +1 -1
  38. package/dist/daemon/skill-pack-installer.d.ts +116 -0
  39. package/dist/daemon/task-runner.d.ts +156 -37
  40. package/dist/daemon/ws-transport.d.ts +3 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +2972 -597
  43. package/dist/index.js.map +1 -1
  44. package/dist/runtime-failure.d.ts +64 -0
  45. package/dist/types.d.ts +114 -58
  46. 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,19 +680,15 @@ 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();
441
687
  }
442
688
  };
443
689
 
444
- // src/adapters/pi/pi-adapter.ts
445
- var execFileAsync = promisify(execFile);
446
- var DETECT_TIMEOUT_MS = 5e3;
447
- function errorMessage(err) {
448
- return err instanceof Error ? err.message : String(err);
449
- }
450
- var KNOWN_PROVIDER_ENV_VARS = [
690
+ // src/adapters/provider-credential-environment.ts
691
+ var PROVIDER_CREDENTIAL_ENV_NAMES = [
451
692
  "ANTHROPIC_API_KEY",
452
693
  "ANTHROPIC_OAUTH_TOKEN",
453
694
  "OPENAI_API_KEY",
@@ -458,123 +699,288 @@ var KNOWN_PROVIDER_ENV_VARS = [
458
699
  "MISTRAL_API_KEY",
459
700
  "OPENROUTER_API_KEY",
460
701
  "XAI_API_KEY",
461
- // Confirmed against the installed pi's own docs/providers.md ("ZAI |
462
- // `ZAI_API_KEY` | `zai`") and exercised live against real GLM traffic
463
- // during this task's acceptance run — omitting it made `authPresent`
464
- // silently false for a perfectly valid, working z.ai/GLM setup.
465
702
  "ZAI_API_KEY"
466
703
  ];
704
+ var PROVIDER_CREDENTIAL_ENV_DENY_NAMES = [
705
+ ...PROVIDER_CREDENTIAL_ENV_NAMES,
706
+ "ANT_LING_API_KEY",
707
+ "NVIDIA_API_KEY",
708
+ "CEREBRAS_API_KEY",
709
+ "CLOUDFLARE_API_KEY",
710
+ "AI_GATEWAY_API_KEY",
711
+ "ZAI_CODING_CN_API_KEY",
712
+ "OPENCODE_API_KEY",
713
+ "RADIUS_API_KEY",
714
+ "FIREWORKS_API_KEY",
715
+ "TOGETHER_API_KEY",
716
+ "BASETEN_API_KEY",
717
+ "KIMI_API_KEY",
718
+ "MINIMAX_API_KEY",
719
+ "MINIMAX_CN_API_KEY",
720
+ "QWEN_TOKEN_PLAN_API_KEY",
721
+ "QWEN_TOKEN_PLAN_CN_API_KEY",
722
+ "XIAOMI_API_KEY",
723
+ "XIAOMI_TOKEN_PLAN_CN_API_KEY",
724
+ "XIAOMI_TOKEN_PLAN_AMS_API_KEY",
725
+ "XIAOMI_TOKEN_PLAN_SGP_API_KEY",
726
+ "AWS_ACCESS_KEY_ID",
727
+ "AWS_SECRET_ACCESS_KEY",
728
+ "AWS_SESSION_TOKEN",
729
+ "GOOGLE_APPLICATION_CREDENTIALS",
730
+ // Reserved by the keys-owned Pi projection. It must never be inherited
731
+ // from the daemon; the launcher deletes any ambient copy and injects only
732
+ // the exact credential it just resolved from OS custody.
733
+ "PI_PROVIDER_API_KEY"
734
+ ];
735
+ function withoutProviderCredentials(env) {
736
+ const sanitized = { ...env };
737
+ for (const name of PROVIDER_CREDENTIAL_ENV_DENY_NAMES) {
738
+ delete sanitized[name];
739
+ }
740
+ return sanitized;
741
+ }
742
+
743
+ // src/adapters/pi/pi-adapter.ts
744
+ var execFileAsync = promisify(execFile);
745
+ var DETECT_TIMEOUT_MS = 5e3;
746
+ function errorMessage(err) {
747
+ return err instanceof Error ? err.message : String(err);
748
+ }
467
749
  var PiAdapter = class {
468
750
  constructor(options = {}) {
469
751
  this.options = options;
470
752
  }
471
753
  options;
472
- id = "pi";
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
+ });
473
765
  async detect() {
474
766
  try {
475
767
  const bin = this.resolveBin();
476
768
  const { stdout, stderr } = await execFileAsync(bin.command, ["--version"], { timeout: DETECT_TIMEOUT_MS });
477
769
  const version = stdout.trim() || stderr.trim();
478
- const authPresent = KNOWN_PROVIDER_ENV_VARS.some((name) => process.env[name] !== void 0);
770
+ const authPresent = PROVIDER_CREDENTIAL_ENV_NAMES.some((name) => process.env[name] !== void 0);
479
771
  return { present: true, version, authPresent };
480
772
  } catch {
481
773
  return { present: false };
482
774
  }
483
775
  }
484
- capabilities() {
485
- return { steer: true, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
486
- }
487
- /**
488
- * M5: pi authenticates to its ~30 supported providers via env-var API
489
- * keys — `detect()`'s own `authPresent` probe above checks this identical
490
- * list — so these MUST keep flowing into pi's spawned process or pi auth
491
- * breaks entirely. `KNOWN_PROVIDER_ENV_VARS` above is the single source
492
- * of truth, reused here rather than duplicated. No `baseNames`: nothing
493
- * in this adapter or `rpc-client.ts` reads a pi-specific config-discovery
494
- * variable beyond the platform baseline (`daemon/environment.ts`).
495
- */
496
- environmentRequirements() {
497
- return { credentialNames: KNOWN_PROVIDER_ENV_VARS };
498
- }
499
- async start(task, ctx) {
500
- if (typeof task.instruction !== "string") {
501
- throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
502
- }
503
- const mapping = mapPermissionPolicyToPiArgs(ctx.policy);
776
+ async prepare(input) {
777
+ const mapping = mapPermissionPolicyToPiArgs(input.policy);
504
778
  if (!mapping.ok) {
505
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
779
+ return { kind: "reject", reason: mapping.reason ?? "policy rejected by pi adapter", retryable: false };
506
780
  }
507
781
  const bin = this.resolveBin();
508
- const resumeSessionId = task.sessionRef;
509
- const args = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
510
- const rpc = new PiRpcClient({
511
- command: bin.command,
512
- args,
513
- cwd: ctx.workspaceDir,
514
- env: ctx.env,
515
- spawnFn: this.options.spawnFn
516
- });
517
- const response = await rpc.send({ type: "prompt", message: task.instruction });
518
- if (response.success === false) {
519
- rpc.kill();
520
- throw new Error(typeof response.error === "string" ? response.error : "pi rejected the initial prompt");
521
- }
522
- let sessionRef;
523
- if (resumeSessionId) {
524
- sessionRef = resumeSessionId;
525
- } else {
526
- try {
527
- sessionRef = await resolveFreshSessionId(rpc);
528
- } catch (err) {
529
- rpc.kill();
530
- throw err;
782
+ const selection = input.offer.dispatchSelection;
783
+ const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
784
+ let command = bin.command;
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 };
789
+ }
790
+ const launcher = this.options.byokLauncher;
791
+ if (launcher === void 0) {
792
+ return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
531
793
  }
794
+ command = launcher.command;
795
+ launcherArgs = [
796
+ ...launcher.args ?? [],
797
+ "--pi-bin",
798
+ bin.command,
799
+ "--profile-db",
800
+ launcher.profileDbPath,
801
+ "--session-dir",
802
+ launcher.sessionDir,
803
+ ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
804
+ "--provider",
805
+ pinnedSelection.providerId,
806
+ "--model",
807
+ pinnedSelection.modelId
808
+ ];
532
809
  }
533
- return new PiSession(sessionRef, rpc);
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
+ }
890
+ }
891
+ };
534
892
  }
535
893
  resolveBin() {
536
894
  return (this.options.resolveBin ?? resolvePiBin)();
537
895
  }
538
896
  };
539
- 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) {
540
902
  let state;
541
903
  try {
542
904
  state = await rpc.send({ type: "get_state" });
543
905
  } catch (err) {
544
- 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
+ }, {
545
913
  cause: err
546
914
  });
547
915
  }
548
916
  if (state.success === false) {
549
917
  const reason = typeof state.error === "string" ? state.error : "get_state reported failure";
550
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${reason}`);
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
+ });
551
924
  }
552
925
  const data = state.data;
553
926
  if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
554
927
  return data.sessionId;
555
928
  }
556
- throw new Error(
557
- "pi did not yield an authoritative session id (get_state succeeded but reported no sessionId) \u2014 cannot mint a resumable session"
558
- );
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
+ });
559
935
  }
560
936
  var PiSession = class {
561
- constructor(sessionRef, rpc) {
937
+ constructor(sessionRef, rpc, selection) {
562
938
  this.sessionRef = sessionRef;
563
939
  this.rpc = rpc;
940
+ this.selection = selection;
564
941
  }
565
942
  sessionRef;
566
943
  rpc;
944
+ selection;
567
945
  get events() {
568
946
  const rpc = this.rpc;
569
947
  return {
570
948
  [Symbol.asyncIterator]() {
571
949
  const inner = rpc.events[Symbol.asyncIterator]();
950
+ let terminalFailure;
572
951
  return {
573
952
  async next() {
574
953
  for (; ; ) {
575
- const { value, done } = await inner.next();
576
- 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
+ }
577
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
+ }
578
984
  if (mapped) return { value: mapped, done: false };
579
985
  if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
580
986
  rpc.recordUnmappedFrame(value.type);
@@ -592,13 +998,19 @@ var PiSession = class {
592
998
  if (typeof task.instruction !== "string") {
593
999
  throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
594
1000
  }
1001
+ const requestedSelection = task.dispatchSelection;
1002
+ if (requestedSelection !== void 0 && (this.selection?.lane !== "byok" || requestedSelection.lane !== "byok" || requestedSelection.runtimeId !== "pi" || requestedSelection.providerId !== this.selection.providerId || requestedSelection.modelId !== this.selection.modelId)) {
1003
+ throw new PolicyUnsupportedError(
1004
+ "pi persistent session cannot change its authoritative BYOK provider/model selection"
1005
+ );
1006
+ }
595
1007
  await this.rpc.send({ type: "prompt", message: task.instruction, streamingBehavior: "followUp" });
596
1008
  }
597
1009
  async interrupt() {
598
1010
  await this.rpc.send({ type: "abort" });
599
1011
  }
600
1012
  async close() {
601
- this.rpc.kill();
1013
+ await this.rpc.dispose();
602
1014
  }
603
1015
  async resolveApproval() {
604
1016
  throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
@@ -810,7 +1222,15 @@ function mapResult(msg) {
810
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`
811
1223
  );
812
1224
  const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
813
- 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
+ };
814
1234
  }
815
1235
  var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
816
1236
  function truncateResultDiagnostic(text) {
@@ -862,16 +1282,22 @@ var ClaudeProcessClient = class {
862
1282
  eventQueue = new AsyncQueue();
863
1283
  closed = false;
864
1284
  exitError;
1285
+ closedPromise;
1286
+ resolveClosed;
1287
+ disposalAttempt;
865
1288
  stderrRing = [];
866
1289
  unmappedFrameCounts = /* @__PURE__ */ new Map();
867
1290
  sessionId;
868
1291
  initWaiter;
869
1292
  constructor(options) {
870
1293
  const spawnFn = options.spawnFn ?? spawn;
871
- this.child = spawnFn(options.command, options.args, {
1294
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
872
1295
  cwd: options.cwd,
873
1296
  env: options.env,
874
1297
  stdio: ["pipe", "pipe", "pipe"]
1298
+ }));
1299
+ this.closedPromise = new Promise((resolve) => {
1300
+ this.resolveClosed = resolve;
875
1301
  });
876
1302
  this.child.stdout.setEncoding("utf8");
877
1303
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -927,6 +1353,10 @@ var ClaudeProcessClient = class {
927
1353
  get events() {
928
1354
  return this.eventQueue;
929
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
+ }
930
1360
  /**
931
1361
  * Record a claude stream-json frame/subtype/content-block label that
932
1362
  * `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
@@ -946,15 +1376,30 @@ var ClaudeProcessClient = class {
946
1376
  );
947
1377
  }
948
1378
  }
949
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes claude itself spawned (e.g. Bash) — mirrors pi's cross-platform `kill()` exactly. Empirically confirmed on this (POSIX) machine: a running claude process exits cleanly within ~1s of SIGTERM (observed exit code 143 = 128+SIGTERM, i.e. claude catches and handles the signal itself rather than needing a harder kill). */
1379
+ /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
950
1380
  kill() {
951
- if (this.closed) return;
952
- const pid = this.child.pid;
953
- if (process.platform === "win32" && pid !== void 0) {
954
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
955
- } else {
956
- this.child.kill("SIGTERM");
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
+ });
957
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
+ };
958
1403
  }
959
1404
  onData(chunk) {
960
1405
  this.buffer += chunk;
@@ -1005,6 +1450,7 @@ var ClaudeProcessClient = class {
1005
1450
  if (this.closed) return;
1006
1451
  this.closed = true;
1007
1452
  this.exitError = err;
1453
+ this.resolveClosed();
1008
1454
  this.initWaiter?.reject(err);
1009
1455
  this.initWaiter = void 0;
1010
1456
  this.eventQueue.end();
@@ -1016,17 +1462,37 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1016
1462
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1017
1463
  var execFileAsync2 = promisify(execFile);
1018
1464
  var DETECT_TIMEOUT_MS2 = 5e3;
1019
- async function cleanupApprovalMcpConfigDir(dir) {
1465
+ function errorMessage2(err) {
1466
+ return err instanceof Error ? err.message : String(err);
1467
+ }
1468
+ async function cleanupMcpConfigDir(dir) {
1020
1469
  if (!dir) return;
1021
- await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1022
- });
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
+ }
1023
1478
  }
1024
1479
  var ClaudeAdapter = class {
1025
1480
  constructor(options = {}) {
1026
1481
  this.options = options;
1027
1482
  }
1028
1483
  options;
1029
- 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
+ });
1030
1496
  async detect() {
1031
1497
  const bin = this.resolveBin();
1032
1498
  try {
@@ -1038,74 +1504,137 @@ var ClaudeAdapter = class {
1038
1504
  return { present: false };
1039
1505
  }
1040
1506
  }
1041
- capabilities() {
1042
- return { steer: false, resume: true, approvalInteractive: true, permissionModes: ["auto", "readonly", "plan", "confirm"] };
1043
- }
1044
- /**
1045
- * M5: deliberate product-boundary decision, not an oversight — byok's
1046
- * current ToS posture for claude is login-state-only (`claude auth
1047
- * login`'s own OAuth session — see `probeAuthPresent` below), so this
1048
- * adapter declares NO credential env vars at all; env-based API-key
1049
- * passthrough for claude is a separate, still-pending product decision.
1050
- * A product that genuinely needs it can opt in locally per-device via
1051
- * `DaemonConfig.runtimeEnvironment.claude.allow` (`create-daemon.ts`).
1052
- * `baseNames` is empty too: nothing in this adapter reads a
1053
- * claude-specific config-discovery variable (e.g. `CLAUDE_CONFIG_DIR`)
1054
- * today — if a future version of this adapter starts reading one, it
1055
- * belongs here, not left to rely on the platform baseline alone.
1056
- */
1057
- environmentRequirements() {
1058
- return { credentialNames: [] };
1059
- }
1060
- async start(task, ctx) {
1061
- if (typeof task.instruction !== "string") {
1062
- throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
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 };
1063
1515
  }
1064
- const mapping = mapPermissionPolicyToClaudeArgs(ctx.policy);
1065
- if (!mapping.ok) {
1066
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
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 };
1067
1524
  }
1068
- let approvalMcpConfigDir;
1525
+ let approvalMcpBin;
1069
1526
  if (mapping.needsApprovalMcp) {
1070
- if (!ctx.approvalChannel) {
1071
- throw new PolicyUnsupportedError(
1072
- 'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1073
- );
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
+ }
1533
+ return {
1534
+ kind: "prepared",
1535
+ operation: {
1536
+ start: (startInput) => this.startPrepared(startInput, mapping, modelId, bin, approvalMcpBin)
1074
1537
  }
1075
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1076
- approvalMcpConfigDir = await promises.mkdtemp(path3.join(os.tmpdir(), "byok-approval-mcp-"));
1077
- await promises.chmod(approvalMcpConfigDir, 448).catch(() => {
1538
+ };
1539
+ }
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"
1078
1553
  });
1079
- const mcpConfigPath = path3.join(approvalMcpConfigDir, "mcp-config.json");
1080
- const mcpConfig = {
1081
- mcpServers: {
1082
- [APPROVAL_MCP_SERVER_NAME]: {
1083
- command: approvalMcpBin.command,
1084
- args: approvalMcpBin.args,
1085
- env: {
1086
- BYOK_STORE_DIR: ctx.approvalChannel.storeDir,
1087
- BYOK_PRODUCT_ID: ctx.approvalChannel.productId,
1088
- BYOK_TASK_ID: ctx.approvalChannel.taskId,
1089
- BYOK_APPROVAL_TIMEOUT_MS: String(ctx.approvalChannel.timeoutMs)
1090
- }
1554
+ }
1555
+ const mapping = { ...initialMapping, args: [...initialMapping.args] };
1556
+ let mcpConfigDir;
1557
+ const taskMcpServers = startInput.mcpServers ?? {};
1558
+ const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
1559
+ if (mapping.needsApprovalMcp) {
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
+ });
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
+ });
1574
+ }
1575
+ if (needsMcpConfig) {
1576
+ mcpConfigDir = await promises.mkdtemp(path3.join(os.tmpdir(), "byok-mcp-"));
1577
+ await promises.chmod(mcpConfigDir, 448).catch(() => {
1578
+ });
1579
+ const mcpConfigPath = path3.join(mcpConfigDir, "mcp-config.json");
1580
+ const mcpServers = { ...taskMcpServers };
1581
+ if (mapping.needsApprovalMcp) {
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
+ });
1596
+ mcpServers[APPROVAL_MCP_SERVER_NAME] = {
1597
+ command: preparedApprovalMcpBin.command,
1598
+ args: preparedApprovalMcpBin.args,
1599
+ env: {
1600
+ BYOK_STORE_DIR: approvalChannel.storeDir,
1601
+ BYOK_PRODUCT_ID: approvalChannel.productId,
1602
+ BYOK_TASK_ID: approvalChannel.taskId,
1603
+ BYOK_APPROVAL_TIMEOUT_MS: String(approvalChannel.timeoutMs)
1091
1604
  }
1092
- }
1093
- };
1094
- await promises.writeFile(mcpConfigPath, JSON.stringify(mcpConfig), { mode: 384 });
1605
+ };
1606
+ }
1607
+ await promises.writeFile(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 384 });
1095
1608
  mapping.args = [
1096
1609
  ...mapping.args,
1097
- "--permission-prompt-tool",
1098
- `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`,
1610
+ ...mapping.needsApprovalMcp ? ["--permission-prompt-tool", `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`] : [],
1099
1611
  "--mcp-config",
1100
1612
  mcpConfigPath,
1101
- // Never let this task's confirm-mode run pick up some OTHER MCP
1102
- // server from ambient project/user config the approval channel is
1103
- // the only MCP server this invocation should ever see.
1613
+ // The generated file is the complete task-scoped MCP authority.
1614
+ // Never merge ambient user/project MCP configuration into it.
1104
1615
  "--strict-mcp-config"
1105
1616
  ];
1106
1617
  }
1107
- const bin = this.resolveBin();
1108
- 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
+ }
1109
1638
  const args = [
1110
1639
  "-p",
1111
1640
  "--input-format",
@@ -1117,33 +1646,72 @@ var ClaudeAdapter = class {
1117
1646
  // "Error: When using --print, --output-format=stream-json requires
1118
1647
  // --verbose", before spawning any model call.
1119
1648
  "--verbose",
1649
+ ...manifestModelId ? ["--model", manifestModelId] : [],
1120
1650
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1121
1651
  ...mapping.args
1122
1652
  ];
1123
- const client = new ClaudeProcessClient({
1124
- command: bin.command,
1125
- args,
1126
- cwd: ctx.workspaceDir,
1127
- env: ctx.env,
1128
- spawnFn: this.options.spawnFn
1129
- });
1130
- client.writeUserMessage(task.instruction);
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
+ }
1131
1683
  let sessionRef;
1132
1684
  try {
1133
1685
  sessionRef = await client.waitForInit();
1134
1686
  } catch (err) {
1135
1687
  client.kill();
1136
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1137
- throw err;
1688
+ await cleanupMcpConfigDir(mcpConfigDir);
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 });
1138
1696
  }
1139
1697
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1140
1698
  client.kill();
1141
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1142
- throw new Error(
1143
- `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1144
- );
1699
+ await cleanupMcpConfigDir(mcpConfigDir);
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
+ });
1145
1706
  }
1146
- return new ClaudeSession(sessionRef, client, ctx.workspaceDir, ctx.approvalChannel, approvalMcpConfigDir);
1707
+ return new ClaudeSession(
1708
+ sessionRef,
1709
+ client,
1710
+ startInput.manifest.workspace.workspaceDir,
1711
+ startInput.approvalChannel,
1712
+ mcpConfigDir,
1713
+ manifestModelId
1714
+ );
1147
1715
  }
1148
1716
  /**
1149
1717
  * `claude auth status --json` is claude's OWN non-secret login-state
@@ -1177,20 +1745,32 @@ var ClaudeAdapter = class {
1177
1745
  return (this.options.resolveBin ?? resolveClaudeBin)();
1178
1746
  }
1179
1747
  };
1748
+ function subscriptionModel(selection, runtimeId) {
1749
+ if (selection === void 0) return void 0;
1750
+ if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
1751
+ throw new PolicyUnsupportedError(
1752
+ `claude adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
1753
+ );
1754
+ }
1755
+ return selection.modelId;
1756
+ }
1180
1757
  var ClaudeSession = class {
1181
- constructor(sessionRef, client, workspaceDir, approvalChannel, approvalMcpConfigDir) {
1758
+ constructor(sessionRef, client, workspaceDir, approvalChannel, mcpConfigDir, modelId) {
1182
1759
  this.sessionRef = sessionRef;
1183
1760
  this.client = client;
1184
1761
  this.workspaceDir = workspaceDir;
1185
1762
  this.approvalChannel = approvalChannel;
1186
- this.approvalMcpConfigDir = approvalMcpConfigDir;
1763
+ this.mcpConfigDir = mcpConfigDir;
1764
+ this.modelId = modelId;
1187
1765
  }
1188
1766
  sessionRef;
1189
1767
  client;
1190
1768
  workspaceDir;
1191
1769
  approvalChannel;
1192
- approvalMcpConfigDir;
1770
+ mcpConfigDir;
1771
+ modelId;
1193
1772
  correlation = createToolUseCorrelation();
1773
+ closeAttempt;
1194
1774
  get events() {
1195
1775
  const client = this.client;
1196
1776
  const correlation = this.correlation;
@@ -1199,17 +1779,40 @@ var ClaudeSession = class {
1199
1779
  [Symbol.asyncIterator]() {
1200
1780
  const inner = client.events[Symbol.asyncIterator]();
1201
1781
  let pending = [];
1782
+ let terminalFailure;
1202
1783
  let turnSettled = false;
1203
1784
  return {
1204
1785
  async next() {
1205
1786
  for (; ; ) {
1206
1787
  const buffered = pending.shift();
1207
1788
  if (buffered) return { value: buffered, done: false };
1208
- if (turnSettled) return { value: void 0, done: true };
1209
- const { value, done } = await inner.next();
1210
- if (done) return { value: void 0, done: true };
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
+ }
1211
1813
  if (value.type === "result") turnSettled = true;
1212
1814
  const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
1815
+ terminalFailure = mapped.terminalFailure ?? terminalFailure;
1213
1816
  if (mapped.unmappedLabel) {
1214
1817
  client.recordUnmappedFrame(mapped.unmappedLabel);
1215
1818
  }
@@ -1240,6 +1843,12 @@ var ClaudeSession = class {
1240
1843
  if (typeof task.instruction !== "string") {
1241
1844
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1242
1845
  }
1846
+ const requestedModel = subscriptionModel(task.dispatchSelection, "claude");
1847
+ if (requestedModel !== void 0 && requestedModel !== this.modelId) {
1848
+ throw new PolicyUnsupportedError(
1849
+ `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
1850
+ );
1851
+ }
1243
1852
  this.client.writeUserMessage(task.instruction);
1244
1853
  }
1245
1854
  /**
@@ -1258,8 +1867,17 @@ var ClaudeSession = class {
1258
1867
  this.client.kill();
1259
1868
  }
1260
1869
  async close() {
1261
- this.client.kill();
1262
- await cleanupApprovalMcpConfigDir(this.approvalMcpConfigDir);
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;
1263
1881
  }
1264
1882
  /**
1265
1883
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -1476,14 +2094,15 @@ var CodexProcessRunner = class {
1476
2094
  exitSignal = null;
1477
2095
  closedPromise;
1478
2096
  resolveClosed;
2097
+ disposalAttempt;
1479
2098
  constructor(options) {
1480
2099
  this.onEvent = options.onEvent;
1481
2100
  const spawnFn = options.spawnFn ?? spawn;
1482
- this.child = spawnFn(options.command, options.args, {
2101
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
1483
2102
  cwd: options.cwd,
1484
2103
  env: options.env,
1485
2104
  stdio: ["ignore", "pipe", "pipe"]
1486
- });
2105
+ }));
1487
2106
  this.closedPromise = new Promise((resolve) => {
1488
2107
  this.resolveClosed = resolve;
1489
2108
  });
@@ -1513,7 +2132,7 @@ var CodexProcessRunner = class {
1513
2132
  return this.closed;
1514
2133
  }
1515
2134
  /**
1516
- * Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
2135
+ * Immediate tree termination request. SIGTERM on POSIX: SIGINT was empirically confirmed
1517
2136
  * to be silently ignored by `codex exec` (a real, direct test — a 60s
1518
2137
  * shell `sleep` ran to full, unaffected completion despite SIGINT sent at
1519
2138
  * t=4s) — a genuine, evidence-based correction to this task's own initial
@@ -1526,13 +2145,25 @@ var CodexProcessRunner = class {
1526
2145
  * `../pi/rpc-client.ts`'s own cross-platform convention.
1527
2146
  */
1528
2147
  kill() {
1529
- if (this.closed) return;
1530
- const pid = this.child.pid;
1531
- if (process.platform === "win32" && pid !== void 0) {
1532
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1533
- } else {
1534
- this.child.kill("SIGTERM");
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
+ });
1535
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
+ };
1536
2167
  }
1537
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. */
1538
2169
  buildExitError(context) {
@@ -1583,7 +2214,17 @@ var CodexAdapter = class {
1583
2214
  this.options = options;
1584
2215
  }
1585
2216
  options;
1586
- 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
+ });
1587
2228
  async detect() {
1588
2229
  const bin = this.resolveBin();
1589
2230
  try {
@@ -1632,57 +2273,100 @@ ${result.stderr}`);
1632
2273
  ${withStreams.stderr ?? ""}`);
1633
2274
  }
1634
2275
  }
1635
- capabilities() {
1636
- return { steer: false, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
1637
- }
1638
- /**
1639
- * M5: same deliberate posture as the claude adapter (see its own doc
1640
- * comment) — codex authenticates via its own `codex login`-managed
1641
- * ChatGPT OAuth session (`probeAuthPresent` above), not an env var, so
1642
- * there is no credential env var this adapter needs forwarded; env-based
1643
- * API-key passthrough remains a separate, pending product decision. No
1644
- * `baseNames` either: nothing in this adapter reads a codex-specific
1645
- * config-discovery variable (e.g. `CODEX_HOME`) today.
1646
- */
1647
- environmentRequirements() {
1648
- return { credentialNames: [] };
1649
- }
1650
- async start(task, ctx) {
1651
- if (typeof task.instruction !== "string") {
1652
- throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
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 };
1653
2284
  }
1654
- const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
1655
- if (!mapping.ok) {
1656
- 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
+ });
1657
2306
  }
1658
- const bin = this.resolveBin();
1659
2307
  const queue = new AsyncQueue();
2308
+ const terminal = {};
1660
2309
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
1661
- const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
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
+ }
1662
2341
  const { sessionRef, runner } = await runCodexTurn({
1663
- command: bin.command,
1664
- resumeRef: task.sessionRef,
1665
- instruction: task.instruction,
1666
- policyArgs: mapping.args,
1667
- cwd: ctx.workspaceDir,
1668
- env: ctx.env,
2342
+ command,
2343
+ resumeRef: startInput.manifest.sessionRef,
2344
+ instruction: startInput.instruction,
2345
+ modelId: manifestModelId,
2346
+ policyArgs: [...policyArgs],
2347
+ cwd: startInput.manifest.workspace.workspaceDir,
2348
+ env: runtimeEnv,
1669
2349
  spawnFn: this.options.spawnFn,
1670
2350
  workspaceDir,
1671
2351
  queue,
1672
2352
  recordUnmapped,
1673
- expectedSessionRef: task.sessionRef,
1674
- preparedGit: ctx.gitWorkspace !== void 0
2353
+ expectedSessionRef: startInput.manifest.sessionRef,
2354
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
2355
+ failurePhase: "start",
2356
+ terminal
1675
2357
  });
1676
2358
  return new CodexSession({
1677
2359
  sessionRef,
1678
- command: bin.command,
2360
+ command,
1679
2361
  workspaceDir,
1680
- env: ctx.env,
2362
+ env: startInput.env,
1681
2363
  spawnFn: this.options.spawnFn,
1682
2364
  queue,
1683
2365
  recordUnmapped,
1684
2366
  initialRunner: runner,
1685
- preparedGit: ctx.gitWorkspace !== void 0
2367
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
2368
+ modelId: manifestModelId,
2369
+ terminal
1686
2370
  });
1687
2371
  }
1688
2372
  resolveBin() {
@@ -1703,12 +2387,25 @@ function makeUnmappedFrameRecorder(counts) {
1703
2387
  }
1704
2388
  };
1705
2389
  }
1706
- function buildArgv(resumeRef, policyArgs, instruction, preparedGit = false) {
2390
+ function buildArgv(resumeRef, policyArgs, instruction, modelId, preparedGit = false) {
1707
2391
  const base = resumeRef !== void 0 ? ["exec", "resume", resumeRef] : ["exec"];
1708
- return [...base, "--json", ...preparedGit ? [] : ["--skip-git-repo-check"], ...policyArgs, instruction];
2392
+ return [
2393
+ ...base,
2394
+ "--json",
2395
+ ...modelId ? ["--model", modelId] : [],
2396
+ ...preparedGit ? [] : ["--skip-git-repo-check"],
2397
+ ...policyArgs,
2398
+ instruction
2399
+ ];
1709
2400
  }
1710
2401
  async function runCodexTurn(params) {
1711
- const argv = buildArgv(params.resumeRef, params.policyArgs, params.instruction, params.preparedGit);
2402
+ const argv = buildArgv(
2403
+ params.resumeRef,
2404
+ params.policyArgs,
2405
+ params.instruction,
2406
+ params.modelId,
2407
+ params.preparedGit
2408
+ );
1712
2409
  let firstLineSettled = false;
1713
2410
  let resolveFirstLine;
1714
2411
  let rejectFirstLine;
@@ -1717,37 +2414,69 @@ async function runCodexTurn(params) {
1717
2414
  rejectFirstLine = reject;
1718
2415
  });
1719
2416
  let turnEnded = false;
1720
- const runner = new CodexProcessRunner({
1721
- command: params.command,
1722
- args: argv,
1723
- cwd: params.cwd,
1724
- env: params.env,
1725
- spawnFn: params.spawnFn,
1726
- onEvent: (evt) => {
1727
- if (!firstLineSettled) {
1728
- firstLineSettled = true;
1729
- if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
1730
- resolveFirstLine(evt.thread_id);
1731
- } else {
1732
- rejectFirstLine(
1733
- new Error(`codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`)
1734
- );
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));
1735
2458
  }
1736
- return;
1737
- }
1738
- const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
1739
- for (const agentEvent of mapped) {
1740
- if (agentEvent.type === "turn_end") turnEnded = true;
1741
- params.queue.push(agentEvent);
1742
- }
1743
- if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
1744
- params.recordUnmapped(unmappedFrameKey(evt));
1745
2459
  }
1746
- }
1747
- });
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);
1748
2470
  void runner.waitClosed().then(() => {
1749
- if (turnEnded) return;
1750
- 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 });
1751
2480
  params.queue.end();
1752
2481
  });
1753
2482
  let sessionRef;
@@ -1771,7 +2500,13 @@ async function runCodexTurn(params) {
1771
2500
  void runner.waitClosed().then(() => {
1772
2501
  if (!settled) {
1773
2502
  settled = true;
1774
- 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 }));
1775
2510
  }
1776
2511
  });
1777
2512
  });
@@ -1781,9 +2516,12 @@ async function runCodexTurn(params) {
1781
2516
  }
1782
2517
  if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
1783
2518
  runner.kill();
1784
- throw new Error(
1785
- `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1786
- );
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
+ });
1787
2525
  }
1788
2526
  return { sessionRef, runner };
1789
2527
  }
@@ -1809,8 +2547,13 @@ var CodexSession = class {
1809
2547
  queue;
1810
2548
  recordUnmapped;
1811
2549
  preparedGit;
2550
+ modelId;
2551
+ terminal;
1812
2552
  currentRunner;
2553
+ ownedRunners = /* @__PURE__ */ new Set();
2554
+ followUpAttempts = /* @__PURE__ */ new Set();
1813
2555
  closed = false;
2556
+ closeAttempt;
1814
2557
  constructor(options) {
1815
2558
  this.sessionRef = options.sessionRef;
1816
2559
  this.command = options.command;
@@ -1820,11 +2563,37 @@ var CodexSession = class {
1820
2563
  this.queue = options.queue;
1821
2564
  this.recordUnmapped = options.recordUnmapped;
1822
2565
  this.preparedGit = options.preparedGit;
2566
+ this.modelId = options.modelId;
2567
+ this.terminal = options.terminal;
1823
2568
  this.currentRunner = options.initialRunner;
2569
+ this.ownedRunners.add(options.initialRunner);
1824
2570
  void this.forgetRunnerOnceClosed(options.initialRunner);
1825
2571
  }
1826
2572
  get events() {
1827
- 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
+ };
1828
2597
  }
1829
2598
  async forgetRunnerOnceClosed(runner) {
1830
2599
  await runner.waitClosed();
@@ -1869,7 +2638,14 @@ var CodexSession = class {
1869
2638
  * stale id even after codex had moved on) — it just can now only ever be
1870
2639
  * the SAME id this call asked to resume, never a silently-different one.
1871
2640
  */
1872
- 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) {
1873
2649
  if (typeof task.instruction !== "string") {
1874
2650
  throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1875
2651
  }
@@ -1880,41 +2656,79 @@ var CodexSession = class {
1880
2656
  if (!mapping.ok) {
1881
2657
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
1882
2658
  }
2659
+ const requestedModel = subscriptionModel2(task.dispatchSelection);
2660
+ if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2661
+ throw new PolicyUnsupportedError(
2662
+ `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
2663
+ );
2664
+ }
2665
+ const modelId = this.modelId;
1883
2666
  const resumeRef = this.sessionRef;
1884
2667
  let sessionRef;
1885
2668
  let runner;
2669
+ const terminal = {};
1886
2670
  try {
1887
2671
  ({ sessionRef, runner } = await runCodexTurn({
1888
2672
  command: this.command,
1889
2673
  resumeRef,
1890
2674
  instruction: task.instruction,
2675
+ modelId,
1891
2676
  policyArgs: mapping.args,
1892
2677
  cwd: this.workspaceDir,
1893
- env: this.env,
2678
+ env: withoutProviderCredentials(this.env),
1894
2679
  spawnFn: this.spawnFn,
1895
2680
  workspaceDir: this.workspaceDir,
1896
2681
  queue: this.queue,
1897
2682
  recordUnmapped: this.recordUnmapped,
1898
2683
  expectedSessionRef: resumeRef,
1899
- 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
+ }
1900
2691
  }));
1901
2692
  } catch (err) {
1902
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
+ };
1903
2702
  throw err;
1904
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;
1905
2709
  this.sessionRef = sessionRef;
1906
2710
  this.currentRunner = runner;
1907
- void this.forgetRunnerOnceClosed(runner);
1908
2711
  }
1909
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. */
1910
2713
  async interrupt() {
1911
2714
  this.currentRunner?.kill();
1912
2715
  }
1913
2716
  async close() {
1914
- if (this.closed) return;
1915
- this.closed = true;
1916
- this.currentRunner?.kill();
1917
- 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;
1918
2732
  }
1919
2733
  /**
1920
2734
  * `codex exec` has no in-band channel to inject text into an already-
@@ -1950,7 +2764,16 @@ var CodexSession = class {
1950
2764
  );
1951
2765
  }
1952
2766
  };
2767
+ function subscriptionModel2(selection) {
2768
+ if (selection === void 0) return void 0;
2769
+ if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
2770
+ throw new PolicyUnsupportedError(
2771
+ `codex adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
2772
+ );
2773
+ }
2774
+ return selection.modelId;
2775
+ }
1953
2776
 
1954
- export { ClaudeAdapter, CodexAdapter, PI_PACKAGE_NAME, PiAdapter };
2777
+ export { ClaudeAdapter, CodexAdapter, PI_PACKAGE_NAME, PiAdapter, RuntimeDisposalFailure, RuntimeExecutionFailure };
1955
2778
  //# sourceMappingURL=index.js.map
1956
2779
  //# sourceMappingURL=index.js.map