@byok-sdk/client 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +14 -1
  2. package/dist/adapters/claude/claude-adapter.d.ts +4 -20
  3. package/dist/adapters/claude/events.d.ts +3 -0
  4. package/dist/adapters/claude/process-client.d.ts +15 -1
  5. package/dist/adapters/codex/codex-adapter.d.ts +4 -16
  6. package/dist/adapters/codex/process-runner.d.ts +9 -1
  7. package/dist/adapters/index.d.ts +3 -1
  8. package/dist/adapters/index.js +1057 -261
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/adapters/pi/pi-adapter.d.ts +3 -16
  11. package/dist/adapters/pi/resolve-bin.d.ts +1 -1
  12. package/dist/adapters/pi/rpc-client.d.ts +15 -1
  13. package/dist/adapters/process-tree.d.ts +60 -0
  14. package/dist/adapters/taskkill-pid-set.d.ts +34 -0
  15. package/dist/bin/audit-log.d.ts +12 -0
  16. package/dist/bin/byok-agent.js +1452 -507
  17. package/dist/bin/byok-agent.js.map +1 -1
  18. package/dist/bin/byok-approval-mcp.js.map +1 -1
  19. package/dist/bin/commands/workspaces.d.ts +11 -0
  20. package/dist/bin/format.d.ts +13 -0
  21. package/dist/bin/runtime-probe.d.ts +1 -1
  22. package/dist/bin/tasks-view.d.ts +13 -0
  23. package/dist/daemon/approvals.d.ts +2 -2
  24. package/dist/daemon/connection-manager.d.ts +15 -17
  25. package/dist/daemon/control-server.d.ts +18 -1
  26. package/dist/daemon/create-daemon.d.ts +2 -2
  27. package/dist/daemon/daemon-owner.d.ts +4 -2
  28. package/dist/daemon/environment.d.ts +9 -9
  29. package/dist/daemon/git-workspace.d.ts +21 -0
  30. package/dist/daemon/long-poll-transport.d.ts +6 -0
  31. package/dist/daemon/observer.d.ts +13 -0
  32. package/dist/daemon/presence-publisher.d.ts +29 -0
  33. package/dist/daemon/runtime-capabilities.d.ts +1 -1
  34. package/dist/daemon/task-runner.d.ts +34 -40
  35. package/dist/daemon/ws-transport.d.ts +3 -1
  36. package/dist/index.d.ts +4 -2
  37. package/dist/index.js +1413 -455
  38. package/dist/index.js.map +1 -1
  39. package/dist/runtime-failure.d.ts +64 -0
  40. package/dist/types.d.ts +100 -73
  41. package/package.json +14 -14
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { execFile, spawn, spawnSync } from 'child_process';
2
+ import { execFile, spawn } from 'child_process';
3
3
  import { randomUUID, createHash, randomBytes, timingSafeEqual, createHmac, createPrivateKey, generateKeyPairSync, sign } from 'crypto';
4
4
  import { readFileSync, promises, linkSync, fstatSync, lstatSync, unlinkSync, constants, readSync, openSync, writeFileSync, fchmodSync, fsyncSync, closeSync, opendirSync, existsSync, realpathSync, mkdirSync, renameSync, chmodSync, statSync, readdirSync } from 'fs';
5
5
  import path20, { isAbsolute, join } from 'path';
6
6
  import os from 'os';
7
7
  import { DEVICE_ASSERTION_AUDIENCE_MAX_BYTES, DEVICE_ASSERTION_DEFAULT_TTL_MS, DEVICE_ASSERTION_MAX_TTL_MS, nonceSigningBytes, CapabilityDeclarationSchema, hasCapability, DeviceAssertionClaimsSchema, deviceAssertionSigningInput, DEVICE_ASSERTION_SCHEMA_ID } from '@byok-sdk/core';
8
- import { TASK_STATES, ToolsetIdSchema, BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, createEnvelope, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, 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
+ import { TASK_STATES, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, createEnvelope, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, 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';
9
9
  import { promisify } from 'util';
10
10
  import { fileURLToPath } from 'url';
11
11
  import 'readline';
@@ -15,6 +15,52 @@ import { createRequire } from 'module';
15
15
  import { createInterface } from 'readline/promises';
16
16
 
17
17
  // src/types.ts
18
+ function frozenStrings(values) {
19
+ return values === void 0 ? void 0 : Object.freeze([...values]);
20
+ }
21
+ function frozenPolicy(policy) {
22
+ const allowTools = policy.allowTools === void 0 ? void 0 : Object.freeze([...policy.allowTools]);
23
+ const denyTools = policy.denyTools === void 0 ? void 0 : Object.freeze([...policy.denyTools]);
24
+ return Object.freeze({
25
+ mode: policy.mode,
26
+ ...allowTools === void 0 ? {} : { allowTools },
27
+ ...denyTools === void 0 ? {} : { denyTools },
28
+ ...policy.workspaceRoot === void 0 ? {} : { workspaceRoot: policy.workspaceRoot },
29
+ ...policy.network === void 0 ? {} : { network: policy.network }
30
+ });
31
+ }
32
+ function freezeRuntimeAdapterDescriptor(descriptor) {
33
+ const baseNames = frozenStrings(descriptor.environmentRequirements.baseNames);
34
+ const credentialNames = frozenStrings(descriptor.environmentRequirements.credentialNames);
35
+ return Object.freeze({
36
+ id: descriptor.id,
37
+ supportsDispatchSelection: descriptor.supportsDispatchSelection === true,
38
+ capabilities: Object.freeze({
39
+ steer: descriptor.capabilities.steer === true,
40
+ resume: descriptor.capabilities.resume === true,
41
+ approvalInteractive: descriptor.capabilities.approvalInteractive === true,
42
+ ...descriptor.capabilities.mcpToolsets === void 0 ? {} : { mcpToolsets: descriptor.capabilities.mcpToolsets === true },
43
+ permissionModes: Object.freeze([...descriptor.capabilities.permissionModes])
44
+ }),
45
+ environmentRequirements: Object.freeze({
46
+ ...baseNames === void 0 ? {} : { baseNames },
47
+ ...credentialNames === void 0 ? {} : { credentialNames }
48
+ })
49
+ });
50
+ }
51
+ function sealRuntimeOperationManifest(manifest) {
52
+ return Object.freeze({
53
+ taskId: manifest.taskId,
54
+ runtimeId: manifest.runtimeId,
55
+ descriptor: freezeRuntimeAdapterDescriptor(manifest.descriptor),
56
+ policy: frozenPolicy(manifest.policy),
57
+ requiredToolsetIds: Object.freeze([...manifest.requiredToolsetIds]),
58
+ ...manifest.dispatchSelection === void 0 ? {} : { dispatchSelection: Object.freeze({ ...manifest.dispatchSelection }) },
59
+ ...manifest.sessionRef === void 0 ? {} : { sessionRef: manifest.sessionRef },
60
+ workspace: Object.freeze({ ...manifest.workspace }),
61
+ forwardedEnvironmentNames: Object.freeze([...manifest.forwardedEnvironmentNames])
62
+ });
63
+ }
18
64
  var PolicyUnsupportedError = class extends Error {
19
65
  constructor(message) {
20
66
  super(message);
@@ -22,7 +68,7 @@ var PolicyUnsupportedError = class extends Error {
22
68
  }
23
69
  };
24
70
  var SteerUnsupportedError = class extends Error {
25
- /** The `RuntimeAdapter.id` that cannot steer (e.g. `claude`, `codex`). */
71
+ /** The `RuntimeAdapter.descriptor.id` that cannot steer (e.g. `claude`, `codex`). */
26
72
  runtimeId;
27
73
  constructor(runtimeId, message) {
28
74
  super(message);
@@ -30,6 +76,112 @@ var SteerUnsupportedError = class extends Error {
30
76
  this.runtimeId = runtimeId;
31
77
  }
32
78
  };
79
+
80
+ // src/runtime-failure.ts
81
+ var RUNTIME_EXECUTION_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeExecutionFailure/v1");
82
+ var RUNTIME_DISPOSAL_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeDisposalFailure/v1");
83
+ var RuntimeDisposalFailure = class extends Error {
84
+ stage;
85
+ constructor(input, options) {
86
+ if (!isRuntimeDisposalStage(input.stage) || typeof input.reason !== "string" || input.reason.length === 0) {
87
+ throw new TypeError("invalid RuntimeDisposalFailure input");
88
+ }
89
+ super(input.reason, options);
90
+ this.name = "RuntimeDisposalFailure";
91
+ this.stage = input.stage;
92
+ Object.defineProperty(this, RUNTIME_DISPOSAL_FAILURE_BRAND, { value: true });
93
+ Object.freeze(this);
94
+ }
95
+ };
96
+ function isRuntimeDisposalStage(value) {
97
+ return value === "signal" || value === "quiescence" || value === "cleanup";
98
+ }
99
+ function isRuntimeDisposalFailure(value) {
100
+ if (typeof value !== "object" || value === null) return false;
101
+ const candidate = value;
102
+ return candidate[RUNTIME_DISPOSAL_FAILURE_BRAND] === true && isRuntimeDisposalStage(candidate.stage) && typeof candidate.message === "string" && candidate.message.length > 0;
103
+ }
104
+ var RuntimeExecutionFailure = class extends Error {
105
+ phase;
106
+ category;
107
+ retry;
108
+ constructor(input, options) {
109
+ if (!isRuntimeFailurePhase(input.phase) || !isRuntimeFailureCategory(input.category) || !isRuntimeRetryDisposition(input.retry) || typeof input.reason !== "string" || input.reason.length === 0) {
110
+ throw new TypeError("invalid RuntimeExecutionFailure input");
111
+ }
112
+ super(input.reason, options);
113
+ this.name = "RuntimeExecutionFailure";
114
+ this.phase = input.phase;
115
+ this.category = input.category;
116
+ this.retry = input.retry;
117
+ Object.defineProperty(this, RUNTIME_EXECUTION_FAILURE_BRAND, { value: true });
118
+ Object.freeze(this);
119
+ }
120
+ };
121
+ function isRuntimeFailurePhase(value) {
122
+ return value === "start" || value === "run";
123
+ }
124
+ function isRuntimeFailureCategory(value) {
125
+ return value === "semantic" || value === "infrastructure" || value === "authority";
126
+ }
127
+ function isRuntimeRetryDisposition(value) {
128
+ return value === "retryable" || value === "non-retryable";
129
+ }
130
+ function isRuntimeExecutionFailure(value) {
131
+ if (typeof value !== "object" || value === null) return false;
132
+ const candidate = value;
133
+ return candidate[RUNTIME_EXECUTION_FAILURE_BRAND] === true && isRuntimeFailurePhase(candidate.phase) && isRuntimeFailureCategory(candidate.category) && isRuntimeRetryDisposition(candidate.retry) && typeof candidate.message === "string" && candidate.message.length > 0;
134
+ }
135
+ function retryableFromDisposition(disposition) {
136
+ switch (disposition) {
137
+ case "retryable":
138
+ return true;
139
+ case "non-retryable":
140
+ return false;
141
+ }
142
+ }
143
+ function projectRuntimeExecutionFailure(failure) {
144
+ return {
145
+ reason: failure.message,
146
+ retryable: retryableFromDisposition(failure.retry)
147
+ };
148
+ }
149
+ var RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON = Object.freeze({
150
+ start: "runtime adapter contract violation during start",
151
+ run: "runtime adapter contract violation during run"
152
+ });
153
+ function projectRuntimeBoundaryFailure(value, expectedPhase) {
154
+ if (isRuntimeExecutionFailure(value) && value.phase === expectedPhase) {
155
+ return { ...projectRuntimeExecutionFailure(value), contractViolation: false };
156
+ }
157
+ return {
158
+ reason: RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON[expectedPhase],
159
+ retryable: false,
160
+ contractViolation: true
161
+ };
162
+ }
163
+ var GIT_ERROR_CATEGORIES = [
164
+ "git-unavailable",
165
+ "git-timeout",
166
+ "git-output-limit",
167
+ "git-command-failed",
168
+ "workspace-root-invalid",
169
+ "workspace-root-conflict",
170
+ "workspace-not-owned",
171
+ "repository-root-mismatch",
172
+ "repository-invalid",
173
+ "lease-busy",
174
+ "ledger-invalid"
175
+ ];
176
+ var GIT_WORKSPACE_PHASES = [
177
+ "preparing",
178
+ "active",
179
+ "completed",
180
+ "failed",
181
+ "cancelled",
182
+ "interrupted",
183
+ "salvage"
184
+ ];
33
185
  var GitWorkspaceError = class extends Error {
34
186
  constructor(category, message = category) {
35
187
  super(message);
@@ -735,12 +887,12 @@ function resolvePiBin() {
735
887
  }
736
888
  } catch (cause) {
737
889
  throw new Error(
738
- `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`,
890
+ `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`,
739
891
  { cause }
740
892
  );
741
893
  }
742
894
  throw new Error(
743
- `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`
895
+ `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`
744
896
  );
745
897
  }
746
898
 
@@ -946,6 +1098,266 @@ var AsyncQueue = class {
946
1098
  }
947
1099
  };
948
1100
 
1101
+ // src/adapters/taskkill-pid-set.ts
1102
+ var INTEGER_PATTERN = /\d+/g;
1103
+ function isCandidatePid(value) {
1104
+ return Number.isSafeInteger(value) && value > 0;
1105
+ }
1106
+ function walkTaskkillPidSet(text, rootPid, excludedPids = []) {
1107
+ const excluded = new Set(excludedPids);
1108
+ const accepted = /* @__PURE__ */ new Set();
1109
+ if (isCandidatePid(rootPid)) accepted.add(rootPid);
1110
+ const lines = text.split(/\r?\n/).map((line) => {
1111
+ const pids = [];
1112
+ for (const match of line.matchAll(INTEGER_PATTERN)) {
1113
+ const pid = Number(match[0]);
1114
+ if (isCandidatePid(pid) && !excluded.has(pid)) pids.push(pid);
1115
+ }
1116
+ return pids;
1117
+ });
1118
+ let changed = true;
1119
+ while (changed) {
1120
+ changed = false;
1121
+ for (const pids of lines) {
1122
+ if (!pids.some((pid) => accepted.has(pid))) continue;
1123
+ for (const pid of pids) {
1124
+ if (accepted.has(pid)) continue;
1125
+ accepted.add(pid);
1126
+ changed = true;
1127
+ }
1128
+ }
1129
+ }
1130
+ return accepted;
1131
+ }
1132
+
1133
+ // src/adapters/process-tree.ts
1134
+ var DEFAULT_TERM_GRACE_MS = 750;
1135
+ var DEFAULT_KILL_GRACE_MS = 2e3;
1136
+ var POLL_MS = 20;
1137
+ var terminationState = /* @__PURE__ */ new WeakMap();
1138
+ function stateFor(child) {
1139
+ const existing = terminationState.get(child);
1140
+ if (existing) return existing;
1141
+ const created = { requested: false, acceptedPids: /* @__PURE__ */ new Set() };
1142
+ terminationState.set(child, created);
1143
+ return created;
1144
+ }
1145
+ function defaultKill(pid, signal) {
1146
+ process.kill(pid, signal);
1147
+ }
1148
+ function withOwnedProcessTree(options) {
1149
+ return {
1150
+ ...options,
1151
+ ...process.platform === "win32" ? { windowsHide: true } : { detached: true }
1152
+ };
1153
+ }
1154
+ function positivePid(child, label) {
1155
+ const pid = child.pid;
1156
+ if (pid === void 0) return void 0;
1157
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
1158
+ throw new RuntimeDisposalFailure({
1159
+ stage: "signal",
1160
+ reason: `${label} runtime process has an unsafe owned pid`
1161
+ });
1162
+ }
1163
+ return pid;
1164
+ }
1165
+ function groupExists(pid, label, kill) {
1166
+ try {
1167
+ kill(-pid, 0);
1168
+ return true;
1169
+ } catch (cause) {
1170
+ const code = cause.code;
1171
+ if (code === "ESRCH") return false;
1172
+ if (code === "EPERM") return true;
1173
+ throw new RuntimeDisposalFailure({
1174
+ stage: "quiescence",
1175
+ reason: `${label} runtime process-group state could not be verified`
1176
+ }, { cause });
1177
+ }
1178
+ }
1179
+ function processExists(pid, label, kill) {
1180
+ try {
1181
+ kill(pid, 0);
1182
+ return true;
1183
+ } catch (cause) {
1184
+ const code = cause.code;
1185
+ if (code === "ESRCH") return false;
1186
+ if (code === "EPERM") return true;
1187
+ throw new RuntimeDisposalFailure({
1188
+ stage: "quiescence",
1189
+ reason: `${label} runtime process ${pid} state could not be verified`
1190
+ }, { cause });
1191
+ }
1192
+ }
1193
+ function signalGroup(pid, signal, label, kill) {
1194
+ try {
1195
+ kill(-pid, signal);
1196
+ } catch (cause) {
1197
+ const code = cause.code;
1198
+ if (code === "ESRCH" || code === "EPERM") return;
1199
+ throw new RuntimeDisposalFailure({
1200
+ stage: "signal",
1201
+ reason: `${label} runtime process group ${pid} could not receive ${signal} (${code ?? "unknown"})`
1202
+ }, { cause });
1203
+ }
1204
+ }
1205
+ async function runTaskkill(pid, options) {
1206
+ const spawnFn = options.spawnFn ?? spawn;
1207
+ return new Promise((resolve, reject) => {
1208
+ const signalFailure = (cause) => {
1209
+ reject(new RuntimeDisposalFailure({
1210
+ stage: "signal",
1211
+ reason: `${options.label} runtime process tree termination could not be requested`
1212
+ }, { cause }));
1213
+ };
1214
+ let taskkill;
1215
+ try {
1216
+ taskkill = spawnFn("taskkill", ["/PID", String(pid), "/T", "/F"], {
1217
+ windowsHide: true,
1218
+ stdio: ["ignore", "pipe", "pipe"]
1219
+ });
1220
+ } catch (cause) {
1221
+ signalFailure(cause);
1222
+ return;
1223
+ }
1224
+ const chunks = [];
1225
+ taskkill.stdout?.on("data", (chunk) => chunks.push(chunk));
1226
+ taskkill.stderr?.on("data", (chunk) => chunks.push(chunk));
1227
+ taskkill.once("error", signalFailure);
1228
+ taskkill.once("close", () => resolve(Buffer.concat(chunks).toString("latin1")));
1229
+ });
1230
+ }
1231
+ function liveAcceptedPids(accepted, label, kill) {
1232
+ const live = [];
1233
+ for (const pid of accepted) {
1234
+ if (processExists(pid, label, kill)) live.push(pid);
1235
+ }
1236
+ return live;
1237
+ }
1238
+ async function waitUntil(predicate, timeoutMs) {
1239
+ const deadline = Date.now() + timeoutMs;
1240
+ while (predicate()) {
1241
+ if (Date.now() >= deadline) return false;
1242
+ await new Promise((resolve) => {
1243
+ setTimeout(resolve, POLL_MS);
1244
+ });
1245
+ }
1246
+ return true;
1247
+ }
1248
+ async function waitWithDeadline(promise, timeoutMs) {
1249
+ return new Promise((resolve) => {
1250
+ let settled = false;
1251
+ const timer = setTimeout(() => {
1252
+ if (!settled) {
1253
+ settled = true;
1254
+ resolve(false);
1255
+ }
1256
+ }, timeoutMs);
1257
+ void promise.then(
1258
+ () => {
1259
+ if (!settled) {
1260
+ settled = true;
1261
+ clearTimeout(timer);
1262
+ resolve(true);
1263
+ }
1264
+ },
1265
+ () => {
1266
+ if (!settled) {
1267
+ settled = true;
1268
+ clearTimeout(timer);
1269
+ resolve(false);
1270
+ }
1271
+ }
1272
+ );
1273
+ });
1274
+ }
1275
+ async function requestOwnedProcessTreeTermination(options) {
1276
+ const platform = options.platform ?? process.platform;
1277
+ if (platform !== "win32" && options.isClosed()) return;
1278
+ const pid = positivePid(options.child, options.label);
1279
+ if (pid === void 0) return;
1280
+ if (platform === "win32") {
1281
+ const output = await runTaskkill(pid, options);
1282
+ const state = stateFor(options.child);
1283
+ for (const walked of walkTaskkillPidSet(output, pid, [process.pid])) state.acceptedPids.add(walked);
1284
+ state.requested = true;
1285
+ return;
1286
+ }
1287
+ signalGroup(pid, "SIGTERM", options.label, options.killFn ?? defaultKill);
1288
+ stateFor(options.child).requested = true;
1289
+ }
1290
+ async function disposeOwnedProcessTree(options) {
1291
+ const pid = positivePid(options.child, options.label);
1292
+ const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
1293
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
1294
+ const platform = options.platform ?? process.platform;
1295
+ const kill = options.killFn ?? defaultKill;
1296
+ if (pid === void 0) {
1297
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1298
+ throw new RuntimeDisposalFailure({
1299
+ stage: "quiescence",
1300
+ reason: `${options.label} runtime process did not settle after spawn failure`
1301
+ });
1302
+ }
1303
+ if (platform === "win32") {
1304
+ if (!terminationState.get(options.child)?.requested) {
1305
+ await requestOwnedProcessTreeTermination(options);
1306
+ }
1307
+ const accepted = terminationState.get(options.child)?.acceptedPids ?? /* @__PURE__ */ new Set();
1308
+ const deadline = Date.now() + killGraceMs;
1309
+ const resweepAt = Date.now() + Math.floor(killGraceMs / 2);
1310
+ let reswept = false;
1311
+ let live = liveAcceptedPids(accepted, options.label, kill);
1312
+ while (live.length > 0) {
1313
+ if (Date.now() >= deadline) {
1314
+ throw new RuntimeDisposalFailure({
1315
+ stage: "quiescence",
1316
+ reason: `${options.label} runtime process tree did not quiesce: ${live.length} of ${accepted.size} walked process ids were still alive at the disposal deadline`
1317
+ });
1318
+ }
1319
+ if (!reswept && Date.now() >= resweepAt) {
1320
+ reswept = true;
1321
+ for (const livePid of live) {
1322
+ for (const walked of walkTaskkillPidSet(await runTaskkill(livePid, options), livePid, [process.pid])) {
1323
+ accepted.add(walked);
1324
+ }
1325
+ }
1326
+ }
1327
+ await new Promise((resolve) => {
1328
+ setTimeout(resolve, POLL_MS);
1329
+ });
1330
+ live = liveAcceptedPids(accepted, options.label, kill);
1331
+ }
1332
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1333
+ throw new RuntimeDisposalFailure({
1334
+ stage: "quiescence",
1335
+ reason: `${options.label} runtime root did not emit close after its process tree quiesced`
1336
+ });
1337
+ }
1338
+ return;
1339
+ }
1340
+ if (groupExists(pid, options.label, kill) && !terminationState.get(options.child)?.requested) {
1341
+ signalGroup(pid, "SIGTERM", options.label, kill);
1342
+ stateFor(options.child).requested = true;
1343
+ }
1344
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), termGraceMs)) {
1345
+ signalGroup(pid, "SIGKILL", options.label, kill);
1346
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), killGraceMs)) {
1347
+ throw new RuntimeDisposalFailure({
1348
+ stage: "quiescence",
1349
+ reason: `${options.label} runtime process group remained live after SIGKILL`
1350
+ });
1351
+ }
1352
+ }
1353
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1354
+ throw new RuntimeDisposalFailure({
1355
+ stage: "quiescence",
1356
+ reason: `${options.label} runtime root did not emit close after its process group exited`
1357
+ });
1358
+ }
1359
+ }
1360
+
949
1361
  // src/adapters/pi/rpc-client.ts
950
1362
  var STDERR_RING_CAPACITY = 20;
951
1363
  var DIALOG_UI_METHODS = /* @__PURE__ */ new Set(["select", "confirm", "input", "editor"]);
@@ -957,16 +1369,22 @@ var PiRpcClient = class {
957
1369
  eventQueue = new AsyncQueue();
958
1370
  closed = false;
959
1371
  exitError;
1372
+ closedPromise;
1373
+ resolveClosed;
1374
+ disposalAttempt;
960
1375
  /** 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`. */
961
1376
  stderrRing = [];
962
1377
  /** Count of pi RPC message types `PiSession` (pi-adapter.ts) has told us have no `AgentEvent` mapping and aren't routine bookkeeping — see `recordUnmappedFrame`. */
963
1378
  unmappedFrameCounts = /* @__PURE__ */ new Map();
964
1379
  constructor(options) {
965
1380
  const spawnFn = options.spawnFn ?? spawn;
966
- this.child = spawnFn(options.command, options.args, {
1381
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
967
1382
  cwd: options.cwd,
968
1383
  env: options.env,
969
1384
  stdio: ["pipe", "pipe", "pipe"]
1385
+ }));
1386
+ this.closedPromise = new Promise((resolve) => {
1387
+ this.resolveClosed = resolve;
970
1388
  });
971
1389
  this.child.stdout.setEncoding("utf8");
972
1390
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1001,6 +1419,10 @@ var PiRpcClient = class {
1001
1419
  get events() {
1002
1420
  return this.eventQueue;
1003
1421
  }
1422
+ /** Local transport diagnostic retained when the process closes; consumers must classify it explicitly. */
1423
+ get terminalError() {
1424
+ return this.exitError;
1425
+ }
1004
1426
  /**
1005
1427
  * Record a pi RPC message `type` that `PiSession` (pi-adapter.ts) decided
1006
1428
  * has no `AgentEvent` mapping and isn't routine bookkeeping (see
@@ -1019,15 +1441,37 @@ var PiRpcClient = class {
1019
1441
  );
1020
1442
  }
1021
1443
  }
1022
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes pi itself spawned (e.g. bash). */
1444
+ /**
1445
+ * Immediate process-tree termination request. `dispose()` is the settlement
1446
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
1447
+ * terminator. A request that could not be spawned is left unrecorded, so
1448
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
1449
+ * swallowing it here loses nothing.
1450
+ */
1023
1451
  kill() {
1024
- if (this.closed) return;
1025
- const pid = this.child.pid;
1026
- if (process.platform === "win32" && pid !== void 0) {
1027
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1028
- } else {
1029
- this.child.kill("SIGTERM");
1452
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
1453
+ });
1454
+ }
1455
+ waitClosed() {
1456
+ return this.closedPromise;
1457
+ }
1458
+ dispose() {
1459
+ if (!this.disposalAttempt) {
1460
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
1461
+ this.disposalAttempt = attempt.catch((error) => {
1462
+ this.disposalAttempt = void 0;
1463
+ throw error;
1464
+ });
1030
1465
  }
1466
+ return this.disposalAttempt;
1467
+ }
1468
+ processTreeOptions() {
1469
+ return {
1470
+ child: this.child,
1471
+ waitClosed: () => this.closedPromise,
1472
+ isClosed: () => this.closed,
1473
+ label: "pi"
1474
+ };
1031
1475
  }
1032
1476
  onData(chunk) {
1033
1477
  this.buffer += chunk;
@@ -1113,6 +1557,7 @@ var PiRpcClient = class {
1113
1557
  if (this.closed) return;
1114
1558
  this.closed = true;
1115
1559
  this.exitError = err;
1560
+ this.resolveClosed();
1116
1561
  for (const [, waiter] of this.pending) waiter.reject(err);
1117
1562
  this.pending.clear();
1118
1563
  this.eventQueue.end();
@@ -1183,8 +1628,17 @@ var PiAdapter = class {
1183
1628
  this.options = options;
1184
1629
  }
1185
1630
  options;
1186
- id = "pi";
1187
- supportsDispatchSelection = true;
1631
+ descriptor = freezeRuntimeAdapterDescriptor({
1632
+ id: "pi",
1633
+ supportsDispatchSelection: true,
1634
+ capabilities: {
1635
+ steer: true,
1636
+ resume: true,
1637
+ approvalInteractive: false,
1638
+ permissionModes: ["auto", "readonly"]
1639
+ },
1640
+ environmentRequirements: { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES }
1641
+ });
1188
1642
  async detect() {
1189
1643
  try {
1190
1644
  const bin = this.resolveBin();
@@ -1196,49 +1650,26 @@ var PiAdapter = class {
1196
1650
  return { present: false };
1197
1651
  }
1198
1652
  }
1199
- capabilities() {
1200
- return { steer: true, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
1201
- }
1202
- /**
1203
- * M5: pi authenticates to its ~30 supported providers via env-var API
1204
- * keys — `detect()`'s own `authPresent` probe above checks this identical
1205
- * list — so these MUST keep flowing into pi's spawned process or pi auth
1206
- * breaks entirely. `KNOWN_PROVIDER_ENV_VARS` above is the single source
1207
- * of truth, reused here rather than duplicated. No `baseNames`: nothing
1208
- * in this adapter or `rpc-client.ts` reads a pi-specific config-discovery
1209
- * variable beyond the platform baseline (`daemon/environment.ts`).
1210
- */
1211
- environmentRequirements() {
1212
- return { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES };
1213
- }
1214
- async start(task, ctx) {
1215
- if (typeof task.instruction !== "string") {
1216
- throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
1217
- }
1218
- const mapping = mapPermissionPolicyToPiArgs(ctx.policy);
1653
+ async prepare(input) {
1654
+ const mapping = mapPermissionPolicyToPiArgs(input.policy);
1219
1655
  if (!mapping.ok) {
1220
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
1656
+ return { kind: "reject", reason: mapping.reason ?? "policy rejected by pi adapter", retryable: false };
1221
1657
  }
1222
1658
  const bin = this.resolveBin();
1223
- const resumeSessionId = task.sessionRef;
1224
- const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1225
- const selection = task.dispatchSelection;
1659
+ const selection = input.offer.dispatchSelection;
1660
+ const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
1226
1661
  let command = bin.command;
1227
- let args = piArgs;
1228
- if (selection !== void 0) {
1229
- if (selection.lane !== "byok" || selection.runtimeId !== "pi") {
1230
- throw new PolicyUnsupportedError(
1231
- `pi adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
1232
- );
1662
+ let launcherArgs;
1663
+ if (pinnedSelection !== void 0) {
1664
+ if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
1665
+ return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
1233
1666
  }
1234
1667
  const launcher = this.options.byokLauncher;
1235
1668
  if (launcher === void 0) {
1236
- throw new PolicyUnsupportedError(
1237
- "pi BYOK selection requires a configured credential-custody launcher"
1238
- );
1669
+ return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
1239
1670
  }
1240
1671
  command = launcher.command;
1241
- args = [
1672
+ launcherArgs = [
1242
1673
  ...launcher.args ?? [],
1243
1674
  "--pi-bin",
1244
1675
  bin.command,
@@ -1248,62 +1679,136 @@ var PiAdapter = class {
1248
1679
  launcher.sessionDir,
1249
1680
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
1250
1681
  "--provider",
1251
- selection.providerId,
1682
+ pinnedSelection.providerId,
1252
1683
  "--model",
1253
- selection.modelId,
1254
- "--",
1255
- ...piArgs
1684
+ pinnedSelection.modelId
1256
1685
  ];
1257
1686
  }
1258
- const rpc = new PiRpcClient({
1259
- command,
1260
- args,
1261
- cwd: ctx.workspaceDir,
1262
- env: selection === void 0 ? ctx.env : withoutProviderCredentials(ctx.env),
1263
- spawnFn: this.options.spawnFn
1264
- });
1265
- const response = await rpc.send({ type: "prompt", message: task.instruction });
1266
- if (response.success === false) {
1267
- rpc.kill();
1268
- throw new Error(typeof response.error === "string" ? response.error : "pi rejected the initial prompt");
1269
- }
1270
- let sessionRef;
1271
- if (resumeSessionId) {
1272
- sessionRef = resumeSessionId;
1273
- } else {
1274
- try {
1275
- sessionRef = await resolveFreshSessionId(rpc);
1276
- } catch (err) {
1277
- rpc.kill();
1278
- throw err;
1687
+ return {
1688
+ kind: "prepared",
1689
+ operation: {
1690
+ start: async (startInput) => {
1691
+ const manifestSelection = startInput.manifest.dispatchSelection;
1692
+ if (!sameDispatchSelection(manifestSelection, pinnedSelection)) {
1693
+ throw new RuntimeExecutionFailure({
1694
+ phase: "start",
1695
+ category: "authority",
1696
+ retry: "non-retryable",
1697
+ reason: "prepared pi operation received a manifest with different runtime selection"
1698
+ });
1699
+ }
1700
+ if (typeof startInput.instruction !== "string") {
1701
+ throw new RuntimeExecutionFailure({
1702
+ phase: "start",
1703
+ category: "authority",
1704
+ retry: "non-retryable",
1705
+ reason: "prepared pi operation requires a resolved string instruction"
1706
+ });
1707
+ }
1708
+ const resumeSessionId = startInput.manifest.sessionRef;
1709
+ const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1710
+ const args = launcherArgs === void 0 ? piArgs : [...launcherArgs, "--", ...piArgs];
1711
+ let rpc;
1712
+ try {
1713
+ rpc = new PiRpcClient({
1714
+ command,
1715
+ args,
1716
+ cwd: startInput.manifest.workspace.workspaceDir,
1717
+ env: manifestSelection === void 0 ? startInput.env : withoutProviderCredentials(startInput.env),
1718
+ spawnFn: this.options.spawnFn
1719
+ });
1720
+ } catch (cause) {
1721
+ throw new RuntimeExecutionFailure({
1722
+ phase: "start",
1723
+ category: "infrastructure",
1724
+ retry: "retryable",
1725
+ reason: "pi runtime process could not be spawned"
1726
+ }, { cause });
1727
+ }
1728
+ let response;
1729
+ try {
1730
+ response = await rpc.send({ type: "prompt", message: startInput.instruction });
1731
+ } catch (cause) {
1732
+ rpc.kill();
1733
+ throw new RuntimeExecutionFailure({
1734
+ phase: "start",
1735
+ category: "infrastructure",
1736
+ retry: "retryable",
1737
+ reason: `pi initial prompt transport failed: ${errorMessage(cause)}`
1738
+ }, { cause });
1739
+ }
1740
+ if (response.success === false) {
1741
+ rpc.kill();
1742
+ throw new RuntimeExecutionFailure({
1743
+ phase: "start",
1744
+ category: "semantic",
1745
+ retry: "non-retryable",
1746
+ reason: typeof response.error === "string" ? response.error : "pi rejected the initial prompt"
1747
+ });
1748
+ }
1749
+ let sessionRef;
1750
+ try {
1751
+ sessionRef = await resolveAuthoritativeSessionId(rpc);
1752
+ } catch (err) {
1753
+ rpc.kill();
1754
+ throw err;
1755
+ }
1756
+ if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1757
+ rpc.kill();
1758
+ throw new RuntimeExecutionFailure({
1759
+ phase: "start",
1760
+ category: "authority",
1761
+ retry: "non-retryable",
1762
+ reason: "pi resumed a different authoritative session than requested"
1763
+ });
1764
+ }
1765
+ return new PiSession(sessionRef, rpc, manifestSelection);
1766
+ }
1279
1767
  }
1280
- }
1281
- return new PiSession(sessionRef, rpc, selection);
1768
+ };
1282
1769
  }
1283
1770
  resolveBin() {
1284
1771
  return (this.options.resolveBin ?? resolvePiBin)();
1285
1772
  }
1286
1773
  };
1287
- async function resolveFreshSessionId(rpc) {
1774
+ function sameDispatchSelection(left, right) {
1775
+ if (left === void 0 || right === void 0) return left === right;
1776
+ return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
1777
+ }
1778
+ async function resolveAuthoritativeSessionId(rpc) {
1288
1779
  let state;
1289
1780
  try {
1290
1781
  state = await rpc.send({ type: "get_state" });
1291
1782
  } catch (err) {
1292
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${errorMessage(err)}`, {
1783
+ if (isRuntimeExecutionFailure(err)) throw err;
1784
+ throw new RuntimeExecutionFailure({
1785
+ phase: "start",
1786
+ category: "infrastructure",
1787
+ retry: "retryable",
1788
+ reason: `pi transport ended before yielding an authoritative session id: ${errorMessage(err)}`
1789
+ }, {
1293
1790
  cause: err
1294
1791
  });
1295
1792
  }
1296
1793
  if (state.success === false) {
1297
1794
  const reason = typeof state.error === "string" ? state.error : "get_state reported failure";
1298
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${reason}`);
1795
+ throw new RuntimeExecutionFailure({
1796
+ phase: "start",
1797
+ category: "authority",
1798
+ retry: "non-retryable",
1799
+ reason: `pi did not yield an authoritative session id: ${reason}`
1800
+ });
1299
1801
  }
1300
1802
  const data = state.data;
1301
1803
  if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
1302
1804
  return data.sessionId;
1303
1805
  }
1304
- throw new Error(
1305
- "pi did not yield an authoritative session id (get_state succeeded but reported no sessionId) \u2014 cannot mint a resumable session"
1306
- );
1806
+ throw new RuntimeExecutionFailure({
1807
+ phase: "start",
1808
+ category: "authority",
1809
+ retry: "non-retryable",
1810
+ reason: "pi get_state reported no authoritative session id"
1811
+ });
1307
1812
  }
1308
1813
  var PiSession = class {
1309
1814
  constructor(sessionRef, rpc, selection) {
@@ -1319,12 +1824,40 @@ var PiSession = class {
1319
1824
  return {
1320
1825
  [Symbol.asyncIterator]() {
1321
1826
  const inner = rpc.events[Symbol.asyncIterator]();
1827
+ let terminalFailure;
1322
1828
  return {
1323
1829
  async next() {
1324
1830
  for (; ; ) {
1325
- const { value, done } = await inner.next();
1326
- if (done) return { value: void 0, done: true };
1831
+ if (terminalFailure) throw terminalFailure;
1832
+ let result;
1833
+ try {
1834
+ result = await inner.next();
1835
+ } catch (cause) {
1836
+ throw new RuntimeExecutionFailure({
1837
+ phase: "run",
1838
+ category: "infrastructure",
1839
+ retry: "retryable",
1840
+ reason: "pi runtime event transport failed"
1841
+ }, { cause });
1842
+ }
1843
+ const { value, done } = result;
1844
+ if (done) {
1845
+ throw new RuntimeExecutionFailure({
1846
+ phase: "run",
1847
+ category: "infrastructure",
1848
+ retry: "retryable",
1849
+ reason: "pi runtime process ended before agent_settled"
1850
+ }, { cause: rpc.terminalError });
1851
+ }
1327
1852
  const mapped = mapPiMessageToAgentEvent(value);
1853
+ if (value.type === "auto_retry_end" && value.success === false) {
1854
+ terminalFailure = new RuntimeExecutionFailure({
1855
+ phase: "run",
1856
+ category: "semantic",
1857
+ retry: "non-retryable",
1858
+ reason: "pi exhausted its native retry policy"
1859
+ });
1860
+ }
1328
1861
  if (mapped) return { value: mapped, done: false };
1329
1862
  if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
1330
1863
  rpc.recordUnmappedFrame(value.type);
@@ -1354,7 +1887,7 @@ var PiSession = class {
1354
1887
  await this.rpc.send({ type: "abort" });
1355
1888
  }
1356
1889
  async close() {
1357
- this.rpc.kill();
1890
+ await this.rpc.dispose();
1358
1891
  }
1359
1892
  async resolveApproval() {
1360
1893
  throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
@@ -1566,7 +2099,15 @@ function mapResult(msg) {
1566
2099
  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`
1567
2100
  );
1568
2101
  const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
1569
- return { events };
2102
+ return {
2103
+ events,
2104
+ terminalFailure: new RuntimeExecutionFailure({
2105
+ phase: "run",
2106
+ category: msg.is_error === true ? "semantic" : "authority",
2107
+ retry: "non-retryable",
2108
+ reason: msg.is_error === true ? "claude reported terminal task failure" : "claude emitted a malformed terminal result frame"
2109
+ })
2110
+ };
1570
2111
  }
1571
2112
  var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
1572
2113
  function truncateResultDiagnostic(text) {
@@ -1618,16 +2159,22 @@ var ClaudeProcessClient = class {
1618
2159
  eventQueue = new AsyncQueue();
1619
2160
  closed = false;
1620
2161
  exitError;
2162
+ closedPromise;
2163
+ resolveClosed;
2164
+ disposalAttempt;
1621
2165
  stderrRing = [];
1622
2166
  unmappedFrameCounts = /* @__PURE__ */ new Map();
1623
2167
  sessionId;
1624
2168
  initWaiter;
1625
2169
  constructor(options) {
1626
2170
  const spawnFn = options.spawnFn ?? spawn;
1627
- this.child = spawnFn(options.command, options.args, {
2171
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
1628
2172
  cwd: options.cwd,
1629
2173
  env: options.env,
1630
2174
  stdio: ["pipe", "pipe", "pipe"]
2175
+ }));
2176
+ this.closedPromise = new Promise((resolve) => {
2177
+ this.resolveClosed = resolve;
1631
2178
  });
1632
2179
  this.child.stdout.setEncoding("utf8");
1633
2180
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1683,6 +2230,10 @@ var ClaudeProcessClient = class {
1683
2230
  get events() {
1684
2231
  return this.eventQueue;
1685
2232
  }
2233
+ /** Local transport diagnostic retained when the process closes; consumers classify it at the session boundary. */
2234
+ get terminalError() {
2235
+ return this.exitError;
2236
+ }
1686
2237
  /**
1687
2238
  * Record a claude stream-json frame/subtype/content-block label that
1688
2239
  * `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
@@ -1702,15 +2253,37 @@ var ClaudeProcessClient = class {
1702
2253
  );
1703
2254
  }
1704
2255
  }
1705
- /** 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). */
2256
+ /**
2257
+ * Immediate process-tree termination request. `dispose()` is the settlement
2258
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
2259
+ * terminator. A request that could not be spawned is left unrecorded, so
2260
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
2261
+ * swallowing it here loses nothing.
2262
+ */
1706
2263
  kill() {
1707
- if (this.closed) return;
1708
- const pid = this.child.pid;
1709
- if (process.platform === "win32" && pid !== void 0) {
1710
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1711
- } else {
1712
- this.child.kill("SIGTERM");
2264
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
2265
+ });
2266
+ }
2267
+ waitClosed() {
2268
+ return this.closedPromise;
2269
+ }
2270
+ dispose() {
2271
+ if (!this.disposalAttempt) {
2272
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2273
+ this.disposalAttempt = attempt.catch((error) => {
2274
+ this.disposalAttempt = void 0;
2275
+ throw error;
2276
+ });
1713
2277
  }
2278
+ return this.disposalAttempt;
2279
+ }
2280
+ processTreeOptions() {
2281
+ return {
2282
+ child: this.child,
2283
+ waitClosed: () => this.closedPromise,
2284
+ isClosed: () => this.closed,
2285
+ label: "claude"
2286
+ };
1714
2287
  }
1715
2288
  onData(chunk) {
1716
2289
  this.buffer += chunk;
@@ -1761,6 +2334,7 @@ var ClaudeProcessClient = class {
1761
2334
  if (this.closed) return;
1762
2335
  this.closed = true;
1763
2336
  this.exitError = err;
2337
+ this.resolveClosed();
1764
2338
  this.initWaiter?.reject(err);
1765
2339
  this.initWaiter = void 0;
1766
2340
  this.eventQueue.end();
@@ -1772,18 +2346,37 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1772
2346
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1773
2347
  var execFileAsync2 = promisify(execFile);
1774
2348
  var DETECT_TIMEOUT_MS2 = 5e3;
2349
+ function errorMessage2(err) {
2350
+ return err instanceof Error ? err.message : String(err);
2351
+ }
1775
2352
  async function cleanupMcpConfigDir(dir) {
1776
2353
  if (!dir) return;
1777
- await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1778
- });
2354
+ try {
2355
+ await promises.rm(dir, { recursive: true, force: true });
2356
+ } catch (cause) {
2357
+ throw new RuntimeDisposalFailure({
2358
+ stage: "cleanup",
2359
+ reason: "claude task-scoped MCP configuration could not be removed"
2360
+ }, { cause });
2361
+ }
1779
2362
  }
1780
2363
  var ClaudeAdapter = class {
1781
2364
  constructor(options = {}) {
1782
2365
  this.options = options;
1783
2366
  }
1784
2367
  options;
1785
- supportsDispatchSelection = true;
1786
- id = "claude";
2368
+ descriptor = freezeRuntimeAdapterDescriptor({
2369
+ id: "claude",
2370
+ supportsDispatchSelection: true,
2371
+ capabilities: {
2372
+ steer: false,
2373
+ resume: true,
2374
+ approvalInteractive: true,
2375
+ mcpToolsets: true,
2376
+ permissionModes: ["auto", "readonly", "plan", "confirm"]
2377
+ },
2378
+ environmentRequirements: { credentialNames: [] }
2379
+ });
1787
2380
  async detect() {
1788
2381
  const bin = this.resolveBin();
1789
2382
  try {
@@ -1795,54 +2388,73 @@ var ClaudeAdapter = class {
1795
2388
  return { present: false };
1796
2389
  }
1797
2390
  }
1798
- capabilities() {
2391
+ async prepare(input) {
2392
+ const mapping = mapPermissionPolicyToClaudeArgs(input.policy);
2393
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by claude adapter", retryable: false };
2394
+ let modelId;
2395
+ try {
2396
+ modelId = subscriptionModel(input.offer.dispatchSelection, "claude");
2397
+ } catch (error) {
2398
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
2399
+ }
2400
+ if (mapping.needsApprovalMcp && Object.prototype.hasOwnProperty.call(input.mcpServers ?? {}, APPROVAL_MCP_SERVER_NAME)) {
2401
+ return { kind: "reject", reason: `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`, retryable: false };
2402
+ }
2403
+ let bin;
2404
+ try {
2405
+ bin = this.resolveBin();
2406
+ } catch (error) {
2407
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2408
+ }
2409
+ let approvalMcpBin;
2410
+ if (mapping.needsApprovalMcp) {
2411
+ try {
2412
+ approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
2413
+ } catch (error) {
2414
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2415
+ }
2416
+ }
1799
2417
  return {
1800
- steer: false,
1801
- resume: true,
1802
- approvalInteractive: true,
1803
- mcpToolsets: true,
1804
- permissionModes: ["auto", "readonly", "plan", "confirm"]
2418
+ kind: "prepared",
2419
+ operation: {
2420
+ start: (startInput) => this.startPrepared(startInput, mapping, modelId, bin, approvalMcpBin)
2421
+ }
1805
2422
  };
1806
2423
  }
1807
- /**
1808
- * M5: deliberate product-boundary decision, not an oversight — byok's
1809
- * current ToS posture for claude is login-state-only (`claude auth
1810
- * login`'s own OAuth session — see `probeAuthPresent` below), so this
1811
- * adapter declares NO credential env vars at all; env-based API-key
1812
- * passthrough for claude is a separate, still-pending product decision.
1813
- * A product that genuinely needs it can opt in locally per-device via
1814
- * `DaemonConfig.runtimeEnvironment.claude.allow` (`create-daemon.ts`).
1815
- * `baseNames` is empty too: nothing in this adapter reads a
1816
- * claude-specific config-discovery variable (e.g. `CLAUDE_CONFIG_DIR`)
1817
- * today — if a future version of this adapter starts reading one, it
1818
- * belongs here, not left to rely on the platform baseline alone.
1819
- */
1820
- environmentRequirements() {
1821
- return { credentialNames: [] };
1822
- }
1823
- async start(task, ctx) {
1824
- if (typeof task.instruction !== "string") {
1825
- throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1826
- }
1827
- const mapping = mapPermissionPolicyToClaudeArgs(ctx.policy);
1828
- if (!mapping.ok) {
1829
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
2424
+ async startPrepared(startInput, initialMapping, modelId, bin, approvalMcpBin) {
2425
+ if (!initialMapping.ok) throw new RuntimeExecutionFailure({
2426
+ phase: "start",
2427
+ category: "authority",
2428
+ retry: "non-retryable",
2429
+ reason: "prepared claude permission mapping was invalid"
2430
+ });
2431
+ if (typeof startInput.instruction !== "string") {
2432
+ throw new RuntimeExecutionFailure({
2433
+ phase: "start",
2434
+ category: "authority",
2435
+ retry: "non-retryable",
2436
+ reason: "prepared claude operation requires a resolved string instruction"
2437
+ });
1830
2438
  }
1831
- const modelId = subscriptionModel(task, "claude");
2439
+ const mapping = { ...initialMapping, args: [...initialMapping.args] };
1832
2440
  let mcpConfigDir;
1833
- const taskMcpServers = ctx.mcpServers ?? {};
2441
+ const taskMcpServers = startInput.mcpServers ?? {};
1834
2442
  const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
1835
2443
  if (mapping.needsApprovalMcp) {
1836
- if (!ctx.approvalChannel) {
1837
- throw new PolicyUnsupportedError(
1838
- 'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1839
- );
1840
- }
1841
- if (Object.prototype.hasOwnProperty.call(taskMcpServers, APPROVAL_MCP_SERVER_NAME)) {
1842
- throw new PolicyUnsupportedError(
1843
- `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`
1844
- );
2444
+ if (!startInput.approvalChannel) {
2445
+ throw new RuntimeExecutionFailure({
2446
+ phase: "start",
2447
+ category: "authority",
2448
+ retry: "non-retryable",
2449
+ reason: 'claude adapter requires policy.mode "confirm" to be started with an approval channel'
2450
+ });
1845
2451
  }
2452
+ if (!approvalMcpBin) throw new RuntimeExecutionFailure({
2453
+ phase: "start",
2454
+ category: "authority",
2455
+ retry: "non-retryable",
2456
+ reason: "prepared claude approval MCP binary was not resolved"
2457
+ });
1846
2458
  }
1847
2459
  if (needsMcpConfig) {
1848
2460
  mcpConfigDir = await promises.mkdtemp(path20.join(os.tmpdir(), "byok-mcp-"));
@@ -1851,12 +2463,23 @@ var ClaudeAdapter = class {
1851
2463
  const mcpConfigPath = path20.join(mcpConfigDir, "mcp-config.json");
1852
2464
  const mcpServers = { ...taskMcpServers };
1853
2465
  if (mapping.needsApprovalMcp) {
1854
- const approvalChannel = ctx.approvalChannel;
1855
- if (!approvalChannel) throw new Error("unreachable: approval channel checked above");
1856
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
2466
+ const approvalChannel = startInput.approvalChannel;
2467
+ if (!approvalChannel) throw new RuntimeExecutionFailure({
2468
+ phase: "start",
2469
+ category: "authority",
2470
+ retry: "non-retryable",
2471
+ reason: "prepared claude approval channel was not available"
2472
+ });
2473
+ const preparedApprovalMcpBin = approvalMcpBin;
2474
+ if (!preparedApprovalMcpBin) throw new RuntimeExecutionFailure({
2475
+ phase: "start",
2476
+ category: "authority",
2477
+ retry: "non-retryable",
2478
+ reason: "prepared claude approval MCP binary was not resolved"
2479
+ });
1857
2480
  mcpServers[APPROVAL_MCP_SERVER_NAME] = {
1858
- command: approvalMcpBin.command,
1859
- args: approvalMcpBin.args,
2481
+ command: preparedApprovalMcpBin.command,
2482
+ args: preparedApprovalMcpBin.args,
1860
2483
  env: {
1861
2484
  BYOK_STORE_DIR: approvalChannel.storeDir,
1862
2485
  BYOK_PRODUCT_ID: approvalChannel.productId,
@@ -1876,8 +2499,26 @@ var ClaudeAdapter = class {
1876
2499
  "--strict-mcp-config"
1877
2500
  ];
1878
2501
  }
1879
- const bin = this.resolveBin();
1880
- const resumeSessionId = task.sessionRef;
2502
+ const resumeSessionId = startInput.manifest.sessionRef;
2503
+ let manifestModelId;
2504
+ try {
2505
+ manifestModelId = subscriptionModel(startInput.manifest.dispatchSelection, "claude");
2506
+ } catch (cause) {
2507
+ throw new RuntimeExecutionFailure({
2508
+ phase: "start",
2509
+ category: "authority",
2510
+ retry: "non-retryable",
2511
+ reason: "prepared claude operation received an invalid runtime selection manifest"
2512
+ }, { cause });
2513
+ }
2514
+ if (manifestModelId !== modelId) {
2515
+ throw new RuntimeExecutionFailure({
2516
+ phase: "start",
2517
+ category: "authority",
2518
+ retry: "non-retryable",
2519
+ reason: "prepared claude operation received a manifest with different runtime selection"
2520
+ });
2521
+ }
1881
2522
  const args = [
1882
2523
  "-p",
1883
2524
  "--input-format",
@@ -1889,40 +2530,71 @@ var ClaudeAdapter = class {
1889
2530
  // "Error: When using --print, --output-format=stream-json requires
1890
2531
  // --verbose", before spawning any model call.
1891
2532
  "--verbose",
1892
- ...modelId ? ["--model", modelId] : [],
2533
+ ...manifestModelId ? ["--model", manifestModelId] : [],
1893
2534
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1894
2535
  ...mapping.args
1895
2536
  ];
1896
- const client = new ClaudeProcessClient({
1897
- command: bin.command,
1898
- args,
1899
- cwd: ctx.workspaceDir,
1900
- env: withoutProviderCredentials(ctx.env),
1901
- spawnFn: this.options.spawnFn
1902
- });
1903
- client.writeUserMessage(task.instruction);
2537
+ let client;
2538
+ try {
2539
+ client = new ClaudeProcessClient({
2540
+ command: bin.command,
2541
+ args,
2542
+ cwd: startInput.manifest.workspace.workspaceDir,
2543
+ env: withoutProviderCredentials(startInput.env),
2544
+ spawnFn: this.options.spawnFn
2545
+ });
2546
+ } catch (cause) {
2547
+ await cleanupMcpConfigDir(mcpConfigDir);
2548
+ throw new RuntimeExecutionFailure({
2549
+ phase: "start",
2550
+ category: "infrastructure",
2551
+ retry: "retryable",
2552
+ reason: "claude runtime process could not be spawned"
2553
+ }, { cause });
2554
+ }
2555
+ try {
2556
+ client.writeUserMessage(startInput.instruction);
2557
+ } catch (cause) {
2558
+ client.kill();
2559
+ await cleanupMcpConfigDir(mcpConfigDir);
2560
+ throw new RuntimeExecutionFailure({
2561
+ phase: "start",
2562
+ category: "infrastructure",
2563
+ retry: "retryable",
2564
+ reason: "claude initial instruction transport failed"
2565
+ }, { cause });
2566
+ }
1904
2567
  let sessionRef;
1905
2568
  try {
1906
2569
  sessionRef = await client.waitForInit();
1907
2570
  } catch (err) {
1908
2571
  client.kill();
1909
2572
  await cleanupMcpConfigDir(mcpConfigDir);
1910
- throw err;
2573
+ if (isRuntimeExecutionFailure(err)) throw err;
2574
+ throw new RuntimeExecutionFailure({
2575
+ phase: "start",
2576
+ category: "infrastructure",
2577
+ retry: "retryable",
2578
+ reason: `claude exited before yielding an authoritative session id: ${errorMessage2(err)}`
2579
+ }, { cause: err });
1911
2580
  }
1912
2581
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1913
2582
  client.kill();
1914
2583
  await cleanupMcpConfigDir(mcpConfigDir);
1915
- throw new Error(
1916
- `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1917
- );
2584
+ throw new RuntimeExecutionFailure({
2585
+ phase: "start",
2586
+ category: "authority",
2587
+ retry: "non-retryable",
2588
+ reason: `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef})`
2589
+ });
1918
2590
  }
1919
2591
  return new ClaudeSession(
1920
2592
  sessionRef,
1921
2593
  client,
1922
- ctx.workspaceDir,
1923
- ctx.approvalChannel,
2594
+ startInput.manifest.workspace.workspaceDir,
2595
+ startInput.approvalChannel,
1924
2596
  mcpConfigDir,
1925
- modelId
2597
+ manifestModelId
1926
2598
  );
1927
2599
  }
1928
2600
  /**
@@ -1957,8 +2629,7 @@ var ClaudeAdapter = class {
1957
2629
  return (this.options.resolveBin ?? resolveClaudeBin)();
1958
2630
  }
1959
2631
  };
1960
- function subscriptionModel(task, runtimeId) {
1961
- const selection = task.dispatchSelection;
2632
+ function subscriptionModel(selection, runtimeId) {
1962
2633
  if (selection === void 0) return void 0;
1963
2634
  if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
1964
2635
  throw new PolicyUnsupportedError(
@@ -1983,6 +2654,7 @@ var ClaudeSession = class {
1983
2654
  mcpConfigDir;
1984
2655
  modelId;
1985
2656
  correlation = createToolUseCorrelation();
2657
+ closeAttempt;
1986
2658
  get events() {
1987
2659
  const client = this.client;
1988
2660
  const correlation = this.correlation;
@@ -1991,17 +2663,40 @@ var ClaudeSession = class {
1991
2663
  [Symbol.asyncIterator]() {
1992
2664
  const inner = client.events[Symbol.asyncIterator]();
1993
2665
  let pending = [];
2666
+ let terminalFailure;
1994
2667
  let turnSettled = false;
1995
2668
  return {
1996
2669
  async next() {
1997
2670
  for (; ; ) {
1998
2671
  const buffered = pending.shift();
1999
2672
  if (buffered) return { value: buffered, done: false };
2000
- if (turnSettled) return { value: void 0, done: true };
2001
- const { value, done } = await inner.next();
2002
- if (done) return { value: void 0, done: true };
2673
+ if (turnSettled) {
2674
+ if (terminalFailure) throw terminalFailure;
2675
+ return { value: void 0, done: true };
2676
+ }
2677
+ let raw;
2678
+ try {
2679
+ raw = await inner.next();
2680
+ } catch (cause) {
2681
+ throw new RuntimeExecutionFailure({
2682
+ phase: "run",
2683
+ category: "infrastructure",
2684
+ retry: "retryable",
2685
+ reason: "claude runtime event transport failed"
2686
+ }, { cause });
2687
+ }
2688
+ const { value, done } = raw;
2689
+ if (done) {
2690
+ throw new RuntimeExecutionFailure({
2691
+ phase: "run",
2692
+ category: "infrastructure",
2693
+ retry: "retryable",
2694
+ reason: "claude runtime process ended before a terminal result frame"
2695
+ }, { cause: client.terminalError });
2696
+ }
2003
2697
  if (value.type === "result") turnSettled = true;
2004
2698
  const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
2699
+ terminalFailure = mapped.terminalFailure ?? terminalFailure;
2005
2700
  if (mapped.unmappedLabel) {
2006
2701
  client.recordUnmappedFrame(mapped.unmappedLabel);
2007
2702
  }
@@ -2032,7 +2727,7 @@ var ClaudeSession = class {
2032
2727
  if (typeof task.instruction !== "string") {
2033
2728
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2034
2729
  }
2035
- const requestedModel = subscriptionModel(task, "claude");
2730
+ const requestedModel = subscriptionModel(task.dispatchSelection, "claude");
2036
2731
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2037
2732
  throw new PolicyUnsupportedError(
2038
2733
  `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2056,8 +2751,17 @@ var ClaudeSession = class {
2056
2751
  this.client.kill();
2057
2752
  }
2058
2753
  async close() {
2059
- this.client.kill();
2060
- await cleanupMcpConfigDir(this.mcpConfigDir);
2754
+ if (!this.closeAttempt) {
2755
+ const attempt = (async () => {
2756
+ await this.client.dispose();
2757
+ await cleanupMcpConfigDir(this.mcpConfigDir);
2758
+ })();
2759
+ this.closeAttempt = attempt.catch((error) => {
2760
+ this.closeAttempt = void 0;
2761
+ throw error;
2762
+ });
2763
+ }
2764
+ await this.closeAttempt;
2061
2765
  }
2062
2766
  /**
2063
2767
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -2274,14 +2978,15 @@ var CodexProcessRunner = class {
2274
2978
  exitSignal = null;
2275
2979
  closedPromise;
2276
2980
  resolveClosed;
2981
+ disposalAttempt;
2277
2982
  constructor(options) {
2278
2983
  this.onEvent = options.onEvent;
2279
2984
  const spawnFn = options.spawnFn ?? spawn;
2280
- this.child = spawnFn(options.command, options.args, {
2985
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
2281
2986
  cwd: options.cwd,
2282
2987
  env: options.env,
2283
2988
  stdio: ["ignore", "pipe", "pipe"]
2284
- });
2989
+ }));
2285
2990
  this.closedPromise = new Promise((resolve) => {
2286
2991
  this.resolveClosed = resolve;
2287
2992
  });
@@ -2311,7 +3016,7 @@ var CodexProcessRunner = class {
2311
3016
  return this.closed;
2312
3017
  }
2313
3018
  /**
2314
- * Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
3019
+ * Immediate tree termination request. SIGTERM on POSIX: SIGINT was empirically confirmed
2315
3020
  * to be silently ignored by `codex exec` (a real, direct test — a 60s
2316
3021
  * shell `sleep` ran to full, unaffected completion despite SIGINT sent at
2317
3022
  * t=4s) — a genuine, evidence-based correction to this task's own initial
@@ -2322,15 +3027,33 @@ var CodexProcessRunner = class {
2322
3027
  * cleanly resumable afterward via `codex exec resume` (no corruption from
2323
3028
  * killing mid-turn). `taskkill /T /F` on Windows, mirroring
2324
3029
  * `../pi/rpc-client.ts`'s own cross-platform convention.
3030
+ *
3031
+ * Fire-and-forget by design: an interrupt must not block on a terminator,
3032
+ * and `dispose()` is the settlement receipt. A request that could not be
3033
+ * spawned is left unrecorded, so `dispose()` re-issues it and raises the
3034
+ * typed `stage:'signal'` failure — swallowing it here loses nothing.
2325
3035
  */
2326
3036
  kill() {
2327
- if (this.closed) return;
2328
- const pid = this.child.pid;
2329
- if (process.platform === "win32" && pid !== void 0) {
2330
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
2331
- } else {
2332
- this.child.kill("SIGTERM");
3037
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
3038
+ });
3039
+ }
3040
+ dispose() {
3041
+ if (!this.disposalAttempt) {
3042
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
3043
+ this.disposalAttempt = attempt.catch((error) => {
3044
+ this.disposalAttempt = void 0;
3045
+ throw error;
3046
+ });
2333
3047
  }
3048
+ return this.disposalAttempt;
3049
+ }
3050
+ processTreeOptions() {
3051
+ return {
3052
+ child: this.child,
3053
+ waitClosed: () => this.closedPromise,
3054
+ isClosed: () => this.closed,
3055
+ label: "codex"
3056
+ };
2334
3057
  }
2335
3058
  /** 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. */
2336
3059
  buildExitError(context) {
@@ -2381,8 +3104,17 @@ var CodexAdapter = class {
2381
3104
  this.options = options;
2382
3105
  }
2383
3106
  options;
2384
- supportsDispatchSelection = true;
2385
- id = "codex";
3107
+ descriptor = freezeRuntimeAdapterDescriptor({
3108
+ id: "codex",
3109
+ supportsDispatchSelection: true,
3110
+ capabilities: {
3111
+ steer: false,
3112
+ resume: true,
3113
+ approvalInteractive: false,
3114
+ permissionModes: ["auto", "readonly"]
3115
+ },
3116
+ environmentRequirements: { credentialNames: [] }
3117
+ });
2386
3118
  async detect() {
2387
3119
  const bin = this.resolveBin();
2388
3120
  try {
@@ -2431,61 +3163,100 @@ ${result.stderr}`);
2431
3163
  ${withStreams.stderr ?? ""}`);
2432
3164
  }
2433
3165
  }
2434
- capabilities() {
2435
- return { steer: false, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
2436
- }
2437
- /**
2438
- * M5: same deliberate posture as the claude adapter (see its own doc
2439
- * comment) — codex authenticates via its own `codex login`-managed
2440
- * ChatGPT OAuth session (`probeAuthPresent` above), not an env var, so
2441
- * there is no credential env var this adapter needs forwarded; env-based
2442
- * API-key passthrough remains a separate, pending product decision. No
2443
- * `baseNames` either: nothing in this adapter reads a codex-specific
2444
- * config-discovery variable (e.g. `CODEX_HOME`) today.
2445
- */
2446
- environmentRequirements() {
2447
- return { credentialNames: [] };
2448
- }
2449
- async start(task, ctx) {
2450
- if (typeof task.instruction !== "string") {
2451
- throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
3166
+ async prepare(input) {
3167
+ const mapping = mapPermissionPolicyToCodexArgs(input.policy);
3168
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by codex adapter", retryable: false };
3169
+ let modelId;
3170
+ try {
3171
+ modelId = subscriptionModel2(input.offer.dispatchSelection);
3172
+ } catch (error) {
3173
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
2452
3174
  }
2453
- const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
2454
- if (!mapping.ok) {
2455
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
3175
+ let command;
3176
+ try {
3177
+ command = this.resolveBin().command;
3178
+ } catch (error) {
3179
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
3180
+ }
3181
+ return {
3182
+ kind: "prepared",
3183
+ operation: {
3184
+ start: (startInput) => this.startPrepared(startInput, mapping.args, modelId, command)
3185
+ }
3186
+ };
3187
+ }
3188
+ async startPrepared(startInput, policyArgs, modelId, command) {
3189
+ if (typeof startInput.instruction !== "string") {
3190
+ throw new RuntimeExecutionFailure({
3191
+ phase: "start",
3192
+ category: "authority",
3193
+ retry: "non-retryable",
3194
+ reason: "prepared codex operation requires a resolved string instruction"
3195
+ });
2456
3196
  }
2457
- const modelId = subscriptionModel2(task);
2458
- const bin = this.resolveBin();
2459
3197
  const queue = new AsyncQueue();
3198
+ const terminal = {};
2460
3199
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
2461
- const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
2462
- const runtimeEnv = withoutProviderCredentials(ctx.env);
3200
+ let workspaceDir;
3201
+ try {
3202
+ workspaceDir = await resolveRealWorkspaceDir(startInput.manifest.workspace.workspaceDir);
3203
+ } catch (cause) {
3204
+ throw new RuntimeExecutionFailure({
3205
+ phase: "start",
3206
+ category: "infrastructure",
3207
+ retry: "retryable",
3208
+ reason: "codex runtime workspace could not be resolved"
3209
+ }, { cause });
3210
+ }
3211
+ const runtimeEnv = withoutProviderCredentials(startInput.env);
3212
+ let manifestModelId;
3213
+ try {
3214
+ manifestModelId = subscriptionModel2(startInput.manifest.dispatchSelection);
3215
+ } catch (cause) {
3216
+ throw new RuntimeExecutionFailure({
3217
+ phase: "start",
3218
+ category: "authority",
3219
+ retry: "non-retryable",
3220
+ reason: "prepared codex operation received an invalid runtime selection manifest"
3221
+ }, { cause });
3222
+ }
3223
+ if (manifestModelId !== modelId) {
3224
+ throw new RuntimeExecutionFailure({
3225
+ phase: "start",
3226
+ category: "authority",
3227
+ retry: "non-retryable",
3228
+ reason: "prepared codex operation received a manifest with different runtime selection"
3229
+ });
3230
+ }
2463
3231
  const { sessionRef, runner } = await runCodexTurn({
2464
- command: bin.command,
2465
- resumeRef: task.sessionRef,
2466
- instruction: task.instruction,
2467
- modelId,
2468
- policyArgs: mapping.args,
2469
- cwd: ctx.workspaceDir,
3232
+ command,
3233
+ resumeRef: startInput.manifest.sessionRef,
3234
+ instruction: startInput.instruction,
3235
+ modelId: manifestModelId,
3236
+ policyArgs: [...policyArgs],
3237
+ cwd: startInput.manifest.workspace.workspaceDir,
2470
3238
  env: runtimeEnv,
2471
3239
  spawnFn: this.options.spawnFn,
2472
3240
  workspaceDir,
2473
3241
  queue,
2474
3242
  recordUnmapped,
2475
- expectedSessionRef: task.sessionRef,
2476
- preparedGit: ctx.gitWorkspace !== void 0
3243
+ expectedSessionRef: startInput.manifest.sessionRef,
3244
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3245
+ failurePhase: "start",
3246
+ terminal
2477
3247
  });
2478
3248
  return new CodexSession({
2479
3249
  sessionRef,
2480
- command: bin.command,
3250
+ command,
2481
3251
  workspaceDir,
2482
- env: ctx.env,
3252
+ env: startInput.env,
2483
3253
  spawnFn: this.options.spawnFn,
2484
3254
  queue,
2485
3255
  recordUnmapped,
2486
3256
  initialRunner: runner,
2487
- preparedGit: ctx.gitWorkspace !== void 0,
2488
- modelId
3257
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3258
+ modelId: manifestModelId,
3259
+ terminal
2489
3260
  });
2490
3261
  }
2491
3262
  resolveBin() {
@@ -2533,37 +3304,69 @@ async function runCodexTurn(params) {
2533
3304
  rejectFirstLine = reject;
2534
3305
  });
2535
3306
  let turnEnded = false;
2536
- const runner = new CodexProcessRunner({
2537
- command: params.command,
2538
- args: argv,
2539
- cwd: params.cwd,
2540
- env: params.env,
2541
- spawnFn: params.spawnFn,
2542
- onEvent: (evt) => {
2543
- if (!firstLineSettled) {
2544
- firstLineSettled = true;
2545
- if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
2546
- resolveFirstLine(evt.thread_id);
2547
- } else {
2548
- rejectFirstLine(
2549
- new Error(`codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`)
2550
- );
3307
+ let runner;
3308
+ try {
3309
+ runner = new CodexProcessRunner({
3310
+ command: params.command,
3311
+ args: argv,
3312
+ cwd: params.cwd,
3313
+ env: params.env,
3314
+ spawnFn: params.spawnFn,
3315
+ onEvent: (evt) => {
3316
+ if (!firstLineSettled) {
3317
+ firstLineSettled = true;
3318
+ if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
3319
+ resolveFirstLine(evt.thread_id);
3320
+ } else {
3321
+ rejectFirstLine(
3322
+ new RuntimeExecutionFailure({
3323
+ phase: params.failurePhase,
3324
+ category: "authority",
3325
+ retry: "non-retryable",
3326
+ reason: `codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`
3327
+ })
3328
+ );
3329
+ }
3330
+ return;
3331
+ }
3332
+ const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
3333
+ for (const agentEvent of mapped) {
3334
+ if (agentEvent.type === "turn_end") turnEnded = true;
3335
+ params.queue.push(agentEvent);
3336
+ }
3337
+ if (evt.type === "turn.failed") {
3338
+ params.terminal.failure = new RuntimeExecutionFailure({
3339
+ phase: "run",
3340
+ category: "semantic",
3341
+ retry: "non-retryable",
3342
+ reason: "codex reported terminal task failure"
3343
+ });
3344
+ params.queue.end();
3345
+ }
3346
+ if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
3347
+ params.recordUnmapped(unmappedFrameKey(evt));
2551
3348
  }
2552
- return;
2553
- }
2554
- const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
2555
- for (const agentEvent of mapped) {
2556
- if (agentEvent.type === "turn_end") turnEnded = true;
2557
- params.queue.push(agentEvent);
2558
- }
2559
- if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
2560
- params.recordUnmapped(unmappedFrameKey(evt));
2561
3349
  }
2562
- }
2563
- });
3350
+ });
3351
+ } catch (cause) {
3352
+ throw new RuntimeExecutionFailure({
3353
+ phase: params.failurePhase,
3354
+ category: "infrastructure",
3355
+ retry: "retryable",
3356
+ reason: "codex runtime process could not be spawned"
3357
+ }, { cause });
3358
+ }
3359
+ params.onRunnerCreated?.(runner);
2564
3360
  void runner.waitClosed().then(() => {
2565
- if (turnEnded) return;
2566
- params.queue.push({ type: "error", message: runner.buildExitError("codex exited without completing the turn").message });
3361
+ if (turnEnded || params.terminal.failure) return;
3362
+ const cause = runner.buildExitError("codex exited without completing the turn");
3363
+ params.terminal.failure = new RuntimeExecutionFailure({
3364
+ phase: "run",
3365
+ category: "infrastructure",
3366
+ retry: "retryable",
3367
+ reason: cause.message
3368
+ }, { cause });
3369
+ params.queue.push({ type: "error", message: cause.message });
2567
3370
  params.queue.end();
2568
3371
  });
2569
3372
  let sessionRef;
@@ -2587,7 +3390,13 @@ async function runCodexTurn(params) {
2587
3390
  void runner.waitClosed().then(() => {
2588
3391
  if (!settled) {
2589
3392
  settled = true;
2590
- reject(runner.buildExitError("codex exited before yielding an authoritative thread id"));
3393
+ const cause = runner.buildExitError("codex exited before yielding an authoritative thread id");
3394
+ reject(new RuntimeExecutionFailure({
3395
+ phase: params.failurePhase,
3396
+ category: "infrastructure",
3397
+ retry: "retryable",
3398
+ reason: cause.message
3399
+ }, { cause }));
2591
3400
  }
2592
3401
  });
2593
3402
  });
@@ -2597,9 +3406,12 @@ async function runCodexTurn(params) {
2597
3406
  }
2598
3407
  if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
2599
3408
  runner.kill();
2600
- throw new Error(
2601
- `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)`
2602
- );
3409
+ throw new RuntimeExecutionFailure({
3410
+ phase: params.failurePhase,
3411
+ category: "authority",
3412
+ retry: "non-retryable",
3413
+ reason: `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef})`
3414
+ });
2603
3415
  }
2604
3416
  return { sessionRef, runner };
2605
3417
  }
@@ -2626,8 +3438,12 @@ var CodexSession = class {
2626
3438
  recordUnmapped;
2627
3439
  preparedGit;
2628
3440
  modelId;
3441
+ terminal;
2629
3442
  currentRunner;
3443
+ ownedRunners = /* @__PURE__ */ new Set();
3444
+ followUpAttempts = /* @__PURE__ */ new Set();
2630
3445
  closed = false;
3446
+ closeAttempt;
2631
3447
  constructor(options) {
2632
3448
  this.sessionRef = options.sessionRef;
2633
3449
  this.command = options.command;
@@ -2638,11 +3454,36 @@ var CodexSession = class {
2638
3454
  this.recordUnmapped = options.recordUnmapped;
2639
3455
  this.preparedGit = options.preparedGit;
2640
3456
  this.modelId = options.modelId;
3457
+ this.terminal = options.terminal;
2641
3458
  this.currentRunner = options.initialRunner;
3459
+ this.ownedRunners.add(options.initialRunner);
2642
3460
  void this.forgetRunnerOnceClosed(options.initialRunner);
2643
3461
  }
2644
3462
  get events() {
2645
- return this.queue;
3463
+ const queue = this.queue;
3464
+ const session = this;
3465
+ return {
3466
+ [Symbol.asyncIterator]() {
3467
+ const inner = queue[Symbol.asyncIterator]();
3468
+ return {
3469
+ async next() {
3470
+ let result;
3471
+ try {
3472
+ result = await inner.next();
3473
+ } catch (cause) {
3474
+ throw new RuntimeExecutionFailure({
3475
+ phase: "run",
3476
+ category: "infrastructure",
3477
+ retry: "retryable",
3478
+ reason: "codex runtime event transport failed"
3479
+ }, { cause });
3480
+ }
3481
+ if (result.done && session.terminal.failure) throw session.terminal.failure;
3482
+ return result;
3483
+ }
3484
+ };
3485
+ }
3486
+ };
2646
3487
  }
2647
3488
  async forgetRunnerOnceClosed(runner) {
2648
3489
  await runner.waitClosed();
@@ -2687,7 +3528,14 @@ var CodexSession = class {
2687
3528
  * stale id even after codex had moved on) — it just can now only ever be
2688
3529
  * the SAME id this call asked to resume, never a silently-different one.
2689
3530
  */
2690
- async followUp(task) {
3531
+ followUp(task) {
3532
+ const attempt = this.runFollowUp(task);
3533
+ this.followUpAttempts.add(attempt);
3534
+ void attempt.finally(() => this.followUpAttempts.delete(attempt)).catch(() => {
3535
+ });
3536
+ return attempt;
3537
+ }
3538
+ async runFollowUp(task) {
2691
3539
  if (typeof task.instruction !== "string") {
2692
3540
  throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2693
3541
  }
@@ -2698,7 +3546,7 @@ var CodexSession = class {
2698
3546
  if (!mapping.ok) {
2699
3547
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2700
3548
  }
2701
- const requestedModel = subscriptionModel2(task);
3549
+ const requestedModel = subscriptionModel2(task.dispatchSelection);
2702
3550
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2703
3551
  throw new PolicyUnsupportedError(
2704
3552
  `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2708,6 +3556,7 @@ var CodexSession = class {
2708
3556
  const resumeRef = this.sessionRef;
2709
3557
  let sessionRef;
2710
3558
  let runner;
3559
+ const terminal = {};
2711
3560
  try {
2712
3561
  ({ sessionRef, runner } = await runCodexTurn({
2713
3562
  command: this.command,
@@ -2722,25 +3571,54 @@ var CodexSession = class {
2722
3571
  queue: this.queue,
2723
3572
  recordUnmapped: this.recordUnmapped,
2724
3573
  expectedSessionRef: resumeRef,
2725
- preparedGit: this.preparedGit
3574
+ preparedGit: this.preparedGit,
3575
+ failurePhase: "run",
3576
+ terminal,
3577
+ onRunnerCreated: (created) => {
3578
+ this.ownedRunners.add(created);
3579
+ void this.forgetRunnerOnceClosed(created);
3580
+ }
2726
3581
  }));
2727
3582
  } catch (err) {
2728
3583
  this.queue.end();
3584
+ this.terminal = {
3585
+ failure: isRuntimeExecutionFailure(err) ? err : new RuntimeExecutionFailure({
3586
+ phase: "run",
3587
+ category: "authority",
3588
+ retry: "non-retryable",
3589
+ reason: "codex follow-up violated the runtime adapter contract"
3590
+ }, { cause: err })
3591
+ };
2729
3592
  throw err;
2730
3593
  }
3594
+ if (this.closed) {
3595
+ await runner.dispose();
3596
+ throw new Error("codex session closed while follow-up was starting");
3597
+ }
3598
+ this.terminal = terminal;
2731
3599
  this.sessionRef = sessionRef;
2732
3600
  this.currentRunner = runner;
2733
- void this.forgetRunnerOnceClosed(runner);
2734
3601
  }
2735
3602
  /** 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. */
2736
3603
  async interrupt() {
2737
3604
  this.currentRunner?.kill();
2738
3605
  }
2739
3606
  async close() {
2740
- if (this.closed) return;
2741
- this.closed = true;
2742
- this.currentRunner?.kill();
2743
- this.queue.end();
3607
+ if (!this.closeAttempt) {
3608
+ this.closed = true;
3609
+ this.queue.end();
3610
+ const attempt = (async () => {
3611
+ const runners = [...this.ownedRunners];
3612
+ await Promise.all(runners.map((runner) => runner.dispose()));
3613
+ for (const runner of runners) this.ownedRunners.delete(runner);
3614
+ await Promise.allSettled([...this.followUpAttempts]);
3615
+ })();
3616
+ this.closeAttempt = attempt.catch((error) => {
3617
+ this.closeAttempt = void 0;
3618
+ throw error;
3619
+ });
3620
+ }
3621
+ await this.closeAttempt;
2744
3622
  }
2745
3623
  /**
2746
3624
  * `codex exec` has no in-band channel to inject text into an already-
@@ -2776,8 +3654,7 @@ var CodexSession = class {
2776
3654
  );
2777
3655
  }
2778
3656
  };
2779
- function subscriptionModel2(task) {
2780
- const selection = task.dispatchSelection;
3657
+ function subscriptionModel2(selection) {
2781
3658
  if (selection === void 0) return void 0;
2782
3659
  if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
2783
3660
  throw new PolicyUnsupportedError(
@@ -2842,7 +3719,7 @@ var ApprovalRegistry = class {
2842
3719
  * `requestApproval` timeout and `finish()` fail-closed cleanup
2843
3720
  * (`task-runner.ts`) — resolves a decision this device made on its own.
2844
3721
  * The one exception, a server-sent wire `task.approve`/`task.reject`
2845
- * relayed through `TaskContext.approvalChannel.resolve`
3722
+ * relayed through `RuntimeOperationStartInput.approvalChannel.resolve`
2846
3723
  * (`task-runner.ts`'s `handleOffer`), passes `'wire'` explicitly.
2847
3724
  */
2848
3725
  resolve(approvalId, decision, reason, origin = "local") {
@@ -3473,7 +4350,10 @@ var PresencePublisher = class {
3473
4350
  {
3474
4351
  method: "PUT",
3475
4352
  headers: { "content-type": "application/json" },
3476
- body: JSON.stringify({ level: "online" })
4353
+ body: JSON.stringify({
4354
+ level: "online",
4355
+ ...this.opts.configuredToolsets === void 0 ? {} : { configuredToolsets: this.opts.configuredToolsets }
4356
+ })
3477
4357
  },
3478
4358
  this.opts.auth
3479
4359
  );
@@ -3667,12 +4547,12 @@ var AnotherControlServerRunningError = class extends Error {
3667
4547
  }
3668
4548
  };
3669
4549
  var MAX_HALF_OPEN_CONNECTIONS = 8;
3670
- function errorMessage2(err) {
4550
+ function errorMessage3(err) {
3671
4551
  return err instanceof Error ? err.message : String(err);
3672
4552
  }
3673
4553
  function toControlErrorShape(err) {
3674
4554
  if (err instanceof ControlError) return { code: err.code, message: err.message };
3675
- return { code: "internal_error", message: errorMessage2(err) };
4555
+ return { code: "internal_error", message: errorMessage3(err) };
3676
4556
  }
3677
4557
  function probeUnixSocketAlive(socketPath) {
3678
4558
  return new Promise((resolve) => {
@@ -3879,12 +4759,19 @@ async function startControlServer(opts) {
3879
4759
  }
3880
4760
  throw err;
3881
4761
  }
4762
+ let stopServingPromise;
4763
+ async function stopServing() {
4764
+ stopServingPromise ??= (async () => {
4765
+ for (const socket of sockets) socket.destroy();
4766
+ await new Promise((resolve) => server.close(() => resolve()));
4767
+ })();
4768
+ await stopServingPromise;
4769
+ }
3882
4770
  let closed = false;
3883
4771
  async function close() {
3884
4772
  if (closed) return;
3885
4773
  closed = true;
3886
- for (const socket of sockets) socket.destroy();
3887
- await new Promise((resolve) => server.close(() => resolve()));
4774
+ await stopServing();
3888
4775
  if (process.platform !== "win32") {
3889
4776
  await promises.rm(endpoint, { force: true }).catch(() => {
3890
4777
  });
@@ -3892,7 +4779,7 @@ async function startControlServer(opts) {
3892
4779
  await promises.rm(tokenPath, { force: true }).catch(() => {
3893
4780
  });
3894
4781
  }
3895
- return { endpoint, close };
4782
+ return { endpoint, stopServing, close };
3896
4783
  }
3897
4784
  function deterministicJitterMs(input) {
3898
4785
  const ratio = input.ratio ?? 0.2;
@@ -3928,14 +4815,17 @@ function parseLooseEventsPollResponse(raw) {
3928
4815
  if (typeof raw !== "object" || raw === null) {
3929
4816
  throw new Error("events poll response is not an object");
3930
4817
  }
3931
- const { events, cursor } = raw;
4818
+ const { events, cursor, capabilities } = raw;
3932
4819
  if (!Array.isArray(events)) {
3933
4820
  throw new Error("events poll response.events is not an array");
3934
4821
  }
3935
4822
  if (typeof cursor !== "number" || !Number.isInteger(cursor)) {
3936
4823
  throw new Error("events poll response.cursor is not an integer");
3937
4824
  }
3938
- return { events, cursor };
4825
+ if (capabilities !== void 0 && (!Array.isArray(capabilities) || capabilities.some((flag) => typeof flag !== "string"))) {
4826
+ throw new Error("events poll response.capabilities is not an array of strings");
4827
+ }
4828
+ return { events, cursor, capabilities: capabilities ?? [] };
3939
4829
  }
3940
4830
  function extractSkippableSeq(raw) {
3941
4831
  if (typeof raw !== "object" || raw === null) return void 0;
@@ -4015,12 +4905,14 @@ var LongPollClient = class {
4015
4905
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
4016
4906
  const res = await authedFetch(url, { method: "GET" }, this.opts.auth);
4017
4907
  if (!res.ok) {
4908
+ this.opts.onServerCapabilities?.([]);
4018
4909
  this.opts.onOperationalOutcome?.("failure");
4019
4910
  const baseMs = this.opts.retryDelayMs ?? 2e3;
4020
4911
  await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
4021
4912
  continue;
4022
4913
  }
4023
4914
  const parsed = parseLooseEventsPollResponse(await res.json());
4915
+ this.opts.onServerCapabilities?.(parsed.capabilities);
4024
4916
  let hadValidationFailureThisBatch = false;
4025
4917
  for (const raw of parsed.events) {
4026
4918
  let envelope;
@@ -4064,6 +4956,7 @@ var LongPollClient = class {
4064
4956
  this.opts.onOperationalOutcome?.("success");
4065
4957
  }
4066
4958
  } catch (err) {
4959
+ this.opts.onServerCapabilities?.([]);
4067
4960
  if (err instanceof DeviceRevokedError) {
4068
4961
  this.running = false;
4069
4962
  this.opts.onRevoked?.();
@@ -4186,6 +5079,7 @@ var WsTransport = class {
4186
5079
  deviceId: this.opts.deviceId,
4187
5080
  productId: this.opts.productId,
4188
5081
  runtimes: this.opts.runtimes,
5082
+ configuredToolsets: this.opts.configuredToolsets === void 0 ? void 0 : [...this.opts.configuredToolsets],
4189
5083
  cursor: this.opts.getCursor?.()
4190
5084
  });
4191
5085
  socket.send(encodeEnvelope(hello));
@@ -4275,6 +5169,7 @@ var ConnectionManager = class {
4275
5169
  productId: opts.productId,
4276
5170
  capabilities: opts.capabilities,
4277
5171
  runtimes: opts.runtimes,
5172
+ configuredToolsets: opts.configuredToolsets,
4278
5173
  getCursor: () => this.cursor,
4279
5174
  onEnvelope: (envelope) => this.deliver(envelope),
4280
5175
  onStateChange: (state) => {
@@ -4297,6 +5192,9 @@ var ConnectionManager = class {
4297
5192
  // re-attempted.
4298
5193
  getCursor: () => this.dedupWatermark(),
4299
5194
  onEnvelope: (envelope) => this.deliver(envelope),
5195
+ onServerCapabilities: (capabilities) => {
5196
+ if (this.mode === "long-poll") this.serverCapabilities = capabilities;
5197
+ },
4300
5198
  onRevoked: () => this.enterRevoked(),
4301
5199
  // M4 Phase 4 (version-negotiation drill fix): a batch entry
4302
5200
  // LongPollClient couldn't parse into a known Envelope at all (an
@@ -4397,7 +5295,7 @@ var ConnectionManager = class {
4397
5295
  * already-delivered seqs (see its own doc comment) so the failed seq's own
4398
5296
  * redelivery can get through — but that same frozen watermark also means
4399
5297
  * every OTHER seq above it rides along on every re-poll too. Without this,
4400
- * a seq already mid-flight (e.g. a `task.offer` whose `adapter.start()`
5298
+ * a seq already mid-flight (e.g. a `task.offer` whose prepared operation start()
4401
5299
  * hasn't resolved yet) would be re-enqueued into `processingChain` on
4402
5300
  * every such re-poll, piling up duplicate copies that — once the first
4403
5301
  * finally resolves and the chain unwinds through them — run its handler
@@ -4428,25 +5326,24 @@ var ConnectionManager = class {
4428
5326
  */
4429
5327
  cancelPendingDrainRetry;
4430
5328
  /**
4431
- * The capabilities the CURRENTLY connected server advertised in its
4432
- * `conn.ack` — untyped `string[]` (forward-compat: a server may advertise
4433
- * a flag this build doesn't recognize yet), populated by {@link onAcked}
4434
- * and read by {@link getServerCapabilities}. Empty until the very first
4435
- * successful handshake.
5329
+ * The capabilities the CURRENT transport's server advertised untyped
5330
+ * `string[]` for forward compatibility. WS populates it from `conn.ack`;
5331
+ * long-poll populates it from each successful events response. Empty until
5332
+ * the active transport supplies an advertisement.
4436
5333
  *
4437
5334
  * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
4438
5335
  * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
4439
5336
  * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
4440
5337
  * `acked` branch), `stop()`, or a transport switch to long-poll
4441
- * (`enterLongPoll`) — and only ever repopulated by a FRESH `conn.ack`.
5338
+ * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
5339
+ * the transport that is still current.
4442
5340
  * The previous version of this doc comment claimed long-poll mode simply
4443
5341
  * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
4444
5342
  * a daemon that once learned e.g. `approval_resolved` from an earlier WS
4445
5343
  * session kept believing it applied to whatever it's connected to NOW,
4446
5344
  * even after a disconnect/degrade where nothing has actually confirmed
4447
5345
  * that's still true (a reconnect could land on a DIFFERENT server behind a
4448
- * load balancer; long-poll fallback itself never performs an equivalent
4449
- * handshake at all). Concretely, `TaskRunner.sendApprovalResolved` gates
5346
+ * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
4450
5347
  * `task.approval_resolved` on this list — sending it to a server that
4451
5348
  * doesn't actually understand it over the long-poll path would get a
4452
5349
  * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
@@ -4566,13 +5463,10 @@ var ConnectionManager = class {
4566
5463
  return this.mode === "long-poll";
4567
5464
  }
4568
5465
  /**
4569
- * The capabilities the CURRENTLY connected server advertised in its
4570
- * `conn.ack` e.g. lets a caller gate a daemon->server message on whether
4571
- * THIS server understands it before sending (see `task-runner.ts`'s
4572
- * `sendApprovalResolved`, gated on `approval_resolved`). Empty before the
4573
- * first handshake completes, AND (finding R2) once again empty after any
4574
- * disconnect/degrade — see `serverCapabilities`'s own doc comment for why
4575
- * this is strictly per-connection rather than "sticky" across one.
5466
+ * The capabilities the CURRENT transport's server advertised: from
5467
+ * `conn.ack` on WS, or the latest successful `GET /byok/events` response
5468
+ * on long-poll. Empty before either transport has supplied its current
5469
+ * advertisement, and cleared across disconnect/switch boundaries.
4576
5470
  */
4577
5471
  getServerCapabilities() {
4578
5472
  return this.serverCapabilities;
@@ -5273,6 +6167,12 @@ var RECLAIM_FILENAME = `${DAEMON_OWNER_FILENAME}.reclaim`;
5273
6167
  var MAX_OWNER_BYTES = 4096;
5274
6168
  var RECLAIM_MALFORMED_GRACE_MS = 3e4;
5275
6169
  var SELF_PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1e3).toISOString();
6170
+ function endOwnershipProbe(socket, response) {
6171
+ socket.on("error", () => {
6172
+ });
6173
+ if (response === void 0) socket.end();
6174
+ else socket.end(response);
6175
+ }
5276
6176
  var STORE_MUTEX_ID_PREFIX = "byok-store-mutex-v1:";
5277
6177
  var STORE_MUTEX_PROBE_TIMEOUT_MS = 1e3;
5278
6178
  var STORE_MUTEX_SOCKET_FILENAME = "mutex.sock";
@@ -5379,7 +6279,7 @@ async function portIsBound(port) {
5379
6279
  });
5380
6280
  }
5381
6281
  async function createLivenessListener() {
5382
- const server = createServer((socket) => socket.end());
6282
+ const server = createServer((socket) => endOwnershipProbe(socket));
5383
6283
  const port = await new Promise((resolve, reject) => {
5384
6284
  server.once("error", reject);
5385
6285
  server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
@@ -5464,7 +6364,7 @@ async function acquireStoreMutex(canonicalStoreDir) {
5464
6364
  }
5465
6365
  await clearStaleStoreMutexSocket(endpoint, identity);
5466
6366
  }
5467
- const server = createServer((socket) => socket.end(`${STORE_MUTEX_ID_PREFIX}${identity}
6367
+ const server = createServer((socket) => endOwnershipProbe(socket, `${STORE_MUTEX_ID_PREFIX}${identity}
5468
6368
  `));
5469
6369
  try {
5470
6370
  await new Promise((resolve, reject) => {
@@ -5606,7 +6506,7 @@ function toRuntimeInfoCapabilities(caps) {
5606
6506
  resume: caps.resume,
5607
6507
  approvalInteractive: caps.approvalInteractive,
5608
6508
  ...caps.mcpToolsets === void 0 ? {} : { mcpToolsets: caps.mcpToolsets },
5609
- permissionModes: caps.permissionModes
6509
+ permissionModes: [...caps.permissionModes]
5610
6510
  };
5611
6511
  }
5612
6512
  var CursorStore = class {
@@ -5846,6 +6746,9 @@ var DaemonObserver = class {
5846
6746
  noteGitWorkspace(event) {
5847
6747
  this.emit({ kind: "git-workspace", ts: nowIso(), ...event });
5848
6748
  }
6749
+ noteRuntimeDisposalFailure(event) {
6750
+ this.emit({ kind: "runtime-disposal-failed", ts: nowIso(), ...event });
6751
+ }
5849
6752
  /**
5850
6753
  * Finding F4: wired from `TaskRunnerDeps.onApprovalDispatched`, called
5851
6754
  * synchronously by `TaskRunner.dispatchApproval` BEFORE its own
@@ -7499,20 +8402,20 @@ function isKnownRuntimeId(id) {
7499
8402
  var DEFAULT_RUNTIME_PREFERENCE = ["claude", "codex", "pi"];
7500
8403
  function orderByPreference(candidates, preference) {
7501
8404
  const rank = new Map(preference.map((id, index) => [id, index]));
7502
- return [...candidates].sort((a, b) => (rank.get(a.id) ?? preference.length) - (rank.get(b.id) ?? preference.length));
8405
+ return [...candidates].sort((a, b) => (rank.get(a.descriptor.id) ?? preference.length) - (rank.get(b.descriptor.id) ?? preference.length));
7503
8406
  }
7504
- function adapterSupportsMode(adapter, mode) {
7505
- return adapter.capabilities().permissionModes.includes(mode);
8407
+ function adapterSupportsMode(descriptor, mode) {
8408
+ return descriptor.capabilities.permissionModes.includes(mode);
7506
8409
  }
7507
- function adapterSupportsMcpToolsets(adapter) {
7508
- return adapter.capabilities().mcpToolsets === true;
8410
+ function adapterSupportsMcpToolsets(descriptor) {
8411
+ return descriptor.capabilities.mcpToolsets === true;
7509
8412
  }
7510
8413
  function withoutRequiredToolsets(payload) {
7511
8414
  if (!("requiredToolsets" in payload)) return payload;
7512
8415
  const { requiredToolsets, ...offer } = payload;
7513
8416
  return offer;
7514
8417
  }
7515
- function errorMessage3(err) {
8418
+ function errorMessage4(err) {
7516
8419
  return err instanceof Error ? err.message : String(err);
7517
8420
  }
7518
8421
  function raceSettleFirst(fn, timeoutMs) {
@@ -7565,7 +8468,7 @@ async function openArtifact(workspaceDir, name) {
7565
8468
  try {
7566
8469
  handle = await promises.open(candidate, constants.O_RDONLY | O_NOFOLLOW);
7567
8470
  } catch (err) {
7568
- return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage3(err)}` };
8471
+ return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage4(err)}` };
7569
8472
  }
7570
8473
  try {
7571
8474
  const st = await handle.stat();
@@ -7577,7 +8480,7 @@ async function openArtifact(workspaceDir, name) {
7577
8480
  } catch (err) {
7578
8481
  await handle.close().catch(() => {
7579
8482
  });
7580
- return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage3(err)}` };
8483
+ return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage4(err)}` };
7581
8484
  }
7582
8485
  return { ok: true, handle };
7583
8486
  }
@@ -7591,13 +8494,13 @@ var TaskRunner = class {
7591
8494
  * Finding F4 (cancel lost during the offer-processing window): a
7592
8495
  * `task.cancel` for a taskId that hasn't finished `handleOffer` yet (still
7593
8496
  * awaiting adapter detection / instruction resolution / workspace setup /
7594
- * `adapter.start()`) has no `this.tasks` entry to land on — it used to be
8497
+ * prepared operation `start()`) has no `this.tasks` entry to land on — it used to be
7595
8498
  * silently dropped, and the runtime session `handleOffer` was about to
7596
8499
  * register would then run an unsupervised ("zombie") turn nobody asked
7597
8500
  * for anymore. Recording the taskId here lets `handleOffer` consult it at
7598
8501
  * the two points where it can still safely react (see its body): before
7599
8502
  * claiming at all (decline instead of ever starting a session), and right
7600
- * after `adapter.start()` resolves but before this task is registered as
8503
+ * after the prepared operation resolves but before this task is registered as
7601
8504
  * active (tear the just-started session down immediately, before its
7602
8505
  * event loop ever pumps a single event). Consumed (deleted) at whichever
7603
8506
  * checkpoint handles it; a cancel for a taskId that's already active,
@@ -7623,12 +8526,12 @@ var TaskRunner = class {
7623
8526
  * checkpoint-2 cancel-teardown, or successful registration into
7624
8527
  * `this.tasks`). Bounded eviction on `pendingCancelled` (below) must never
7625
8528
  * remove an entry for a taskId in this set: doing so is exactly the bug —
7626
- * block task A in `adapter.start()`, deliver A's own `task.cancel` (so
8529
+ * block task A in prepared-operation `start()`, deliver A's own `task.cancel` (so
7627
8530
  * `pendingCancelled` gets an entry for A while A is still in-flight),
7628
8531
  * then deliver `MAX_TRACKED_TASK_IDS` more cancels for unrelated taskIds
7629
8532
  * nobody ever offered — under naive oldest-wins eviction, A's entry (the
7630
8533
  * single oldest) gets evicted purely because of unrelated churn, so when
7631
- * `adapter.start()` finally resolves, checkpoint 2 finds no cancel marker
8534
+ * the prepared operation finally resolves, checkpoint 2 finds no cancel marker
7632
8535
  * and the already-cancelled task starts a real session. See
7633
8536
  * `evictPendingCancelled` below for the fix, and
7634
8537
  * `task-runner-bounded-collections.test.ts` for a test mirroring this
@@ -7648,7 +8551,7 @@ var TaskRunner = class {
7648
8551
  * explicitly relies on redelivered handlers being idempotent for exactly
7649
8552
  * this reason). `handleOffer` must treat a redelivered offer for a taskId
7650
8553
  * that's already active (`this.tasks`) or already finished (this set) as
7651
- * a no-op — never a second `adapter.start()` call, which would orphan the
8554
+ * a no-op — never a second prepared-operation `start()` call, which would orphan the
7652
8555
  * first session.
7653
8556
  *
7654
8557
  * M3-B: unbounded otherwise — a long-lived daemon that's finished many
@@ -7717,10 +8620,9 @@ var TaskRunner = class {
7717
8620
  this.stoppingOffers = true;
7718
8621
  }
7719
8622
  /**
7720
- * M4 Phase 2: best-effort shutdown of every currently ACTIVE task, for the
7721
- * control socket's `shutdown` RPC. Mirrors `handleCancel`'s best-effort
7722
- * `session.interrupt()` style (an interrupt failure is swallowed; the
7723
- * terminal message is sent either way) but reports `task.fail` rather than
8623
+ * Shutdown of every currently ACTIVE task for the control socket's
8624
+ * `shutdown` RPC. Soft interrupt remains bounded, but each task's
8625
+ * authoritative close receipt must settle successfully. Reports `task.fail` rather than
7724
8626
  * `task.cancelled` — these tasks aren't ending because the SERVER
7725
8627
  * cancelled them, they're ending because this device is shutting down.
7726
8628
  * `retryable: true` throughout: nothing about the task/policy itself was
@@ -7774,28 +8676,13 @@ var TaskRunner = class {
7774
8676
  * unconditionally, so a hung `interrupt()` (a misbehaving adapter) can
7775
8677
  * never block `task.fail` from being sent at all.
7776
8678
  *
7777
- * New in this batch hard-kill escalation: when `interrupt()` does NOT
7778
- * settle within that same grace window, `session.close()` is tried next
7779
- * (ALSO raced against `timeoutMs`, for the identical reason: a hung
7780
- * `close()` must not be able to block this forever either — which matters
7781
- * far more here than it used to for the pre-existing graceful-shutdown-only
7782
- * caller, since THAT path is additionally bounded by an outer deadline
7783
- * (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`/`DaemonConfig.shutdownGraceMs`,
7784
- * `create-daemon.ts`), while resource-limit enforcement fires during
7785
- * ordinary operation with no such outer bound watching it). `close()` is
7786
- * every adapter's harder teardown primitive — an actual process-level kill
7787
- * (SIGTERM, or `taskkill /F` on Windows — see e.g.
7788
- * `ClaudeProcessClient.kill()`/`PiRpcClient.kill()`) as opposed to pi's own
7789
- * soft in-band `interrupt()` (an RPC `abort` message that leaves the
7790
- * process alive and resumable) — so escalating to it is the closest thing
7791
- * to a "hard kill" the `Session` interface exposes. `finish()` below calls
7792
- * `session.close()` again regardless (documented idempotent) — this isn't
7793
- * a substitute for that, only an earlier, bounded attempt at actually
7794
- * stopping a stuck runtime before this method gives up and reports failure
7795
- * anyway.
8679
+ * After the bounded soft interrupt, `finish()` always awaits the authoritative
8680
+ * `Session.close()` receipt. A failed receipt retains active/Git ownership;
8681
+ * shutdown surfaces the rejection while resource enforcement leaves local
8682
+ * evidence for a later retry.
7796
8683
  *
7797
8684
  * Re-checks task identity (`this.tasks.get(...) === active`) immediately
7798
- * before sending `task.fail`: the interrupt/hard-kill race above has await
8685
+ * before sending `task.fail`: the interrupt race above has await
7799
8686
  * points during which a DIFFERENT path (a racing `task.cancel`/
7800
8687
  * `task.reject`, or the session completing normally on its own) may have
7801
8688
  * already finished this exact task and sent its own terminal message.
@@ -7804,20 +8691,25 @@ var TaskRunner = class {
7804
8691
  * identity-check guard for the same class of race.
7805
8692
  */
7806
8693
  async teardownActiveTask(active, reason, retryable) {
8694
+ if (active.finalizationStarted) return this.finish(active.taskId);
8695
+ if (!this.reserveSemanticTerminal(active)) return active.semanticTerminalSettled ?? false;
7807
8696
  active.beingTornDown = true;
7808
8697
  await this.observeGit(active, "salvage");
7809
8698
  const timeoutMs = this.deps.shutdownInterruptTimeoutMs ?? DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS;
7810
- const interrupted = await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
7811
- if (!interrupted) {
7812
- await raceSettleFirst(() => active.session.close(), timeoutMs);
7813
- }
7814
- if (this.tasks.get(active.taskId) !== active) return;
8699
+ await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
8700
+ if (this.tasks.get(active.taskId) !== active) return true;
7815
8701
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId: active.taskId }));
7816
- await this.finish(active.taskId);
8702
+ return this.finish(active.taskId);
7817
8703
  }
7818
8704
  /** 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. */
7819
8705
  async shutdownTask(active, reason) {
7820
- await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8706
+ const disposed = await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8707
+ if (!disposed) {
8708
+ throw new RuntimeDisposalFailure({
8709
+ stage: "quiescence",
8710
+ reason: `${active.adapter.descriptor.id} runtime ownership remains active after shutdown disposal failed`
8711
+ });
8712
+ }
7821
8713
  }
7822
8714
  /**
7823
8715
  * M5 batch-3 (workstream 2): shared entry point for both resource-limit
@@ -7937,15 +8829,33 @@ var TaskRunner = class {
7937
8829
  this.decline(taskId, resolvedMcp.reason, true);
7938
8830
  return;
7939
8831
  }
8832
+ const decision = computeEffectivePolicy(payload.policy, this.deps.permissionDefaults);
8833
+ if (!decision.ok) {
8834
+ this.decline(taskId, decision.reason ?? "policy rejected", false);
8835
+ return;
8836
+ }
8837
+ const offered = withoutRequiredToolsets(payload);
7940
8838
  const requestedRuntime = payload.dispatchSelection?.runtimeId ?? payload.runtime;
7941
8839
  const pick = await this.pickAdapter(requestedRuntime, payload.policy.mode, requiredToolsets !== void 0);
7942
8840
  if (!pick.ok) {
7943
8841
  this.decline(taskId, pick.reason, pick.retryable);
7944
8842
  return;
7945
8843
  }
7946
- const decision = computeEffectivePolicy(payload.policy, this.deps.permissionDefaults);
7947
- if (!decision.ok) {
7948
- this.decline(taskId, decision.reason ?? "policy rejected", false);
8844
+ let prepared;
8845
+ try {
8846
+ prepared = await pick.adapter.prepare({
8847
+ offer: offered,
8848
+ policy: decision.policy,
8849
+ descriptor: pick.descriptor,
8850
+ requiredToolsetIds: requiredToolsets ?? [],
8851
+ ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {}
8852
+ });
8853
+ } catch (error) {
8854
+ this.decline(taskId, `runtime preparation failed: ${errorMessage4(error)}`, true);
8855
+ return;
8856
+ }
8857
+ if (prepared.kind === "reject") {
8858
+ this.decline(taskId, prepared.reason, prepared.retryable);
7949
8859
  return;
7950
8860
  }
7951
8861
  let known = void 0;
@@ -7980,6 +8890,7 @@ var TaskRunner = class {
7980
8890
  }
7981
8891
  } else {
7982
8892
  workspaceDir = path20.join(this.deps.workspaceRoot, taskId);
8893
+ gitWorkspaceId = randomUUID();
7983
8894
  }
7984
8895
  try {
7985
8896
  gitLease = await gitManager.acquireLease(workspaceDir, payload.sessionRef);
@@ -7995,6 +8906,26 @@ var TaskRunner = class {
7995
8906
  this.decline(taskId, "workspace mode is unavailable", true);
7996
8907
  return;
7997
8908
  }
8909
+ const env = buildRuntimeEnv({
8910
+ ambient: process.env,
8911
+ requirements: pick.descriptor.environmentRequirements,
8912
+ locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.descriptor.id]?.allow
8913
+ });
8914
+ const manifest = sealRuntimeOperationManifest({
8915
+ taskId,
8916
+ runtimeId: pick.descriptor.id,
8917
+ descriptor: pick.descriptor,
8918
+ policy: decision.policy,
8919
+ requiredToolsetIds: requiredToolsets ?? [],
8920
+ ...offered.dispatchSelection === void 0 ? {} : { dispatchSelection: offered.dispatchSelection },
8921
+ ...known === void 0 || payload.sessionRef === void 0 ? {} : { sessionRef: payload.sessionRef },
8922
+ workspace: {
8923
+ workspaceDir,
8924
+ ...gitWorkspaceId === void 0 ? {} : { workspaceId: gitWorkspaceId },
8925
+ ...gitBaseline === void 0 ? {} : { baseline: gitBaseline }
8926
+ },
8927
+ forwardedEnvironmentNames: Object.freeze(Object.keys(env).sort())
8928
+ });
7998
8929
  this.deps.send(
7999
8930
  createEnvelope(
8000
8931
  "task.claim",
@@ -8010,7 +8941,7 @@ var TaskRunner = class {
8010
8941
  // (the merely REQUESTED runtime): this is what closes the gap
8011
8942
  // where an auto-selected task left the server never learning
8012
8943
  // which runtime actually ran.
8013
- runtime: isKnownRuntimeId(pick.adapter.id) ? pick.adapter.id : void 0,
8944
+ runtime: isKnownRuntimeId(manifest.descriptor.id) ? manifest.descriptor.id : void 0,
8014
8945
  // S0/D-4 (`task.claim.capabilities`, docs/protocol.md §2.4): the
8015
8946
  // selected adapter's own capability self-report, carried on the
8016
8947
  // same message that establishes the task↔runtime binding. The
@@ -8027,7 +8958,7 @@ var TaskRunner = class {
8027
8958
  // Gating them would silently strip a custom steer-capable
8028
8959
  // adapter's own truth and leave the server fail-closing on it
8029
8960
  // forever.
8030
- capabilities: toRuntimeInfoCapabilities(pick.adapter.capabilities())
8961
+ capabilities: toRuntimeInfoCapabilities(manifest.descriptor.capabilities)
8031
8962
  },
8032
8963
  { taskId }
8033
8964
  )
@@ -8038,7 +8969,7 @@ var TaskRunner = class {
8038
8969
  if (plainWorkspaceNeedsResolve) workspaceDir = await this.resolveWorkspaceDir(taskId, known?.workspaceDir);
8039
8970
  } catch (err) {
8040
8971
  gitLease?.release();
8041
- await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage3(err)}`, true);
8972
+ await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage4(err)}`, true);
8042
8973
  return;
8043
8974
  }
8044
8975
  if (this.deps.gitWorkspaceManager && gitLease) {
@@ -8047,7 +8978,7 @@ var TaskRunner = class {
8047
8978
  if (gitExisting) {
8048
8979
  observation = await this.deps.gitWorkspaceManager.validateExisting(workspaceDir);
8049
8980
  } else {
8050
- const workspaceId2 = randomUUID();
8981
+ const workspaceId2 = gitWorkspaceId ?? randomUUID();
8051
8982
  const now2 = (/* @__PURE__ */ new Date()).toISOString();
8052
8983
  gitWorkspaceId = workspaceId2;
8053
8984
  await this.deps.gitWorkspaceStore?.upsert({
@@ -8098,30 +9029,11 @@ var TaskRunner = class {
8098
9029
  return;
8099
9030
  }
8100
9031
  }
8101
- const ctx = {
8102
- workspaceDir,
8103
- policy: decision.policy,
9032
+ const startInput = {
9033
+ manifest,
9034
+ instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
9035
+ env,
8104
9036
  ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {},
8105
- ...gitWorkspaceId ? { gitWorkspace: { workspaceId: gitWorkspaceId, baseline: gitBaseline } } : {},
8106
- // M5: no longer `process.env` verbatim (see `environment.ts`'s own
8107
- // module doc comment for the credential-leak gap that closed) —
8108
- // built fresh per task from the SPECIFIC adapter `pickAdapter`
8109
- // above already selected, so this always runs after adapter
8110
- // selection: `pick.adapter.environmentRequirements?.()` (undefined
8111
- // ⇒ platform baseline only, fail-closed) plus this device's own
8112
- // `runtimeEnvironment` override, keyed by that same adapter's `id`.
8113
- env: buildRuntimeEnv({
8114
- ambient: process.env,
8115
- requirements: pick.adapter.environmentRequirements?.(),
8116
- locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.adapter.id]?.allow
8117
- }),
8118
- // M4 Phase 3: adapter-agnostic and cheap to always populate — only an
8119
- // adapter whose runtime genuinely supports an out-of-band approval
8120
- // pause (claude, today) ever reads this. `resolve` is a closure over
8121
- // `taskId` (not a pre-bound approvalId): it looks up whichever
8122
- // approval is CURRENTLY pending for this task at call time, since one
8123
- // task/session can face several approval requests, one at a time,
8124
- // over its life. See `types.ts`'s `ApprovalChannel` doc comment.
8125
9037
  approvalChannel: {
8126
9038
  taskId,
8127
9039
  storeDir: this.deps.storeDir,
@@ -8137,45 +9049,19 @@ var TaskRunner = class {
8137
9049
  }
8138
9050
  }
8139
9051
  };
8140
- const effectiveOffer = {
8141
- ...withoutRequiredToolsets(payload),
8142
- instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
8143
- // Never forward a sessionRef this device has no recorded workspace
8144
- // for (stale, from another device, or simply made up) — an adapter
8145
- // that tries to resume an id it never minted fails outright (pi:
8146
- // "No session found matching '<id>'", exit 1, empirically confirmed)
8147
- // instead of silently starting fresh, so an unresolvable sessionRef
8148
- // must look identical to "none supplied" by the time it reaches the
8149
- // adapter, not get forwarded as a resume attempt doomed to fail.
8150
- sessionRef: known ? payload.sessionRef : void 0
8151
- };
8152
9052
  let session;
8153
9053
  try {
8154
- session = await pick.adapter.start(effectiveOffer, ctx);
9054
+ session = await prepared.operation.start(startInput);
8155
9055
  } catch (err) {
8156
- const retryable = !(err instanceof PolicyUnsupportedError);
8157
- await this.updateGitPhaseBestEffort(gitWorkspaceId, "failed", "repository-invalid");
8158
- gitLease?.release();
8159
- await this.fail(taskId, `adapter failed to start: ${errorMessage3(err)}`, retryable);
8160
- return;
8161
- }
8162
- if (this.pendingCancelled.has(taskId)) {
8163
- const reason = this.pendingCancelled.get(taskId);
8164
- this.pendingCancelled.delete(taskId);
8165
- try {
8166
- await session.interrupt();
8167
- } catch {
8168
- }
8169
- try {
8170
- await session.close();
8171
- } catch {
9056
+ const failure = projectRuntimeBoundaryFailure(err, "start");
9057
+ if (failure.contractViolation) {
9058
+ console.error("[byok/client] runtime adapter start() returned an untyped failure", err);
8172
9059
  }
9060
+ await this.updateGitPhaseBestEffort(gitWorkspaceId, "failed", "repository-invalid");
8173
9061
  gitLease?.release();
8174
- await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
8175
- this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
9062
+ await this.fail(taskId, failure.reason, failure.retryable);
8176
9063
  return;
8177
9064
  }
8178
- this.deps.send(createEnvelope("task.started", {}, { taskId }));
8179
9065
  const active = {
8180
9066
  taskId,
8181
9067
  adapter: pick.adapter,
@@ -8192,6 +9078,21 @@ var TaskRunner = class {
8192
9078
  approvalQueue: [],
8193
9079
  outputBytesSoFar: 0
8194
9080
  };
9081
+ if (this.pendingCancelled.has(taskId)) {
9082
+ const reason = this.pendingCancelled.get(taskId);
9083
+ this.pendingCancelled.delete(taskId);
9084
+ this.tasks.set(taskId, active);
9085
+ this.reserveSemanticTerminal(active);
9086
+ try {
9087
+ await session.interrupt();
9088
+ } catch {
9089
+ }
9090
+ await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
9091
+ this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
9092
+ await this.finish(taskId);
9093
+ return;
9094
+ }
9095
+ this.deps.send(createEnvelope("task.started", {}, { taskId }));
8195
9096
  this.tasks.set(taskId, active);
8196
9097
  if (payload.limits?.maxDurationMs !== void 0) {
8197
9098
  this.armMaxDurationTimer(active, payload.limits.maxDurationMs);
@@ -8249,6 +9150,7 @@ var TaskRunner = class {
8249
9150
  async pump(active) {
8250
9151
  try {
8251
9152
  for await (const event of active.session.events) {
9153
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8252
9154
  if (this.tasks.get(active.taskId) !== active) return;
8253
9155
  active.outputBytesSoFar += estimateEventBytes(event);
8254
9156
  if (active.outputBytesSoFar > this.maxTaskOutputBytes) {
@@ -8271,7 +9173,7 @@ var TaskRunner = class {
8271
9173
  try {
8272
9174
  await active.session.resolveApproval(approved, reason);
8273
9175
  } catch (err) {
8274
- await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage3(err)}`, false);
9176
+ await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage4(err)}`, false);
8275
9177
  }
8276
9178
  });
8277
9179
  continue;
@@ -8283,6 +9185,7 @@ var TaskRunner = class {
8283
9185
  const outcome = await this.resolveResultDocument(active, finalOutput);
8284
9186
  if (!outcome.deliver) return;
8285
9187
  await this.observeGit(active, "completed");
9188
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8286
9189
  if (outcome.document !== void 0 && !this.hasResultDocumentCapability()) {
8287
9190
  await this.fail(
8288
9191
  active.taskId,
@@ -8291,6 +9194,7 @@ var TaskRunner = class {
8291
9194
  );
8292
9195
  return;
8293
9196
  }
9197
+ if (!this.reserveSemanticTerminal(active)) return;
8294
9198
  this.deps.send(
8295
9199
  createEnvelope(
8296
9200
  "task.complete",
@@ -8326,11 +9230,17 @@ var TaskRunner = class {
8326
9230
  active.batcher.push(event);
8327
9231
  }
8328
9232
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8329
- await this.fail(active.taskId, "runtime session ended without completing the task", true);
9233
+ const failure = projectRuntimeBoundaryFailure(void 0, "run");
9234
+ console.error("[byok/client] runtime adapter events iterable ended without terminal authority");
9235
+ await this.fail(active.taskId, failure.reason, failure.retryable);
8330
9236
  } catch (err) {
8331
9237
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8332
9238
  active.batcher.flush();
8333
- await this.fail(active.taskId, `runtime error: ${errorMessage3(err)}`, true);
9239
+ const failure = projectRuntimeBoundaryFailure(err, "run");
9240
+ if (failure.contractViolation) {
9241
+ console.error("[byok/client] runtime adapter events iterable returned an untyped failure", err);
9242
+ }
9243
+ await this.fail(active.taskId, failure.reason, failure.retryable);
8334
9244
  }
8335
9245
  }
8336
9246
  /**
@@ -8369,7 +9279,7 @@ var TaskRunner = class {
8369
9279
  try {
8370
9280
  bytes = await opened.handle.readFile();
8371
9281
  } catch (err) {
8372
- this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage3(err)}`);
9282
+ this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage4(err)}`);
8373
9283
  return;
8374
9284
  } finally {
8375
9285
  await opened.handle.close().catch(() => {
@@ -8386,7 +9296,7 @@ var TaskRunner = class {
8386
9296
  const blobRef = await this.deps.blobClient.uploadArtifact(bytes, contentType);
8387
9297
  this.deps.send(createEnvelope("task.artifact", { name, contentType, blobRef }, { taskId: active.taskId }));
8388
9298
  } catch (err) {
8389
- this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage3(err)}`);
9299
+ this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage4(err)}`);
8390
9300
  }
8391
9301
  }
8392
9302
  /** 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. */
@@ -8400,6 +9310,14 @@ var TaskRunner = class {
8400
9310
  this.setPendingCancelled(taskId, reason);
8401
9311
  return;
8402
9312
  }
9313
+ if (active.finalizationStarted) {
9314
+ await this.finish(taskId);
9315
+ return;
9316
+ }
9317
+ if (!this.reserveSemanticTerminal(active)) {
9318
+ await active.semanticTerminalSettled;
9319
+ return;
9320
+ }
8403
9321
  try {
8404
9322
  await active.session.interrupt();
8405
9323
  } catch {
@@ -8428,7 +9346,7 @@ var TaskRunner = class {
8428
9346
  *
8429
9347
  * `inFlightOffers` is naturally tiny (bounded by this device's real
8430
9348
  * concurrent-offer-processing count — normally single digits, driven by
8431
- * how many `task.offer`s are simultaneously mid-`adapter.start()` — nowhere
9349
+ * how many `task.offer`s are simultaneously mid-prepared-operation start() — nowhere
8432
9350
  * near `MAX_TRACKED_TASK_IDS`), so this scan is cheap in practice: it
8433
9351
  * finds a safe entry at or near the front almost always. The only case
8434
9352
  * where NO entry is safe to evict is every single tracked cancel
@@ -8781,7 +9699,7 @@ var TaskRunner = class {
8781
9699
  this.deps.onStaleApprovalDecision?.(taskId, "approve");
8782
9700
  return;
8783
9701
  }
8784
- await this.fail(taskId, `failed to resume session after approval: ${errorMessage3(err)}`, false);
9702
+ await this.fail(taskId, `failed to resume session after approval: ${errorMessage4(err)}`, false);
8785
9703
  return;
8786
9704
  }
8787
9705
  this.clearPendingApproval(resolvedId, "approve", void 0);
@@ -8812,6 +9730,10 @@ var TaskRunner = class {
8812
9730
  async handleReject(taskId, reason, approvalId) {
8813
9731
  const active = this.tasks.get(taskId);
8814
9732
  if (!active) return;
9733
+ if (active.finalizationStarted) {
9734
+ await this.finish(taskId);
9735
+ return;
9736
+ }
8815
9737
  if (approvalId !== void 0 && approvalId !== active.pendingApprovalId) {
8816
9738
  this.deps.onStaleApprovalDecision?.(
8817
9739
  taskId,
@@ -8830,6 +9752,10 @@ var TaskRunner = class {
8830
9752
  }
8831
9753
  }
8832
9754
  this.clearPendingApproval(resolvedId, "reject", reason);
9755
+ if (!this.reserveSemanticTerminal(active)) {
9756
+ await active.semanticTerminalSettled;
9757
+ return;
9758
+ }
8833
9759
  try {
8834
9760
  await active.session.interrupt();
8835
9761
  } catch {
@@ -8844,6 +9770,14 @@ var TaskRunner = class {
8844
9770
  }
8845
9771
  async fail(taskId, reason, retryable) {
8846
9772
  const active = this.tasks.get(taskId);
9773
+ if (active?.finalizationStarted) {
9774
+ await this.finish(taskId);
9775
+ return;
9776
+ }
9777
+ if (active && !this.reserveSemanticTerminal(active)) {
9778
+ await active.semanticTerminalSettled;
9779
+ return;
9780
+ }
8847
9781
  if (active) await this.observeGit(active, "salvage");
8848
9782
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId }));
8849
9783
  await this.finish(taskId);
@@ -8903,7 +9837,7 @@ var TaskRunner = class {
8903
9837
  } catch (err) {
8904
9838
  await this.fail(
8905
9839
  active.taskId,
8906
- `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage3(err)}`,
9840
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage4(err)}`,
8907
9841
  false
8908
9842
  );
8909
9843
  return { deliver: false };
@@ -8936,9 +9870,9 @@ var TaskRunner = class {
8936
9870
  /**
8937
9871
  * Whether the CURRENTLY connected server advertised `result-document` —
8938
9872
  * read fresh on every call, never captured, because the answer changes
8939
- * across a reconnect (`ConnectionManager.getServerCapabilities` returns
8940
- * `[]` from the moment an acked connection closes until a fresh
8941
- * `conn.ack` repopulates it). An absent `getServerCapabilities` seam is
9873
+ * across a reconnect or transport switch (`ConnectionManager` clears the
9874
+ * old advertisement at the boundary, then repopulates it from a fresh WS
9875
+ * ack or successful poll response). An absent `getServerCapabilities` seam is
8942
9876
  * "no capabilities", the fail-closed reading.
8943
9877
  */
8944
9878
  hasResultDocumentCapability() {
@@ -8978,32 +9912,64 @@ var TaskRunner = class {
8978
9912
  }
8979
9913
  async finish(taskId) {
8980
9914
  const active = this.tasks.get(taskId);
8981
- if (!active) return;
8982
- if (active.maxDurationTimer) {
8983
- clearTimeout(active.maxDurationTimer);
8984
- active.maxDurationTimer = void 0;
8985
- }
8986
- active.batcher.stop();
8987
- this.tasks.delete(taskId);
8988
- this.addFinishedTaskId(taskId);
8989
- const queued = active.approvalQueue.splice(0);
8990
- for (const request of queued) {
8991
- request.resolve({
8992
- approved: false,
8993
- reason: `task ${taskId} finished before this queued approval request could be dispatched`
8994
- });
8995
- }
8996
- if (active.pendingApprovalId !== void 0) {
8997
- try {
8998
- this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
8999
- } catch {
9915
+ if (!active) return true;
9916
+ if (!active.finalizationStarted) {
9917
+ active.finalizationStarted = true;
9918
+ active.beingTornDown = true;
9919
+ if (active.maxDurationTimer) {
9920
+ clearTimeout(active.maxDurationTimer);
9921
+ active.maxDurationTimer = void 0;
9922
+ }
9923
+ active.batcher.stop();
9924
+ this.addFinishedTaskId(taskId);
9925
+ const queued = active.approvalQueue.splice(0);
9926
+ for (const request of queued) {
9927
+ request.resolve({
9928
+ approved: false,
9929
+ reason: `task ${taskId} finished before this queued approval request could be dispatched`
9930
+ });
9931
+ }
9932
+ if (active.pendingApprovalId !== void 0) {
9933
+ try {
9934
+ this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
9935
+ } catch {
9936
+ }
9000
9937
  }
9001
9938
  }
9002
- if (active.gitLease) active.gitLease.release();
9939
+ const attempt = active.disposalAttempt ?? active.session.close();
9940
+ active.disposalAttempt = attempt;
9003
9941
  try {
9004
- await active.session.close();
9005
- } catch {
9942
+ await attempt;
9943
+ } catch (caught) {
9944
+ if (active.disposalAttempt === attempt) active.disposalAttempt = void 0;
9945
+ const failure = isRuntimeDisposalFailure(caught) ? caught : new RuntimeDisposalFailure({
9946
+ stage: "quiescence",
9947
+ reason: `${active.adapter.descriptor.id} session.close() returned an untyped disposal failure`
9948
+ }, { cause: caught });
9949
+ console.error(`[byok/client] runtime disposal failed for task ${taskId}: ${failure.message}`);
9950
+ this.deps.onRuntimeDisposalFailure?.({
9951
+ taskId,
9952
+ runtimeId: active.adapter.descriptor.id,
9953
+ stage: failure.stage,
9954
+ reason: failure.message
9955
+ });
9956
+ active.resolveSemanticTerminalSettled?.(false);
9957
+ return false;
9006
9958
  }
9959
+ if (this.tasks.get(taskId) !== active) return true;
9960
+ active.gitLease?.release();
9961
+ this.tasks.delete(taskId);
9962
+ active.resolveSemanticTerminalSettled?.(true);
9963
+ return true;
9964
+ }
9965
+ reserveSemanticTerminal(active) {
9966
+ if (active.semanticTerminalReserved || active.finalizationStarted) return false;
9967
+ active.semanticTerminalReserved = true;
9968
+ active.beingTornDown = true;
9969
+ active.semanticTerminalSettled = new Promise((resolve) => {
9970
+ active.resolveSemanticTerminalSettled = resolve;
9971
+ });
9972
+ return true;
9007
9973
  }
9008
9974
  /** 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). */
9009
9975
  addFinishedTaskId(taskId) {
@@ -9056,18 +10022,19 @@ var TaskRunner = class {
9056
10022
  retryable: false
9057
10023
  };
9058
10024
  }
9059
- const adapter = this.deps.adapters.find((a) => a.id === requestedRuntime);
10025
+ const adapter = this.deps.adapters.find((a) => a.descriptor.id === requestedRuntime);
9060
10026
  if (!adapter) {
9061
10027
  return { ok: false, reason: `unknown runtime "${requestedRuntime}"`, retryable: false };
9062
10028
  }
9063
- if (!adapterSupportsMode(adapter, policyMode)) {
10029
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
10030
+ if (!adapterSupportsMode(descriptor, policyMode)) {
9064
10031
  return {
9065
10032
  ok: false,
9066
10033
  reason: `runtime "${requestedRuntime}" cannot express permission mode "${policyMode}"`,
9067
10034
  retryable: false
9068
10035
  };
9069
10036
  }
9070
- if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) {
10037
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) {
9071
10038
  return {
9072
10039
  ok: false,
9073
10040
  reason: `runtime "${requestedRuntime}" cannot project required MCP toolsets`,
@@ -9082,15 +10049,16 @@ var TaskRunner = class {
9082
10049
  retryable: true
9083
10050
  };
9084
10051
  }
9085
- return { ok: true, adapter };
10052
+ return { ok: true, adapter, descriptor };
9086
10053
  }
9087
- const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.id)) : this.deps.adapters;
10054
+ const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.descriptor.id)) : this.deps.adapters;
9088
10055
  const candidates = orderByPreference(eligible, this.deps.runtimePreference ?? DEFAULT_RUNTIME_PREFERENCE);
9089
10056
  for (const adapter of candidates) {
9090
- if (!adapterSupportsMode(adapter, policyMode)) continue;
9091
- if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) continue;
10057
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
10058
+ if (!adapterSupportsMode(descriptor, policyMode)) continue;
10059
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) continue;
9092
10060
  const detected = await adapter.detect();
9093
- if (detected.present) return { ok: true, adapter };
10061
+ if (detected.present) return { ok: true, adapter, descriptor };
9094
10062
  }
9095
10063
  return {
9096
10064
  ok: false,
@@ -9135,27 +10103,27 @@ async function detectRuntimes(adapters) {
9135
10103
  const detections = await Promise.all(adapters.map(async (adapter) => ({ adapter, detected: await adapter.detect() })));
9136
10104
  const runtimes = [];
9137
10105
  for (const { adapter, detected } of detections) {
9138
- if (!detected.present || !isRuntimeId(adapter.id)) continue;
9139
- const info = { id: adapter.id };
10106
+ if (!detected.present || !isRuntimeId(adapter.descriptor.id)) continue;
10107
+ const info = { id: adapter.descriptor.id };
9140
10108
  if (detected.version !== void 0) info.version = detected.version;
9141
10109
  if (detected.authPresent !== void 0) info.authPresent = detected.authPresent;
9142
- info.capabilities = toRuntimeInfoCapabilities(adapter.capabilities());
10110
+ info.capabilities = toRuntimeInfoCapabilities(adapter.descriptor.capabilities);
9143
10111
  runtimes.push(info);
9144
10112
  }
9145
10113
  return runtimes;
9146
10114
  }
9147
10115
  function computeCapabilities(adapters) {
9148
10116
  const flags = [];
9149
- if (adapters.some((adapter) => adapter.capabilities().steer)) flags.push("steer");
10117
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.steer)) flags.push("steer");
9150
10118
  flags.push("blob-upload");
9151
10119
  flags.push("approval-targeting");
9152
10120
  const selectionAdapters = adapters.filter(
9153
- (adapter) => ALL_RUNTIME_IDS.includes(adapter.id)
10121
+ (adapter) => ALL_RUNTIME_IDS.includes(adapter.descriptor.id)
9154
10122
  );
9155
- if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.supportsDispatchSelection === true)) {
10123
+ if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.descriptor.supportsDispatchSelection === true)) {
9156
10124
  flags.push("dispatch-selection");
9157
10125
  }
9158
- if (adapters.some((adapter) => adapter.capabilities().mcpToolsets === true)) {
10126
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.mcpToolsets === true)) {
9159
10127
  flags.push("toolset-selection");
9160
10128
  }
9161
10129
  return flags;
@@ -9215,7 +10183,6 @@ function validatePiByokLauncherConfig(launcher) {
9215
10183
  throw new Error("DaemonConfig.piByokLauncher.args must contain only non-empty single-line strings");
9216
10184
  }
9217
10185
  }
9218
- var MAX_LOCAL_MCP_TOOLSETS = 64;
9219
10186
  var MAX_LOCAL_MCP_SERVERS_PER_TOOLSET = 16;
9220
10187
  var MAX_LOCAL_MCP_ARGS = 64;
9221
10188
  var MAX_LOCAL_MCP_TOKEN_CHARS = 4096;
@@ -9228,8 +10195,10 @@ function resolveMcpToolsets(configured) {
9228
10195
  throw new Error("DaemonConfig.mcpToolsets must be an object keyed by logical toolset id");
9229
10196
  }
9230
10197
  const toolsetEntries = Object.entries(configured);
9231
- if (toolsetEntries.length > MAX_LOCAL_MCP_TOOLSETS) {
9232
- throw new Error(`DaemonConfig.mcpToolsets may contain at most ${MAX_LOCAL_MCP_TOOLSETS} toolsets`);
10198
+ if (toolsetEntries.length > CONFIGURED_TOOLSETS_MAX_ITEMS) {
10199
+ throw new Error(
10200
+ `DaemonConfig.mcpToolsets may contain at most ${CONFIGURED_TOOLSETS_MAX_ITEMS} toolsets`
10201
+ );
9233
10202
  }
9234
10203
  const resolved = /* @__PURE__ */ new Map();
9235
10204
  for (const [toolsetId, rawToolset] of toolsetEntries) {
@@ -9342,6 +10311,9 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9342
10311
  }
9343
10312
  function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProbe) {
9344
10313
  const mcpToolsets = resolveMcpToolsets(config.mcpToolsets);
10314
+ const configuredToolsets = Object.freeze(
10315
+ [...mcpToolsets?.keys() ?? []].sort()
10316
+ );
9345
10317
  if (config.piByokLauncher !== void 0) {
9346
10318
  validatePiByokLauncherConfig(config.piByokLauncher);
9347
10319
  }
@@ -9648,12 +10620,13 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9648
10620
  dirty: event.observation ? { staged: event.observation.staged, unstaged: event.observation.unstaged, untracked: event.observation.untracked, conflicted: event.observation.conflicted } : void 0,
9649
10621
  errorCategory: event.errorCategory
9650
10622
  }),
10623
+ onRuntimeDisposalFailure: (event) => observer.noteRuntimeDisposalFailure(event),
9651
10624
  // M4 Phase 3: the SAME `ApprovalRegistry` instance the control
9652
10625
  // socket's own `approvals.list`/`approvals.resolve` methods already
9653
10626
  // share (see that field's own construction above) — `TaskRunner
9654
10627
  // .requestApproval` registers into it directly, so a decision arriving
9655
10628
  // via either the server wire or the local CLI resolves the identical
9656
- // entry. `storeDir`/`productId` let `TaskContext.approvalChannel`
10629
+ // entry. `storeDir`/`productId` let the prepared operation approval channel
9657
10630
  // (populated per-task by `TaskRunner`) tell an out-of-process helper
9658
10631
  // (`bin/byok-approval-mcp.ts`) exactly which control socket to dial.
9659
10632
  approvalRegistry,
@@ -9705,6 +10678,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9705
10678
  productId: config.productId,
9706
10679
  capabilities,
9707
10680
  runtimes,
10681
+ configuredToolsets,
9708
10682
  auth,
9709
10683
  cursorStore,
9710
10684
  // Finding F3: return (not void-and-forget) so ConnectionManager can
@@ -9791,6 +10765,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9791
10765
  presencePublisher ??= new PresencePublisher({
9792
10766
  serverUrl: config.serverUrl,
9793
10767
  auth,
10768
+ configuredToolsets,
9794
10769
  ...presenceCadence,
9795
10770
  onDegraded: (reason) => console.warn(`[byok/client] ${reason}`)
9796
10771
  });
@@ -9884,8 +10859,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9884
10859
  await attempt(() => auth.stop(), true);
9885
10860
  connectionState = "closed";
9886
10861
  await attempt(async () => {
9887
- await controlServerHandle?.close();
9888
- controlServerHandle = void 0;
10862
+ await controlServerHandle?.stopServing();
9889
10863
  }, true);
9890
10864
  if (mutationBarrierComplete && daemonOwnerLease) {
9891
10865
  await attempt(() => operationalHealth.markCleanStop(), false);
@@ -9894,6 +10868,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9894
10868
  daemonOwnerLease = void 0;
9895
10869
  }, false);
9896
10870
  }
10871
+ if (daemonOwnerLease === void 0) {
10872
+ await attempt(async () => {
10873
+ await controlServerHandle?.close();
10874
+ controlServerHandle = void 0;
10875
+ }, false);
10876
+ }
9897
10877
  if (errors.length === 1) throw errors[0];
9898
10878
  if (errors.length > 1) {
9899
10879
  throw new AggregateError(errors, "daemon shutdown completed with errors");
@@ -9973,7 +10953,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9973
10953
  deviceId: auth.deviceId,
9974
10954
  transport: connectionState,
9975
10955
  activeTasks,
9976
- runtimeIds: adapters.map((adapter) => adapter.id),
10956
+ runtimeIds: adapters.map((adapter) => adapter.descriptor.id),
9977
10957
  // M4 Phase 4 (part B.3): queue watermarks come from TaskRunner's own
9978
10958
  // active-task map (distinct from `observer.tasks()` above, which is
9979
10959
  // derived from the envelope feed) — see `TaskRunner.getQueueWatermarks`'s
@@ -10181,7 +11161,7 @@ function createDaemon(config) {
10181
11161
  return createDaemonWithAdapters(config, buildDefaultAdapters(config));
10182
11162
  }
10183
11163
  var MAX_CONTROL_TOKEN_BYTES = 256;
10184
- function errorMessage4(err) {
11164
+ function errorMessage5(err) {
10185
11165
  return err instanceof Error ? err.message : String(err);
10186
11166
  }
10187
11167
  function sameFileState3(left, right) {
@@ -10234,7 +11214,7 @@ async function connectControlClient(opts) {
10234
11214
  }
10235
11215
  token = read;
10236
11216
  } catch (err) {
10237
- return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
11217
+ return { ok: false, reason: `could not read the control token: ${errorMessage5(err)}` };
10238
11218
  }
10239
11219
  if (!token) {
10240
11220
  return { ok: false, reason: "control token file is empty" };
@@ -10244,7 +11224,7 @@ async function connectControlClient(opts) {
10244
11224
  const client = await connectAndHandshake(endpoint, token, opts);
10245
11225
  return { ok: true, client };
10246
11226
  } catch (err) {
10247
- return { ok: false, reason: `daemon control socket not reachable: ${errorMessage4(err)}` };
11227
+ return { ok: false, reason: `daemon control socket not reachable: ${errorMessage5(err)}` };
10248
11228
  }
10249
11229
  }
10250
11230
  function connectAndHandshake(endpoint, token, opts) {
@@ -10863,19 +11843,7 @@ function quote(text) {
10863
11843
  function redactedByteCountPlaceholder(text) {
10864
11844
  return `[redacted: ${Buffer.byteLength(text, "utf8")} bytes]`;
10865
11845
  }
10866
- var STABLE_GIT_ERROR_CATEGORIES = /* @__PURE__ */ new Set([
10867
- "git-unavailable",
10868
- "git-timeout",
10869
- "git-output-limit",
10870
- "git-command-failed",
10871
- "workspace-root-invalid",
10872
- "workspace-root-conflict",
10873
- "workspace-not-owned",
10874
- "repository-root-mismatch",
10875
- "repository-invalid",
10876
- "lease-busy",
10877
- "ledger-invalid"
10878
- ]);
11846
+ var STABLE_GIT_ERROR_CATEGORIES = new Set(GIT_ERROR_CATEGORIES);
10879
11847
  function stableGitErrorCategory(value) {
10880
11848
  return value !== void 0 && STABLE_GIT_ERROR_CATEGORIES.has(value) ? value : void 0;
10881
11849
  }
@@ -10941,6 +11909,8 @@ function formatDaemonEventLine(event, options = {}) {
10941
11909
  return `${prefix} shutdown-complete reason=${quote(event.reason)}${event.undeliveredOutboxCount !== void 0 ? ` undeliveredOutboxCount=${event.undeliveredOutboxCount}` : ""}`;
10942
11910
  case "stale-approval-decision":
10943
11911
  return `${prefix} stale-approval-decision taskId=${event.taskId} decision=${event.decision}${event.reason ? ` reason=${quote(event.reason)}` : ""}`;
11912
+ case "runtime-disposal-failed":
11913
+ return `${prefix} runtime-disposal-failed taskId=${event.taskId} runtime=${event.runtimeId} stage=${event.stage} reason=${quote(event.reason)}`;
10944
11914
  case "git-workspace": {
10945
11915
  const parts = [
10946
11916
  `${prefix} git-workspace taskId=${event.taskId}`,
@@ -11219,8 +12189,8 @@ async function probeRuntimes(adapters, options = {}) {
11219
12189
  let resume = false;
11220
12190
  let permissionModes = [];
11221
12191
  try {
11222
- id = boundedSingleLine(adapter.id, MAX_RUNTIME_ID_CHARS);
11223
- const caps = adapter.capabilities();
12192
+ id = boundedSingleLine(adapter.descriptor.id, MAX_RUNTIME_ID_CHARS);
12193
+ const caps = adapter.descriptor.capabilities;
11224
12194
  steer = caps.steer === true;
11225
12195
  resume = caps.resume === true;
11226
12196
  permissionModes = caps.permissionModes.slice(0, MAX_PERMISSION_MODES).map((mode) => boundedSingleLine(mode, MAX_PERMISSION_MODE_CHARS));
@@ -12085,19 +13055,7 @@ function valueByteSize(value) {
12085
13055
  function placeholderFor(size) {
12086
13056
  return size === void 0 ? "[redacted]" : `[redacted: ${size} bytes]`;
12087
13057
  }
12088
- var STABLE_GIT_ERROR_CATEGORIES2 = /* @__PURE__ */ new Set([
12089
- "git-unavailable",
12090
- "git-timeout",
12091
- "git-output-limit",
12092
- "git-command-failed",
12093
- "workspace-root-invalid",
12094
- "workspace-root-conflict",
12095
- "workspace-not-owned",
12096
- "repository-root-mismatch",
12097
- "repository-invalid",
12098
- "lease-busy",
12099
- "ledger-invalid"
12100
- ]);
13058
+ var STABLE_GIT_ERROR_CATEGORIES2 = new Set(GIT_ERROR_CATEGORIES);
12101
13059
  function stableGitErrorCategory2(value) {
12102
13060
  return typeof value === "string" && STABLE_GIT_ERROR_CATEGORIES2.has(value) ? value : void 0;
12103
13061
  }
@@ -12196,6 +13154,8 @@ function redactForAudit(event) {
12196
13154
  return { ...base, reason: event.reason, undeliveredOutboxCount: event.undeliveredOutboxCount };
12197
13155
  case "stale-approval-decision":
12198
13156
  return { ...base, taskId: event.taskId, decision: event.decision, reasonSize: byteSize(event.reason) };
13157
+ case "runtime-disposal-failed":
13158
+ return { ...base, taskId: event.taskId, runtimeId: event.runtimeId, stage: event.stage, reason: event.reason };
12199
13159
  case "device-assertion":
12200
13160
  return event.result === "issued" ? {
12201
13161
  ...base,
@@ -12344,6 +13304,15 @@ function reconstructDaemonEvent(raw) {
12344
13304
  reason: reasonSize === void 0 ? void 0 : placeholderFor(reasonSize)
12345
13305
  };
12346
13306
  }
13307
+ case "runtime-disposal-failed":
13308
+ return {
13309
+ kind: "runtime-disposal-failed",
13310
+ ts,
13311
+ taskId: str(raw.taskId),
13312
+ runtimeId: str(raw.runtimeId),
13313
+ stage: str(raw.stage),
13314
+ reason: str(raw.reason)
13315
+ };
12347
13316
  case "device-assertion": {
12348
13317
  if (raw.result === "issued") {
12349
13318
  return {
@@ -12616,20 +13585,8 @@ async function runStartCommand(config, deps) {
12616
13585
  throw err;
12617
13586
  }
12618
13587
  }
12619
- var STABLE_GIT_PHASES = /* @__PURE__ */ new Set(["preparing", "active", "completed", "failed", "cancelled", "interrupted", "salvage"]);
12620
- var STABLE_GIT_ERROR_CATEGORIES3 = /* @__PURE__ */ new Set([
12621
- "git-unavailable",
12622
- "git-timeout",
12623
- "git-output-limit",
12624
- "git-command-failed",
12625
- "workspace-root-invalid",
12626
- "workspace-root-conflict",
12627
- "workspace-not-owned",
12628
- "repository-root-mismatch",
12629
- "repository-invalid",
12630
- "lease-busy",
12631
- "ledger-invalid"
12632
- ]);
13588
+ var STABLE_GIT_PHASES = new Set(GIT_WORKSPACE_PHASES);
13589
+ var STABLE_GIT_ERROR_CATEGORIES3 = new Set(GIT_ERROR_CATEGORIES);
12633
13590
  function nonNegativeSafeInteger(value) {
12634
13591
  return value !== void 0 && Number.isSafeInteger(value) && value >= 0 ? value : void 0;
12635
13592
  }
@@ -13128,19 +14085,7 @@ async function runUnpairCommand(daemon, deps = {}) {
13128
14085
  }
13129
14086
 
13130
14087
  // src/bin/commands/workspaces.ts
13131
- var STABLE_ERROR_CATEGORIES = /* @__PURE__ */ new Set([
13132
- "git-unavailable",
13133
- "git-timeout",
13134
- "git-output-limit",
13135
- "git-command-failed",
13136
- "workspace-root-invalid",
13137
- "workspace-root-conflict",
13138
- "workspace-not-owned",
13139
- "repository-root-mismatch",
13140
- "repository-invalid",
13141
- "lease-busy",
13142
- "ledger-invalid"
13143
- ]);
14088
+ var STABLE_ERROR_CATEGORIES = new Set(GIT_ERROR_CATEGORIES);
13144
14089
  function abbreviateCommit(value) {
13145
14090
  if (!value) return "-";
13146
14091
  return value.length > 8 ? value.slice(0, 8) : value;