@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
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { promises, mkdirSync, existsSync, renameSync, writeFileSync, chmodSync,
4
4
  import path17, { join, isAbsolute } from 'path';
5
5
  import os5 from 'os';
6
6
  import { parseDeviceAssertionEnvelope, tenantId, DeviceProofProtectedClaimsSchema, deviceProofSigningInput, DEVICE_PROOF_SCHEMA_ID, SKILL_PACK_MAX_BYTES, hasCapability, parseSkillPackManifest, checkSkillPackManifest, skillPackContentHashInput, checkSkillPackFileContent, SKILL_PACK_ENTRY_PATH, checkSkillPackEntry, isSkillPackPathSafe, DEVICE_PROOF_HEADER, contentHash, TRUTH_RECORD_KINDS, nonceSigningBytes, DEVICE_ASSERTION_AUDIENCE_MAX_BYTES, DEVICE_ASSERTION_DEFAULT_TTL_MS, DEVICE_ASSERTION_MAX_TTL_MS, CONTENT_HASH_PATTERN, CapabilityDeclarationSchema, DeviceAssertionClaimsSchema, deviceAssertionSigningInput, DEVICE_ASSERTION_SCHEMA_ID } from '@byok-sdk/core';
7
- import { BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, partitionAgentEvents, BYOK_SKILL_PACKS_PATH, byokSkillPackFilePath, BYOK_RECORDS_PATH, byokRecordPath, TASK_TRANSITIONS, ToolsetIdSchema, encodeEnvelope, createEnvelope, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, RuntimeIdSchema, RESULT_DOCUMENT_MAX_BYTES, PROTOCOL_VERSION, decodeEnvelope, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, BYOK_EVENTS_PATH, parseMessage, UnknownMessageTypeError, BYOK_WS_PATH } from '@byok-sdk/protocol';
7
+ import { BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, partitionAgentEvents, BYOK_SKILL_PACKS_PATH, byokSkillPackFilePath, BYOK_RECORDS_PATH, byokRecordPath, TASK_TRANSITIONS, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, encodeEnvelope, createEnvelope, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, RuntimeIdSchema, RESULT_DOCUMENT_MAX_BYTES, PROTOCOL_VERSION, decodeEnvelope, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, BYOK_EVENTS_PATH, parseMessage, UnknownMessageTypeError, BYOK_WS_PATH } from '@byok-sdk/protocol';
8
8
  import { promisify } from 'util';
9
9
  import { fileURLToPath } from 'url';
10
10
  import 'readline';
@@ -13,6 +13,52 @@ import { WebSocket } from 'ws';
13
13
  import { createRequire } from 'module';
14
14
 
15
15
  // src/types.ts
16
+ function frozenStrings(values) {
17
+ return values === void 0 ? void 0 : Object.freeze([...values]);
18
+ }
19
+ function frozenPolicy(policy) {
20
+ const allowTools = policy.allowTools === void 0 ? void 0 : Object.freeze([...policy.allowTools]);
21
+ const denyTools = policy.denyTools === void 0 ? void 0 : Object.freeze([...policy.denyTools]);
22
+ return Object.freeze({
23
+ mode: policy.mode,
24
+ ...allowTools === void 0 ? {} : { allowTools },
25
+ ...denyTools === void 0 ? {} : { denyTools },
26
+ ...policy.workspaceRoot === void 0 ? {} : { workspaceRoot: policy.workspaceRoot },
27
+ ...policy.network === void 0 ? {} : { network: policy.network }
28
+ });
29
+ }
30
+ function freezeRuntimeAdapterDescriptor(descriptor) {
31
+ const baseNames = frozenStrings(descriptor.environmentRequirements.baseNames);
32
+ const credentialNames = frozenStrings(descriptor.environmentRequirements.credentialNames);
33
+ return Object.freeze({
34
+ id: descriptor.id,
35
+ supportsDispatchSelection: descriptor.supportsDispatchSelection === true,
36
+ capabilities: Object.freeze({
37
+ steer: descriptor.capabilities.steer === true,
38
+ resume: descriptor.capabilities.resume === true,
39
+ approvalInteractive: descriptor.capabilities.approvalInteractive === true,
40
+ ...descriptor.capabilities.mcpToolsets === void 0 ? {} : { mcpToolsets: descriptor.capabilities.mcpToolsets === true },
41
+ permissionModes: Object.freeze([...descriptor.capabilities.permissionModes])
42
+ }),
43
+ environmentRequirements: Object.freeze({
44
+ ...baseNames === void 0 ? {} : { baseNames },
45
+ ...credentialNames === void 0 ? {} : { credentialNames }
46
+ })
47
+ });
48
+ }
49
+ function sealRuntimeOperationManifest(manifest) {
50
+ return Object.freeze({
51
+ taskId: manifest.taskId,
52
+ runtimeId: manifest.runtimeId,
53
+ descriptor: freezeRuntimeAdapterDescriptor(manifest.descriptor),
54
+ policy: frozenPolicy(manifest.policy),
55
+ requiredToolsetIds: Object.freeze([...manifest.requiredToolsetIds]),
56
+ ...manifest.dispatchSelection === void 0 ? {} : { dispatchSelection: Object.freeze({ ...manifest.dispatchSelection }) },
57
+ ...manifest.sessionRef === void 0 ? {} : { sessionRef: manifest.sessionRef },
58
+ workspace: Object.freeze({ ...manifest.workspace }),
59
+ forwardedEnvironmentNames: Object.freeze([...manifest.forwardedEnvironmentNames])
60
+ });
61
+ }
16
62
  var PolicyUnsupportedError = class extends Error {
17
63
  constructor(message) {
18
64
  super(message);
@@ -20,7 +66,7 @@ var PolicyUnsupportedError = class extends Error {
20
66
  }
21
67
  };
22
68
  var SteerUnsupportedError = class extends Error {
23
- /** The `RuntimeAdapter.id` that cannot steer (e.g. `claude`, `codex`). */
69
+ /** The `RuntimeAdapter.descriptor.id` that cannot steer (e.g. `claude`, `codex`). */
24
70
  runtimeId;
25
71
  constructor(runtimeId, message) {
26
72
  super(message);
@@ -28,6 +74,90 @@ var SteerUnsupportedError = class extends Error {
28
74
  this.runtimeId = runtimeId;
29
75
  }
30
76
  };
77
+
78
+ // src/runtime-failure.ts
79
+ var RUNTIME_EXECUTION_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeExecutionFailure/v1");
80
+ var RUNTIME_DISPOSAL_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeDisposalFailure/v1");
81
+ var RuntimeDisposalFailure = class extends Error {
82
+ stage;
83
+ constructor(input, options) {
84
+ if (!isRuntimeDisposalStage(input.stage) || typeof input.reason !== "string" || input.reason.length === 0) {
85
+ throw new TypeError("invalid RuntimeDisposalFailure input");
86
+ }
87
+ super(input.reason, options);
88
+ this.name = "RuntimeDisposalFailure";
89
+ this.stage = input.stage;
90
+ Object.defineProperty(this, RUNTIME_DISPOSAL_FAILURE_BRAND, { value: true });
91
+ Object.freeze(this);
92
+ }
93
+ };
94
+ function isRuntimeDisposalStage(value) {
95
+ return value === "signal" || value === "quiescence" || value === "cleanup";
96
+ }
97
+ function isRuntimeDisposalFailure(value) {
98
+ if (typeof value !== "object" || value === null) return false;
99
+ const candidate = value;
100
+ return candidate[RUNTIME_DISPOSAL_FAILURE_BRAND] === true && isRuntimeDisposalStage(candidate.stage) && typeof candidate.message === "string" && candidate.message.length > 0;
101
+ }
102
+ var RuntimeExecutionFailure = class extends Error {
103
+ phase;
104
+ category;
105
+ retry;
106
+ constructor(input, options) {
107
+ if (!isRuntimeFailurePhase(input.phase) || !isRuntimeFailureCategory(input.category) || !isRuntimeRetryDisposition(input.retry) || typeof input.reason !== "string" || input.reason.length === 0) {
108
+ throw new TypeError("invalid RuntimeExecutionFailure input");
109
+ }
110
+ super(input.reason, options);
111
+ this.name = "RuntimeExecutionFailure";
112
+ this.phase = input.phase;
113
+ this.category = input.category;
114
+ this.retry = input.retry;
115
+ Object.defineProperty(this, RUNTIME_EXECUTION_FAILURE_BRAND, { value: true });
116
+ Object.freeze(this);
117
+ }
118
+ };
119
+ function isRuntimeFailurePhase(value) {
120
+ return value === "start" || value === "run";
121
+ }
122
+ function isRuntimeFailureCategory(value) {
123
+ return value === "semantic" || value === "infrastructure" || value === "authority";
124
+ }
125
+ function isRuntimeRetryDisposition(value) {
126
+ return value === "retryable" || value === "non-retryable";
127
+ }
128
+ function isRuntimeExecutionFailure(value) {
129
+ if (typeof value !== "object" || value === null) return false;
130
+ const candidate = value;
131
+ return candidate[RUNTIME_EXECUTION_FAILURE_BRAND] === true && isRuntimeFailurePhase(candidate.phase) && isRuntimeFailureCategory(candidate.category) && isRuntimeRetryDisposition(candidate.retry) && typeof candidate.message === "string" && candidate.message.length > 0;
132
+ }
133
+ function retryableFromDisposition(disposition) {
134
+ switch (disposition) {
135
+ case "retryable":
136
+ return true;
137
+ case "non-retryable":
138
+ return false;
139
+ }
140
+ }
141
+ function projectRuntimeExecutionFailure(failure) {
142
+ return {
143
+ reason: failure.message,
144
+ retryable: retryableFromDisposition(failure.retry)
145
+ };
146
+ }
147
+ var RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON = Object.freeze({
148
+ start: "runtime adapter contract violation during start",
149
+ run: "runtime adapter contract violation during run"
150
+ });
151
+ function projectRuntimeBoundaryFailure(value, expectedPhase) {
152
+ if (isRuntimeExecutionFailure(value) && value.phase === expectedPhase) {
153
+ return { ...projectRuntimeExecutionFailure(value), contractViolation: false };
154
+ }
155
+ return {
156
+ reason: RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON[expectedPhase],
157
+ retryable: false,
158
+ contractViolation: true
159
+ };
160
+ }
31
161
  var GitWorkspaceError = class extends Error {
32
162
  constructor(category, message = category) {
33
163
  super(message);
@@ -914,6 +1044,155 @@ var AsyncQueue = class {
914
1044
  };
915
1045
  }
916
1046
  };
1047
+ var DEFAULT_TERM_GRACE_MS = 750;
1048
+ var DEFAULT_KILL_GRACE_MS = 2e3;
1049
+ var POLL_MS = 20;
1050
+ var terminationRequested = /* @__PURE__ */ new WeakSet();
1051
+ var terminationRequestFailed = /* @__PURE__ */ new WeakSet();
1052
+ function withOwnedProcessTree(options) {
1053
+ return {
1054
+ ...options,
1055
+ ...process.platform === "win32" ? { windowsHide: true } : { detached: true }
1056
+ };
1057
+ }
1058
+ function positivePid(child, label) {
1059
+ const pid = child.pid;
1060
+ if (pid === void 0) return void 0;
1061
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
1062
+ throw new RuntimeDisposalFailure({
1063
+ stage: "signal",
1064
+ reason: `${label} runtime process has an unsafe owned pid`
1065
+ });
1066
+ }
1067
+ return pid;
1068
+ }
1069
+ function groupExists(pid, label) {
1070
+ try {
1071
+ process.kill(-pid, 0);
1072
+ return true;
1073
+ } catch (cause) {
1074
+ const code = cause.code;
1075
+ if (code === "ESRCH") return false;
1076
+ if (code === "EPERM") return true;
1077
+ throw new RuntimeDisposalFailure({
1078
+ stage: "quiescence",
1079
+ reason: `${label} runtime process-group state could not be verified`
1080
+ }, { cause });
1081
+ }
1082
+ }
1083
+ function signalGroup(pid, signal, label) {
1084
+ try {
1085
+ process.kill(-pid, signal);
1086
+ } catch (cause) {
1087
+ const code = cause.code;
1088
+ if (code === "ESRCH" || code === "EPERM") return;
1089
+ throw new RuntimeDisposalFailure({
1090
+ stage: "signal",
1091
+ reason: `${label} runtime process group ${pid} could not receive ${signal} (${code ?? "unknown"})`
1092
+ }, { cause });
1093
+ }
1094
+ }
1095
+ async function waitUntil(predicate, timeoutMs) {
1096
+ const deadline = Date.now() + timeoutMs;
1097
+ while (predicate()) {
1098
+ if (Date.now() >= deadline) return false;
1099
+ await new Promise((resolve) => {
1100
+ setTimeout(resolve, POLL_MS);
1101
+ });
1102
+ }
1103
+ return true;
1104
+ }
1105
+ async function waitWithDeadline(promise, timeoutMs) {
1106
+ return new Promise((resolve) => {
1107
+ let settled = false;
1108
+ const timer = setTimeout(() => {
1109
+ if (!settled) {
1110
+ settled = true;
1111
+ resolve(false);
1112
+ }
1113
+ }, timeoutMs);
1114
+ void promise.then(
1115
+ () => {
1116
+ if (!settled) {
1117
+ settled = true;
1118
+ clearTimeout(timer);
1119
+ resolve(true);
1120
+ }
1121
+ },
1122
+ () => {
1123
+ if (!settled) {
1124
+ settled = true;
1125
+ clearTimeout(timer);
1126
+ resolve(false);
1127
+ }
1128
+ }
1129
+ );
1130
+ });
1131
+ }
1132
+ function requestOwnedProcessTreeTermination(options) {
1133
+ if (options.isClosed()) return;
1134
+ const pid = positivePid(options.child, options.label);
1135
+ if (pid === void 0) return;
1136
+ if (process.platform === "win32") {
1137
+ const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
1138
+ if (result.error) {
1139
+ throw new RuntimeDisposalFailure({
1140
+ stage: "signal",
1141
+ reason: `${options.label} runtime process tree could not be terminated`
1142
+ }, { cause: result.error });
1143
+ }
1144
+ terminationRequested.add(options.child);
1145
+ if (result.status !== 0) terminationRequestFailed.add(options.child);
1146
+ return;
1147
+ }
1148
+ signalGroup(pid, "SIGTERM", options.label);
1149
+ terminationRequested.add(options.child);
1150
+ }
1151
+ async function disposeOwnedProcessTree(options) {
1152
+ const pid = positivePid(options.child, options.label);
1153
+ const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
1154
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
1155
+ if (pid === void 0) {
1156
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1157
+ throw new RuntimeDisposalFailure({
1158
+ stage: "quiescence",
1159
+ reason: `${options.label} runtime process did not settle after spawn failure`
1160
+ });
1161
+ }
1162
+ if (process.platform === "win32") {
1163
+ if (!options.isClosed() && !terminationRequested.has(options.child)) requestOwnedProcessTreeTermination(options);
1164
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1165
+ if (terminationRequestFailed.has(options.child)) {
1166
+ throw new RuntimeDisposalFailure({
1167
+ stage: "signal",
1168
+ reason: `${options.label} runtime process tree could not be terminated`
1169
+ });
1170
+ }
1171
+ throw new RuntimeDisposalFailure({
1172
+ stage: "quiescence",
1173
+ reason: `${options.label} runtime process tree did not close before the disposal deadline`
1174
+ });
1175
+ }
1176
+ if (groupExists(pid, options.label) && !terminationRequested.has(options.child)) {
1177
+ signalGroup(pid, "SIGTERM", options.label);
1178
+ terminationRequested.add(options.child);
1179
+ }
1180
+ if (!await waitUntil(() => groupExists(pid, options.label), termGraceMs)) {
1181
+ signalGroup(pid, "SIGKILL", options.label);
1182
+ if (!await waitUntil(() => groupExists(pid, options.label), killGraceMs)) {
1183
+ throw new RuntimeDisposalFailure({
1184
+ stage: "quiescence",
1185
+ reason: `${options.label} runtime process group remained live after SIGKILL`
1186
+ });
1187
+ }
1188
+ }
1189
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1190
+ throw new RuntimeDisposalFailure({
1191
+ stage: "quiescence",
1192
+ reason: `${options.label} runtime root did not emit close after its process group exited`
1193
+ });
1194
+ }
1195
+ }
917
1196
 
918
1197
  // src/adapters/pi/rpc-client.ts
919
1198
  var STDERR_RING_CAPACITY = 20;
@@ -926,16 +1205,22 @@ var PiRpcClient = class {
926
1205
  eventQueue = new AsyncQueue();
927
1206
  closed = false;
928
1207
  exitError;
1208
+ closedPromise;
1209
+ resolveClosed;
1210
+ disposalAttempt;
929
1211
  /** 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`. */
930
1212
  stderrRing = [];
931
1213
  /** Count of pi RPC message types `PiSession` (pi-adapter.ts) has told us have no `AgentEvent` mapping and aren't routine bookkeeping — see `recordUnmappedFrame`. */
932
1214
  unmappedFrameCounts = /* @__PURE__ */ new Map();
933
1215
  constructor(options) {
934
1216
  const spawnFn = options.spawnFn ?? spawn;
935
- this.child = spawnFn(options.command, options.args, {
1217
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
936
1218
  cwd: options.cwd,
937
1219
  env: options.env,
938
1220
  stdio: ["pipe", "pipe", "pipe"]
1221
+ }));
1222
+ this.closedPromise = new Promise((resolve) => {
1223
+ this.resolveClosed = resolve;
939
1224
  });
940
1225
  this.child.stdout.setEncoding("utf8");
941
1226
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -970,6 +1255,10 @@ var PiRpcClient = class {
970
1255
  get events() {
971
1256
  return this.eventQueue;
972
1257
  }
1258
+ /** Local transport diagnostic retained when the process closes; consumers must classify it explicitly. */
1259
+ get terminalError() {
1260
+ return this.exitError;
1261
+ }
973
1262
  /**
974
1263
  * Record a pi RPC message `type` that `PiSession` (pi-adapter.ts) decided
975
1264
  * has no `AgentEvent` mapping and isn't routine bookkeeping (see
@@ -988,15 +1277,30 @@ var PiRpcClient = class {
988
1277
  );
989
1278
  }
990
1279
  }
991
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes pi itself spawned (e.g. bash). */
1280
+ /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
992
1281
  kill() {
993
- if (this.closed) return;
994
- const pid = this.child.pid;
995
- if (process.platform === "win32" && pid !== void 0) {
996
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
997
- } else {
998
- this.child.kill("SIGTERM");
1282
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
1283
+ }
1284
+ waitClosed() {
1285
+ return this.closedPromise;
1286
+ }
1287
+ dispose() {
1288
+ if (!this.disposalAttempt) {
1289
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
1290
+ this.disposalAttempt = attempt.catch((error) => {
1291
+ this.disposalAttempt = void 0;
1292
+ throw error;
1293
+ });
999
1294
  }
1295
+ return this.disposalAttempt;
1296
+ }
1297
+ processTreeOptions() {
1298
+ return {
1299
+ child: this.child,
1300
+ waitClosed: () => this.closedPromise,
1301
+ isClosed: () => this.closed,
1302
+ label: "pi"
1303
+ };
1000
1304
  }
1001
1305
  onData(chunk) {
1002
1306
  this.buffer += chunk;
@@ -1082,6 +1386,7 @@ var PiRpcClient = class {
1082
1386
  if (this.closed) return;
1083
1387
  this.closed = true;
1084
1388
  this.exitError = err;
1389
+ this.resolveClosed();
1085
1390
  for (const [, waiter] of this.pending) waiter.reject(err);
1086
1391
  this.pending.clear();
1087
1392
  this.eventQueue.end();
@@ -1152,8 +1457,17 @@ var PiAdapter = class {
1152
1457
  this.options = options;
1153
1458
  }
1154
1459
  options;
1155
- id = "pi";
1156
- supportsDispatchSelection = true;
1460
+ descriptor = freezeRuntimeAdapterDescriptor({
1461
+ id: "pi",
1462
+ supportsDispatchSelection: true,
1463
+ capabilities: {
1464
+ steer: true,
1465
+ resume: true,
1466
+ approvalInteractive: false,
1467
+ permissionModes: ["auto", "readonly"]
1468
+ },
1469
+ environmentRequirements: { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES }
1470
+ });
1157
1471
  async detect() {
1158
1472
  try {
1159
1473
  const bin = this.resolveBin();
@@ -1165,49 +1479,26 @@ var PiAdapter = class {
1165
1479
  return { present: false };
1166
1480
  }
1167
1481
  }
1168
- capabilities() {
1169
- return { steer: true, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
1170
- }
1171
- /**
1172
- * M5: pi authenticates to its ~30 supported providers via env-var API
1173
- * keys — `detect()`'s own `authPresent` probe above checks this identical
1174
- * list — so these MUST keep flowing into pi's spawned process or pi auth
1175
- * breaks entirely. `KNOWN_PROVIDER_ENV_VARS` above is the single source
1176
- * of truth, reused here rather than duplicated. No `baseNames`: nothing
1177
- * in this adapter or `rpc-client.ts` reads a pi-specific config-discovery
1178
- * variable beyond the platform baseline (`daemon/environment.ts`).
1179
- */
1180
- environmentRequirements() {
1181
- return { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES };
1182
- }
1183
- async start(task, ctx) {
1184
- if (typeof task.instruction !== "string") {
1185
- throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
1186
- }
1187
- const mapping = mapPermissionPolicyToPiArgs(ctx.policy);
1482
+ async prepare(input) {
1483
+ const mapping = mapPermissionPolicyToPiArgs(input.policy);
1188
1484
  if (!mapping.ok) {
1189
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
1485
+ return { kind: "reject", reason: mapping.reason ?? "policy rejected by pi adapter", retryable: false };
1190
1486
  }
1191
1487
  const bin = this.resolveBin();
1192
- const resumeSessionId = task.sessionRef;
1193
- const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1194
- const selection = task.dispatchSelection;
1488
+ const selection = input.offer.dispatchSelection;
1489
+ const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
1195
1490
  let command = bin.command;
1196
- let args = piArgs;
1197
- if (selection !== void 0) {
1198
- if (selection.lane !== "byok" || selection.runtimeId !== "pi") {
1199
- throw new PolicyUnsupportedError(
1200
- `pi adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
1201
- );
1491
+ let launcherArgs;
1492
+ if (pinnedSelection !== void 0) {
1493
+ if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
1494
+ return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
1202
1495
  }
1203
1496
  const launcher = this.options.byokLauncher;
1204
1497
  if (launcher === void 0) {
1205
- throw new PolicyUnsupportedError(
1206
- "pi BYOK selection requires a configured credential-custody launcher"
1207
- );
1498
+ return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
1208
1499
  }
1209
1500
  command = launcher.command;
1210
- args = [
1501
+ launcherArgs = [
1211
1502
  ...launcher.args ?? [],
1212
1503
  "--pi-bin",
1213
1504
  bin.command,
@@ -1217,62 +1508,136 @@ var PiAdapter = class {
1217
1508
  launcher.sessionDir,
1218
1509
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
1219
1510
  "--provider",
1220
- selection.providerId,
1511
+ pinnedSelection.providerId,
1221
1512
  "--model",
1222
- selection.modelId,
1223
- "--",
1224
- ...piArgs
1513
+ pinnedSelection.modelId
1225
1514
  ];
1226
1515
  }
1227
- const rpc = new PiRpcClient({
1228
- command,
1229
- args,
1230
- cwd: ctx.workspaceDir,
1231
- env: selection === void 0 ? ctx.env : withoutProviderCredentials(ctx.env),
1232
- spawnFn: this.options.spawnFn
1233
- });
1234
- const response = await rpc.send({ type: "prompt", message: task.instruction });
1235
- if (response.success === false) {
1236
- rpc.kill();
1237
- throw new Error(typeof response.error === "string" ? response.error : "pi rejected the initial prompt");
1238
- }
1239
- let sessionRef;
1240
- if (resumeSessionId) {
1241
- sessionRef = resumeSessionId;
1242
- } else {
1243
- try {
1244
- sessionRef = await resolveFreshSessionId(rpc);
1245
- } catch (err) {
1246
- rpc.kill();
1247
- throw err;
1516
+ return {
1517
+ kind: "prepared",
1518
+ operation: {
1519
+ start: async (startInput) => {
1520
+ const manifestSelection = startInput.manifest.dispatchSelection;
1521
+ if (!sameDispatchSelection(manifestSelection, pinnedSelection)) {
1522
+ throw new RuntimeExecutionFailure({
1523
+ phase: "start",
1524
+ category: "authority",
1525
+ retry: "non-retryable",
1526
+ reason: "prepared pi operation received a manifest with different runtime selection"
1527
+ });
1528
+ }
1529
+ if (typeof startInput.instruction !== "string") {
1530
+ throw new RuntimeExecutionFailure({
1531
+ phase: "start",
1532
+ category: "authority",
1533
+ retry: "non-retryable",
1534
+ reason: "prepared pi operation requires a resolved string instruction"
1535
+ });
1536
+ }
1537
+ const resumeSessionId = startInput.manifest.sessionRef;
1538
+ const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1539
+ const args = launcherArgs === void 0 ? piArgs : [...launcherArgs, "--", ...piArgs];
1540
+ let rpc;
1541
+ try {
1542
+ rpc = new PiRpcClient({
1543
+ command,
1544
+ args,
1545
+ cwd: startInput.manifest.workspace.workspaceDir,
1546
+ env: manifestSelection === void 0 ? startInput.env : withoutProviderCredentials(startInput.env),
1547
+ spawnFn: this.options.spawnFn
1548
+ });
1549
+ } catch (cause) {
1550
+ throw new RuntimeExecutionFailure({
1551
+ phase: "start",
1552
+ category: "infrastructure",
1553
+ retry: "retryable",
1554
+ reason: "pi runtime process could not be spawned"
1555
+ }, { cause });
1556
+ }
1557
+ let response;
1558
+ try {
1559
+ response = await rpc.send({ type: "prompt", message: startInput.instruction });
1560
+ } catch (cause) {
1561
+ rpc.kill();
1562
+ throw new RuntimeExecutionFailure({
1563
+ phase: "start",
1564
+ category: "infrastructure",
1565
+ retry: "retryable",
1566
+ reason: `pi initial prompt transport failed: ${errorMessage(cause)}`
1567
+ }, { cause });
1568
+ }
1569
+ if (response.success === false) {
1570
+ rpc.kill();
1571
+ throw new RuntimeExecutionFailure({
1572
+ phase: "start",
1573
+ category: "semantic",
1574
+ retry: "non-retryable",
1575
+ reason: typeof response.error === "string" ? response.error : "pi rejected the initial prompt"
1576
+ });
1577
+ }
1578
+ let sessionRef;
1579
+ try {
1580
+ sessionRef = await resolveAuthoritativeSessionId(rpc);
1581
+ } catch (err) {
1582
+ rpc.kill();
1583
+ throw err;
1584
+ }
1585
+ if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1586
+ rpc.kill();
1587
+ throw new RuntimeExecutionFailure({
1588
+ phase: "start",
1589
+ category: "authority",
1590
+ retry: "non-retryable",
1591
+ reason: "pi resumed a different authoritative session than requested"
1592
+ });
1593
+ }
1594
+ return new PiSession(sessionRef, rpc, manifestSelection);
1595
+ }
1248
1596
  }
1249
- }
1250
- return new PiSession(sessionRef, rpc, selection);
1597
+ };
1251
1598
  }
1252
1599
  resolveBin() {
1253
1600
  return (this.options.resolveBin ?? resolvePiBin)();
1254
1601
  }
1255
1602
  };
1256
- async function resolveFreshSessionId(rpc) {
1603
+ function sameDispatchSelection(left, right) {
1604
+ if (left === void 0 || right === void 0) return left === right;
1605
+ return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
1606
+ }
1607
+ async function resolveAuthoritativeSessionId(rpc) {
1257
1608
  let state;
1258
1609
  try {
1259
1610
  state = await rpc.send({ type: "get_state" });
1260
1611
  } catch (err) {
1261
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${errorMessage(err)}`, {
1612
+ if (isRuntimeExecutionFailure(err)) throw err;
1613
+ throw new RuntimeExecutionFailure({
1614
+ phase: "start",
1615
+ category: "infrastructure",
1616
+ retry: "retryable",
1617
+ reason: `pi transport ended before yielding an authoritative session id: ${errorMessage(err)}`
1618
+ }, {
1262
1619
  cause: err
1263
1620
  });
1264
1621
  }
1265
1622
  if (state.success === false) {
1266
1623
  const reason = typeof state.error === "string" ? state.error : "get_state reported failure";
1267
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${reason}`);
1624
+ throw new RuntimeExecutionFailure({
1625
+ phase: "start",
1626
+ category: "authority",
1627
+ retry: "non-retryable",
1628
+ reason: `pi did not yield an authoritative session id: ${reason}`
1629
+ });
1268
1630
  }
1269
1631
  const data = state.data;
1270
1632
  if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
1271
1633
  return data.sessionId;
1272
1634
  }
1273
- throw new Error(
1274
- "pi did not yield an authoritative session id (get_state succeeded but reported no sessionId) \u2014 cannot mint a resumable session"
1275
- );
1635
+ throw new RuntimeExecutionFailure({
1636
+ phase: "start",
1637
+ category: "authority",
1638
+ retry: "non-retryable",
1639
+ reason: "pi get_state reported no authoritative session id"
1640
+ });
1276
1641
  }
1277
1642
  var PiSession = class {
1278
1643
  constructor(sessionRef, rpc, selection) {
@@ -1288,12 +1653,40 @@ var PiSession = class {
1288
1653
  return {
1289
1654
  [Symbol.asyncIterator]() {
1290
1655
  const inner = rpc.events[Symbol.asyncIterator]();
1656
+ let terminalFailure;
1291
1657
  return {
1292
1658
  async next() {
1293
1659
  for (; ; ) {
1294
- const { value, done } = await inner.next();
1295
- if (done) return { value: void 0, done: true };
1660
+ if (terminalFailure) throw terminalFailure;
1661
+ let result;
1662
+ try {
1663
+ result = await inner.next();
1664
+ } catch (cause) {
1665
+ throw new RuntimeExecutionFailure({
1666
+ phase: "run",
1667
+ category: "infrastructure",
1668
+ retry: "retryable",
1669
+ reason: "pi runtime event transport failed"
1670
+ }, { cause });
1671
+ }
1672
+ const { value, done } = result;
1673
+ if (done) {
1674
+ throw new RuntimeExecutionFailure({
1675
+ phase: "run",
1676
+ category: "infrastructure",
1677
+ retry: "retryable",
1678
+ reason: "pi runtime process ended before agent_settled"
1679
+ }, { cause: rpc.terminalError });
1680
+ }
1296
1681
  const mapped = mapPiMessageToAgentEvent(value);
1682
+ if (value.type === "auto_retry_end" && value.success === false) {
1683
+ terminalFailure = new RuntimeExecutionFailure({
1684
+ phase: "run",
1685
+ category: "semantic",
1686
+ retry: "non-retryable",
1687
+ reason: "pi exhausted its native retry policy"
1688
+ });
1689
+ }
1297
1690
  if (mapped) return { value: mapped, done: false };
1298
1691
  if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
1299
1692
  rpc.recordUnmappedFrame(value.type);
@@ -1323,7 +1716,7 @@ var PiSession = class {
1323
1716
  await this.rpc.send({ type: "abort" });
1324
1717
  }
1325
1718
  async close() {
1326
- this.rpc.kill();
1719
+ await this.rpc.dispose();
1327
1720
  }
1328
1721
  async resolveApproval() {
1329
1722
  throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
@@ -1535,7 +1928,15 @@ function mapResult(msg) {
1535
1928
  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`
1536
1929
  );
1537
1930
  const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
1538
- return { events };
1931
+ return {
1932
+ events,
1933
+ terminalFailure: new RuntimeExecutionFailure({
1934
+ phase: "run",
1935
+ category: msg.is_error === true ? "semantic" : "authority",
1936
+ retry: "non-retryable",
1937
+ reason: msg.is_error === true ? "claude reported terminal task failure" : "claude emitted a malformed terminal result frame"
1938
+ })
1939
+ };
1539
1940
  }
1540
1941
  var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
1541
1942
  function truncateResultDiagnostic(text) {
@@ -1587,16 +1988,22 @@ var ClaudeProcessClient = class {
1587
1988
  eventQueue = new AsyncQueue();
1588
1989
  closed = false;
1589
1990
  exitError;
1991
+ closedPromise;
1992
+ resolveClosed;
1993
+ disposalAttempt;
1590
1994
  stderrRing = [];
1591
1995
  unmappedFrameCounts = /* @__PURE__ */ new Map();
1592
1996
  sessionId;
1593
1997
  initWaiter;
1594
1998
  constructor(options) {
1595
1999
  const spawnFn = options.spawnFn ?? spawn;
1596
- this.child = spawnFn(options.command, options.args, {
2000
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
1597
2001
  cwd: options.cwd,
1598
2002
  env: options.env,
1599
2003
  stdio: ["pipe", "pipe", "pipe"]
2004
+ }));
2005
+ this.closedPromise = new Promise((resolve) => {
2006
+ this.resolveClosed = resolve;
1600
2007
  });
1601
2008
  this.child.stdout.setEncoding("utf8");
1602
2009
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1652,6 +2059,10 @@ var ClaudeProcessClient = class {
1652
2059
  get events() {
1653
2060
  return this.eventQueue;
1654
2061
  }
2062
+ /** Local transport diagnostic retained when the process closes; consumers classify it at the session boundary. */
2063
+ get terminalError() {
2064
+ return this.exitError;
2065
+ }
1655
2066
  /**
1656
2067
  * Record a claude stream-json frame/subtype/content-block label that
1657
2068
  * `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
@@ -1671,15 +2082,30 @@ var ClaudeProcessClient = class {
1671
2082
  );
1672
2083
  }
1673
2084
  }
1674
- /** 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). */
2085
+ /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
1675
2086
  kill() {
1676
- if (this.closed) return;
1677
- const pid = this.child.pid;
1678
- if (process.platform === "win32" && pid !== void 0) {
1679
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1680
- } else {
1681
- this.child.kill("SIGTERM");
2087
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
2088
+ }
2089
+ waitClosed() {
2090
+ return this.closedPromise;
2091
+ }
2092
+ dispose() {
2093
+ if (!this.disposalAttempt) {
2094
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2095
+ this.disposalAttempt = attempt.catch((error) => {
2096
+ this.disposalAttempt = void 0;
2097
+ throw error;
2098
+ });
1682
2099
  }
2100
+ return this.disposalAttempt;
2101
+ }
2102
+ processTreeOptions() {
2103
+ return {
2104
+ child: this.child,
2105
+ waitClosed: () => this.closedPromise,
2106
+ isClosed: () => this.closed,
2107
+ label: "claude"
2108
+ };
1683
2109
  }
1684
2110
  onData(chunk) {
1685
2111
  this.buffer += chunk;
@@ -1730,6 +2156,7 @@ var ClaudeProcessClient = class {
1730
2156
  if (this.closed) return;
1731
2157
  this.closed = true;
1732
2158
  this.exitError = err;
2159
+ this.resolveClosed();
1733
2160
  this.initWaiter?.reject(err);
1734
2161
  this.initWaiter = void 0;
1735
2162
  this.eventQueue.end();
@@ -1741,18 +2168,37 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1741
2168
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1742
2169
  var execFileAsync2 = promisify(execFile);
1743
2170
  var DETECT_TIMEOUT_MS2 = 5e3;
2171
+ function errorMessage2(err) {
2172
+ return err instanceof Error ? err.message : String(err);
2173
+ }
1744
2174
  async function cleanupMcpConfigDir(dir) {
1745
2175
  if (!dir) return;
1746
- await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1747
- });
2176
+ try {
2177
+ await promises.rm(dir, { recursive: true, force: true });
2178
+ } catch (cause) {
2179
+ throw new RuntimeDisposalFailure({
2180
+ stage: "cleanup",
2181
+ reason: "claude task-scoped MCP configuration could not be removed"
2182
+ }, { cause });
2183
+ }
1748
2184
  }
1749
2185
  var ClaudeAdapter = class {
1750
2186
  constructor(options = {}) {
1751
2187
  this.options = options;
1752
2188
  }
1753
2189
  options;
1754
- supportsDispatchSelection = true;
1755
- id = "claude";
2190
+ descriptor = freezeRuntimeAdapterDescriptor({
2191
+ id: "claude",
2192
+ supportsDispatchSelection: true,
2193
+ capabilities: {
2194
+ steer: false,
2195
+ resume: true,
2196
+ approvalInteractive: true,
2197
+ mcpToolsets: true,
2198
+ permissionModes: ["auto", "readonly", "plan", "confirm"]
2199
+ },
2200
+ environmentRequirements: { credentialNames: [] }
2201
+ });
1756
2202
  async detect() {
1757
2203
  const bin = this.resolveBin();
1758
2204
  try {
@@ -1764,54 +2210,73 @@ var ClaudeAdapter = class {
1764
2210
  return { present: false };
1765
2211
  }
1766
2212
  }
1767
- capabilities() {
2213
+ async prepare(input) {
2214
+ const mapping = mapPermissionPolicyToClaudeArgs(input.policy);
2215
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by claude adapter", retryable: false };
2216
+ let modelId;
2217
+ try {
2218
+ modelId = subscriptionModel(input.offer.dispatchSelection, "claude");
2219
+ } catch (error) {
2220
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
2221
+ }
2222
+ if (mapping.needsApprovalMcp && Object.prototype.hasOwnProperty.call(input.mcpServers ?? {}, APPROVAL_MCP_SERVER_NAME)) {
2223
+ return { kind: "reject", reason: `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`, retryable: false };
2224
+ }
2225
+ let bin;
2226
+ try {
2227
+ bin = this.resolveBin();
2228
+ } catch (error) {
2229
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2230
+ }
2231
+ let approvalMcpBin;
2232
+ if (mapping.needsApprovalMcp) {
2233
+ try {
2234
+ approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
2235
+ } catch (error) {
2236
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2237
+ }
2238
+ }
1768
2239
  return {
1769
- steer: false,
1770
- resume: true,
1771
- approvalInteractive: true,
1772
- mcpToolsets: true,
1773
- permissionModes: ["auto", "readonly", "plan", "confirm"]
2240
+ kind: "prepared",
2241
+ operation: {
2242
+ start: (startInput) => this.startPrepared(startInput, mapping, modelId, bin, approvalMcpBin)
2243
+ }
1774
2244
  };
1775
2245
  }
1776
- /**
1777
- * M5: deliberate product-boundary decision, not an oversight — byok's
1778
- * current ToS posture for claude is login-state-only (`claude auth
1779
- * login`'s own OAuth session — see `probeAuthPresent` below), so this
1780
- * adapter declares NO credential env vars at all; env-based API-key
1781
- * passthrough for claude is a separate, still-pending product decision.
1782
- * A product that genuinely needs it can opt in locally per-device via
1783
- * `DaemonConfig.runtimeEnvironment.claude.allow` (`create-daemon.ts`).
1784
- * `baseNames` is empty too: nothing in this adapter reads a
1785
- * claude-specific config-discovery variable (e.g. `CLAUDE_CONFIG_DIR`)
1786
- * today — if a future version of this adapter starts reading one, it
1787
- * belongs here, not left to rely on the platform baseline alone.
1788
- */
1789
- environmentRequirements() {
1790
- return { credentialNames: [] };
1791
- }
1792
- async start(task, ctx) {
1793
- if (typeof task.instruction !== "string") {
1794
- throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1795
- }
1796
- const mapping = mapPermissionPolicyToClaudeArgs(ctx.policy);
1797
- if (!mapping.ok) {
1798
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
2246
+ async startPrepared(startInput, initialMapping, modelId, bin, approvalMcpBin) {
2247
+ if (!initialMapping.ok) throw new RuntimeExecutionFailure({
2248
+ phase: "start",
2249
+ category: "authority",
2250
+ retry: "non-retryable",
2251
+ reason: "prepared claude permission mapping was invalid"
2252
+ });
2253
+ if (typeof startInput.instruction !== "string") {
2254
+ throw new RuntimeExecutionFailure({
2255
+ phase: "start",
2256
+ category: "authority",
2257
+ retry: "non-retryable",
2258
+ reason: "prepared claude operation requires a resolved string instruction"
2259
+ });
1799
2260
  }
1800
- const modelId = subscriptionModel(task, "claude");
2261
+ const mapping = { ...initialMapping, args: [...initialMapping.args] };
1801
2262
  let mcpConfigDir;
1802
- const taskMcpServers = ctx.mcpServers ?? {};
2263
+ const taskMcpServers = startInput.mcpServers ?? {};
1803
2264
  const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
1804
2265
  if (mapping.needsApprovalMcp) {
1805
- if (!ctx.approvalChannel) {
1806
- throw new PolicyUnsupportedError(
1807
- 'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1808
- );
1809
- }
1810
- if (Object.prototype.hasOwnProperty.call(taskMcpServers, APPROVAL_MCP_SERVER_NAME)) {
1811
- throw new PolicyUnsupportedError(
1812
- `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`
1813
- );
2266
+ if (!startInput.approvalChannel) {
2267
+ throw new RuntimeExecutionFailure({
2268
+ phase: "start",
2269
+ category: "authority",
2270
+ retry: "non-retryable",
2271
+ reason: 'claude adapter requires policy.mode "confirm" to be started with an approval channel'
2272
+ });
1814
2273
  }
2274
+ if (!approvalMcpBin) throw new RuntimeExecutionFailure({
2275
+ phase: "start",
2276
+ category: "authority",
2277
+ retry: "non-retryable",
2278
+ reason: "prepared claude approval MCP binary was not resolved"
2279
+ });
1815
2280
  }
1816
2281
  if (needsMcpConfig) {
1817
2282
  mcpConfigDir = await promises.mkdtemp(path17.join(os5.tmpdir(), "byok-mcp-"));
@@ -1820,12 +2285,23 @@ var ClaudeAdapter = class {
1820
2285
  const mcpConfigPath = path17.join(mcpConfigDir, "mcp-config.json");
1821
2286
  const mcpServers = { ...taskMcpServers };
1822
2287
  if (mapping.needsApprovalMcp) {
1823
- const approvalChannel = ctx.approvalChannel;
1824
- if (!approvalChannel) throw new Error("unreachable: approval channel checked above");
1825
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
2288
+ const approvalChannel = startInput.approvalChannel;
2289
+ if (!approvalChannel) throw new RuntimeExecutionFailure({
2290
+ phase: "start",
2291
+ category: "authority",
2292
+ retry: "non-retryable",
2293
+ reason: "prepared claude approval channel was not available"
2294
+ });
2295
+ const preparedApprovalMcpBin = approvalMcpBin;
2296
+ if (!preparedApprovalMcpBin) throw new RuntimeExecutionFailure({
2297
+ phase: "start",
2298
+ category: "authority",
2299
+ retry: "non-retryable",
2300
+ reason: "prepared claude approval MCP binary was not resolved"
2301
+ });
1826
2302
  mcpServers[APPROVAL_MCP_SERVER_NAME] = {
1827
- command: approvalMcpBin.command,
1828
- args: approvalMcpBin.args,
2303
+ command: preparedApprovalMcpBin.command,
2304
+ args: preparedApprovalMcpBin.args,
1829
2305
  env: {
1830
2306
  BYOK_STORE_DIR: approvalChannel.storeDir,
1831
2307
  BYOK_PRODUCT_ID: approvalChannel.productId,
@@ -1845,8 +2321,26 @@ var ClaudeAdapter = class {
1845
2321
  "--strict-mcp-config"
1846
2322
  ];
1847
2323
  }
1848
- const bin = this.resolveBin();
1849
- const resumeSessionId = task.sessionRef;
2324
+ const resumeSessionId = startInput.manifest.sessionRef;
2325
+ let manifestModelId;
2326
+ try {
2327
+ manifestModelId = subscriptionModel(startInput.manifest.dispatchSelection, "claude");
2328
+ } catch (cause) {
2329
+ throw new RuntimeExecutionFailure({
2330
+ phase: "start",
2331
+ category: "authority",
2332
+ retry: "non-retryable",
2333
+ reason: "prepared claude operation received an invalid runtime selection manifest"
2334
+ }, { cause });
2335
+ }
2336
+ if (manifestModelId !== modelId) {
2337
+ throw new RuntimeExecutionFailure({
2338
+ phase: "start",
2339
+ category: "authority",
2340
+ retry: "non-retryable",
2341
+ reason: "prepared claude operation received a manifest with different runtime selection"
2342
+ });
2343
+ }
1850
2344
  const args = [
1851
2345
  "-p",
1852
2346
  "--input-format",
@@ -1858,40 +2352,71 @@ var ClaudeAdapter = class {
1858
2352
  // "Error: When using --print, --output-format=stream-json requires
1859
2353
  // --verbose", before spawning any model call.
1860
2354
  "--verbose",
1861
- ...modelId ? ["--model", modelId] : [],
2355
+ ...manifestModelId ? ["--model", manifestModelId] : [],
1862
2356
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1863
2357
  ...mapping.args
1864
2358
  ];
1865
- const client = new ClaudeProcessClient({
1866
- command: bin.command,
1867
- args,
1868
- cwd: ctx.workspaceDir,
1869
- env: withoutProviderCredentials(ctx.env),
1870
- spawnFn: this.options.spawnFn
1871
- });
1872
- client.writeUserMessage(task.instruction);
2359
+ let client;
2360
+ try {
2361
+ client = new ClaudeProcessClient({
2362
+ command: bin.command,
2363
+ args,
2364
+ cwd: startInput.manifest.workspace.workspaceDir,
2365
+ env: withoutProviderCredentials(startInput.env),
2366
+ spawnFn: this.options.spawnFn
2367
+ });
2368
+ } catch (cause) {
2369
+ await cleanupMcpConfigDir(mcpConfigDir);
2370
+ throw new RuntimeExecutionFailure({
2371
+ phase: "start",
2372
+ category: "infrastructure",
2373
+ retry: "retryable",
2374
+ reason: "claude runtime process could not be spawned"
2375
+ }, { cause });
2376
+ }
2377
+ try {
2378
+ client.writeUserMessage(startInput.instruction);
2379
+ } catch (cause) {
2380
+ client.kill();
2381
+ await cleanupMcpConfigDir(mcpConfigDir);
2382
+ throw new RuntimeExecutionFailure({
2383
+ phase: "start",
2384
+ category: "infrastructure",
2385
+ retry: "retryable",
2386
+ reason: "claude initial instruction transport failed"
2387
+ }, { cause });
2388
+ }
1873
2389
  let sessionRef;
1874
2390
  try {
1875
2391
  sessionRef = await client.waitForInit();
1876
2392
  } catch (err) {
1877
2393
  client.kill();
1878
2394
  await cleanupMcpConfigDir(mcpConfigDir);
1879
- throw err;
2395
+ if (isRuntimeExecutionFailure(err)) throw err;
2396
+ throw new RuntimeExecutionFailure({
2397
+ phase: "start",
2398
+ category: "infrastructure",
2399
+ retry: "retryable",
2400
+ reason: `claude exited before yielding an authoritative session id: ${errorMessage2(err)}`
2401
+ }, { cause: err });
1880
2402
  }
1881
2403
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1882
2404
  client.kill();
1883
2405
  await cleanupMcpConfigDir(mcpConfigDir);
1884
- throw new Error(
1885
- `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1886
- );
2406
+ throw new RuntimeExecutionFailure({
2407
+ phase: "start",
2408
+ category: "authority",
2409
+ retry: "non-retryable",
2410
+ reason: `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef})`
2411
+ });
1887
2412
  }
1888
2413
  return new ClaudeSession(
1889
2414
  sessionRef,
1890
2415
  client,
1891
- ctx.workspaceDir,
1892
- ctx.approvalChannel,
2416
+ startInput.manifest.workspace.workspaceDir,
2417
+ startInput.approvalChannel,
1893
2418
  mcpConfigDir,
1894
- modelId
2419
+ manifestModelId
1895
2420
  );
1896
2421
  }
1897
2422
  /**
@@ -1926,8 +2451,7 @@ var ClaudeAdapter = class {
1926
2451
  return (this.options.resolveBin ?? resolveClaudeBin)();
1927
2452
  }
1928
2453
  };
1929
- function subscriptionModel(task, runtimeId) {
1930
- const selection = task.dispatchSelection;
2454
+ function subscriptionModel(selection, runtimeId) {
1931
2455
  if (selection === void 0) return void 0;
1932
2456
  if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
1933
2457
  throw new PolicyUnsupportedError(
@@ -1952,6 +2476,7 @@ var ClaudeSession = class {
1952
2476
  mcpConfigDir;
1953
2477
  modelId;
1954
2478
  correlation = createToolUseCorrelation();
2479
+ closeAttempt;
1955
2480
  get events() {
1956
2481
  const client = this.client;
1957
2482
  const correlation = this.correlation;
@@ -1960,17 +2485,40 @@ var ClaudeSession = class {
1960
2485
  [Symbol.asyncIterator]() {
1961
2486
  const inner = client.events[Symbol.asyncIterator]();
1962
2487
  let pending = [];
2488
+ let terminalFailure;
1963
2489
  let turnSettled = false;
1964
2490
  return {
1965
2491
  async next() {
1966
2492
  for (; ; ) {
1967
2493
  const buffered = pending.shift();
1968
2494
  if (buffered) return { value: buffered, done: false };
1969
- if (turnSettled) return { value: void 0, done: true };
1970
- const { value, done } = await inner.next();
1971
- if (done) return { value: void 0, done: true };
2495
+ if (turnSettled) {
2496
+ if (terminalFailure) throw terminalFailure;
2497
+ return { value: void 0, done: true };
2498
+ }
2499
+ let raw;
2500
+ try {
2501
+ raw = await inner.next();
2502
+ } catch (cause) {
2503
+ throw new RuntimeExecutionFailure({
2504
+ phase: "run",
2505
+ category: "infrastructure",
2506
+ retry: "retryable",
2507
+ reason: "claude runtime event transport failed"
2508
+ }, { cause });
2509
+ }
2510
+ const { value, done } = raw;
2511
+ if (done) {
2512
+ throw new RuntimeExecutionFailure({
2513
+ phase: "run",
2514
+ category: "infrastructure",
2515
+ retry: "retryable",
2516
+ reason: "claude runtime process ended before a terminal result frame"
2517
+ }, { cause: client.terminalError });
2518
+ }
1972
2519
  if (value.type === "result") turnSettled = true;
1973
2520
  const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
2521
+ terminalFailure = mapped.terminalFailure ?? terminalFailure;
1974
2522
  if (mapped.unmappedLabel) {
1975
2523
  client.recordUnmappedFrame(mapped.unmappedLabel);
1976
2524
  }
@@ -2001,7 +2549,7 @@ var ClaudeSession = class {
2001
2549
  if (typeof task.instruction !== "string") {
2002
2550
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2003
2551
  }
2004
- const requestedModel = subscriptionModel(task, "claude");
2552
+ const requestedModel = subscriptionModel(task.dispatchSelection, "claude");
2005
2553
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2006
2554
  throw new PolicyUnsupportedError(
2007
2555
  `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2025,8 +2573,17 @@ var ClaudeSession = class {
2025
2573
  this.client.kill();
2026
2574
  }
2027
2575
  async close() {
2028
- this.client.kill();
2029
- await cleanupMcpConfigDir(this.mcpConfigDir);
2576
+ if (!this.closeAttempt) {
2577
+ const attempt = (async () => {
2578
+ await this.client.dispose();
2579
+ await cleanupMcpConfigDir(this.mcpConfigDir);
2580
+ })();
2581
+ this.closeAttempt = attempt.catch((error) => {
2582
+ this.closeAttempt = void 0;
2583
+ throw error;
2584
+ });
2585
+ }
2586
+ await this.closeAttempt;
2030
2587
  }
2031
2588
  /**
2032
2589
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -2243,14 +2800,15 @@ var CodexProcessRunner = class {
2243
2800
  exitSignal = null;
2244
2801
  closedPromise;
2245
2802
  resolveClosed;
2803
+ disposalAttempt;
2246
2804
  constructor(options) {
2247
2805
  this.onEvent = options.onEvent;
2248
2806
  const spawnFn = options.spawnFn ?? spawn;
2249
- this.child = spawnFn(options.command, options.args, {
2807
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
2250
2808
  cwd: options.cwd,
2251
2809
  env: options.env,
2252
2810
  stdio: ["ignore", "pipe", "pipe"]
2253
- });
2811
+ }));
2254
2812
  this.closedPromise = new Promise((resolve) => {
2255
2813
  this.resolveClosed = resolve;
2256
2814
  });
@@ -2280,7 +2838,7 @@ var CodexProcessRunner = class {
2280
2838
  return this.closed;
2281
2839
  }
2282
2840
  /**
2283
- * Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
2841
+ * Immediate tree termination request. SIGTERM on POSIX: SIGINT was empirically confirmed
2284
2842
  * to be silently ignored by `codex exec` (a real, direct test — a 60s
2285
2843
  * shell `sleep` ran to full, unaffected completion despite SIGINT sent at
2286
2844
  * t=4s) — a genuine, evidence-based correction to this task's own initial
@@ -2293,13 +2851,25 @@ var CodexProcessRunner = class {
2293
2851
  * `../pi/rpc-client.ts`'s own cross-platform convention.
2294
2852
  */
2295
2853
  kill() {
2296
- if (this.closed) return;
2297
- const pid = this.child.pid;
2298
- if (process.platform === "win32" && pid !== void 0) {
2299
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
2300
- } else {
2301
- this.child.kill("SIGTERM");
2854
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
2855
+ }
2856
+ dispose() {
2857
+ if (!this.disposalAttempt) {
2858
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2859
+ this.disposalAttempt = attempt.catch((error) => {
2860
+ this.disposalAttempt = void 0;
2861
+ throw error;
2862
+ });
2302
2863
  }
2864
+ return this.disposalAttempt;
2865
+ }
2866
+ processTreeOptions() {
2867
+ return {
2868
+ child: this.child,
2869
+ waitClosed: () => this.closedPromise,
2870
+ isClosed: () => this.closed,
2871
+ label: "codex"
2872
+ };
2303
2873
  }
2304
2874
  /** 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. */
2305
2875
  buildExitError(context) {
@@ -2350,8 +2920,17 @@ var CodexAdapter = class {
2350
2920
  this.options = options;
2351
2921
  }
2352
2922
  options;
2353
- supportsDispatchSelection = true;
2354
- id = "codex";
2923
+ descriptor = freezeRuntimeAdapterDescriptor({
2924
+ id: "codex",
2925
+ supportsDispatchSelection: true,
2926
+ capabilities: {
2927
+ steer: false,
2928
+ resume: true,
2929
+ approvalInteractive: false,
2930
+ permissionModes: ["auto", "readonly"]
2931
+ },
2932
+ environmentRequirements: { credentialNames: [] }
2933
+ });
2355
2934
  async detect() {
2356
2935
  const bin = this.resolveBin();
2357
2936
  try {
@@ -2400,61 +2979,100 @@ ${result.stderr}`);
2400
2979
  ${withStreams.stderr ?? ""}`);
2401
2980
  }
2402
2981
  }
2403
- capabilities() {
2404
- return { steer: false, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
2405
- }
2406
- /**
2407
- * M5: same deliberate posture as the claude adapter (see its own doc
2408
- * comment) — codex authenticates via its own `codex login`-managed
2409
- * ChatGPT OAuth session (`probeAuthPresent` above), not an env var, so
2410
- * there is no credential env var this adapter needs forwarded; env-based
2411
- * API-key passthrough remains a separate, pending product decision. No
2412
- * `baseNames` either: nothing in this adapter reads a codex-specific
2413
- * config-discovery variable (e.g. `CODEX_HOME`) today.
2414
- */
2415
- environmentRequirements() {
2416
- return { credentialNames: [] };
2417
- }
2418
- async start(task, ctx) {
2419
- if (typeof task.instruction !== "string") {
2420
- throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2982
+ async prepare(input) {
2983
+ const mapping = mapPermissionPolicyToCodexArgs(input.policy);
2984
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by codex adapter", retryable: false };
2985
+ let modelId;
2986
+ try {
2987
+ modelId = subscriptionModel2(input.offer.dispatchSelection);
2988
+ } catch (error) {
2989
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
2421
2990
  }
2422
- const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
2423
- if (!mapping.ok) {
2424
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2991
+ let command;
2992
+ try {
2993
+ command = this.resolveBin().command;
2994
+ } catch (error) {
2995
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2996
+ }
2997
+ return {
2998
+ kind: "prepared",
2999
+ operation: {
3000
+ start: (startInput) => this.startPrepared(startInput, mapping.args, modelId, command)
3001
+ }
3002
+ };
3003
+ }
3004
+ async startPrepared(startInput, policyArgs, modelId, command) {
3005
+ if (typeof startInput.instruction !== "string") {
3006
+ throw new RuntimeExecutionFailure({
3007
+ phase: "start",
3008
+ category: "authority",
3009
+ retry: "non-retryable",
3010
+ reason: "prepared codex operation requires a resolved string instruction"
3011
+ });
2425
3012
  }
2426
- const modelId = subscriptionModel2(task);
2427
- const bin = this.resolveBin();
2428
3013
  const queue = new AsyncQueue();
3014
+ const terminal = {};
2429
3015
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
2430
- const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
2431
- const runtimeEnv = withoutProviderCredentials(ctx.env);
3016
+ let workspaceDir;
3017
+ try {
3018
+ workspaceDir = await resolveRealWorkspaceDir(startInput.manifest.workspace.workspaceDir);
3019
+ } catch (cause) {
3020
+ throw new RuntimeExecutionFailure({
3021
+ phase: "start",
3022
+ category: "infrastructure",
3023
+ retry: "retryable",
3024
+ reason: "codex runtime workspace could not be resolved"
3025
+ }, { cause });
3026
+ }
3027
+ const runtimeEnv = withoutProviderCredentials(startInput.env);
3028
+ let manifestModelId;
3029
+ try {
3030
+ manifestModelId = subscriptionModel2(startInput.manifest.dispatchSelection);
3031
+ } catch (cause) {
3032
+ throw new RuntimeExecutionFailure({
3033
+ phase: "start",
3034
+ category: "authority",
3035
+ retry: "non-retryable",
3036
+ reason: "prepared codex operation received an invalid runtime selection manifest"
3037
+ }, { cause });
3038
+ }
3039
+ if (manifestModelId !== modelId) {
3040
+ throw new RuntimeExecutionFailure({
3041
+ phase: "start",
3042
+ category: "authority",
3043
+ retry: "non-retryable",
3044
+ reason: "prepared codex operation received a manifest with different runtime selection"
3045
+ });
3046
+ }
2432
3047
  const { sessionRef, runner } = await runCodexTurn({
2433
- command: bin.command,
2434
- resumeRef: task.sessionRef,
2435
- instruction: task.instruction,
2436
- modelId,
2437
- policyArgs: mapping.args,
2438
- cwd: ctx.workspaceDir,
3048
+ command,
3049
+ resumeRef: startInput.manifest.sessionRef,
3050
+ instruction: startInput.instruction,
3051
+ modelId: manifestModelId,
3052
+ policyArgs: [...policyArgs],
3053
+ cwd: startInput.manifest.workspace.workspaceDir,
2439
3054
  env: runtimeEnv,
2440
3055
  spawnFn: this.options.spawnFn,
2441
3056
  workspaceDir,
2442
3057
  queue,
2443
3058
  recordUnmapped,
2444
- expectedSessionRef: task.sessionRef,
2445
- preparedGit: ctx.gitWorkspace !== void 0
3059
+ expectedSessionRef: startInput.manifest.sessionRef,
3060
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3061
+ failurePhase: "start",
3062
+ terminal
2446
3063
  });
2447
3064
  return new CodexSession({
2448
3065
  sessionRef,
2449
- command: bin.command,
3066
+ command,
2450
3067
  workspaceDir,
2451
- env: ctx.env,
3068
+ env: startInput.env,
2452
3069
  spawnFn: this.options.spawnFn,
2453
3070
  queue,
2454
3071
  recordUnmapped,
2455
3072
  initialRunner: runner,
2456
- preparedGit: ctx.gitWorkspace !== void 0,
2457
- modelId
3073
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3074
+ modelId: manifestModelId,
3075
+ terminal
2458
3076
  });
2459
3077
  }
2460
3078
  resolveBin() {
@@ -2502,37 +3120,69 @@ async function runCodexTurn(params) {
2502
3120
  rejectFirstLine = reject;
2503
3121
  });
2504
3122
  let turnEnded = false;
2505
- const runner = new CodexProcessRunner({
2506
- command: params.command,
2507
- args: argv,
2508
- cwd: params.cwd,
2509
- env: params.env,
2510
- spawnFn: params.spawnFn,
2511
- onEvent: (evt) => {
2512
- if (!firstLineSettled) {
2513
- firstLineSettled = true;
2514
- if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
2515
- resolveFirstLine(evt.thread_id);
2516
- } else {
2517
- rejectFirstLine(
2518
- new Error(`codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`)
2519
- );
3123
+ let runner;
3124
+ try {
3125
+ runner = new CodexProcessRunner({
3126
+ command: params.command,
3127
+ args: argv,
3128
+ cwd: params.cwd,
3129
+ env: params.env,
3130
+ spawnFn: params.spawnFn,
3131
+ onEvent: (evt) => {
3132
+ if (!firstLineSettled) {
3133
+ firstLineSettled = true;
3134
+ if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
3135
+ resolveFirstLine(evt.thread_id);
3136
+ } else {
3137
+ rejectFirstLine(
3138
+ new RuntimeExecutionFailure({
3139
+ phase: params.failurePhase,
3140
+ category: "authority",
3141
+ retry: "non-retryable",
3142
+ reason: `codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`
3143
+ })
3144
+ );
3145
+ }
3146
+ return;
3147
+ }
3148
+ const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
3149
+ for (const agentEvent of mapped) {
3150
+ if (agentEvent.type === "turn_end") turnEnded = true;
3151
+ params.queue.push(agentEvent);
3152
+ }
3153
+ if (evt.type === "turn.failed") {
3154
+ params.terminal.failure = new RuntimeExecutionFailure({
3155
+ phase: "run",
3156
+ category: "semantic",
3157
+ retry: "non-retryable",
3158
+ reason: "codex reported terminal task failure"
3159
+ });
3160
+ params.queue.end();
3161
+ }
3162
+ if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
3163
+ params.recordUnmapped(unmappedFrameKey(evt));
2520
3164
  }
2521
- return;
2522
- }
2523
- const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
2524
- for (const agentEvent of mapped) {
2525
- if (agentEvent.type === "turn_end") turnEnded = true;
2526
- params.queue.push(agentEvent);
2527
- }
2528
- if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
2529
- params.recordUnmapped(unmappedFrameKey(evt));
2530
3165
  }
2531
- }
2532
- });
3166
+ });
3167
+ } catch (cause) {
3168
+ throw new RuntimeExecutionFailure({
3169
+ phase: params.failurePhase,
3170
+ category: "infrastructure",
3171
+ retry: "retryable",
3172
+ reason: "codex runtime process could not be spawned"
3173
+ }, { cause });
3174
+ }
3175
+ params.onRunnerCreated?.(runner);
2533
3176
  void runner.waitClosed().then(() => {
2534
- if (turnEnded) return;
2535
- params.queue.push({ type: "error", message: runner.buildExitError("codex exited without completing the turn").message });
3177
+ if (turnEnded || params.terminal.failure) return;
3178
+ const cause = runner.buildExitError("codex exited without completing the turn");
3179
+ params.terminal.failure = new RuntimeExecutionFailure({
3180
+ phase: "run",
3181
+ category: "infrastructure",
3182
+ retry: "retryable",
3183
+ reason: cause.message
3184
+ }, { cause });
3185
+ params.queue.push({ type: "error", message: cause.message });
2536
3186
  params.queue.end();
2537
3187
  });
2538
3188
  let sessionRef;
@@ -2556,7 +3206,13 @@ async function runCodexTurn(params) {
2556
3206
  void runner.waitClosed().then(() => {
2557
3207
  if (!settled) {
2558
3208
  settled = true;
2559
- reject(runner.buildExitError("codex exited before yielding an authoritative thread id"));
3209
+ const cause = runner.buildExitError("codex exited before yielding an authoritative thread id");
3210
+ reject(new RuntimeExecutionFailure({
3211
+ phase: params.failurePhase,
3212
+ category: "infrastructure",
3213
+ retry: "retryable",
3214
+ reason: cause.message
3215
+ }, { cause }));
2560
3216
  }
2561
3217
  });
2562
3218
  });
@@ -2566,9 +3222,12 @@ async function runCodexTurn(params) {
2566
3222
  }
2567
3223
  if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
2568
3224
  runner.kill();
2569
- throw new Error(
2570
- `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)`
2571
- );
3225
+ throw new RuntimeExecutionFailure({
3226
+ phase: params.failurePhase,
3227
+ category: "authority",
3228
+ retry: "non-retryable",
3229
+ reason: `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef})`
3230
+ });
2572
3231
  }
2573
3232
  return { sessionRef, runner };
2574
3233
  }
@@ -2595,8 +3254,12 @@ var CodexSession = class {
2595
3254
  recordUnmapped;
2596
3255
  preparedGit;
2597
3256
  modelId;
3257
+ terminal;
2598
3258
  currentRunner;
3259
+ ownedRunners = /* @__PURE__ */ new Set();
3260
+ followUpAttempts = /* @__PURE__ */ new Set();
2599
3261
  closed = false;
3262
+ closeAttempt;
2600
3263
  constructor(options) {
2601
3264
  this.sessionRef = options.sessionRef;
2602
3265
  this.command = options.command;
@@ -2607,11 +3270,36 @@ var CodexSession = class {
2607
3270
  this.recordUnmapped = options.recordUnmapped;
2608
3271
  this.preparedGit = options.preparedGit;
2609
3272
  this.modelId = options.modelId;
3273
+ this.terminal = options.terminal;
2610
3274
  this.currentRunner = options.initialRunner;
3275
+ this.ownedRunners.add(options.initialRunner);
2611
3276
  void this.forgetRunnerOnceClosed(options.initialRunner);
2612
3277
  }
2613
3278
  get events() {
2614
- return this.queue;
3279
+ const queue = this.queue;
3280
+ const session = this;
3281
+ return {
3282
+ [Symbol.asyncIterator]() {
3283
+ const inner = queue[Symbol.asyncIterator]();
3284
+ return {
3285
+ async next() {
3286
+ let result;
3287
+ try {
3288
+ result = await inner.next();
3289
+ } catch (cause) {
3290
+ throw new RuntimeExecutionFailure({
3291
+ phase: "run",
3292
+ category: "infrastructure",
3293
+ retry: "retryable",
3294
+ reason: "codex runtime event transport failed"
3295
+ }, { cause });
3296
+ }
3297
+ if (result.done && session.terminal.failure) throw session.terminal.failure;
3298
+ return result;
3299
+ }
3300
+ };
3301
+ }
3302
+ };
2615
3303
  }
2616
3304
  async forgetRunnerOnceClosed(runner) {
2617
3305
  await runner.waitClosed();
@@ -2656,7 +3344,14 @@ var CodexSession = class {
2656
3344
  * stale id even after codex had moved on) — it just can now only ever be
2657
3345
  * the SAME id this call asked to resume, never a silently-different one.
2658
3346
  */
2659
- async followUp(task) {
3347
+ followUp(task) {
3348
+ const attempt = this.runFollowUp(task);
3349
+ this.followUpAttempts.add(attempt);
3350
+ void attempt.finally(() => this.followUpAttempts.delete(attempt)).catch(() => {
3351
+ });
3352
+ return attempt;
3353
+ }
3354
+ async runFollowUp(task) {
2660
3355
  if (typeof task.instruction !== "string") {
2661
3356
  throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2662
3357
  }
@@ -2667,7 +3362,7 @@ var CodexSession = class {
2667
3362
  if (!mapping.ok) {
2668
3363
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2669
3364
  }
2670
- const requestedModel = subscriptionModel2(task);
3365
+ const requestedModel = subscriptionModel2(task.dispatchSelection);
2671
3366
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2672
3367
  throw new PolicyUnsupportedError(
2673
3368
  `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2677,6 +3372,7 @@ var CodexSession = class {
2677
3372
  const resumeRef = this.sessionRef;
2678
3373
  let sessionRef;
2679
3374
  let runner;
3375
+ const terminal = {};
2680
3376
  try {
2681
3377
  ({ sessionRef, runner } = await runCodexTurn({
2682
3378
  command: this.command,
@@ -2691,25 +3387,54 @@ var CodexSession = class {
2691
3387
  queue: this.queue,
2692
3388
  recordUnmapped: this.recordUnmapped,
2693
3389
  expectedSessionRef: resumeRef,
2694
- preparedGit: this.preparedGit
3390
+ preparedGit: this.preparedGit,
3391
+ failurePhase: "run",
3392
+ terminal,
3393
+ onRunnerCreated: (created) => {
3394
+ this.ownedRunners.add(created);
3395
+ void this.forgetRunnerOnceClosed(created);
3396
+ }
2695
3397
  }));
2696
3398
  } catch (err) {
2697
3399
  this.queue.end();
3400
+ this.terminal = {
3401
+ failure: isRuntimeExecutionFailure(err) ? err : new RuntimeExecutionFailure({
3402
+ phase: "run",
3403
+ category: "authority",
3404
+ retry: "non-retryable",
3405
+ reason: "codex follow-up violated the runtime adapter contract"
3406
+ }, { cause: err })
3407
+ };
2698
3408
  throw err;
2699
3409
  }
3410
+ if (this.closed) {
3411
+ await runner.dispose();
3412
+ throw new Error("codex session closed while follow-up was starting");
3413
+ }
3414
+ this.terminal = terminal;
2700
3415
  this.sessionRef = sessionRef;
2701
3416
  this.currentRunner = runner;
2702
- void this.forgetRunnerOnceClosed(runner);
2703
3417
  }
2704
3418
  /** 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. */
2705
3419
  async interrupt() {
2706
3420
  this.currentRunner?.kill();
2707
3421
  }
2708
3422
  async close() {
2709
- if (this.closed) return;
2710
- this.closed = true;
2711
- this.currentRunner?.kill();
2712
- this.queue.end();
3423
+ if (!this.closeAttempt) {
3424
+ this.closed = true;
3425
+ this.queue.end();
3426
+ const attempt = (async () => {
3427
+ const runners = [...this.ownedRunners];
3428
+ await Promise.all(runners.map((runner) => runner.dispose()));
3429
+ for (const runner of runners) this.ownedRunners.delete(runner);
3430
+ await Promise.allSettled([...this.followUpAttempts]);
3431
+ })();
3432
+ this.closeAttempt = attempt.catch((error) => {
3433
+ this.closeAttempt = void 0;
3434
+ throw error;
3435
+ });
3436
+ }
3437
+ await this.closeAttempt;
2713
3438
  }
2714
3439
  /**
2715
3440
  * `codex exec` has no in-band channel to inject text into an already-
@@ -2745,8 +3470,7 @@ var CodexSession = class {
2745
3470
  );
2746
3471
  }
2747
3472
  };
2748
- function subscriptionModel2(task) {
2749
- const selection = task.dispatchSelection;
3473
+ function subscriptionModel2(selection) {
2750
3474
  if (selection === void 0) return void 0;
2751
3475
  if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
2752
3476
  throw new PolicyUnsupportedError(
@@ -2811,7 +3535,7 @@ var ApprovalRegistry = class {
2811
3535
  * `requestApproval` timeout and `finish()` fail-closed cleanup
2812
3536
  * (`task-runner.ts`) — resolves a decision this device made on its own.
2813
3537
  * The one exception, a server-sent wire `task.approve`/`task.reject`
2814
- * relayed through `TaskContext.approvalChannel.resolve`
3538
+ * relayed through `RuntimeOperationStartInput.approvalChannel.resolve`
2815
3539
  * (`task-runner.ts`'s `handleOffer`), passes `'wire'` explicitly.
2816
3540
  */
2817
3541
  resolve(approvalId, decision, reason, origin = "local") {
@@ -3442,7 +4166,10 @@ var PresencePublisher = class {
3442
4166
  {
3443
4167
  method: "PUT",
3444
4168
  headers: { "content-type": "application/json" },
3445
- body: JSON.stringify({ level: "online" })
4169
+ body: JSON.stringify({
4170
+ level: "online",
4171
+ ...this.opts.configuredToolsets === void 0 ? {} : { configuredToolsets: this.opts.configuredToolsets }
4172
+ })
3446
4173
  },
3447
4174
  this.opts.auth
3448
4175
  );
@@ -3636,12 +4363,12 @@ var AnotherControlServerRunningError = class extends Error {
3636
4363
  }
3637
4364
  };
3638
4365
  var MAX_HALF_OPEN_CONNECTIONS = 8;
3639
- function errorMessage2(err) {
4366
+ function errorMessage3(err) {
3640
4367
  return err instanceof Error ? err.message : String(err);
3641
4368
  }
3642
4369
  function toControlErrorShape(err) {
3643
4370
  if (err instanceof ControlError) return { code: err.code, message: err.message };
3644
- return { code: "internal_error", message: errorMessage2(err) };
4371
+ return { code: "internal_error", message: errorMessage3(err) };
3645
4372
  }
3646
4373
  function probeUnixSocketAlive(socketPath) {
3647
4374
  return new Promise((resolve) => {
@@ -3848,12 +4575,19 @@ async function startControlServer(opts) {
3848
4575
  }
3849
4576
  throw err;
3850
4577
  }
4578
+ let stopServingPromise;
4579
+ async function stopServing() {
4580
+ stopServingPromise ??= (async () => {
4581
+ for (const socket of sockets) socket.destroy();
4582
+ await new Promise((resolve) => server.close(() => resolve()));
4583
+ })();
4584
+ await stopServingPromise;
4585
+ }
3851
4586
  let closed = false;
3852
4587
  async function close() {
3853
4588
  if (closed) return;
3854
4589
  closed = true;
3855
- for (const socket of sockets) socket.destroy();
3856
- await new Promise((resolve) => server.close(() => resolve()));
4590
+ await stopServing();
3857
4591
  if (process.platform !== "win32") {
3858
4592
  await promises.rm(endpoint, { force: true }).catch(() => {
3859
4593
  });
@@ -3861,7 +4595,7 @@ async function startControlServer(opts) {
3861
4595
  await promises.rm(tokenPath, { force: true }).catch(() => {
3862
4596
  });
3863
4597
  }
3864
- return { endpoint, close };
4598
+ return { endpoint, stopServing, close };
3865
4599
  }
3866
4600
  function deterministicJitterMs(input) {
3867
4601
  const ratio = input.ratio ?? 0.2;
@@ -4155,6 +4889,7 @@ var WsTransport = class {
4155
4889
  deviceId: this.opts.deviceId,
4156
4890
  productId: this.opts.productId,
4157
4891
  runtimes: this.opts.runtimes,
4892
+ configuredToolsets: this.opts.configuredToolsets === void 0 ? void 0 : [...this.opts.configuredToolsets],
4158
4893
  cursor: this.opts.getCursor?.()
4159
4894
  });
4160
4895
  socket.send(encodeEnvelope(hello));
@@ -4244,6 +4979,7 @@ var ConnectionManager = class {
4244
4979
  productId: opts.productId,
4245
4980
  capabilities: opts.capabilities,
4246
4981
  runtimes: opts.runtimes,
4982
+ configuredToolsets: opts.configuredToolsets,
4247
4983
  getCursor: () => this.cursor,
4248
4984
  onEnvelope: (envelope) => this.deliver(envelope),
4249
4985
  onStateChange: (state) => {
@@ -4366,7 +5102,7 @@ var ConnectionManager = class {
4366
5102
  * already-delivered seqs (see its own doc comment) so the failed seq's own
4367
5103
  * redelivery can get through — but that same frozen watermark also means
4368
5104
  * every OTHER seq above it rides along on every re-poll too. Without this,
4369
- * a seq already mid-flight (e.g. a `task.offer` whose `adapter.start()`
5105
+ * a seq already mid-flight (e.g. a `task.offer` whose prepared operation start()
4370
5106
  * hasn't resolved yet) would be re-enqueued into `processingChain` on
4371
5107
  * every such re-poll, piling up duplicate copies that — once the first
4372
5108
  * finally resolves and the chain unwinds through them — run its handler
@@ -5205,6 +5941,12 @@ var RECLAIM_FILENAME = `${DAEMON_OWNER_FILENAME}.reclaim`;
5205
5941
  var MAX_OWNER_BYTES = 4096;
5206
5942
  var RECLAIM_MALFORMED_GRACE_MS = 3e4;
5207
5943
  var SELF_PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1e3).toISOString();
5944
+ function endOwnershipProbe(socket, response) {
5945
+ socket.on("error", () => {
5946
+ });
5947
+ if (response === void 0) socket.end();
5948
+ else socket.end(response);
5949
+ }
5208
5950
  var STORE_MUTEX_ID_PREFIX = "byok-store-mutex-v1:";
5209
5951
  var STORE_MUTEX_PROBE_TIMEOUT_MS = 1e3;
5210
5952
  var STORE_MUTEX_SOCKET_FILENAME = "mutex.sock";
@@ -5311,7 +6053,7 @@ async function portIsBound(port) {
5311
6053
  });
5312
6054
  }
5313
6055
  async function createLivenessListener() {
5314
- const server = createServer((socket) => socket.end());
6056
+ const server = createServer((socket) => endOwnershipProbe(socket));
5315
6057
  const port = await new Promise((resolve, reject) => {
5316
6058
  server.once("error", reject);
5317
6059
  server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
@@ -5396,7 +6138,7 @@ async function acquireStoreMutex(canonicalStoreDir) {
5396
6138
  }
5397
6139
  await clearStaleStoreMutexSocket(endpoint, identity);
5398
6140
  }
5399
- const server = createServer((socket) => socket.end(`${STORE_MUTEX_ID_PREFIX}${identity}
6141
+ const server = createServer((socket) => endOwnershipProbe(socket, `${STORE_MUTEX_ID_PREFIX}${identity}
5400
6142
  `));
5401
6143
  try {
5402
6144
  await new Promise((resolve, reject) => {
@@ -5538,7 +6280,7 @@ function toRuntimeInfoCapabilities(caps) {
5538
6280
  resume: caps.resume,
5539
6281
  approvalInteractive: caps.approvalInteractive,
5540
6282
  ...caps.mcpToolsets === void 0 ? {} : { mcpToolsets: caps.mcpToolsets },
5541
- permissionModes: caps.permissionModes
6283
+ permissionModes: [...caps.permissionModes]
5542
6284
  };
5543
6285
  }
5544
6286
  var CursorStore = class {
@@ -5778,6 +6520,9 @@ var DaemonObserver = class {
5778
6520
  noteGitWorkspace(event) {
5779
6521
  this.emit({ kind: "git-workspace", ts: nowIso(), ...event });
5780
6522
  }
6523
+ noteRuntimeDisposalFailure(event) {
6524
+ this.emit({ kind: "runtime-disposal-failed", ts: nowIso(), ...event });
6525
+ }
5781
6526
  /**
5782
6527
  * Finding F4: wired from `TaskRunnerDeps.onApprovalDispatched`, called
5783
6528
  * synchronously by `TaskRunner.dispatchApproval` BEFORE its own
@@ -7434,20 +8179,20 @@ function isKnownRuntimeId(id) {
7434
8179
  var DEFAULT_RUNTIME_PREFERENCE = ["claude", "codex", "pi"];
7435
8180
  function orderByPreference(candidates, preference) {
7436
8181
  const rank = new Map(preference.map((id, index) => [id, index]));
7437
- return [...candidates].sort((a, b) => (rank.get(a.id) ?? preference.length) - (rank.get(b.id) ?? preference.length));
8182
+ return [...candidates].sort((a, b) => (rank.get(a.descriptor.id) ?? preference.length) - (rank.get(b.descriptor.id) ?? preference.length));
7438
8183
  }
7439
- function adapterSupportsMode(adapter, mode) {
7440
- return adapter.capabilities().permissionModes.includes(mode);
8184
+ function adapterSupportsMode(descriptor, mode) {
8185
+ return descriptor.capabilities.permissionModes.includes(mode);
7441
8186
  }
7442
- function adapterSupportsMcpToolsets(adapter) {
7443
- return adapter.capabilities().mcpToolsets === true;
8187
+ function adapterSupportsMcpToolsets(descriptor) {
8188
+ return descriptor.capabilities.mcpToolsets === true;
7444
8189
  }
7445
8190
  function withoutRequiredToolsets(payload) {
7446
8191
  if (!("requiredToolsets" in payload)) return payload;
7447
8192
  const { requiredToolsets, ...offer } = payload;
7448
8193
  return offer;
7449
8194
  }
7450
- function errorMessage3(err) {
8195
+ function errorMessage4(err) {
7451
8196
  return err instanceof Error ? err.message : String(err);
7452
8197
  }
7453
8198
  function raceSettleFirst(fn, timeoutMs) {
@@ -7500,7 +8245,7 @@ async function openArtifact(workspaceDir, name) {
7500
8245
  try {
7501
8246
  handle = await promises.open(candidate, constants.O_RDONLY | O_NOFOLLOW);
7502
8247
  } catch (err) {
7503
- return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage3(err)}` };
8248
+ return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage4(err)}` };
7504
8249
  }
7505
8250
  try {
7506
8251
  const st = await handle.stat();
@@ -7512,7 +8257,7 @@ async function openArtifact(workspaceDir, name) {
7512
8257
  } catch (err) {
7513
8258
  await handle.close().catch(() => {
7514
8259
  });
7515
- return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage3(err)}` };
8260
+ return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage4(err)}` };
7516
8261
  }
7517
8262
  return { ok: true, handle };
7518
8263
  }
@@ -7526,13 +8271,13 @@ var TaskRunner = class {
7526
8271
  * Finding F4 (cancel lost during the offer-processing window): a
7527
8272
  * `task.cancel` for a taskId that hasn't finished `handleOffer` yet (still
7528
8273
  * awaiting adapter detection / instruction resolution / workspace setup /
7529
- * `adapter.start()`) has no `this.tasks` entry to land on — it used to be
8274
+ * prepared operation `start()`) has no `this.tasks` entry to land on — it used to be
7530
8275
  * silently dropped, and the runtime session `handleOffer` was about to
7531
8276
  * register would then run an unsupervised ("zombie") turn nobody asked
7532
8277
  * for anymore. Recording the taskId here lets `handleOffer` consult it at
7533
8278
  * the two points where it can still safely react (see its body): before
7534
8279
  * claiming at all (decline instead of ever starting a session), and right
7535
- * after `adapter.start()` resolves but before this task is registered as
8280
+ * after the prepared operation resolves but before this task is registered as
7536
8281
  * active (tear the just-started session down immediately, before its
7537
8282
  * event loop ever pumps a single event). Consumed (deleted) at whichever
7538
8283
  * checkpoint handles it; a cancel for a taskId that's already active,
@@ -7558,12 +8303,12 @@ var TaskRunner = class {
7558
8303
  * checkpoint-2 cancel-teardown, or successful registration into
7559
8304
  * `this.tasks`). Bounded eviction on `pendingCancelled` (below) must never
7560
8305
  * remove an entry for a taskId in this set: doing so is exactly the bug —
7561
- * block task A in `adapter.start()`, deliver A's own `task.cancel` (so
8306
+ * block task A in prepared-operation `start()`, deliver A's own `task.cancel` (so
7562
8307
  * `pendingCancelled` gets an entry for A while A is still in-flight),
7563
8308
  * then deliver `MAX_TRACKED_TASK_IDS` more cancels for unrelated taskIds
7564
8309
  * nobody ever offered — under naive oldest-wins eviction, A's entry (the
7565
8310
  * single oldest) gets evicted purely because of unrelated churn, so when
7566
- * `adapter.start()` finally resolves, checkpoint 2 finds no cancel marker
8311
+ * the prepared operation finally resolves, checkpoint 2 finds no cancel marker
7567
8312
  * and the already-cancelled task starts a real session. See
7568
8313
  * `evictPendingCancelled` below for the fix, and
7569
8314
  * `task-runner-bounded-collections.test.ts` for a test mirroring this
@@ -7583,7 +8328,7 @@ var TaskRunner = class {
7583
8328
  * explicitly relies on redelivered handlers being idempotent for exactly
7584
8329
  * this reason). `handleOffer` must treat a redelivered offer for a taskId
7585
8330
  * that's already active (`this.tasks`) or already finished (this set) as
7586
- * a no-op — never a second `adapter.start()` call, which would orphan the
8331
+ * a no-op — never a second prepared-operation `start()` call, which would orphan the
7587
8332
  * first session.
7588
8333
  *
7589
8334
  * M3-B: unbounded otherwise — a long-lived daemon that's finished many
@@ -7652,10 +8397,9 @@ var TaskRunner = class {
7652
8397
  this.stoppingOffers = true;
7653
8398
  }
7654
8399
  /**
7655
- * M4 Phase 2: best-effort shutdown of every currently ACTIVE task, for the
7656
- * control socket's `shutdown` RPC. Mirrors `handleCancel`'s best-effort
7657
- * `session.interrupt()` style (an interrupt failure is swallowed; the
7658
- * terminal message is sent either way) but reports `task.fail` rather than
8400
+ * Shutdown of every currently ACTIVE task for the control socket's
8401
+ * `shutdown` RPC. Soft interrupt remains bounded, but each task's
8402
+ * authoritative close receipt must settle successfully. Reports `task.fail` rather than
7659
8403
  * `task.cancelled` — these tasks aren't ending because the SERVER
7660
8404
  * cancelled them, they're ending because this device is shutting down.
7661
8405
  * `retryable: true` throughout: nothing about the task/policy itself was
@@ -7709,28 +8453,13 @@ var TaskRunner = class {
7709
8453
  * unconditionally, so a hung `interrupt()` (a misbehaving adapter) can
7710
8454
  * never block `task.fail` from being sent at all.
7711
8455
  *
7712
- * New in this batch hard-kill escalation: when `interrupt()` does NOT
7713
- * settle within that same grace window, `session.close()` is tried next
7714
- * (ALSO raced against `timeoutMs`, for the identical reason: a hung
7715
- * `close()` must not be able to block this forever either — which matters
7716
- * far more here than it used to for the pre-existing graceful-shutdown-only
7717
- * caller, since THAT path is additionally bounded by an outer deadline
7718
- * (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`/`DaemonConfig.shutdownGraceMs`,
7719
- * `create-daemon.ts`), while resource-limit enforcement fires during
7720
- * ordinary operation with no such outer bound watching it). `close()` is
7721
- * every adapter's harder teardown primitive — an actual process-level kill
7722
- * (SIGTERM, or `taskkill /F` on Windows — see e.g.
7723
- * `ClaudeProcessClient.kill()`/`PiRpcClient.kill()`) as opposed to pi's own
7724
- * soft in-band `interrupt()` (an RPC `abort` message that leaves the
7725
- * process alive and resumable) — so escalating to it is the closest thing
7726
- * to a "hard kill" the `Session` interface exposes. `finish()` below calls
7727
- * `session.close()` again regardless (documented idempotent) — this isn't
7728
- * a substitute for that, only an earlier, bounded attempt at actually
7729
- * stopping a stuck runtime before this method gives up and reports failure
7730
- * anyway.
8456
+ * After the bounded soft interrupt, `finish()` always awaits the authoritative
8457
+ * `Session.close()` receipt. A failed receipt retains active/Git ownership;
8458
+ * shutdown surfaces the rejection while resource enforcement leaves local
8459
+ * evidence for a later retry.
7731
8460
  *
7732
8461
  * Re-checks task identity (`this.tasks.get(...) === active`) immediately
7733
- * before sending `task.fail`: the interrupt/hard-kill race above has await
8462
+ * before sending `task.fail`: the interrupt race above has await
7734
8463
  * points during which a DIFFERENT path (a racing `task.cancel`/
7735
8464
  * `task.reject`, or the session completing normally on its own) may have
7736
8465
  * already finished this exact task and sent its own terminal message.
@@ -7739,20 +8468,25 @@ var TaskRunner = class {
7739
8468
  * identity-check guard for the same class of race.
7740
8469
  */
7741
8470
  async teardownActiveTask(active, reason, retryable) {
8471
+ if (active.finalizationStarted) return this.finish(active.taskId);
8472
+ if (!this.reserveSemanticTerminal(active)) return active.semanticTerminalSettled ?? false;
7742
8473
  active.beingTornDown = true;
7743
8474
  await this.observeGit(active, "salvage");
7744
8475
  const timeoutMs = this.deps.shutdownInterruptTimeoutMs ?? DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS;
7745
- const interrupted = await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
7746
- if (!interrupted) {
7747
- await raceSettleFirst(() => active.session.close(), timeoutMs);
7748
- }
7749
- if (this.tasks.get(active.taskId) !== active) return;
8476
+ await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
8477
+ if (this.tasks.get(active.taskId) !== active) return true;
7750
8478
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId: active.taskId }));
7751
- await this.finish(active.taskId);
8479
+ return this.finish(active.taskId);
7752
8480
  }
7753
8481
  /** Graceful-shutdown caller of {@link teardownActiveTask} — see `shutdownActiveTasks`'s own doc comment. `retryable: true`: nothing about the task/policy itself was ever at fault, only this device's own availability right now. */
7754
8482
  async shutdownTask(active, reason) {
7755
- await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8483
+ const disposed = await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8484
+ if (!disposed) {
8485
+ throw new RuntimeDisposalFailure({
8486
+ stage: "quiescence",
8487
+ reason: `${active.adapter.descriptor.id} runtime ownership remains active after shutdown disposal failed`
8488
+ });
8489
+ }
7756
8490
  }
7757
8491
  /**
7758
8492
  * M5 batch-3 (workstream 2): shared entry point for both resource-limit
@@ -7872,15 +8606,33 @@ var TaskRunner = class {
7872
8606
  this.decline(taskId, resolvedMcp.reason, true);
7873
8607
  return;
7874
8608
  }
8609
+ const decision = computeEffectivePolicy(payload.policy, this.deps.permissionDefaults);
8610
+ if (!decision.ok) {
8611
+ this.decline(taskId, decision.reason ?? "policy rejected", false);
8612
+ return;
8613
+ }
8614
+ const offered = withoutRequiredToolsets(payload);
7875
8615
  const requestedRuntime = payload.dispatchSelection?.runtimeId ?? payload.runtime;
7876
8616
  const pick = await this.pickAdapter(requestedRuntime, payload.policy.mode, requiredToolsets !== void 0);
7877
8617
  if (!pick.ok) {
7878
8618
  this.decline(taskId, pick.reason, pick.retryable);
7879
8619
  return;
7880
8620
  }
7881
- const decision = computeEffectivePolicy(payload.policy, this.deps.permissionDefaults);
7882
- if (!decision.ok) {
7883
- this.decline(taskId, decision.reason ?? "policy rejected", false);
8621
+ let prepared;
8622
+ try {
8623
+ prepared = await pick.adapter.prepare({
8624
+ offer: offered,
8625
+ policy: decision.policy,
8626
+ descriptor: pick.descriptor,
8627
+ requiredToolsetIds: requiredToolsets ?? [],
8628
+ ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {}
8629
+ });
8630
+ } catch (error) {
8631
+ this.decline(taskId, `runtime preparation failed: ${errorMessage4(error)}`, true);
8632
+ return;
8633
+ }
8634
+ if (prepared.kind === "reject") {
8635
+ this.decline(taskId, prepared.reason, prepared.retryable);
7884
8636
  return;
7885
8637
  }
7886
8638
  let known = void 0;
@@ -7915,6 +8667,7 @@ var TaskRunner = class {
7915
8667
  }
7916
8668
  } else {
7917
8669
  workspaceDir = path17.join(this.deps.workspaceRoot, taskId);
8670
+ gitWorkspaceId = randomUUID();
7918
8671
  }
7919
8672
  try {
7920
8673
  gitLease = await gitManager.acquireLease(workspaceDir, payload.sessionRef);
@@ -7930,6 +8683,26 @@ var TaskRunner = class {
7930
8683
  this.decline(taskId, "workspace mode is unavailable", true);
7931
8684
  return;
7932
8685
  }
8686
+ const env = buildRuntimeEnv({
8687
+ ambient: process.env,
8688
+ requirements: pick.descriptor.environmentRequirements,
8689
+ locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.descriptor.id]?.allow
8690
+ });
8691
+ const manifest = sealRuntimeOperationManifest({
8692
+ taskId,
8693
+ runtimeId: pick.descriptor.id,
8694
+ descriptor: pick.descriptor,
8695
+ policy: decision.policy,
8696
+ requiredToolsetIds: requiredToolsets ?? [],
8697
+ ...offered.dispatchSelection === void 0 ? {} : { dispatchSelection: offered.dispatchSelection },
8698
+ ...known === void 0 || payload.sessionRef === void 0 ? {} : { sessionRef: payload.sessionRef },
8699
+ workspace: {
8700
+ workspaceDir,
8701
+ ...gitWorkspaceId === void 0 ? {} : { workspaceId: gitWorkspaceId },
8702
+ ...gitBaseline === void 0 ? {} : { baseline: gitBaseline }
8703
+ },
8704
+ forwardedEnvironmentNames: Object.freeze(Object.keys(env).sort())
8705
+ });
7933
8706
  this.deps.send(
7934
8707
  createEnvelope(
7935
8708
  "task.claim",
@@ -7945,7 +8718,7 @@ var TaskRunner = class {
7945
8718
  // (the merely REQUESTED runtime): this is what closes the gap
7946
8719
  // where an auto-selected task left the server never learning
7947
8720
  // which runtime actually ran.
7948
- runtime: isKnownRuntimeId(pick.adapter.id) ? pick.adapter.id : void 0,
8721
+ runtime: isKnownRuntimeId(manifest.descriptor.id) ? manifest.descriptor.id : void 0,
7949
8722
  // S0/D-4 (`task.claim.capabilities`, docs/protocol.md §2.4): the
7950
8723
  // selected adapter's own capability self-report, carried on the
7951
8724
  // same message that establishes the task↔runtime binding. The
@@ -7962,7 +8735,7 @@ var TaskRunner = class {
7962
8735
  // Gating them would silently strip a custom steer-capable
7963
8736
  // adapter's own truth and leave the server fail-closing on it
7964
8737
  // forever.
7965
- capabilities: toRuntimeInfoCapabilities(pick.adapter.capabilities())
8738
+ capabilities: toRuntimeInfoCapabilities(manifest.descriptor.capabilities)
7966
8739
  },
7967
8740
  { taskId }
7968
8741
  )
@@ -7973,7 +8746,7 @@ var TaskRunner = class {
7973
8746
  if (plainWorkspaceNeedsResolve) workspaceDir = await this.resolveWorkspaceDir(taskId, known?.workspaceDir);
7974
8747
  } catch (err) {
7975
8748
  gitLease?.release();
7976
- await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage3(err)}`, true);
8749
+ await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage4(err)}`, true);
7977
8750
  return;
7978
8751
  }
7979
8752
  if (this.deps.gitWorkspaceManager && gitLease) {
@@ -7982,7 +8755,7 @@ var TaskRunner = class {
7982
8755
  if (gitExisting) {
7983
8756
  observation = await this.deps.gitWorkspaceManager.validateExisting(workspaceDir);
7984
8757
  } else {
7985
- const workspaceId2 = randomUUID();
8758
+ const workspaceId2 = gitWorkspaceId ?? randomUUID();
7986
8759
  const now2 = (/* @__PURE__ */ new Date()).toISOString();
7987
8760
  gitWorkspaceId = workspaceId2;
7988
8761
  await this.deps.gitWorkspaceStore?.upsert({
@@ -8033,30 +8806,11 @@ var TaskRunner = class {
8033
8806
  return;
8034
8807
  }
8035
8808
  }
8036
- const ctx = {
8037
- workspaceDir,
8038
- policy: decision.policy,
8809
+ const startInput = {
8810
+ manifest,
8811
+ instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
8812
+ env,
8039
8813
  ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {},
8040
- ...gitWorkspaceId ? { gitWorkspace: { workspaceId: gitWorkspaceId, baseline: gitBaseline } } : {},
8041
- // M5: no longer `process.env` verbatim (see `environment.ts`'s own
8042
- // module doc comment for the credential-leak gap that closed) —
8043
- // built fresh per task from the SPECIFIC adapter `pickAdapter`
8044
- // above already selected, so this always runs after adapter
8045
- // selection: `pick.adapter.environmentRequirements?.()` (undefined
8046
- // ⇒ platform baseline only, fail-closed) plus this device's own
8047
- // `runtimeEnvironment` override, keyed by that same adapter's `id`.
8048
- env: buildRuntimeEnv({
8049
- ambient: process.env,
8050
- requirements: pick.adapter.environmentRequirements?.(),
8051
- locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.adapter.id]?.allow
8052
- }),
8053
- // M4 Phase 3: adapter-agnostic and cheap to always populate — only an
8054
- // adapter whose runtime genuinely supports an out-of-band approval
8055
- // pause (claude, today) ever reads this. `resolve` is a closure over
8056
- // `taskId` (not a pre-bound approvalId): it looks up whichever
8057
- // approval is CURRENTLY pending for this task at call time, since one
8058
- // task/session can face several approval requests, one at a time,
8059
- // over its life. See `types.ts`'s `ApprovalChannel` doc comment.
8060
8814
  approvalChannel: {
8061
8815
  taskId,
8062
8816
  storeDir: this.deps.storeDir,
@@ -8072,45 +8826,19 @@ var TaskRunner = class {
8072
8826
  }
8073
8827
  }
8074
8828
  };
8075
- const effectiveOffer = {
8076
- ...withoutRequiredToolsets(payload),
8077
- instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
8078
- // Never forward a sessionRef this device has no recorded workspace
8079
- // for (stale, from another device, or simply made up) — an adapter
8080
- // that tries to resume an id it never minted fails outright (pi:
8081
- // "No session found matching '<id>'", exit 1, empirically confirmed)
8082
- // instead of silently starting fresh, so an unresolvable sessionRef
8083
- // must look identical to "none supplied" by the time it reaches the
8084
- // adapter, not get forwarded as a resume attempt doomed to fail.
8085
- sessionRef: known ? payload.sessionRef : void 0
8086
- };
8087
8829
  let session;
8088
8830
  try {
8089
- session = await pick.adapter.start(effectiveOffer, ctx);
8831
+ session = await prepared.operation.start(startInput);
8090
8832
  } catch (err) {
8091
- const retryable = !(err instanceof PolicyUnsupportedError);
8092
- await this.updateGitPhaseBestEffort(gitWorkspaceId, "failed", "repository-invalid");
8093
- gitLease?.release();
8094
- await this.fail(taskId, `adapter failed to start: ${errorMessage3(err)}`, retryable);
8095
- return;
8096
- }
8097
- if (this.pendingCancelled.has(taskId)) {
8098
- const reason = this.pendingCancelled.get(taskId);
8099
- this.pendingCancelled.delete(taskId);
8100
- try {
8101
- await session.interrupt();
8102
- } catch {
8103
- }
8104
- try {
8105
- await session.close();
8106
- } catch {
8833
+ const failure = projectRuntimeBoundaryFailure(err, "start");
8834
+ if (failure.contractViolation) {
8835
+ console.error("[byok/client] runtime adapter start() returned an untyped failure", err);
8107
8836
  }
8837
+ await this.updateGitPhaseBestEffort(gitWorkspaceId, "failed", "repository-invalid");
8108
8838
  gitLease?.release();
8109
- await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
8110
- this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
8839
+ await this.fail(taskId, failure.reason, failure.retryable);
8111
8840
  return;
8112
8841
  }
8113
- this.deps.send(createEnvelope("task.started", {}, { taskId }));
8114
8842
  const active = {
8115
8843
  taskId,
8116
8844
  adapter: pick.adapter,
@@ -8127,6 +8855,21 @@ var TaskRunner = class {
8127
8855
  approvalQueue: [],
8128
8856
  outputBytesSoFar: 0
8129
8857
  };
8858
+ if (this.pendingCancelled.has(taskId)) {
8859
+ const reason = this.pendingCancelled.get(taskId);
8860
+ this.pendingCancelled.delete(taskId);
8861
+ this.tasks.set(taskId, active);
8862
+ this.reserveSemanticTerminal(active);
8863
+ try {
8864
+ await session.interrupt();
8865
+ } catch {
8866
+ }
8867
+ await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
8868
+ this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
8869
+ await this.finish(taskId);
8870
+ return;
8871
+ }
8872
+ this.deps.send(createEnvelope("task.started", {}, { taskId }));
8130
8873
  this.tasks.set(taskId, active);
8131
8874
  if (payload.limits?.maxDurationMs !== void 0) {
8132
8875
  this.armMaxDurationTimer(active, payload.limits.maxDurationMs);
@@ -8184,6 +8927,7 @@ var TaskRunner = class {
8184
8927
  async pump(active) {
8185
8928
  try {
8186
8929
  for await (const event of active.session.events) {
8930
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8187
8931
  if (this.tasks.get(active.taskId) !== active) return;
8188
8932
  active.outputBytesSoFar += estimateEventBytes(event);
8189
8933
  if (active.outputBytesSoFar > this.maxTaskOutputBytes) {
@@ -8206,7 +8950,7 @@ var TaskRunner = class {
8206
8950
  try {
8207
8951
  await active.session.resolveApproval(approved, reason);
8208
8952
  } catch (err) {
8209
- await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage3(err)}`, false);
8953
+ await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage4(err)}`, false);
8210
8954
  }
8211
8955
  });
8212
8956
  continue;
@@ -8218,6 +8962,7 @@ var TaskRunner = class {
8218
8962
  const outcome = await this.resolveResultDocument(active, finalOutput);
8219
8963
  if (!outcome.deliver) return;
8220
8964
  await this.observeGit(active, "completed");
8965
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8221
8966
  if (outcome.document !== void 0 && !this.hasResultDocumentCapability()) {
8222
8967
  await this.fail(
8223
8968
  active.taskId,
@@ -8226,6 +8971,7 @@ var TaskRunner = class {
8226
8971
  );
8227
8972
  return;
8228
8973
  }
8974
+ if (!this.reserveSemanticTerminal(active)) return;
8229
8975
  this.deps.send(
8230
8976
  createEnvelope(
8231
8977
  "task.complete",
@@ -8261,11 +9007,17 @@ var TaskRunner = class {
8261
9007
  active.batcher.push(event);
8262
9008
  }
8263
9009
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8264
- await this.fail(active.taskId, "runtime session ended without completing the task", true);
9010
+ const failure = projectRuntimeBoundaryFailure(void 0, "run");
9011
+ console.error("[byok/client] runtime adapter events iterable ended without terminal authority");
9012
+ await this.fail(active.taskId, failure.reason, failure.retryable);
8265
9013
  } catch (err) {
8266
9014
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8267
9015
  active.batcher.flush();
8268
- await this.fail(active.taskId, `runtime error: ${errorMessage3(err)}`, true);
9016
+ const failure = projectRuntimeBoundaryFailure(err, "run");
9017
+ if (failure.contractViolation) {
9018
+ console.error("[byok/client] runtime adapter events iterable returned an untyped failure", err);
9019
+ }
9020
+ await this.fail(active.taskId, failure.reason, failure.retryable);
8269
9021
  }
8270
9022
  }
8271
9023
  /**
@@ -8304,7 +9056,7 @@ var TaskRunner = class {
8304
9056
  try {
8305
9057
  bytes = await opened.handle.readFile();
8306
9058
  } catch (err) {
8307
- this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage3(err)}`);
9059
+ this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage4(err)}`);
8308
9060
  return;
8309
9061
  } finally {
8310
9062
  await opened.handle.close().catch(() => {
@@ -8321,7 +9073,7 @@ var TaskRunner = class {
8321
9073
  const blobRef = await this.deps.blobClient.uploadArtifact(bytes, contentType);
8322
9074
  this.deps.send(createEnvelope("task.artifact", { name, contentType, blobRef }, { taskId: active.taskId }));
8323
9075
  } catch (err) {
8324
- this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage3(err)}`);
9076
+ this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage4(err)}`);
8325
9077
  }
8326
9078
  }
8327
9079
  /** Loud, non-silent artifact failure (finding F7): logged, and folded into this task's own progress stream as an `error` AgentEvent rather than swallowed — the task itself can still complete normally, but the omission is now visible. */
@@ -8335,6 +9087,14 @@ var TaskRunner = class {
8335
9087
  this.setPendingCancelled(taskId, reason);
8336
9088
  return;
8337
9089
  }
9090
+ if (active.finalizationStarted) {
9091
+ await this.finish(taskId);
9092
+ return;
9093
+ }
9094
+ if (!this.reserveSemanticTerminal(active)) {
9095
+ await active.semanticTerminalSettled;
9096
+ return;
9097
+ }
8338
9098
  try {
8339
9099
  await active.session.interrupt();
8340
9100
  } catch {
@@ -8363,7 +9123,7 @@ var TaskRunner = class {
8363
9123
  *
8364
9124
  * `inFlightOffers` is naturally tiny (bounded by this device's real
8365
9125
  * concurrent-offer-processing count — normally single digits, driven by
8366
- * how many `task.offer`s are simultaneously mid-`adapter.start()` — nowhere
9126
+ * how many `task.offer`s are simultaneously mid-prepared-operation start() — nowhere
8367
9127
  * near `MAX_TRACKED_TASK_IDS`), so this scan is cheap in practice: it
8368
9128
  * finds a safe entry at or near the front almost always. The only case
8369
9129
  * where NO entry is safe to evict is every single tracked cancel
@@ -8716,7 +9476,7 @@ var TaskRunner = class {
8716
9476
  this.deps.onStaleApprovalDecision?.(taskId, "approve");
8717
9477
  return;
8718
9478
  }
8719
- await this.fail(taskId, `failed to resume session after approval: ${errorMessage3(err)}`, false);
9479
+ await this.fail(taskId, `failed to resume session after approval: ${errorMessage4(err)}`, false);
8720
9480
  return;
8721
9481
  }
8722
9482
  this.clearPendingApproval(resolvedId, "approve", void 0);
@@ -8747,6 +9507,10 @@ var TaskRunner = class {
8747
9507
  async handleReject(taskId, reason, approvalId) {
8748
9508
  const active = this.tasks.get(taskId);
8749
9509
  if (!active) return;
9510
+ if (active.finalizationStarted) {
9511
+ await this.finish(taskId);
9512
+ return;
9513
+ }
8750
9514
  if (approvalId !== void 0 && approvalId !== active.pendingApprovalId) {
8751
9515
  this.deps.onStaleApprovalDecision?.(
8752
9516
  taskId,
@@ -8765,6 +9529,10 @@ var TaskRunner = class {
8765
9529
  }
8766
9530
  }
8767
9531
  this.clearPendingApproval(resolvedId, "reject", reason);
9532
+ if (!this.reserveSemanticTerminal(active)) {
9533
+ await active.semanticTerminalSettled;
9534
+ return;
9535
+ }
8768
9536
  try {
8769
9537
  await active.session.interrupt();
8770
9538
  } catch {
@@ -8779,6 +9547,14 @@ var TaskRunner = class {
8779
9547
  }
8780
9548
  async fail(taskId, reason, retryable) {
8781
9549
  const active = this.tasks.get(taskId);
9550
+ if (active?.finalizationStarted) {
9551
+ await this.finish(taskId);
9552
+ return;
9553
+ }
9554
+ if (active && !this.reserveSemanticTerminal(active)) {
9555
+ await active.semanticTerminalSettled;
9556
+ return;
9557
+ }
8782
9558
  if (active) await this.observeGit(active, "salvage");
8783
9559
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId }));
8784
9560
  await this.finish(taskId);
@@ -8838,7 +9614,7 @@ var TaskRunner = class {
8838
9614
  } catch (err) {
8839
9615
  await this.fail(
8840
9616
  active.taskId,
8841
- `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage3(err)}`,
9617
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage4(err)}`,
8842
9618
  false
8843
9619
  );
8844
9620
  return { deliver: false };
@@ -8913,32 +9689,64 @@ var TaskRunner = class {
8913
9689
  }
8914
9690
  async finish(taskId) {
8915
9691
  const active = this.tasks.get(taskId);
8916
- if (!active) return;
8917
- if (active.maxDurationTimer) {
8918
- clearTimeout(active.maxDurationTimer);
8919
- active.maxDurationTimer = void 0;
8920
- }
8921
- active.batcher.stop();
8922
- this.tasks.delete(taskId);
8923
- this.addFinishedTaskId(taskId);
8924
- const queued = active.approvalQueue.splice(0);
8925
- for (const request of queued) {
8926
- request.resolve({
8927
- approved: false,
8928
- reason: `task ${taskId} finished before this queued approval request could be dispatched`
8929
- });
8930
- }
8931
- if (active.pendingApprovalId !== void 0) {
8932
- try {
8933
- this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
8934
- } catch {
9692
+ if (!active) return true;
9693
+ if (!active.finalizationStarted) {
9694
+ active.finalizationStarted = true;
9695
+ active.beingTornDown = true;
9696
+ if (active.maxDurationTimer) {
9697
+ clearTimeout(active.maxDurationTimer);
9698
+ active.maxDurationTimer = void 0;
9699
+ }
9700
+ active.batcher.stop();
9701
+ this.addFinishedTaskId(taskId);
9702
+ const queued = active.approvalQueue.splice(0);
9703
+ for (const request of queued) {
9704
+ request.resolve({
9705
+ approved: false,
9706
+ reason: `task ${taskId} finished before this queued approval request could be dispatched`
9707
+ });
9708
+ }
9709
+ if (active.pendingApprovalId !== void 0) {
9710
+ try {
9711
+ this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
9712
+ } catch {
9713
+ }
8935
9714
  }
8936
9715
  }
8937
- if (active.gitLease) active.gitLease.release();
9716
+ const attempt = active.disposalAttempt ?? active.session.close();
9717
+ active.disposalAttempt = attempt;
8938
9718
  try {
8939
- await active.session.close();
8940
- } catch {
9719
+ await attempt;
9720
+ } catch (caught) {
9721
+ if (active.disposalAttempt === attempt) active.disposalAttempt = void 0;
9722
+ const failure = isRuntimeDisposalFailure(caught) ? caught : new RuntimeDisposalFailure({
9723
+ stage: "quiescence",
9724
+ reason: `${active.adapter.descriptor.id} session.close() returned an untyped disposal failure`
9725
+ }, { cause: caught });
9726
+ console.error(`[byok/client] runtime disposal failed for task ${taskId}: ${failure.message}`);
9727
+ this.deps.onRuntimeDisposalFailure?.({
9728
+ taskId,
9729
+ runtimeId: active.adapter.descriptor.id,
9730
+ stage: failure.stage,
9731
+ reason: failure.message
9732
+ });
9733
+ active.resolveSemanticTerminalSettled?.(false);
9734
+ return false;
8941
9735
  }
9736
+ if (this.tasks.get(taskId) !== active) return true;
9737
+ active.gitLease?.release();
9738
+ this.tasks.delete(taskId);
9739
+ active.resolveSemanticTerminalSettled?.(true);
9740
+ return true;
9741
+ }
9742
+ reserveSemanticTerminal(active) {
9743
+ if (active.semanticTerminalReserved || active.finalizationStarted) return false;
9744
+ active.semanticTerminalReserved = true;
9745
+ active.beingTornDown = true;
9746
+ active.semanticTerminalSettled = new Promise((resolve) => {
9747
+ active.resolveSemanticTerminalSettled = resolve;
9748
+ });
9749
+ return true;
8942
9750
  }
8943
9751
  /** M3-B: bounded insert for `finishedTaskIds` — see its class-level doc comment and `MAX_TRACKED_TASK_IDS`. Evicts the oldest (first-inserted) entry once over cap, same idiom as `ConnectionHub.checkAndRecordDuplicate` (packages/server/src/hub.ts). */
8944
9752
  addFinishedTaskId(taskId) {
@@ -8991,18 +9799,19 @@ var TaskRunner = class {
8991
9799
  retryable: false
8992
9800
  };
8993
9801
  }
8994
- const adapter = this.deps.adapters.find((a) => a.id === requestedRuntime);
9802
+ const adapter = this.deps.adapters.find((a) => a.descriptor.id === requestedRuntime);
8995
9803
  if (!adapter) {
8996
9804
  return { ok: false, reason: `unknown runtime "${requestedRuntime}"`, retryable: false };
8997
9805
  }
8998
- if (!adapterSupportsMode(adapter, policyMode)) {
9806
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
9807
+ if (!adapterSupportsMode(descriptor, policyMode)) {
8999
9808
  return {
9000
9809
  ok: false,
9001
9810
  reason: `runtime "${requestedRuntime}" cannot express permission mode "${policyMode}"`,
9002
9811
  retryable: false
9003
9812
  };
9004
9813
  }
9005
- if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) {
9814
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) {
9006
9815
  return {
9007
9816
  ok: false,
9008
9817
  reason: `runtime "${requestedRuntime}" cannot project required MCP toolsets`,
@@ -9017,15 +9826,16 @@ var TaskRunner = class {
9017
9826
  retryable: true
9018
9827
  };
9019
9828
  }
9020
- return { ok: true, adapter };
9829
+ return { ok: true, adapter, descriptor };
9021
9830
  }
9022
- const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.id)) : this.deps.adapters;
9831
+ const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.descriptor.id)) : this.deps.adapters;
9023
9832
  const candidates = orderByPreference(eligible, this.deps.runtimePreference ?? DEFAULT_RUNTIME_PREFERENCE);
9024
9833
  for (const adapter of candidates) {
9025
- if (!adapterSupportsMode(adapter, policyMode)) continue;
9026
- if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) continue;
9834
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
9835
+ if (!adapterSupportsMode(descriptor, policyMode)) continue;
9836
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) continue;
9027
9837
  const detected = await adapter.detect();
9028
- if (detected.present) return { ok: true, adapter };
9838
+ if (detected.present) return { ok: true, adapter, descriptor };
9029
9839
  }
9030
9840
  return {
9031
9841
  ok: false,
@@ -9070,27 +9880,27 @@ async function detectRuntimes(adapters) {
9070
9880
  const detections = await Promise.all(adapters.map(async (adapter) => ({ adapter, detected: await adapter.detect() })));
9071
9881
  const runtimes = [];
9072
9882
  for (const { adapter, detected } of detections) {
9073
- if (!detected.present || !isRuntimeId(adapter.id)) continue;
9074
- const info = { id: adapter.id };
9883
+ if (!detected.present || !isRuntimeId(adapter.descriptor.id)) continue;
9884
+ const info = { id: adapter.descriptor.id };
9075
9885
  if (detected.version !== void 0) info.version = detected.version;
9076
9886
  if (detected.authPresent !== void 0) info.authPresent = detected.authPresent;
9077
- info.capabilities = toRuntimeInfoCapabilities(adapter.capabilities());
9887
+ info.capabilities = toRuntimeInfoCapabilities(adapter.descriptor.capabilities);
9078
9888
  runtimes.push(info);
9079
9889
  }
9080
9890
  return runtimes;
9081
9891
  }
9082
9892
  function computeCapabilities(adapters) {
9083
9893
  const flags = [];
9084
- if (adapters.some((adapter) => adapter.capabilities().steer)) flags.push("steer");
9894
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.steer)) flags.push("steer");
9085
9895
  flags.push("blob-upload");
9086
9896
  flags.push("approval-targeting");
9087
9897
  const selectionAdapters = adapters.filter(
9088
- (adapter) => ALL_RUNTIME_IDS.includes(adapter.id)
9898
+ (adapter) => ALL_RUNTIME_IDS.includes(adapter.descriptor.id)
9089
9899
  );
9090
- if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.supportsDispatchSelection === true)) {
9900
+ if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.descriptor.supportsDispatchSelection === true)) {
9091
9901
  flags.push("dispatch-selection");
9092
9902
  }
9093
- if (adapters.some((adapter) => adapter.capabilities().mcpToolsets === true)) {
9903
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.mcpToolsets === true)) {
9094
9904
  flags.push("toolset-selection");
9095
9905
  }
9096
9906
  return flags;
@@ -9150,7 +9960,6 @@ function validatePiByokLauncherConfig(launcher) {
9150
9960
  throw new Error("DaemonConfig.piByokLauncher.args must contain only non-empty single-line strings");
9151
9961
  }
9152
9962
  }
9153
- var MAX_LOCAL_MCP_TOOLSETS = 64;
9154
9963
  var MAX_LOCAL_MCP_SERVERS_PER_TOOLSET = 16;
9155
9964
  var MAX_LOCAL_MCP_ARGS = 64;
9156
9965
  var MAX_LOCAL_MCP_TOKEN_CHARS = 4096;
@@ -9163,8 +9972,10 @@ function resolveMcpToolsets(configured) {
9163
9972
  throw new Error("DaemonConfig.mcpToolsets must be an object keyed by logical toolset id");
9164
9973
  }
9165
9974
  const toolsetEntries = Object.entries(configured);
9166
- if (toolsetEntries.length > MAX_LOCAL_MCP_TOOLSETS) {
9167
- throw new Error(`DaemonConfig.mcpToolsets may contain at most ${MAX_LOCAL_MCP_TOOLSETS} toolsets`);
9975
+ if (toolsetEntries.length > CONFIGURED_TOOLSETS_MAX_ITEMS) {
9976
+ throw new Error(
9977
+ `DaemonConfig.mcpToolsets may contain at most ${CONFIGURED_TOOLSETS_MAX_ITEMS} toolsets`
9978
+ );
9168
9979
  }
9169
9980
  const resolved = /* @__PURE__ */ new Map();
9170
9981
  for (const [toolsetId, rawToolset] of toolsetEntries) {
@@ -9277,6 +10088,9 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9277
10088
  }
9278
10089
  function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProbe) {
9279
10090
  const mcpToolsets = resolveMcpToolsets(config.mcpToolsets);
10091
+ const configuredToolsets = Object.freeze(
10092
+ [...mcpToolsets?.keys() ?? []].sort()
10093
+ );
9280
10094
  if (config.piByokLauncher !== void 0) {
9281
10095
  validatePiByokLauncherConfig(config.piByokLauncher);
9282
10096
  }
@@ -9583,12 +10397,13 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9583
10397
  dirty: event.observation ? { staged: event.observation.staged, unstaged: event.observation.unstaged, untracked: event.observation.untracked, conflicted: event.observation.conflicted } : void 0,
9584
10398
  errorCategory: event.errorCategory
9585
10399
  }),
10400
+ onRuntimeDisposalFailure: (event) => observer.noteRuntimeDisposalFailure(event),
9586
10401
  // M4 Phase 3: the SAME `ApprovalRegistry` instance the control
9587
10402
  // socket's own `approvals.list`/`approvals.resolve` methods already
9588
10403
  // share (see that field's own construction above) — `TaskRunner
9589
10404
  // .requestApproval` registers into it directly, so a decision arriving
9590
10405
  // via either the server wire or the local CLI resolves the identical
9591
- // entry. `storeDir`/`productId` let `TaskContext.approvalChannel`
10406
+ // entry. `storeDir`/`productId` let the prepared operation approval channel
9592
10407
  // (populated per-task by `TaskRunner`) tell an out-of-process helper
9593
10408
  // (`bin/byok-approval-mcp.ts`) exactly which control socket to dial.
9594
10409
  approvalRegistry,
@@ -9640,6 +10455,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9640
10455
  productId: config.productId,
9641
10456
  capabilities,
9642
10457
  runtimes,
10458
+ configuredToolsets,
9643
10459
  auth,
9644
10460
  cursorStore,
9645
10461
  // Finding F3: return (not void-and-forget) so ConnectionManager can
@@ -9726,6 +10542,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9726
10542
  presencePublisher ??= new PresencePublisher({
9727
10543
  serverUrl: config.serverUrl,
9728
10544
  auth,
10545
+ configuredToolsets,
9729
10546
  ...presenceCadence,
9730
10547
  onDegraded: (reason) => console.warn(`[byok/client] ${reason}`)
9731
10548
  });
@@ -9819,8 +10636,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9819
10636
  await attempt(() => auth.stop(), true);
9820
10637
  connectionState = "closed";
9821
10638
  await attempt(async () => {
9822
- await controlServerHandle?.close();
9823
- controlServerHandle = void 0;
10639
+ await controlServerHandle?.stopServing();
9824
10640
  }, true);
9825
10641
  if (mutationBarrierComplete && daemonOwnerLease) {
9826
10642
  await attempt(() => operationalHealth.markCleanStop(), false);
@@ -9829,6 +10645,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9829
10645
  daemonOwnerLease = void 0;
9830
10646
  }, false);
9831
10647
  }
10648
+ if (daemonOwnerLease === void 0) {
10649
+ await attempt(async () => {
10650
+ await controlServerHandle?.close();
10651
+ controlServerHandle = void 0;
10652
+ }, false);
10653
+ }
9832
10654
  if (errors.length === 1) throw errors[0];
9833
10655
  if (errors.length > 1) {
9834
10656
  throw new AggregateError(errors, "daemon shutdown completed with errors");
@@ -9908,7 +10730,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9908
10730
  deviceId: auth.deviceId,
9909
10731
  transport: connectionState,
9910
10732
  activeTasks,
9911
- runtimeIds: adapters.map((adapter) => adapter.id),
10733
+ runtimeIds: adapters.map((adapter) => adapter.descriptor.id),
9912
10734
  // M4 Phase 4 (part B.3): queue watermarks come from TaskRunner's own
9913
10735
  // active-task map (distinct from `observer.tasks()` above, which is
9914
10736
  // derived from the envelope feed) — see `TaskRunner.getQueueWatermarks`'s
@@ -10116,7 +10938,7 @@ function createDaemon(config) {
10116
10938
  return createDaemonWithAdapters(config, buildDefaultAdapters(config));
10117
10939
  }
10118
10940
  var MAX_CONTROL_TOKEN_BYTES = 256;
10119
- function errorMessage4(err) {
10941
+ function errorMessage5(err) {
10120
10942
  return err instanceof Error ? err.message : String(err);
10121
10943
  }
10122
10944
  function sameFileState3(left, right) {
@@ -10169,7 +10991,7 @@ async function connectControlClient(opts) {
10169
10991
  }
10170
10992
  token = read;
10171
10993
  } catch (err) {
10172
- return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
10994
+ return { ok: false, reason: `could not read the control token: ${errorMessage5(err)}` };
10173
10995
  }
10174
10996
  if (!token) {
10175
10997
  return { ok: false, reason: "control token file is empty" };
@@ -10179,7 +11001,7 @@ async function connectControlClient(opts) {
10179
11001
  const client = await connectAndHandshake(endpoint, token, opts);
10180
11002
  return { ok: true, client };
10181
11003
  } catch (err) {
10182
- return { ok: false, reason: `daemon control socket not reachable: ${errorMessage4(err)}` };
11004
+ return { ok: false, reason: `daemon control socket not reachable: ${errorMessage5(err)}` };
10183
11005
  }
10184
11006
  }
10185
11007
  function connectAndHandshake(endpoint, token, opts) {
@@ -10356,7 +11178,7 @@ function createControlClient(socket, reader, opts) {
10356
11178
  }
10357
11179
 
10358
11180
  // src/daemon/assertion-client.ts
10359
- function errorMessage5(err) {
11181
+ function errorMessage6(err) {
10360
11182
  return err instanceof Error ? err.message : String(err);
10361
11183
  }
10362
11184
  async function requestDeviceAssertion(options) {
@@ -10378,7 +11200,7 @@ async function requestDeviceAssertion(options) {
10378
11200
  return { ok: true, assertion, expiresAt: result.expiresAt };
10379
11201
  } catch (err) {
10380
11202
  if (err instanceof ControlError) return { ok: false, code: err.code, reason: err.message };
10381
- return { ok: false, code: "bad_response", reason: errorMessage5(err) };
11203
+ return { ok: false, code: "bad_response", reason: errorMessage6(err) };
10382
11204
  } finally {
10383
11205
  connected.client.close();
10384
11206
  }
@@ -11614,6 +12436,6 @@ function createServiceLifecycle(def, opts = {}) {
11614
12436
  }
11615
12437
  }
11616
12438
 
11617
- export { AuthManager, BlobClient, ClaudeAdapter, CodexAdapter, DEFAULT_ACK_CRITICAL_RESERVE_BYTES, DEFAULT_CLEANUP_BATCH_LIMIT, DEFAULT_HARD_BUDGET_RATIO, DEFAULT_INCREMENTAL_VACUUM_PAGES, DEFAULT_LOG_ROTATION, DEFAULT_NORMAL_COMPACTION_INTERVAL_MS, DEFAULT_PRESSURE_COMPACTION_INTERVAL_MS, DEFAULT_RETENTION_MS, DEFAULT_SOFT_BUDGET_RATIO, DaemonObserver, DeviceRevokedError, GitWorkspaceError, GitWorkspaceManager, GitWorkspaceStore, JOURNAL_DB_FILENAME, JOURNAL_QUARANTINE_DIRNAME, JOURNAL_TASK_REF_PREFIX, JournalClosedError, JournalCorruptError, JournalRecordTooLargeError, JournalUnavailableError, JournalUnknownTaskError, LocalStorageEmergencyError, LocalStoragePolicyError, LocalStoragePressureEngine, PI_PACKAGE_NAME, PiAdapter, PolicyUnsupportedError, SKILL_PACKS_CAPABILITY, SKILL_PACKS_DIRNAME, SKILL_PACK_AUDIT_FILENAME, SKILL_PACK_INSTALL_ERROR_CODES, SKILL_PACK_LOCK_FILENAME, SKILL_PACK_LOCK_SCHEMA, SKILL_PACK_RESPONSE_MAX_BYTES, SecureDirHardeningError, SkillPackInstallError, SqliteLocalTaskJournal, SteerUnsupportedError, StoredDeviceProofSigner, TruthMemoryClient, TruthMemoryClientError, UnsupportedServicePlatformError, buildIcaclsArgs, cleanupEligibleAt, cleanupOrderFor, computePressureState, createDaemon, createDaemonWithAdapters, createFilesystemCleanupExecutor, createServiceLifecycle, createStatfsFreeBytesProvider, ensureSecureDir, generateLaunchdPlist, generateSystemdUnit, generateWinswXml, installSkillPacks, isGitWorkspaceConfig, journalHash, listInstalledSkillPacks, nodeAgentProgram, prependGitWorkspaceGuidance, projectSkillPack, requestDeviceAssertion, resolveLocalStoragePolicy, sanitizeServiceName, skillPacksRoot };
12439
+ export { AuthManager, BlobClient, ClaudeAdapter, CodexAdapter, DEFAULT_ACK_CRITICAL_RESERVE_BYTES, DEFAULT_CLEANUP_BATCH_LIMIT, DEFAULT_HARD_BUDGET_RATIO, DEFAULT_INCREMENTAL_VACUUM_PAGES, DEFAULT_LOG_ROTATION, DEFAULT_NORMAL_COMPACTION_INTERVAL_MS, DEFAULT_PRESSURE_COMPACTION_INTERVAL_MS, DEFAULT_RETENTION_MS, DEFAULT_SOFT_BUDGET_RATIO, DaemonObserver, DeviceRevokedError, GitWorkspaceError, GitWorkspaceManager, GitWorkspaceStore, JOURNAL_DB_FILENAME, JOURNAL_QUARANTINE_DIRNAME, JOURNAL_TASK_REF_PREFIX, JournalClosedError, JournalCorruptError, JournalRecordTooLargeError, JournalUnavailableError, JournalUnknownTaskError, LocalStorageEmergencyError, LocalStoragePolicyError, LocalStoragePressureEngine, PI_PACKAGE_NAME, PiAdapter, PolicyUnsupportedError, RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON, RuntimeDisposalFailure, RuntimeExecutionFailure, SKILL_PACKS_CAPABILITY, SKILL_PACKS_DIRNAME, SKILL_PACK_AUDIT_FILENAME, SKILL_PACK_INSTALL_ERROR_CODES, SKILL_PACK_LOCK_FILENAME, SKILL_PACK_LOCK_SCHEMA, SKILL_PACK_RESPONSE_MAX_BYTES, SecureDirHardeningError, SkillPackInstallError, SqliteLocalTaskJournal, SteerUnsupportedError, StoredDeviceProofSigner, TruthMemoryClient, TruthMemoryClientError, UnsupportedServicePlatformError, buildIcaclsArgs, cleanupEligibleAt, cleanupOrderFor, computePressureState, createDaemon, createDaemonWithAdapters, createFilesystemCleanupExecutor, createServiceLifecycle, createStatfsFreeBytesProvider, ensureSecureDir, freezeRuntimeAdapterDescriptor, generateLaunchdPlist, generateSystemdUnit, generateWinswXml, installSkillPacks, isGitWorkspaceConfig, isRuntimeDisposalFailure, isRuntimeExecutionFailure, journalHash, listInstalledSkillPacks, nodeAgentProgram, prependGitWorkspaceGuidance, projectRuntimeBoundaryFailure, projectRuntimeExecutionFailure, projectSkillPack, requestDeviceAssertion, resolveLocalStoragePolicy, sanitizeServiceName, sealRuntimeOperationManifest, skillPacksRoot };
11618
12440
  //# sourceMappingURL=index.js.map
11619
12441
  //# sourceMappingURL=index.js.map