@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
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { execFile, spawn, spawnSync } from 'child_process';
1
+ import { execFile, spawn } from 'child_process';
2
2
  import { createHash, randomUUID, sign, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual, createHmac } from 'crypto';
3
3
  import { promises, mkdirSync, existsSync, renameSync, writeFileSync, chmodSync, statSync, readdirSync, linkSync, fstatSync, lstatSync, unlinkSync, constants, readFileSync, realpathSync } from 'fs';
4
4
  import path17, { join, isAbsolute } from 'path';
5
5
  import os5 from 'os';
6
6
  import { parseDeviceAssertionEnvelope, tenantId, DeviceProofProtectedClaimsSchema, deviceProofSigningInput, DEVICE_PROOF_SCHEMA_ID, SKILL_PACK_MAX_BYTES, hasCapability, parseSkillPackManifest, checkSkillPackManifest, skillPackContentHashInput, checkSkillPackFileContent, SKILL_PACK_ENTRY_PATH, checkSkillPackEntry, isSkillPackPathSafe, DEVICE_PROOF_HEADER, contentHash, TRUTH_RECORD_KINDS, nonceSigningBytes, DEVICE_ASSERTION_AUDIENCE_MAX_BYTES, DEVICE_ASSERTION_DEFAULT_TTL_MS, DEVICE_ASSERTION_MAX_TTL_MS, CONTENT_HASH_PATTERN, CapabilityDeclarationSchema, DeviceAssertionClaimsSchema, deviceAssertionSigningInput, DEVICE_ASSERTION_SCHEMA_ID } from '@byok-sdk/core';
7
- import { BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, partitionAgentEvents, BYOK_SKILL_PACKS_PATH, byokSkillPackFilePath, BYOK_RECORDS_PATH, byokRecordPath, TASK_TRANSITIONS, ToolsetIdSchema, encodeEnvelope, createEnvelope, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, RuntimeIdSchema, RESULT_DOCUMENT_MAX_BYTES, PROTOCOL_VERSION, decodeEnvelope, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, BYOK_EVENTS_PATH, parseMessage, UnknownMessageTypeError, BYOK_WS_PATH } from '@byok-sdk/protocol';
7
+ import { BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, partitionAgentEvents, BYOK_SKILL_PACKS_PATH, byokSkillPackFilePath, BYOK_RECORDS_PATH, byokRecordPath, TASK_TRANSITIONS, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, encodeEnvelope, createEnvelope, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, RuntimeIdSchema, RESULT_DOCUMENT_MAX_BYTES, PROTOCOL_VERSION, decodeEnvelope, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, BYOK_EVENTS_PATH, parseMessage, UnknownMessageTypeError, BYOK_WS_PATH } from '@byok-sdk/protocol';
8
8
  import { promisify } from 'util';
9
9
  import { fileURLToPath } from 'url';
10
10
  import 'readline';
@@ -13,6 +13,52 @@ import { WebSocket } from 'ws';
13
13
  import { createRequire } from 'module';
14
14
 
15
15
  // src/types.ts
16
+ function frozenStrings(values) {
17
+ return values === void 0 ? void 0 : Object.freeze([...values]);
18
+ }
19
+ function frozenPolicy(policy) {
20
+ const allowTools = policy.allowTools === void 0 ? void 0 : Object.freeze([...policy.allowTools]);
21
+ const denyTools = policy.denyTools === void 0 ? void 0 : Object.freeze([...policy.denyTools]);
22
+ return Object.freeze({
23
+ mode: policy.mode,
24
+ ...allowTools === void 0 ? {} : { allowTools },
25
+ ...denyTools === void 0 ? {} : { denyTools },
26
+ ...policy.workspaceRoot === void 0 ? {} : { workspaceRoot: policy.workspaceRoot },
27
+ ...policy.network === void 0 ? {} : { network: policy.network }
28
+ });
29
+ }
30
+ function freezeRuntimeAdapterDescriptor(descriptor) {
31
+ const baseNames = frozenStrings(descriptor.environmentRequirements.baseNames);
32
+ const credentialNames = frozenStrings(descriptor.environmentRequirements.credentialNames);
33
+ return Object.freeze({
34
+ id: descriptor.id,
35
+ supportsDispatchSelection: descriptor.supportsDispatchSelection === true,
36
+ capabilities: Object.freeze({
37
+ steer: descriptor.capabilities.steer === true,
38
+ resume: descriptor.capabilities.resume === true,
39
+ approvalInteractive: descriptor.capabilities.approvalInteractive === true,
40
+ ...descriptor.capabilities.mcpToolsets === void 0 ? {} : { mcpToolsets: descriptor.capabilities.mcpToolsets === true },
41
+ permissionModes: Object.freeze([...descriptor.capabilities.permissionModes])
42
+ }),
43
+ environmentRequirements: Object.freeze({
44
+ ...baseNames === void 0 ? {} : { baseNames },
45
+ ...credentialNames === void 0 ? {} : { credentialNames }
46
+ })
47
+ });
48
+ }
49
+ function sealRuntimeOperationManifest(manifest) {
50
+ return Object.freeze({
51
+ taskId: manifest.taskId,
52
+ runtimeId: manifest.runtimeId,
53
+ descriptor: freezeRuntimeAdapterDescriptor(manifest.descriptor),
54
+ policy: frozenPolicy(manifest.policy),
55
+ requiredToolsetIds: Object.freeze([...manifest.requiredToolsetIds]),
56
+ ...manifest.dispatchSelection === void 0 ? {} : { dispatchSelection: Object.freeze({ ...manifest.dispatchSelection }) },
57
+ ...manifest.sessionRef === void 0 ? {} : { sessionRef: manifest.sessionRef },
58
+ workspace: Object.freeze({ ...manifest.workspace }),
59
+ forwardedEnvironmentNames: Object.freeze([...manifest.forwardedEnvironmentNames])
60
+ });
61
+ }
16
62
  var PolicyUnsupportedError = class extends Error {
17
63
  constructor(message) {
18
64
  super(message);
@@ -20,7 +66,7 @@ var PolicyUnsupportedError = class extends Error {
20
66
  }
21
67
  };
22
68
  var SteerUnsupportedError = class extends Error {
23
- /** The `RuntimeAdapter.id` that cannot steer (e.g. `claude`, `codex`). */
69
+ /** The `RuntimeAdapter.descriptor.id` that cannot steer (e.g. `claude`, `codex`). */
24
70
  runtimeId;
25
71
  constructor(runtimeId, message) {
26
72
  super(message);
@@ -28,6 +74,90 @@ var SteerUnsupportedError = class extends Error {
28
74
  this.runtimeId = runtimeId;
29
75
  }
30
76
  };
77
+
78
+ // src/runtime-failure.ts
79
+ var RUNTIME_EXECUTION_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeExecutionFailure/v1");
80
+ var RUNTIME_DISPOSAL_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeDisposalFailure/v1");
81
+ var RuntimeDisposalFailure = class extends Error {
82
+ stage;
83
+ constructor(input, options) {
84
+ if (!isRuntimeDisposalStage(input.stage) || typeof input.reason !== "string" || input.reason.length === 0) {
85
+ throw new TypeError("invalid RuntimeDisposalFailure input");
86
+ }
87
+ super(input.reason, options);
88
+ this.name = "RuntimeDisposalFailure";
89
+ this.stage = input.stage;
90
+ Object.defineProperty(this, RUNTIME_DISPOSAL_FAILURE_BRAND, { value: true });
91
+ Object.freeze(this);
92
+ }
93
+ };
94
+ function isRuntimeDisposalStage(value) {
95
+ return value === "signal" || value === "quiescence" || value === "cleanup";
96
+ }
97
+ function isRuntimeDisposalFailure(value) {
98
+ if (typeof value !== "object" || value === null) return false;
99
+ const candidate = value;
100
+ return candidate[RUNTIME_DISPOSAL_FAILURE_BRAND] === true && isRuntimeDisposalStage(candidate.stage) && typeof candidate.message === "string" && candidate.message.length > 0;
101
+ }
102
+ var RuntimeExecutionFailure = class extends Error {
103
+ phase;
104
+ category;
105
+ retry;
106
+ constructor(input, options) {
107
+ if (!isRuntimeFailurePhase(input.phase) || !isRuntimeFailureCategory(input.category) || !isRuntimeRetryDisposition(input.retry) || typeof input.reason !== "string" || input.reason.length === 0) {
108
+ throw new TypeError("invalid RuntimeExecutionFailure input");
109
+ }
110
+ super(input.reason, options);
111
+ this.name = "RuntimeExecutionFailure";
112
+ this.phase = input.phase;
113
+ this.category = input.category;
114
+ this.retry = input.retry;
115
+ Object.defineProperty(this, RUNTIME_EXECUTION_FAILURE_BRAND, { value: true });
116
+ Object.freeze(this);
117
+ }
118
+ };
119
+ function isRuntimeFailurePhase(value) {
120
+ return value === "start" || value === "run";
121
+ }
122
+ function isRuntimeFailureCategory(value) {
123
+ return value === "semantic" || value === "infrastructure" || value === "authority";
124
+ }
125
+ function isRuntimeRetryDisposition(value) {
126
+ return value === "retryable" || value === "non-retryable";
127
+ }
128
+ function isRuntimeExecutionFailure(value) {
129
+ if (typeof value !== "object" || value === null) return false;
130
+ const candidate = value;
131
+ return candidate[RUNTIME_EXECUTION_FAILURE_BRAND] === true && isRuntimeFailurePhase(candidate.phase) && isRuntimeFailureCategory(candidate.category) && isRuntimeRetryDisposition(candidate.retry) && typeof candidate.message === "string" && candidate.message.length > 0;
132
+ }
133
+ function retryableFromDisposition(disposition) {
134
+ switch (disposition) {
135
+ case "retryable":
136
+ return true;
137
+ case "non-retryable":
138
+ return false;
139
+ }
140
+ }
141
+ function projectRuntimeExecutionFailure(failure) {
142
+ return {
143
+ reason: failure.message,
144
+ retryable: retryableFromDisposition(failure.retry)
145
+ };
146
+ }
147
+ var RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON = Object.freeze({
148
+ start: "runtime adapter contract violation during start",
149
+ run: "runtime adapter contract violation during run"
150
+ });
151
+ function projectRuntimeBoundaryFailure(value, expectedPhase) {
152
+ if (isRuntimeExecutionFailure(value) && value.phase === expectedPhase) {
153
+ return { ...projectRuntimeExecutionFailure(value), contractViolation: false };
154
+ }
155
+ return {
156
+ reason: RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON[expectedPhase],
157
+ retryable: false,
158
+ contractViolation: true
159
+ };
160
+ }
31
161
  var GitWorkspaceError = class extends Error {
32
162
  constructor(category, message = category) {
33
163
  super(message);
@@ -704,12 +834,12 @@ function resolvePiBin() {
704
834
  }
705
835
  } catch (cause) {
706
836
  throw new Error(
707
- `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`,
837
+ `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`,
708
838
  { cause }
709
839
  );
710
840
  }
711
841
  throw new Error(
712
- `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`
842
+ `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`
713
843
  );
714
844
  }
715
845
 
@@ -915,6 +1045,266 @@ var AsyncQueue = class {
915
1045
  }
916
1046
  };
917
1047
 
1048
+ // src/adapters/taskkill-pid-set.ts
1049
+ var INTEGER_PATTERN = /\d+/g;
1050
+ function isCandidatePid(value) {
1051
+ return Number.isSafeInteger(value) && value > 0;
1052
+ }
1053
+ function walkTaskkillPidSet(text, rootPid, excludedPids = []) {
1054
+ const excluded = new Set(excludedPids);
1055
+ const accepted = /* @__PURE__ */ new Set();
1056
+ if (isCandidatePid(rootPid)) accepted.add(rootPid);
1057
+ const lines = text.split(/\r?\n/).map((line) => {
1058
+ const pids = [];
1059
+ for (const match of line.matchAll(INTEGER_PATTERN)) {
1060
+ const pid = Number(match[0]);
1061
+ if (isCandidatePid(pid) && !excluded.has(pid)) pids.push(pid);
1062
+ }
1063
+ return pids;
1064
+ });
1065
+ let changed = true;
1066
+ while (changed) {
1067
+ changed = false;
1068
+ for (const pids of lines) {
1069
+ if (!pids.some((pid) => accepted.has(pid))) continue;
1070
+ for (const pid of pids) {
1071
+ if (accepted.has(pid)) continue;
1072
+ accepted.add(pid);
1073
+ changed = true;
1074
+ }
1075
+ }
1076
+ }
1077
+ return accepted;
1078
+ }
1079
+
1080
+ // src/adapters/process-tree.ts
1081
+ var DEFAULT_TERM_GRACE_MS = 750;
1082
+ var DEFAULT_KILL_GRACE_MS = 2e3;
1083
+ var POLL_MS = 20;
1084
+ var terminationState = /* @__PURE__ */ new WeakMap();
1085
+ function stateFor(child) {
1086
+ const existing = terminationState.get(child);
1087
+ if (existing) return existing;
1088
+ const created = { requested: false, acceptedPids: /* @__PURE__ */ new Set() };
1089
+ terminationState.set(child, created);
1090
+ return created;
1091
+ }
1092
+ function defaultKill(pid, signal) {
1093
+ process.kill(pid, signal);
1094
+ }
1095
+ function withOwnedProcessTree(options) {
1096
+ return {
1097
+ ...options,
1098
+ ...process.platform === "win32" ? { windowsHide: true } : { detached: true }
1099
+ };
1100
+ }
1101
+ function positivePid(child, label) {
1102
+ const pid = child.pid;
1103
+ if (pid === void 0) return void 0;
1104
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
1105
+ throw new RuntimeDisposalFailure({
1106
+ stage: "signal",
1107
+ reason: `${label} runtime process has an unsafe owned pid`
1108
+ });
1109
+ }
1110
+ return pid;
1111
+ }
1112
+ function groupExists(pid, label, kill) {
1113
+ try {
1114
+ kill(-pid, 0);
1115
+ return true;
1116
+ } catch (cause) {
1117
+ const code = cause.code;
1118
+ if (code === "ESRCH") return false;
1119
+ if (code === "EPERM") return true;
1120
+ throw new RuntimeDisposalFailure({
1121
+ stage: "quiescence",
1122
+ reason: `${label} runtime process-group state could not be verified`
1123
+ }, { cause });
1124
+ }
1125
+ }
1126
+ function processExists(pid, label, kill) {
1127
+ try {
1128
+ kill(pid, 0);
1129
+ return true;
1130
+ } catch (cause) {
1131
+ const code = cause.code;
1132
+ if (code === "ESRCH") return false;
1133
+ if (code === "EPERM") return true;
1134
+ throw new RuntimeDisposalFailure({
1135
+ stage: "quiescence",
1136
+ reason: `${label} runtime process ${pid} state could not be verified`
1137
+ }, { cause });
1138
+ }
1139
+ }
1140
+ function signalGroup(pid, signal, label, kill) {
1141
+ try {
1142
+ kill(-pid, signal);
1143
+ } catch (cause) {
1144
+ const code = cause.code;
1145
+ if (code === "ESRCH" || code === "EPERM") return;
1146
+ throw new RuntimeDisposalFailure({
1147
+ stage: "signal",
1148
+ reason: `${label} runtime process group ${pid} could not receive ${signal} (${code ?? "unknown"})`
1149
+ }, { cause });
1150
+ }
1151
+ }
1152
+ async function runTaskkill(pid, options) {
1153
+ const spawnFn = options.spawnFn ?? spawn;
1154
+ return new Promise((resolve, reject) => {
1155
+ const signalFailure = (cause) => {
1156
+ reject(new RuntimeDisposalFailure({
1157
+ stage: "signal",
1158
+ reason: `${options.label} runtime process tree termination could not be requested`
1159
+ }, { cause }));
1160
+ };
1161
+ let taskkill;
1162
+ try {
1163
+ taskkill = spawnFn("taskkill", ["/PID", String(pid), "/T", "/F"], {
1164
+ windowsHide: true,
1165
+ stdio: ["ignore", "pipe", "pipe"]
1166
+ });
1167
+ } catch (cause) {
1168
+ signalFailure(cause);
1169
+ return;
1170
+ }
1171
+ const chunks = [];
1172
+ taskkill.stdout?.on("data", (chunk) => chunks.push(chunk));
1173
+ taskkill.stderr?.on("data", (chunk) => chunks.push(chunk));
1174
+ taskkill.once("error", signalFailure);
1175
+ taskkill.once("close", () => resolve(Buffer.concat(chunks).toString("latin1")));
1176
+ });
1177
+ }
1178
+ function liveAcceptedPids(accepted, label, kill) {
1179
+ const live = [];
1180
+ for (const pid of accepted) {
1181
+ if (processExists(pid, label, kill)) live.push(pid);
1182
+ }
1183
+ return live;
1184
+ }
1185
+ async function waitUntil(predicate, timeoutMs) {
1186
+ const deadline = Date.now() + timeoutMs;
1187
+ while (predicate()) {
1188
+ if (Date.now() >= deadline) return false;
1189
+ await new Promise((resolve) => {
1190
+ setTimeout(resolve, POLL_MS);
1191
+ });
1192
+ }
1193
+ return true;
1194
+ }
1195
+ async function waitWithDeadline(promise, timeoutMs) {
1196
+ return new Promise((resolve) => {
1197
+ let settled = false;
1198
+ const timer = setTimeout(() => {
1199
+ if (!settled) {
1200
+ settled = true;
1201
+ resolve(false);
1202
+ }
1203
+ }, timeoutMs);
1204
+ void promise.then(
1205
+ () => {
1206
+ if (!settled) {
1207
+ settled = true;
1208
+ clearTimeout(timer);
1209
+ resolve(true);
1210
+ }
1211
+ },
1212
+ () => {
1213
+ if (!settled) {
1214
+ settled = true;
1215
+ clearTimeout(timer);
1216
+ resolve(false);
1217
+ }
1218
+ }
1219
+ );
1220
+ });
1221
+ }
1222
+ async function requestOwnedProcessTreeTermination(options) {
1223
+ const platform = options.platform ?? process.platform;
1224
+ if (platform !== "win32" && options.isClosed()) return;
1225
+ const pid = positivePid(options.child, options.label);
1226
+ if (pid === void 0) return;
1227
+ if (platform === "win32") {
1228
+ const output = await runTaskkill(pid, options);
1229
+ const state = stateFor(options.child);
1230
+ for (const walked of walkTaskkillPidSet(output, pid, [process.pid])) state.acceptedPids.add(walked);
1231
+ state.requested = true;
1232
+ return;
1233
+ }
1234
+ signalGroup(pid, "SIGTERM", options.label, options.killFn ?? defaultKill);
1235
+ stateFor(options.child).requested = true;
1236
+ }
1237
+ async function disposeOwnedProcessTree(options) {
1238
+ const pid = positivePid(options.child, options.label);
1239
+ const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
1240
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
1241
+ const platform = options.platform ?? process.platform;
1242
+ const kill = options.killFn ?? defaultKill;
1243
+ if (pid === void 0) {
1244
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1245
+ throw new RuntimeDisposalFailure({
1246
+ stage: "quiescence",
1247
+ reason: `${options.label} runtime process did not settle after spawn failure`
1248
+ });
1249
+ }
1250
+ if (platform === "win32") {
1251
+ if (!terminationState.get(options.child)?.requested) {
1252
+ await requestOwnedProcessTreeTermination(options);
1253
+ }
1254
+ const accepted = terminationState.get(options.child)?.acceptedPids ?? /* @__PURE__ */ new Set();
1255
+ const deadline = Date.now() + killGraceMs;
1256
+ const resweepAt = Date.now() + Math.floor(killGraceMs / 2);
1257
+ let reswept = false;
1258
+ let live = liveAcceptedPids(accepted, options.label, kill);
1259
+ while (live.length > 0) {
1260
+ if (Date.now() >= deadline) {
1261
+ throw new RuntimeDisposalFailure({
1262
+ stage: "quiescence",
1263
+ reason: `${options.label} runtime process tree did not quiesce: ${live.length} of ${accepted.size} walked process ids were still alive at the disposal deadline`
1264
+ });
1265
+ }
1266
+ if (!reswept && Date.now() >= resweepAt) {
1267
+ reswept = true;
1268
+ for (const livePid of live) {
1269
+ for (const walked of walkTaskkillPidSet(await runTaskkill(livePid, options), livePid, [process.pid])) {
1270
+ accepted.add(walked);
1271
+ }
1272
+ }
1273
+ }
1274
+ await new Promise((resolve) => {
1275
+ setTimeout(resolve, POLL_MS);
1276
+ });
1277
+ live = liveAcceptedPids(accepted, options.label, kill);
1278
+ }
1279
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1280
+ throw new RuntimeDisposalFailure({
1281
+ stage: "quiescence",
1282
+ reason: `${options.label} runtime root did not emit close after its process tree quiesced`
1283
+ });
1284
+ }
1285
+ return;
1286
+ }
1287
+ if (groupExists(pid, options.label, kill) && !terminationState.get(options.child)?.requested) {
1288
+ signalGroup(pid, "SIGTERM", options.label, kill);
1289
+ stateFor(options.child).requested = true;
1290
+ }
1291
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), termGraceMs)) {
1292
+ signalGroup(pid, "SIGKILL", options.label, kill);
1293
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), killGraceMs)) {
1294
+ throw new RuntimeDisposalFailure({
1295
+ stage: "quiescence",
1296
+ reason: `${options.label} runtime process group remained live after SIGKILL`
1297
+ });
1298
+ }
1299
+ }
1300
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1301
+ throw new RuntimeDisposalFailure({
1302
+ stage: "quiescence",
1303
+ reason: `${options.label} runtime root did not emit close after its process group exited`
1304
+ });
1305
+ }
1306
+ }
1307
+
918
1308
  // src/adapters/pi/rpc-client.ts
919
1309
  var STDERR_RING_CAPACITY = 20;
920
1310
  var DIALOG_UI_METHODS = /* @__PURE__ */ new Set(["select", "confirm", "input", "editor"]);
@@ -926,16 +1316,22 @@ var PiRpcClient = class {
926
1316
  eventQueue = new AsyncQueue();
927
1317
  closed = false;
928
1318
  exitError;
1319
+ closedPromise;
1320
+ resolveClosed;
1321
+ disposalAttempt;
929
1322
  /** Bounded tail of recent stderr lines — pi discarded this entirely before (nothing ever read `child.stderr`), which is exactly why finding #1 (`Error: Unknown option: --session-id`, exit 1) had to be root-caused by hand instead of reading it off a thrown error. See `buildExitError`. */
930
1323
  stderrRing = [];
931
1324
  /** Count of pi RPC message types `PiSession` (pi-adapter.ts) has told us have no `AgentEvent` mapping and aren't routine bookkeeping — see `recordUnmappedFrame`. */
932
1325
  unmappedFrameCounts = /* @__PURE__ */ new Map();
933
1326
  constructor(options) {
934
1327
  const spawnFn = options.spawnFn ?? spawn;
935
- this.child = spawnFn(options.command, options.args, {
1328
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
936
1329
  cwd: options.cwd,
937
1330
  env: options.env,
938
1331
  stdio: ["pipe", "pipe", "pipe"]
1332
+ }));
1333
+ this.closedPromise = new Promise((resolve) => {
1334
+ this.resolveClosed = resolve;
939
1335
  });
940
1336
  this.child.stdout.setEncoding("utf8");
941
1337
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -970,6 +1366,10 @@ var PiRpcClient = class {
970
1366
  get events() {
971
1367
  return this.eventQueue;
972
1368
  }
1369
+ /** Local transport diagnostic retained when the process closes; consumers must classify it explicitly. */
1370
+ get terminalError() {
1371
+ return this.exitError;
1372
+ }
973
1373
  /**
974
1374
  * Record a pi RPC message `type` that `PiSession` (pi-adapter.ts) decided
975
1375
  * has no `AgentEvent` mapping and isn't routine bookkeeping (see
@@ -988,15 +1388,37 @@ var PiRpcClient = class {
988
1388
  );
989
1389
  }
990
1390
  }
991
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes pi itself spawned (e.g. bash). */
1391
+ /**
1392
+ * Immediate process-tree termination request. `dispose()` is the settlement
1393
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
1394
+ * terminator. A request that could not be spawned is left unrecorded, so
1395
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
1396
+ * swallowing it here loses nothing.
1397
+ */
992
1398
  kill() {
993
- if (this.closed) return;
994
- const pid = this.child.pid;
995
- if (process.platform === "win32" && pid !== void 0) {
996
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
997
- } else {
998
- this.child.kill("SIGTERM");
1399
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
1400
+ });
1401
+ }
1402
+ waitClosed() {
1403
+ return this.closedPromise;
1404
+ }
1405
+ dispose() {
1406
+ if (!this.disposalAttempt) {
1407
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
1408
+ this.disposalAttempt = attempt.catch((error) => {
1409
+ this.disposalAttempt = void 0;
1410
+ throw error;
1411
+ });
999
1412
  }
1413
+ return this.disposalAttempt;
1414
+ }
1415
+ processTreeOptions() {
1416
+ return {
1417
+ child: this.child,
1418
+ waitClosed: () => this.closedPromise,
1419
+ isClosed: () => this.closed,
1420
+ label: "pi"
1421
+ };
1000
1422
  }
1001
1423
  onData(chunk) {
1002
1424
  this.buffer += chunk;
@@ -1082,6 +1504,7 @@ var PiRpcClient = class {
1082
1504
  if (this.closed) return;
1083
1505
  this.closed = true;
1084
1506
  this.exitError = err;
1507
+ this.resolveClosed();
1085
1508
  for (const [, waiter] of this.pending) waiter.reject(err);
1086
1509
  this.pending.clear();
1087
1510
  this.eventQueue.end();
@@ -1152,8 +1575,17 @@ var PiAdapter = class {
1152
1575
  this.options = options;
1153
1576
  }
1154
1577
  options;
1155
- id = "pi";
1156
- supportsDispatchSelection = true;
1578
+ descriptor = freezeRuntimeAdapterDescriptor({
1579
+ id: "pi",
1580
+ supportsDispatchSelection: true,
1581
+ capabilities: {
1582
+ steer: true,
1583
+ resume: true,
1584
+ approvalInteractive: false,
1585
+ permissionModes: ["auto", "readonly"]
1586
+ },
1587
+ environmentRequirements: { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES }
1588
+ });
1157
1589
  async detect() {
1158
1590
  try {
1159
1591
  const bin = this.resolveBin();
@@ -1165,49 +1597,26 @@ var PiAdapter = class {
1165
1597
  return { present: false };
1166
1598
  }
1167
1599
  }
1168
- capabilities() {
1169
- return { steer: true, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
1170
- }
1171
- /**
1172
- * M5: pi authenticates to its ~30 supported providers via env-var API
1173
- * keys — `detect()`'s own `authPresent` probe above checks this identical
1174
- * list — so these MUST keep flowing into pi's spawned process or pi auth
1175
- * breaks entirely. `KNOWN_PROVIDER_ENV_VARS` above is the single source
1176
- * of truth, reused here rather than duplicated. No `baseNames`: nothing
1177
- * in this adapter or `rpc-client.ts` reads a pi-specific config-discovery
1178
- * variable beyond the platform baseline (`daemon/environment.ts`).
1179
- */
1180
- environmentRequirements() {
1181
- return { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES };
1182
- }
1183
- async start(task, ctx) {
1184
- if (typeof task.instruction !== "string") {
1185
- throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
1186
- }
1187
- const mapping = mapPermissionPolicyToPiArgs(ctx.policy);
1600
+ async prepare(input) {
1601
+ const mapping = mapPermissionPolicyToPiArgs(input.policy);
1188
1602
  if (!mapping.ok) {
1189
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
1603
+ return { kind: "reject", reason: mapping.reason ?? "policy rejected by pi adapter", retryable: false };
1190
1604
  }
1191
1605
  const bin = this.resolveBin();
1192
- const resumeSessionId = task.sessionRef;
1193
- const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1194
- const selection = task.dispatchSelection;
1606
+ const selection = input.offer.dispatchSelection;
1607
+ const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
1195
1608
  let command = bin.command;
1196
- let args = piArgs;
1197
- if (selection !== void 0) {
1198
- if (selection.lane !== "byok" || selection.runtimeId !== "pi") {
1199
- throw new PolicyUnsupportedError(
1200
- `pi adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
1201
- );
1609
+ let launcherArgs;
1610
+ if (pinnedSelection !== void 0) {
1611
+ if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
1612
+ return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
1202
1613
  }
1203
1614
  const launcher = this.options.byokLauncher;
1204
1615
  if (launcher === void 0) {
1205
- throw new PolicyUnsupportedError(
1206
- "pi BYOK selection requires a configured credential-custody launcher"
1207
- );
1616
+ return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
1208
1617
  }
1209
1618
  command = launcher.command;
1210
- args = [
1619
+ launcherArgs = [
1211
1620
  ...launcher.args ?? [],
1212
1621
  "--pi-bin",
1213
1622
  bin.command,
@@ -1217,62 +1626,136 @@ var PiAdapter = class {
1217
1626
  launcher.sessionDir,
1218
1627
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
1219
1628
  "--provider",
1220
- selection.providerId,
1629
+ pinnedSelection.providerId,
1221
1630
  "--model",
1222
- selection.modelId,
1223
- "--",
1224
- ...piArgs
1631
+ pinnedSelection.modelId
1225
1632
  ];
1226
1633
  }
1227
- const rpc = new PiRpcClient({
1228
- command,
1229
- args,
1230
- cwd: ctx.workspaceDir,
1231
- env: selection === void 0 ? ctx.env : withoutProviderCredentials(ctx.env),
1232
- spawnFn: this.options.spawnFn
1233
- });
1234
- const response = await rpc.send({ type: "prompt", message: task.instruction });
1235
- if (response.success === false) {
1236
- rpc.kill();
1237
- throw new Error(typeof response.error === "string" ? response.error : "pi rejected the initial prompt");
1238
- }
1239
- let sessionRef;
1240
- if (resumeSessionId) {
1241
- sessionRef = resumeSessionId;
1242
- } else {
1243
- try {
1244
- sessionRef = await resolveFreshSessionId(rpc);
1245
- } catch (err) {
1246
- rpc.kill();
1247
- throw err;
1634
+ return {
1635
+ kind: "prepared",
1636
+ operation: {
1637
+ start: async (startInput) => {
1638
+ const manifestSelection = startInput.manifest.dispatchSelection;
1639
+ if (!sameDispatchSelection(manifestSelection, pinnedSelection)) {
1640
+ throw new RuntimeExecutionFailure({
1641
+ phase: "start",
1642
+ category: "authority",
1643
+ retry: "non-retryable",
1644
+ reason: "prepared pi operation received a manifest with different runtime selection"
1645
+ });
1646
+ }
1647
+ if (typeof startInput.instruction !== "string") {
1648
+ throw new RuntimeExecutionFailure({
1649
+ phase: "start",
1650
+ category: "authority",
1651
+ retry: "non-retryable",
1652
+ reason: "prepared pi operation requires a resolved string instruction"
1653
+ });
1654
+ }
1655
+ const resumeSessionId = startInput.manifest.sessionRef;
1656
+ const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1657
+ const args = launcherArgs === void 0 ? piArgs : [...launcherArgs, "--", ...piArgs];
1658
+ let rpc;
1659
+ try {
1660
+ rpc = new PiRpcClient({
1661
+ command,
1662
+ args,
1663
+ cwd: startInput.manifest.workspace.workspaceDir,
1664
+ env: manifestSelection === void 0 ? startInput.env : withoutProviderCredentials(startInput.env),
1665
+ spawnFn: this.options.spawnFn
1666
+ });
1667
+ } catch (cause) {
1668
+ throw new RuntimeExecutionFailure({
1669
+ phase: "start",
1670
+ category: "infrastructure",
1671
+ retry: "retryable",
1672
+ reason: "pi runtime process could not be spawned"
1673
+ }, { cause });
1674
+ }
1675
+ let response;
1676
+ try {
1677
+ response = await rpc.send({ type: "prompt", message: startInput.instruction });
1678
+ } catch (cause) {
1679
+ rpc.kill();
1680
+ throw new RuntimeExecutionFailure({
1681
+ phase: "start",
1682
+ category: "infrastructure",
1683
+ retry: "retryable",
1684
+ reason: `pi initial prompt transport failed: ${errorMessage(cause)}`
1685
+ }, { cause });
1686
+ }
1687
+ if (response.success === false) {
1688
+ rpc.kill();
1689
+ throw new RuntimeExecutionFailure({
1690
+ phase: "start",
1691
+ category: "semantic",
1692
+ retry: "non-retryable",
1693
+ reason: typeof response.error === "string" ? response.error : "pi rejected the initial prompt"
1694
+ });
1695
+ }
1696
+ let sessionRef;
1697
+ try {
1698
+ sessionRef = await resolveAuthoritativeSessionId(rpc);
1699
+ } catch (err) {
1700
+ rpc.kill();
1701
+ throw err;
1702
+ }
1703
+ if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1704
+ rpc.kill();
1705
+ throw new RuntimeExecutionFailure({
1706
+ phase: "start",
1707
+ category: "authority",
1708
+ retry: "non-retryable",
1709
+ reason: "pi resumed a different authoritative session than requested"
1710
+ });
1711
+ }
1712
+ return new PiSession(sessionRef, rpc, manifestSelection);
1713
+ }
1248
1714
  }
1249
- }
1250
- return new PiSession(sessionRef, rpc, selection);
1715
+ };
1251
1716
  }
1252
1717
  resolveBin() {
1253
1718
  return (this.options.resolveBin ?? resolvePiBin)();
1254
1719
  }
1255
1720
  };
1256
- async function resolveFreshSessionId(rpc) {
1721
+ function sameDispatchSelection(left, right) {
1722
+ if (left === void 0 || right === void 0) return left === right;
1723
+ return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
1724
+ }
1725
+ async function resolveAuthoritativeSessionId(rpc) {
1257
1726
  let state;
1258
1727
  try {
1259
1728
  state = await rpc.send({ type: "get_state" });
1260
1729
  } catch (err) {
1261
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${errorMessage(err)}`, {
1730
+ if (isRuntimeExecutionFailure(err)) throw err;
1731
+ throw new RuntimeExecutionFailure({
1732
+ phase: "start",
1733
+ category: "infrastructure",
1734
+ retry: "retryable",
1735
+ reason: `pi transport ended before yielding an authoritative session id: ${errorMessage(err)}`
1736
+ }, {
1262
1737
  cause: err
1263
1738
  });
1264
1739
  }
1265
1740
  if (state.success === false) {
1266
1741
  const reason = typeof state.error === "string" ? state.error : "get_state reported failure";
1267
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${reason}`);
1742
+ throw new RuntimeExecutionFailure({
1743
+ phase: "start",
1744
+ category: "authority",
1745
+ retry: "non-retryable",
1746
+ reason: `pi did not yield an authoritative session id: ${reason}`
1747
+ });
1268
1748
  }
1269
1749
  const data = state.data;
1270
1750
  if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
1271
1751
  return data.sessionId;
1272
1752
  }
1273
- throw new Error(
1274
- "pi did not yield an authoritative session id (get_state succeeded but reported no sessionId) \u2014 cannot mint a resumable session"
1275
- );
1753
+ throw new RuntimeExecutionFailure({
1754
+ phase: "start",
1755
+ category: "authority",
1756
+ retry: "non-retryable",
1757
+ reason: "pi get_state reported no authoritative session id"
1758
+ });
1276
1759
  }
1277
1760
  var PiSession = class {
1278
1761
  constructor(sessionRef, rpc, selection) {
@@ -1288,12 +1771,40 @@ var PiSession = class {
1288
1771
  return {
1289
1772
  [Symbol.asyncIterator]() {
1290
1773
  const inner = rpc.events[Symbol.asyncIterator]();
1774
+ let terminalFailure;
1291
1775
  return {
1292
1776
  async next() {
1293
1777
  for (; ; ) {
1294
- const { value, done } = await inner.next();
1295
- if (done) return { value: void 0, done: true };
1778
+ if (terminalFailure) throw terminalFailure;
1779
+ let result;
1780
+ try {
1781
+ result = await inner.next();
1782
+ } catch (cause) {
1783
+ throw new RuntimeExecutionFailure({
1784
+ phase: "run",
1785
+ category: "infrastructure",
1786
+ retry: "retryable",
1787
+ reason: "pi runtime event transport failed"
1788
+ }, { cause });
1789
+ }
1790
+ const { value, done } = result;
1791
+ if (done) {
1792
+ throw new RuntimeExecutionFailure({
1793
+ phase: "run",
1794
+ category: "infrastructure",
1795
+ retry: "retryable",
1796
+ reason: "pi runtime process ended before agent_settled"
1797
+ }, { cause: rpc.terminalError });
1798
+ }
1296
1799
  const mapped = mapPiMessageToAgentEvent(value);
1800
+ if (value.type === "auto_retry_end" && value.success === false) {
1801
+ terminalFailure = new RuntimeExecutionFailure({
1802
+ phase: "run",
1803
+ category: "semantic",
1804
+ retry: "non-retryable",
1805
+ reason: "pi exhausted its native retry policy"
1806
+ });
1807
+ }
1297
1808
  if (mapped) return { value: mapped, done: false };
1298
1809
  if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
1299
1810
  rpc.recordUnmappedFrame(value.type);
@@ -1323,7 +1834,7 @@ var PiSession = class {
1323
1834
  await this.rpc.send({ type: "abort" });
1324
1835
  }
1325
1836
  async close() {
1326
- this.rpc.kill();
1837
+ await this.rpc.dispose();
1327
1838
  }
1328
1839
  async resolveApproval() {
1329
1840
  throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
@@ -1535,7 +2046,15 @@ function mapResult(msg) {
1535
2046
  diagnostic ? `claude result frame had a missing/invalid is_error flag (got ${JSON.stringify(msg.is_error)}) \u2014 treating as failure, fail-closed; diagnostic content on the frame: ${truncateResultDiagnostic(diagnostic)}` : `claude result frame had a missing/invalid is_error flag (got ${JSON.stringify(msg.is_error)}) \u2014 treating as failure, fail-closed`
1536
2047
  );
1537
2048
  const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
1538
- return { events };
2049
+ return {
2050
+ events,
2051
+ terminalFailure: new RuntimeExecutionFailure({
2052
+ phase: "run",
2053
+ category: msg.is_error === true ? "semantic" : "authority",
2054
+ retry: "non-retryable",
2055
+ reason: msg.is_error === true ? "claude reported terminal task failure" : "claude emitted a malformed terminal result frame"
2056
+ })
2057
+ };
1539
2058
  }
1540
2059
  var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
1541
2060
  function truncateResultDiagnostic(text) {
@@ -1587,16 +2106,22 @@ var ClaudeProcessClient = class {
1587
2106
  eventQueue = new AsyncQueue();
1588
2107
  closed = false;
1589
2108
  exitError;
2109
+ closedPromise;
2110
+ resolveClosed;
2111
+ disposalAttempt;
1590
2112
  stderrRing = [];
1591
2113
  unmappedFrameCounts = /* @__PURE__ */ new Map();
1592
2114
  sessionId;
1593
2115
  initWaiter;
1594
2116
  constructor(options) {
1595
2117
  const spawnFn = options.spawnFn ?? spawn;
1596
- this.child = spawnFn(options.command, options.args, {
2118
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
1597
2119
  cwd: options.cwd,
1598
2120
  env: options.env,
1599
2121
  stdio: ["pipe", "pipe", "pipe"]
2122
+ }));
2123
+ this.closedPromise = new Promise((resolve) => {
2124
+ this.resolveClosed = resolve;
1600
2125
  });
1601
2126
  this.child.stdout.setEncoding("utf8");
1602
2127
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1652,6 +2177,10 @@ var ClaudeProcessClient = class {
1652
2177
  get events() {
1653
2178
  return this.eventQueue;
1654
2179
  }
2180
+ /** Local transport diagnostic retained when the process closes; consumers classify it at the session boundary. */
2181
+ get terminalError() {
2182
+ return this.exitError;
2183
+ }
1655
2184
  /**
1656
2185
  * Record a claude stream-json frame/subtype/content-block label that
1657
2186
  * `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
@@ -1671,15 +2200,37 @@ var ClaudeProcessClient = class {
1671
2200
  );
1672
2201
  }
1673
2202
  }
1674
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes claude itself spawned (e.g. Bash) — mirrors pi's cross-platform `kill()` exactly. Empirically confirmed on this (POSIX) machine: a running claude process exits cleanly within ~1s of SIGTERM (observed exit code 143 = 128+SIGTERM, i.e. claude catches and handles the signal itself rather than needing a harder kill). */
2203
+ /**
2204
+ * Immediate process-tree termination request. `dispose()` is the settlement
2205
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
2206
+ * terminator. A request that could not be spawned is left unrecorded, so
2207
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
2208
+ * swallowing it here loses nothing.
2209
+ */
1675
2210
  kill() {
1676
- if (this.closed) return;
1677
- const pid = this.child.pid;
1678
- if (process.platform === "win32" && pid !== void 0) {
1679
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1680
- } else {
1681
- this.child.kill("SIGTERM");
2211
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
2212
+ });
2213
+ }
2214
+ waitClosed() {
2215
+ return this.closedPromise;
2216
+ }
2217
+ dispose() {
2218
+ if (!this.disposalAttempt) {
2219
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2220
+ this.disposalAttempt = attempt.catch((error) => {
2221
+ this.disposalAttempt = void 0;
2222
+ throw error;
2223
+ });
1682
2224
  }
2225
+ return this.disposalAttempt;
2226
+ }
2227
+ processTreeOptions() {
2228
+ return {
2229
+ child: this.child,
2230
+ waitClosed: () => this.closedPromise,
2231
+ isClosed: () => this.closed,
2232
+ label: "claude"
2233
+ };
1683
2234
  }
1684
2235
  onData(chunk) {
1685
2236
  this.buffer += chunk;
@@ -1730,6 +2281,7 @@ var ClaudeProcessClient = class {
1730
2281
  if (this.closed) return;
1731
2282
  this.closed = true;
1732
2283
  this.exitError = err;
2284
+ this.resolveClosed();
1733
2285
  this.initWaiter?.reject(err);
1734
2286
  this.initWaiter = void 0;
1735
2287
  this.eventQueue.end();
@@ -1741,18 +2293,37 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1741
2293
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1742
2294
  var execFileAsync2 = promisify(execFile);
1743
2295
  var DETECT_TIMEOUT_MS2 = 5e3;
2296
+ function errorMessage2(err) {
2297
+ return err instanceof Error ? err.message : String(err);
2298
+ }
1744
2299
  async function cleanupMcpConfigDir(dir) {
1745
2300
  if (!dir) return;
1746
- await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1747
- });
2301
+ try {
2302
+ await promises.rm(dir, { recursive: true, force: true });
2303
+ } catch (cause) {
2304
+ throw new RuntimeDisposalFailure({
2305
+ stage: "cleanup",
2306
+ reason: "claude task-scoped MCP configuration could not be removed"
2307
+ }, { cause });
2308
+ }
1748
2309
  }
1749
2310
  var ClaudeAdapter = class {
1750
2311
  constructor(options = {}) {
1751
2312
  this.options = options;
1752
2313
  }
1753
2314
  options;
1754
- supportsDispatchSelection = true;
1755
- id = "claude";
2315
+ descriptor = freezeRuntimeAdapterDescriptor({
2316
+ id: "claude",
2317
+ supportsDispatchSelection: true,
2318
+ capabilities: {
2319
+ steer: false,
2320
+ resume: true,
2321
+ approvalInteractive: true,
2322
+ mcpToolsets: true,
2323
+ permissionModes: ["auto", "readonly", "plan", "confirm"]
2324
+ },
2325
+ environmentRequirements: { credentialNames: [] }
2326
+ });
1756
2327
  async detect() {
1757
2328
  const bin = this.resolveBin();
1758
2329
  try {
@@ -1764,54 +2335,73 @@ var ClaudeAdapter = class {
1764
2335
  return { present: false };
1765
2336
  }
1766
2337
  }
1767
- capabilities() {
2338
+ async prepare(input) {
2339
+ const mapping = mapPermissionPolicyToClaudeArgs(input.policy);
2340
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by claude adapter", retryable: false };
2341
+ let modelId;
2342
+ try {
2343
+ modelId = subscriptionModel(input.offer.dispatchSelection, "claude");
2344
+ } catch (error) {
2345
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
2346
+ }
2347
+ if (mapping.needsApprovalMcp && Object.prototype.hasOwnProperty.call(input.mcpServers ?? {}, APPROVAL_MCP_SERVER_NAME)) {
2348
+ return { kind: "reject", reason: `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`, retryable: false };
2349
+ }
2350
+ let bin;
2351
+ try {
2352
+ bin = this.resolveBin();
2353
+ } catch (error) {
2354
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2355
+ }
2356
+ let approvalMcpBin;
2357
+ if (mapping.needsApprovalMcp) {
2358
+ try {
2359
+ approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
2360
+ } catch (error) {
2361
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2362
+ }
2363
+ }
1768
2364
  return {
1769
- steer: false,
1770
- resume: true,
1771
- approvalInteractive: true,
1772
- mcpToolsets: true,
1773
- permissionModes: ["auto", "readonly", "plan", "confirm"]
2365
+ kind: "prepared",
2366
+ operation: {
2367
+ start: (startInput) => this.startPrepared(startInput, mapping, modelId, bin, approvalMcpBin)
2368
+ }
1774
2369
  };
1775
2370
  }
1776
- /**
1777
- * M5: deliberate product-boundary decision, not an oversight — byok's
1778
- * current ToS posture for claude is login-state-only (`claude auth
1779
- * login`'s own OAuth session — see `probeAuthPresent` below), so this
1780
- * adapter declares NO credential env vars at all; env-based API-key
1781
- * passthrough for claude is a separate, still-pending product decision.
1782
- * A product that genuinely needs it can opt in locally per-device via
1783
- * `DaemonConfig.runtimeEnvironment.claude.allow` (`create-daemon.ts`).
1784
- * `baseNames` is empty too: nothing in this adapter reads a
1785
- * claude-specific config-discovery variable (e.g. `CLAUDE_CONFIG_DIR`)
1786
- * today — if a future version of this adapter starts reading one, it
1787
- * belongs here, not left to rely on the platform baseline alone.
1788
- */
1789
- environmentRequirements() {
1790
- return { credentialNames: [] };
1791
- }
1792
- async start(task, ctx) {
1793
- if (typeof task.instruction !== "string") {
1794
- throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1795
- }
1796
- const mapping = mapPermissionPolicyToClaudeArgs(ctx.policy);
1797
- if (!mapping.ok) {
1798
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
2371
+ async startPrepared(startInput, initialMapping, modelId, bin, approvalMcpBin) {
2372
+ if (!initialMapping.ok) throw new RuntimeExecutionFailure({
2373
+ phase: "start",
2374
+ category: "authority",
2375
+ retry: "non-retryable",
2376
+ reason: "prepared claude permission mapping was invalid"
2377
+ });
2378
+ if (typeof startInput.instruction !== "string") {
2379
+ throw new RuntimeExecutionFailure({
2380
+ phase: "start",
2381
+ category: "authority",
2382
+ retry: "non-retryable",
2383
+ reason: "prepared claude operation requires a resolved string instruction"
2384
+ });
1799
2385
  }
1800
- const modelId = subscriptionModel(task, "claude");
2386
+ const mapping = { ...initialMapping, args: [...initialMapping.args] };
1801
2387
  let mcpConfigDir;
1802
- const taskMcpServers = ctx.mcpServers ?? {};
2388
+ const taskMcpServers = startInput.mcpServers ?? {};
1803
2389
  const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
1804
2390
  if (mapping.needsApprovalMcp) {
1805
- if (!ctx.approvalChannel) {
1806
- throw new PolicyUnsupportedError(
1807
- 'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1808
- );
1809
- }
1810
- if (Object.prototype.hasOwnProperty.call(taskMcpServers, APPROVAL_MCP_SERVER_NAME)) {
1811
- throw new PolicyUnsupportedError(
1812
- `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`
1813
- );
2391
+ if (!startInput.approvalChannel) {
2392
+ throw new RuntimeExecutionFailure({
2393
+ phase: "start",
2394
+ category: "authority",
2395
+ retry: "non-retryable",
2396
+ reason: 'claude adapter requires policy.mode "confirm" to be started with an approval channel'
2397
+ });
1814
2398
  }
2399
+ if (!approvalMcpBin) throw new RuntimeExecutionFailure({
2400
+ phase: "start",
2401
+ category: "authority",
2402
+ retry: "non-retryable",
2403
+ reason: "prepared claude approval MCP binary was not resolved"
2404
+ });
1815
2405
  }
1816
2406
  if (needsMcpConfig) {
1817
2407
  mcpConfigDir = await promises.mkdtemp(path17.join(os5.tmpdir(), "byok-mcp-"));
@@ -1820,12 +2410,23 @@ var ClaudeAdapter = class {
1820
2410
  const mcpConfigPath = path17.join(mcpConfigDir, "mcp-config.json");
1821
2411
  const mcpServers = { ...taskMcpServers };
1822
2412
  if (mapping.needsApprovalMcp) {
1823
- const approvalChannel = ctx.approvalChannel;
1824
- if (!approvalChannel) throw new Error("unreachable: approval channel checked above");
1825
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
2413
+ const approvalChannel = startInput.approvalChannel;
2414
+ if (!approvalChannel) throw new RuntimeExecutionFailure({
2415
+ phase: "start",
2416
+ category: "authority",
2417
+ retry: "non-retryable",
2418
+ reason: "prepared claude approval channel was not available"
2419
+ });
2420
+ const preparedApprovalMcpBin = approvalMcpBin;
2421
+ if (!preparedApprovalMcpBin) throw new RuntimeExecutionFailure({
2422
+ phase: "start",
2423
+ category: "authority",
2424
+ retry: "non-retryable",
2425
+ reason: "prepared claude approval MCP binary was not resolved"
2426
+ });
1826
2427
  mcpServers[APPROVAL_MCP_SERVER_NAME] = {
1827
- command: approvalMcpBin.command,
1828
- args: approvalMcpBin.args,
2428
+ command: preparedApprovalMcpBin.command,
2429
+ args: preparedApprovalMcpBin.args,
1829
2430
  env: {
1830
2431
  BYOK_STORE_DIR: approvalChannel.storeDir,
1831
2432
  BYOK_PRODUCT_ID: approvalChannel.productId,
@@ -1845,8 +2446,26 @@ var ClaudeAdapter = class {
1845
2446
  "--strict-mcp-config"
1846
2447
  ];
1847
2448
  }
1848
- const bin = this.resolveBin();
1849
- const resumeSessionId = task.sessionRef;
2449
+ const resumeSessionId = startInput.manifest.sessionRef;
2450
+ let manifestModelId;
2451
+ try {
2452
+ manifestModelId = subscriptionModel(startInput.manifest.dispatchSelection, "claude");
2453
+ } catch (cause) {
2454
+ throw new RuntimeExecutionFailure({
2455
+ phase: "start",
2456
+ category: "authority",
2457
+ retry: "non-retryable",
2458
+ reason: "prepared claude operation received an invalid runtime selection manifest"
2459
+ }, { cause });
2460
+ }
2461
+ if (manifestModelId !== modelId) {
2462
+ throw new RuntimeExecutionFailure({
2463
+ phase: "start",
2464
+ category: "authority",
2465
+ retry: "non-retryable",
2466
+ reason: "prepared claude operation received a manifest with different runtime selection"
2467
+ });
2468
+ }
1850
2469
  const args = [
1851
2470
  "-p",
1852
2471
  "--input-format",
@@ -1858,40 +2477,71 @@ var ClaudeAdapter = class {
1858
2477
  // "Error: When using --print, --output-format=stream-json requires
1859
2478
  // --verbose", before spawning any model call.
1860
2479
  "--verbose",
1861
- ...modelId ? ["--model", modelId] : [],
2480
+ ...manifestModelId ? ["--model", manifestModelId] : [],
1862
2481
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1863
2482
  ...mapping.args
1864
2483
  ];
1865
- const client = new ClaudeProcessClient({
1866
- command: bin.command,
1867
- args,
1868
- cwd: ctx.workspaceDir,
1869
- env: withoutProviderCredentials(ctx.env),
1870
- spawnFn: this.options.spawnFn
1871
- });
1872
- client.writeUserMessage(task.instruction);
2484
+ let client;
2485
+ try {
2486
+ client = new ClaudeProcessClient({
2487
+ command: bin.command,
2488
+ args,
2489
+ cwd: startInput.manifest.workspace.workspaceDir,
2490
+ env: withoutProviderCredentials(startInput.env),
2491
+ spawnFn: this.options.spawnFn
2492
+ });
2493
+ } catch (cause) {
2494
+ await cleanupMcpConfigDir(mcpConfigDir);
2495
+ throw new RuntimeExecutionFailure({
2496
+ phase: "start",
2497
+ category: "infrastructure",
2498
+ retry: "retryable",
2499
+ reason: "claude runtime process could not be spawned"
2500
+ }, { cause });
2501
+ }
2502
+ try {
2503
+ client.writeUserMessage(startInput.instruction);
2504
+ } catch (cause) {
2505
+ client.kill();
2506
+ await cleanupMcpConfigDir(mcpConfigDir);
2507
+ throw new RuntimeExecutionFailure({
2508
+ phase: "start",
2509
+ category: "infrastructure",
2510
+ retry: "retryable",
2511
+ reason: "claude initial instruction transport failed"
2512
+ }, { cause });
2513
+ }
1873
2514
  let sessionRef;
1874
2515
  try {
1875
2516
  sessionRef = await client.waitForInit();
1876
2517
  } catch (err) {
1877
2518
  client.kill();
1878
2519
  await cleanupMcpConfigDir(mcpConfigDir);
1879
- throw err;
2520
+ if (isRuntimeExecutionFailure(err)) throw err;
2521
+ throw new RuntimeExecutionFailure({
2522
+ phase: "start",
2523
+ category: "infrastructure",
2524
+ retry: "retryable",
2525
+ reason: `claude exited before yielding an authoritative session id: ${errorMessage2(err)}`
2526
+ }, { cause: err });
1880
2527
  }
1881
2528
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1882
2529
  client.kill();
1883
2530
  await cleanupMcpConfigDir(mcpConfigDir);
1884
- throw new Error(
1885
- `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1886
- );
2531
+ throw new RuntimeExecutionFailure({
2532
+ phase: "start",
2533
+ category: "authority",
2534
+ retry: "non-retryable",
2535
+ reason: `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef})`
2536
+ });
1887
2537
  }
1888
2538
  return new ClaudeSession(
1889
2539
  sessionRef,
1890
2540
  client,
1891
- ctx.workspaceDir,
1892
- ctx.approvalChannel,
2541
+ startInput.manifest.workspace.workspaceDir,
2542
+ startInput.approvalChannel,
1893
2543
  mcpConfigDir,
1894
- modelId
2544
+ manifestModelId
1895
2545
  );
1896
2546
  }
1897
2547
  /**
@@ -1926,8 +2576,7 @@ var ClaudeAdapter = class {
1926
2576
  return (this.options.resolveBin ?? resolveClaudeBin)();
1927
2577
  }
1928
2578
  };
1929
- function subscriptionModel(task, runtimeId) {
1930
- const selection = task.dispatchSelection;
2579
+ function subscriptionModel(selection, runtimeId) {
1931
2580
  if (selection === void 0) return void 0;
1932
2581
  if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
1933
2582
  throw new PolicyUnsupportedError(
@@ -1952,6 +2601,7 @@ var ClaudeSession = class {
1952
2601
  mcpConfigDir;
1953
2602
  modelId;
1954
2603
  correlation = createToolUseCorrelation();
2604
+ closeAttempt;
1955
2605
  get events() {
1956
2606
  const client = this.client;
1957
2607
  const correlation = this.correlation;
@@ -1960,17 +2610,40 @@ var ClaudeSession = class {
1960
2610
  [Symbol.asyncIterator]() {
1961
2611
  const inner = client.events[Symbol.asyncIterator]();
1962
2612
  let pending = [];
2613
+ let terminalFailure;
1963
2614
  let turnSettled = false;
1964
2615
  return {
1965
2616
  async next() {
1966
2617
  for (; ; ) {
1967
2618
  const buffered = pending.shift();
1968
2619
  if (buffered) return { value: buffered, done: false };
1969
- if (turnSettled) return { value: void 0, done: true };
1970
- const { value, done } = await inner.next();
1971
- if (done) return { value: void 0, done: true };
2620
+ if (turnSettled) {
2621
+ if (terminalFailure) throw terminalFailure;
2622
+ return { value: void 0, done: true };
2623
+ }
2624
+ let raw;
2625
+ try {
2626
+ raw = await inner.next();
2627
+ } catch (cause) {
2628
+ throw new RuntimeExecutionFailure({
2629
+ phase: "run",
2630
+ category: "infrastructure",
2631
+ retry: "retryable",
2632
+ reason: "claude runtime event transport failed"
2633
+ }, { cause });
2634
+ }
2635
+ const { value, done } = raw;
2636
+ if (done) {
2637
+ throw new RuntimeExecutionFailure({
2638
+ phase: "run",
2639
+ category: "infrastructure",
2640
+ retry: "retryable",
2641
+ reason: "claude runtime process ended before a terminal result frame"
2642
+ }, { cause: client.terminalError });
2643
+ }
1972
2644
  if (value.type === "result") turnSettled = true;
1973
2645
  const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
2646
+ terminalFailure = mapped.terminalFailure ?? terminalFailure;
1974
2647
  if (mapped.unmappedLabel) {
1975
2648
  client.recordUnmappedFrame(mapped.unmappedLabel);
1976
2649
  }
@@ -2001,7 +2674,7 @@ var ClaudeSession = class {
2001
2674
  if (typeof task.instruction !== "string") {
2002
2675
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2003
2676
  }
2004
- const requestedModel = subscriptionModel(task, "claude");
2677
+ const requestedModel = subscriptionModel(task.dispatchSelection, "claude");
2005
2678
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2006
2679
  throw new PolicyUnsupportedError(
2007
2680
  `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2025,8 +2698,17 @@ var ClaudeSession = class {
2025
2698
  this.client.kill();
2026
2699
  }
2027
2700
  async close() {
2028
- this.client.kill();
2029
- await cleanupMcpConfigDir(this.mcpConfigDir);
2701
+ if (!this.closeAttempt) {
2702
+ const attempt = (async () => {
2703
+ await this.client.dispose();
2704
+ await cleanupMcpConfigDir(this.mcpConfigDir);
2705
+ })();
2706
+ this.closeAttempt = attempt.catch((error) => {
2707
+ this.closeAttempt = void 0;
2708
+ throw error;
2709
+ });
2710
+ }
2711
+ await this.closeAttempt;
2030
2712
  }
2031
2713
  /**
2032
2714
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -2243,14 +2925,15 @@ var CodexProcessRunner = class {
2243
2925
  exitSignal = null;
2244
2926
  closedPromise;
2245
2927
  resolveClosed;
2928
+ disposalAttempt;
2246
2929
  constructor(options) {
2247
2930
  this.onEvent = options.onEvent;
2248
2931
  const spawnFn = options.spawnFn ?? spawn;
2249
- this.child = spawnFn(options.command, options.args, {
2932
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
2250
2933
  cwd: options.cwd,
2251
2934
  env: options.env,
2252
2935
  stdio: ["ignore", "pipe", "pipe"]
2253
- });
2936
+ }));
2254
2937
  this.closedPromise = new Promise((resolve) => {
2255
2938
  this.resolveClosed = resolve;
2256
2939
  });
@@ -2280,7 +2963,7 @@ var CodexProcessRunner = class {
2280
2963
  return this.closed;
2281
2964
  }
2282
2965
  /**
2283
- * Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
2966
+ * Immediate tree termination request. SIGTERM on POSIX: SIGINT was empirically confirmed
2284
2967
  * to be silently ignored by `codex exec` (a real, direct test — a 60s
2285
2968
  * shell `sleep` ran to full, unaffected completion despite SIGINT sent at
2286
2969
  * t=4s) — a genuine, evidence-based correction to this task's own initial
@@ -2291,15 +2974,33 @@ var CodexProcessRunner = class {
2291
2974
  * cleanly resumable afterward via `codex exec resume` (no corruption from
2292
2975
  * killing mid-turn). `taskkill /T /F` on Windows, mirroring
2293
2976
  * `../pi/rpc-client.ts`'s own cross-platform convention.
2977
+ *
2978
+ * Fire-and-forget by design: an interrupt must not block on a terminator,
2979
+ * and `dispose()` is the settlement receipt. A request that could not be
2980
+ * spawned is left unrecorded, so `dispose()` re-issues it and raises the
2981
+ * typed `stage:'signal'` failure — swallowing it here loses nothing.
2294
2982
  */
2295
2983
  kill() {
2296
- if (this.closed) return;
2297
- const pid = this.child.pid;
2298
- if (process.platform === "win32" && pid !== void 0) {
2299
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
2300
- } else {
2301
- this.child.kill("SIGTERM");
2984
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
2985
+ });
2986
+ }
2987
+ dispose() {
2988
+ if (!this.disposalAttempt) {
2989
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2990
+ this.disposalAttempt = attempt.catch((error) => {
2991
+ this.disposalAttempt = void 0;
2992
+ throw error;
2993
+ });
2302
2994
  }
2995
+ return this.disposalAttempt;
2996
+ }
2997
+ processTreeOptions() {
2998
+ return {
2999
+ child: this.child,
3000
+ waitClosed: () => this.closedPromise,
3001
+ isClosed: () => this.closed,
3002
+ label: "codex"
3003
+ };
2303
3004
  }
2304
3005
  /** Builds a descriptive error folding in the exit code/signal and the stderr tail — mirrors `PiRpcClient.buildExitError`'s reasoning: a post-mortem on a failed start/resume should never need separately re-running codex by hand with a raw JSONL logger to learn why. */
2305
3006
  buildExitError(context) {
@@ -2350,8 +3051,17 @@ var CodexAdapter = class {
2350
3051
  this.options = options;
2351
3052
  }
2352
3053
  options;
2353
- supportsDispatchSelection = true;
2354
- id = "codex";
3054
+ descriptor = freezeRuntimeAdapterDescriptor({
3055
+ id: "codex",
3056
+ supportsDispatchSelection: true,
3057
+ capabilities: {
3058
+ steer: false,
3059
+ resume: true,
3060
+ approvalInteractive: false,
3061
+ permissionModes: ["auto", "readonly"]
3062
+ },
3063
+ environmentRequirements: { credentialNames: [] }
3064
+ });
2355
3065
  async detect() {
2356
3066
  const bin = this.resolveBin();
2357
3067
  try {
@@ -2400,61 +3110,100 @@ ${result.stderr}`);
2400
3110
  ${withStreams.stderr ?? ""}`);
2401
3111
  }
2402
3112
  }
2403
- capabilities() {
2404
- return { steer: false, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
2405
- }
2406
- /**
2407
- * M5: same deliberate posture as the claude adapter (see its own doc
2408
- * comment) — codex authenticates via its own `codex login`-managed
2409
- * ChatGPT OAuth session (`probeAuthPresent` above), not an env var, so
2410
- * there is no credential env var this adapter needs forwarded; env-based
2411
- * API-key passthrough remains a separate, pending product decision. No
2412
- * `baseNames` either: nothing in this adapter reads a codex-specific
2413
- * config-discovery variable (e.g. `CODEX_HOME`) today.
2414
- */
2415
- environmentRequirements() {
2416
- return { credentialNames: [] };
2417
- }
2418
- async start(task, ctx) {
2419
- if (typeof task.instruction !== "string") {
2420
- throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
3113
+ async prepare(input) {
3114
+ const mapping = mapPermissionPolicyToCodexArgs(input.policy);
3115
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by codex adapter", retryable: false };
3116
+ let modelId;
3117
+ try {
3118
+ modelId = subscriptionModel2(input.offer.dispatchSelection);
3119
+ } catch (error) {
3120
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
2421
3121
  }
2422
- const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
2423
- if (!mapping.ok) {
2424
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
3122
+ let command;
3123
+ try {
3124
+ command = this.resolveBin().command;
3125
+ } catch (error) {
3126
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
3127
+ }
3128
+ return {
3129
+ kind: "prepared",
3130
+ operation: {
3131
+ start: (startInput) => this.startPrepared(startInput, mapping.args, modelId, command)
3132
+ }
3133
+ };
3134
+ }
3135
+ async startPrepared(startInput, policyArgs, modelId, command) {
3136
+ if (typeof startInput.instruction !== "string") {
3137
+ throw new RuntimeExecutionFailure({
3138
+ phase: "start",
3139
+ category: "authority",
3140
+ retry: "non-retryable",
3141
+ reason: "prepared codex operation requires a resolved string instruction"
3142
+ });
2425
3143
  }
2426
- const modelId = subscriptionModel2(task);
2427
- const bin = this.resolveBin();
2428
3144
  const queue = new AsyncQueue();
3145
+ const terminal = {};
2429
3146
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
2430
- const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
2431
- const runtimeEnv = withoutProviderCredentials(ctx.env);
3147
+ let workspaceDir;
3148
+ try {
3149
+ workspaceDir = await resolveRealWorkspaceDir(startInput.manifest.workspace.workspaceDir);
3150
+ } catch (cause) {
3151
+ throw new RuntimeExecutionFailure({
3152
+ phase: "start",
3153
+ category: "infrastructure",
3154
+ retry: "retryable",
3155
+ reason: "codex runtime workspace could not be resolved"
3156
+ }, { cause });
3157
+ }
3158
+ const runtimeEnv = withoutProviderCredentials(startInput.env);
3159
+ let manifestModelId;
3160
+ try {
3161
+ manifestModelId = subscriptionModel2(startInput.manifest.dispatchSelection);
3162
+ } catch (cause) {
3163
+ throw new RuntimeExecutionFailure({
3164
+ phase: "start",
3165
+ category: "authority",
3166
+ retry: "non-retryable",
3167
+ reason: "prepared codex operation received an invalid runtime selection manifest"
3168
+ }, { cause });
3169
+ }
3170
+ if (manifestModelId !== modelId) {
3171
+ throw new RuntimeExecutionFailure({
3172
+ phase: "start",
3173
+ category: "authority",
3174
+ retry: "non-retryable",
3175
+ reason: "prepared codex operation received a manifest with different runtime selection"
3176
+ });
3177
+ }
2432
3178
  const { sessionRef, runner } = await runCodexTurn({
2433
- command: bin.command,
2434
- resumeRef: task.sessionRef,
2435
- instruction: task.instruction,
2436
- modelId,
2437
- policyArgs: mapping.args,
2438
- cwd: ctx.workspaceDir,
3179
+ command,
3180
+ resumeRef: startInput.manifest.sessionRef,
3181
+ instruction: startInput.instruction,
3182
+ modelId: manifestModelId,
3183
+ policyArgs: [...policyArgs],
3184
+ cwd: startInput.manifest.workspace.workspaceDir,
2439
3185
  env: runtimeEnv,
2440
3186
  spawnFn: this.options.spawnFn,
2441
3187
  workspaceDir,
2442
3188
  queue,
2443
3189
  recordUnmapped,
2444
- expectedSessionRef: task.sessionRef,
2445
- preparedGit: ctx.gitWorkspace !== void 0
3190
+ expectedSessionRef: startInput.manifest.sessionRef,
3191
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3192
+ failurePhase: "start",
3193
+ terminal
2446
3194
  });
2447
3195
  return new CodexSession({
2448
3196
  sessionRef,
2449
- command: bin.command,
3197
+ command,
2450
3198
  workspaceDir,
2451
- env: ctx.env,
3199
+ env: startInput.env,
2452
3200
  spawnFn: this.options.spawnFn,
2453
3201
  queue,
2454
3202
  recordUnmapped,
2455
3203
  initialRunner: runner,
2456
- preparedGit: ctx.gitWorkspace !== void 0,
2457
- modelId
3204
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3205
+ modelId: manifestModelId,
3206
+ terminal
2458
3207
  });
2459
3208
  }
2460
3209
  resolveBin() {
@@ -2502,37 +3251,69 @@ async function runCodexTurn(params) {
2502
3251
  rejectFirstLine = reject;
2503
3252
  });
2504
3253
  let turnEnded = false;
2505
- const runner = new CodexProcessRunner({
2506
- command: params.command,
2507
- args: argv,
2508
- cwd: params.cwd,
2509
- env: params.env,
2510
- spawnFn: params.spawnFn,
2511
- onEvent: (evt) => {
2512
- if (!firstLineSettled) {
2513
- firstLineSettled = true;
2514
- if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
2515
- resolveFirstLine(evt.thread_id);
2516
- } else {
2517
- rejectFirstLine(
2518
- new Error(`codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`)
2519
- );
3254
+ let runner;
3255
+ try {
3256
+ runner = new CodexProcessRunner({
3257
+ command: params.command,
3258
+ args: argv,
3259
+ cwd: params.cwd,
3260
+ env: params.env,
3261
+ spawnFn: params.spawnFn,
3262
+ onEvent: (evt) => {
3263
+ if (!firstLineSettled) {
3264
+ firstLineSettled = true;
3265
+ if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
3266
+ resolveFirstLine(evt.thread_id);
3267
+ } else {
3268
+ rejectFirstLine(
3269
+ new RuntimeExecutionFailure({
3270
+ phase: params.failurePhase,
3271
+ category: "authority",
3272
+ retry: "non-retryable",
3273
+ reason: `codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`
3274
+ })
3275
+ );
3276
+ }
3277
+ return;
3278
+ }
3279
+ const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
3280
+ for (const agentEvent of mapped) {
3281
+ if (agentEvent.type === "turn_end") turnEnded = true;
3282
+ params.queue.push(agentEvent);
3283
+ }
3284
+ if (evt.type === "turn.failed") {
3285
+ params.terminal.failure = new RuntimeExecutionFailure({
3286
+ phase: "run",
3287
+ category: "semantic",
3288
+ retry: "non-retryable",
3289
+ reason: "codex reported terminal task failure"
3290
+ });
3291
+ params.queue.end();
3292
+ }
3293
+ if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
3294
+ params.recordUnmapped(unmappedFrameKey(evt));
2520
3295
  }
2521
- return;
2522
- }
2523
- const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
2524
- for (const agentEvent of mapped) {
2525
- if (agentEvent.type === "turn_end") turnEnded = true;
2526
- params.queue.push(agentEvent);
2527
- }
2528
- if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
2529
- params.recordUnmapped(unmappedFrameKey(evt));
2530
3296
  }
2531
- }
2532
- });
3297
+ });
3298
+ } catch (cause) {
3299
+ throw new RuntimeExecutionFailure({
3300
+ phase: params.failurePhase,
3301
+ category: "infrastructure",
3302
+ retry: "retryable",
3303
+ reason: "codex runtime process could not be spawned"
3304
+ }, { cause });
3305
+ }
3306
+ params.onRunnerCreated?.(runner);
2533
3307
  void runner.waitClosed().then(() => {
2534
- if (turnEnded) return;
2535
- params.queue.push({ type: "error", message: runner.buildExitError("codex exited without completing the turn").message });
3308
+ if (turnEnded || params.terminal.failure) return;
3309
+ const cause = runner.buildExitError("codex exited without completing the turn");
3310
+ params.terminal.failure = new RuntimeExecutionFailure({
3311
+ phase: "run",
3312
+ category: "infrastructure",
3313
+ retry: "retryable",
3314
+ reason: cause.message
3315
+ }, { cause });
3316
+ params.queue.push({ type: "error", message: cause.message });
2536
3317
  params.queue.end();
2537
3318
  });
2538
3319
  let sessionRef;
@@ -2556,7 +3337,13 @@ async function runCodexTurn(params) {
2556
3337
  void runner.waitClosed().then(() => {
2557
3338
  if (!settled) {
2558
3339
  settled = true;
2559
- reject(runner.buildExitError("codex exited before yielding an authoritative thread id"));
3340
+ const cause = runner.buildExitError("codex exited before yielding an authoritative thread id");
3341
+ reject(new RuntimeExecutionFailure({
3342
+ phase: params.failurePhase,
3343
+ category: "infrastructure",
3344
+ retry: "retryable",
3345
+ reason: cause.message
3346
+ }, { cause }));
2560
3347
  }
2561
3348
  });
2562
3349
  });
@@ -2566,9 +3353,12 @@ async function runCodexTurn(params) {
2566
3353
  }
2567
3354
  if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
2568
3355
  runner.kill();
2569
- throw new Error(
2570
- `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
2571
- );
3356
+ throw new RuntimeExecutionFailure({
3357
+ phase: params.failurePhase,
3358
+ category: "authority",
3359
+ retry: "non-retryable",
3360
+ reason: `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef})`
3361
+ });
2572
3362
  }
2573
3363
  return { sessionRef, runner };
2574
3364
  }
@@ -2595,8 +3385,12 @@ var CodexSession = class {
2595
3385
  recordUnmapped;
2596
3386
  preparedGit;
2597
3387
  modelId;
3388
+ terminal;
2598
3389
  currentRunner;
3390
+ ownedRunners = /* @__PURE__ */ new Set();
3391
+ followUpAttempts = /* @__PURE__ */ new Set();
2599
3392
  closed = false;
3393
+ closeAttempt;
2600
3394
  constructor(options) {
2601
3395
  this.sessionRef = options.sessionRef;
2602
3396
  this.command = options.command;
@@ -2607,11 +3401,36 @@ var CodexSession = class {
2607
3401
  this.recordUnmapped = options.recordUnmapped;
2608
3402
  this.preparedGit = options.preparedGit;
2609
3403
  this.modelId = options.modelId;
3404
+ this.terminal = options.terminal;
2610
3405
  this.currentRunner = options.initialRunner;
3406
+ this.ownedRunners.add(options.initialRunner);
2611
3407
  void this.forgetRunnerOnceClosed(options.initialRunner);
2612
3408
  }
2613
3409
  get events() {
2614
- return this.queue;
3410
+ const queue = this.queue;
3411
+ const session = this;
3412
+ return {
3413
+ [Symbol.asyncIterator]() {
3414
+ const inner = queue[Symbol.asyncIterator]();
3415
+ return {
3416
+ async next() {
3417
+ let result;
3418
+ try {
3419
+ result = await inner.next();
3420
+ } catch (cause) {
3421
+ throw new RuntimeExecutionFailure({
3422
+ phase: "run",
3423
+ category: "infrastructure",
3424
+ retry: "retryable",
3425
+ reason: "codex runtime event transport failed"
3426
+ }, { cause });
3427
+ }
3428
+ if (result.done && session.terminal.failure) throw session.terminal.failure;
3429
+ return result;
3430
+ }
3431
+ };
3432
+ }
3433
+ };
2615
3434
  }
2616
3435
  async forgetRunnerOnceClosed(runner) {
2617
3436
  await runner.waitClosed();
@@ -2656,7 +3475,14 @@ var CodexSession = class {
2656
3475
  * stale id even after codex had moved on) — it just can now only ever be
2657
3476
  * the SAME id this call asked to resume, never a silently-different one.
2658
3477
  */
2659
- async followUp(task) {
3478
+ followUp(task) {
3479
+ const attempt = this.runFollowUp(task);
3480
+ this.followUpAttempts.add(attempt);
3481
+ void attempt.finally(() => this.followUpAttempts.delete(attempt)).catch(() => {
3482
+ });
3483
+ return attempt;
3484
+ }
3485
+ async runFollowUp(task) {
2660
3486
  if (typeof task.instruction !== "string") {
2661
3487
  throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2662
3488
  }
@@ -2667,7 +3493,7 @@ var CodexSession = class {
2667
3493
  if (!mapping.ok) {
2668
3494
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2669
3495
  }
2670
- const requestedModel = subscriptionModel2(task);
3496
+ const requestedModel = subscriptionModel2(task.dispatchSelection);
2671
3497
  if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2672
3498
  throw new PolicyUnsupportedError(
2673
3499
  `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
@@ -2677,6 +3503,7 @@ var CodexSession = class {
2677
3503
  const resumeRef = this.sessionRef;
2678
3504
  let sessionRef;
2679
3505
  let runner;
3506
+ const terminal = {};
2680
3507
  try {
2681
3508
  ({ sessionRef, runner } = await runCodexTurn({
2682
3509
  command: this.command,
@@ -2691,25 +3518,54 @@ var CodexSession = class {
2691
3518
  queue: this.queue,
2692
3519
  recordUnmapped: this.recordUnmapped,
2693
3520
  expectedSessionRef: resumeRef,
2694
- preparedGit: this.preparedGit
3521
+ preparedGit: this.preparedGit,
3522
+ failurePhase: "run",
3523
+ terminal,
3524
+ onRunnerCreated: (created) => {
3525
+ this.ownedRunners.add(created);
3526
+ void this.forgetRunnerOnceClosed(created);
3527
+ }
2695
3528
  }));
2696
3529
  } catch (err) {
2697
3530
  this.queue.end();
3531
+ this.terminal = {
3532
+ failure: isRuntimeExecutionFailure(err) ? err : new RuntimeExecutionFailure({
3533
+ phase: "run",
3534
+ category: "authority",
3535
+ retry: "non-retryable",
3536
+ reason: "codex follow-up violated the runtime adapter contract"
3537
+ }, { cause: err })
3538
+ };
2698
3539
  throw err;
2699
3540
  }
3541
+ if (this.closed) {
3542
+ await runner.dispose();
3543
+ throw new Error("codex session closed while follow-up was starting");
3544
+ }
3545
+ this.terminal = terminal;
2700
3546
  this.sessionRef = sessionRef;
2701
3547
  this.currentRunner = runner;
2702
- void this.forgetRunnerOnceClosed(runner);
2703
3548
  }
2704
3549
  /** Best-effort abort of the current turn. SIGTERM's the currently-running child, if any — see `process-runner.ts`'s `kill()` doc comment for why SIGTERM (not SIGINT) and why this is safe: the underlying codex thread survives and stays resumable, confirmed empirically. A no-op when no turn is currently in flight. */
2705
3550
  async interrupt() {
2706
3551
  this.currentRunner?.kill();
2707
3552
  }
2708
3553
  async close() {
2709
- if (this.closed) return;
2710
- this.closed = true;
2711
- this.currentRunner?.kill();
2712
- this.queue.end();
3554
+ if (!this.closeAttempt) {
3555
+ this.closed = true;
3556
+ this.queue.end();
3557
+ const attempt = (async () => {
3558
+ const runners = [...this.ownedRunners];
3559
+ await Promise.all(runners.map((runner) => runner.dispose()));
3560
+ for (const runner of runners) this.ownedRunners.delete(runner);
3561
+ await Promise.allSettled([...this.followUpAttempts]);
3562
+ })();
3563
+ this.closeAttempt = attempt.catch((error) => {
3564
+ this.closeAttempt = void 0;
3565
+ throw error;
3566
+ });
3567
+ }
3568
+ await this.closeAttempt;
2713
3569
  }
2714
3570
  /**
2715
3571
  * `codex exec` has no in-band channel to inject text into an already-
@@ -2745,8 +3601,7 @@ var CodexSession = class {
2745
3601
  );
2746
3602
  }
2747
3603
  };
2748
- function subscriptionModel2(task) {
2749
- const selection = task.dispatchSelection;
3604
+ function subscriptionModel2(selection) {
2750
3605
  if (selection === void 0) return void 0;
2751
3606
  if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
2752
3607
  throw new PolicyUnsupportedError(
@@ -2811,7 +3666,7 @@ var ApprovalRegistry = class {
2811
3666
  * `requestApproval` timeout and `finish()` fail-closed cleanup
2812
3667
  * (`task-runner.ts`) — resolves a decision this device made on its own.
2813
3668
  * The one exception, a server-sent wire `task.approve`/`task.reject`
2814
- * relayed through `TaskContext.approvalChannel.resolve`
3669
+ * relayed through `RuntimeOperationStartInput.approvalChannel.resolve`
2815
3670
  * (`task-runner.ts`'s `handleOffer`), passes `'wire'` explicitly.
2816
3671
  */
2817
3672
  resolve(approvalId, decision, reason, origin = "local") {
@@ -3442,7 +4297,10 @@ var PresencePublisher = class {
3442
4297
  {
3443
4298
  method: "PUT",
3444
4299
  headers: { "content-type": "application/json" },
3445
- body: JSON.stringify({ level: "online" })
4300
+ body: JSON.stringify({
4301
+ level: "online",
4302
+ ...this.opts.configuredToolsets === void 0 ? {} : { configuredToolsets: this.opts.configuredToolsets }
4303
+ })
3446
4304
  },
3447
4305
  this.opts.auth
3448
4306
  );
@@ -3636,12 +4494,12 @@ var AnotherControlServerRunningError = class extends Error {
3636
4494
  }
3637
4495
  };
3638
4496
  var MAX_HALF_OPEN_CONNECTIONS = 8;
3639
- function errorMessage2(err) {
4497
+ function errorMessage3(err) {
3640
4498
  return err instanceof Error ? err.message : String(err);
3641
4499
  }
3642
4500
  function toControlErrorShape(err) {
3643
4501
  if (err instanceof ControlError) return { code: err.code, message: err.message };
3644
- return { code: "internal_error", message: errorMessage2(err) };
4502
+ return { code: "internal_error", message: errorMessage3(err) };
3645
4503
  }
3646
4504
  function probeUnixSocketAlive(socketPath) {
3647
4505
  return new Promise((resolve) => {
@@ -3848,12 +4706,19 @@ async function startControlServer(opts) {
3848
4706
  }
3849
4707
  throw err;
3850
4708
  }
4709
+ let stopServingPromise;
4710
+ async function stopServing() {
4711
+ stopServingPromise ??= (async () => {
4712
+ for (const socket of sockets) socket.destroy();
4713
+ await new Promise((resolve) => server.close(() => resolve()));
4714
+ })();
4715
+ await stopServingPromise;
4716
+ }
3851
4717
  let closed = false;
3852
4718
  async function close() {
3853
4719
  if (closed) return;
3854
4720
  closed = true;
3855
- for (const socket of sockets) socket.destroy();
3856
- await new Promise((resolve) => server.close(() => resolve()));
4721
+ await stopServing();
3857
4722
  if (process.platform !== "win32") {
3858
4723
  await promises.rm(endpoint, { force: true }).catch(() => {
3859
4724
  });
@@ -3861,7 +4726,7 @@ async function startControlServer(opts) {
3861
4726
  await promises.rm(tokenPath, { force: true }).catch(() => {
3862
4727
  });
3863
4728
  }
3864
- return { endpoint, close };
4729
+ return { endpoint, stopServing, close };
3865
4730
  }
3866
4731
  function deterministicJitterMs(input) {
3867
4732
  const ratio = input.ratio ?? 0.2;
@@ -3897,14 +4762,17 @@ function parseLooseEventsPollResponse(raw) {
3897
4762
  if (typeof raw !== "object" || raw === null) {
3898
4763
  throw new Error("events poll response is not an object");
3899
4764
  }
3900
- const { events, cursor } = raw;
4765
+ const { events, cursor, capabilities } = raw;
3901
4766
  if (!Array.isArray(events)) {
3902
4767
  throw new Error("events poll response.events is not an array");
3903
4768
  }
3904
4769
  if (typeof cursor !== "number" || !Number.isInteger(cursor)) {
3905
4770
  throw new Error("events poll response.cursor is not an integer");
3906
4771
  }
3907
- return { events, cursor };
4772
+ if (capabilities !== void 0 && (!Array.isArray(capabilities) || capabilities.some((flag) => typeof flag !== "string"))) {
4773
+ throw new Error("events poll response.capabilities is not an array of strings");
4774
+ }
4775
+ return { events, cursor, capabilities: capabilities ?? [] };
3908
4776
  }
3909
4777
  function extractSkippableSeq(raw) {
3910
4778
  if (typeof raw !== "object" || raw === null) return void 0;
@@ -3984,12 +4852,14 @@ var LongPollClient = class {
3984
4852
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
3985
4853
  const res = await authedFetch(url, { method: "GET" }, this.opts.auth);
3986
4854
  if (!res.ok) {
4855
+ this.opts.onServerCapabilities?.([]);
3987
4856
  this.opts.onOperationalOutcome?.("failure");
3988
4857
  const baseMs = this.opts.retryDelayMs ?? 2e3;
3989
4858
  await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
3990
4859
  continue;
3991
4860
  }
3992
4861
  const parsed = parseLooseEventsPollResponse(await res.json());
4862
+ this.opts.onServerCapabilities?.(parsed.capabilities);
3993
4863
  let hadValidationFailureThisBatch = false;
3994
4864
  for (const raw of parsed.events) {
3995
4865
  let envelope;
@@ -4033,6 +4903,7 @@ var LongPollClient = class {
4033
4903
  this.opts.onOperationalOutcome?.("success");
4034
4904
  }
4035
4905
  } catch (err) {
4906
+ this.opts.onServerCapabilities?.([]);
4036
4907
  if (err instanceof DeviceRevokedError) {
4037
4908
  this.running = false;
4038
4909
  this.opts.onRevoked?.();
@@ -4155,6 +5026,7 @@ var WsTransport = class {
4155
5026
  deviceId: this.opts.deviceId,
4156
5027
  productId: this.opts.productId,
4157
5028
  runtimes: this.opts.runtimes,
5029
+ configuredToolsets: this.opts.configuredToolsets === void 0 ? void 0 : [...this.opts.configuredToolsets],
4158
5030
  cursor: this.opts.getCursor?.()
4159
5031
  });
4160
5032
  socket.send(encodeEnvelope(hello));
@@ -4244,6 +5116,7 @@ var ConnectionManager = class {
4244
5116
  productId: opts.productId,
4245
5117
  capabilities: opts.capabilities,
4246
5118
  runtimes: opts.runtimes,
5119
+ configuredToolsets: opts.configuredToolsets,
4247
5120
  getCursor: () => this.cursor,
4248
5121
  onEnvelope: (envelope) => this.deliver(envelope),
4249
5122
  onStateChange: (state) => {
@@ -4266,6 +5139,9 @@ var ConnectionManager = class {
4266
5139
  // re-attempted.
4267
5140
  getCursor: () => this.dedupWatermark(),
4268
5141
  onEnvelope: (envelope) => this.deliver(envelope),
5142
+ onServerCapabilities: (capabilities) => {
5143
+ if (this.mode === "long-poll") this.serverCapabilities = capabilities;
5144
+ },
4269
5145
  onRevoked: () => this.enterRevoked(),
4270
5146
  // M4 Phase 4 (version-negotiation drill fix): a batch entry
4271
5147
  // LongPollClient couldn't parse into a known Envelope at all (an
@@ -4366,7 +5242,7 @@ var ConnectionManager = class {
4366
5242
  * already-delivered seqs (see its own doc comment) so the failed seq's own
4367
5243
  * redelivery can get through — but that same frozen watermark also means
4368
5244
  * every OTHER seq above it rides along on every re-poll too. Without this,
4369
- * a seq already mid-flight (e.g. a `task.offer` whose `adapter.start()`
5245
+ * a seq already mid-flight (e.g. a `task.offer` whose prepared operation start()
4370
5246
  * hasn't resolved yet) would be re-enqueued into `processingChain` on
4371
5247
  * every such re-poll, piling up duplicate copies that — once the first
4372
5248
  * finally resolves and the chain unwinds through them — run its handler
@@ -4397,25 +5273,24 @@ var ConnectionManager = class {
4397
5273
  */
4398
5274
  cancelPendingDrainRetry;
4399
5275
  /**
4400
- * The capabilities the CURRENTLY connected server advertised in its
4401
- * `conn.ack` — untyped `string[]` (forward-compat: a server may advertise
4402
- * a flag this build doesn't recognize yet), populated by {@link onAcked}
4403
- * and read by {@link getServerCapabilities}. Empty until the very first
4404
- * successful handshake.
5276
+ * The capabilities the CURRENT transport's server advertised untyped
5277
+ * `string[]` for forward compatibility. WS populates it from `conn.ack`;
5278
+ * long-poll populates it from each successful events response. Empty until
5279
+ * the active transport supplies an advertisement.
4405
5280
  *
4406
5281
  * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
4407
5282
  * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
4408
5283
  * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
4409
5284
  * `acked` branch), `stop()`, or a transport switch to long-poll
4410
- * (`enterLongPoll`) — and only ever repopulated by a FRESH `conn.ack`.
5285
+ * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
5286
+ * the transport that is still current.
4411
5287
  * The previous version of this doc comment claimed long-poll mode simply
4412
5288
  * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
4413
5289
  * a daemon that once learned e.g. `approval_resolved` from an earlier WS
4414
5290
  * session kept believing it applied to whatever it's connected to NOW,
4415
5291
  * even after a disconnect/degrade where nothing has actually confirmed
4416
5292
  * that's still true (a reconnect could land on a DIFFERENT server behind a
4417
- * load balancer; long-poll fallback itself never performs an equivalent
4418
- * handshake at all). Concretely, `TaskRunner.sendApprovalResolved` gates
5293
+ * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
4419
5294
  * `task.approval_resolved` on this list — sending it to a server that
4420
5295
  * doesn't actually understand it over the long-poll path would get a
4421
5296
  * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
@@ -4535,13 +5410,10 @@ var ConnectionManager = class {
4535
5410
  return this.mode === "long-poll";
4536
5411
  }
4537
5412
  /**
4538
- * The capabilities the CURRENTLY connected server advertised in its
4539
- * `conn.ack` e.g. lets a caller gate a daemon->server message on whether
4540
- * THIS server understands it before sending (see `task-runner.ts`'s
4541
- * `sendApprovalResolved`, gated on `approval_resolved`). Empty before the
4542
- * first handshake completes, AND (finding R2) once again empty after any
4543
- * disconnect/degrade — see `serverCapabilities`'s own doc comment for why
4544
- * this is strictly per-connection rather than "sticky" across one.
5413
+ * The capabilities the CURRENT transport's server advertised: from
5414
+ * `conn.ack` on WS, or the latest successful `GET /byok/events` response
5415
+ * on long-poll. Empty before either transport has supplied its current
5416
+ * advertisement, and cleared across disconnect/switch boundaries.
4545
5417
  */
4546
5418
  getServerCapabilities() {
4547
5419
  return this.serverCapabilities;
@@ -5205,6 +6077,12 @@ var RECLAIM_FILENAME = `${DAEMON_OWNER_FILENAME}.reclaim`;
5205
6077
  var MAX_OWNER_BYTES = 4096;
5206
6078
  var RECLAIM_MALFORMED_GRACE_MS = 3e4;
5207
6079
  var SELF_PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1e3).toISOString();
6080
+ function endOwnershipProbe(socket, response) {
6081
+ socket.on("error", () => {
6082
+ });
6083
+ if (response === void 0) socket.end();
6084
+ else socket.end(response);
6085
+ }
5208
6086
  var STORE_MUTEX_ID_PREFIX = "byok-store-mutex-v1:";
5209
6087
  var STORE_MUTEX_PROBE_TIMEOUT_MS = 1e3;
5210
6088
  var STORE_MUTEX_SOCKET_FILENAME = "mutex.sock";
@@ -5311,7 +6189,7 @@ async function portIsBound(port) {
5311
6189
  });
5312
6190
  }
5313
6191
  async function createLivenessListener() {
5314
- const server = createServer((socket) => socket.end());
6192
+ const server = createServer((socket) => endOwnershipProbe(socket));
5315
6193
  const port = await new Promise((resolve, reject) => {
5316
6194
  server.once("error", reject);
5317
6195
  server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
@@ -5396,7 +6274,7 @@ async function acquireStoreMutex(canonicalStoreDir) {
5396
6274
  }
5397
6275
  await clearStaleStoreMutexSocket(endpoint, identity);
5398
6276
  }
5399
- const server = createServer((socket) => socket.end(`${STORE_MUTEX_ID_PREFIX}${identity}
6277
+ const server = createServer((socket) => endOwnershipProbe(socket, `${STORE_MUTEX_ID_PREFIX}${identity}
5400
6278
  `));
5401
6279
  try {
5402
6280
  await new Promise((resolve, reject) => {
@@ -5538,7 +6416,7 @@ function toRuntimeInfoCapabilities(caps) {
5538
6416
  resume: caps.resume,
5539
6417
  approvalInteractive: caps.approvalInteractive,
5540
6418
  ...caps.mcpToolsets === void 0 ? {} : { mcpToolsets: caps.mcpToolsets },
5541
- permissionModes: caps.permissionModes
6419
+ permissionModes: [...caps.permissionModes]
5542
6420
  };
5543
6421
  }
5544
6422
  var CursorStore = class {
@@ -5778,6 +6656,9 @@ var DaemonObserver = class {
5778
6656
  noteGitWorkspace(event) {
5779
6657
  this.emit({ kind: "git-workspace", ts: nowIso(), ...event });
5780
6658
  }
6659
+ noteRuntimeDisposalFailure(event) {
6660
+ this.emit({ kind: "runtime-disposal-failed", ts: nowIso(), ...event });
6661
+ }
5781
6662
  /**
5782
6663
  * Finding F4: wired from `TaskRunnerDeps.onApprovalDispatched`, called
5783
6664
  * synchronously by `TaskRunner.dispatchApproval` BEFORE its own
@@ -7434,20 +8315,20 @@ function isKnownRuntimeId(id) {
7434
8315
  var DEFAULT_RUNTIME_PREFERENCE = ["claude", "codex", "pi"];
7435
8316
  function orderByPreference(candidates, preference) {
7436
8317
  const rank = new Map(preference.map((id, index) => [id, index]));
7437
- return [...candidates].sort((a, b) => (rank.get(a.id) ?? preference.length) - (rank.get(b.id) ?? preference.length));
8318
+ return [...candidates].sort((a, b) => (rank.get(a.descriptor.id) ?? preference.length) - (rank.get(b.descriptor.id) ?? preference.length));
7438
8319
  }
7439
- function adapterSupportsMode(adapter, mode) {
7440
- return adapter.capabilities().permissionModes.includes(mode);
8320
+ function adapterSupportsMode(descriptor, mode) {
8321
+ return descriptor.capabilities.permissionModes.includes(mode);
7441
8322
  }
7442
- function adapterSupportsMcpToolsets(adapter) {
7443
- return adapter.capabilities().mcpToolsets === true;
8323
+ function adapterSupportsMcpToolsets(descriptor) {
8324
+ return descriptor.capabilities.mcpToolsets === true;
7444
8325
  }
7445
8326
  function withoutRequiredToolsets(payload) {
7446
8327
  if (!("requiredToolsets" in payload)) return payload;
7447
8328
  const { requiredToolsets, ...offer } = payload;
7448
8329
  return offer;
7449
8330
  }
7450
- function errorMessage3(err) {
8331
+ function errorMessage4(err) {
7451
8332
  return err instanceof Error ? err.message : String(err);
7452
8333
  }
7453
8334
  function raceSettleFirst(fn, timeoutMs) {
@@ -7500,7 +8381,7 @@ async function openArtifact(workspaceDir, name) {
7500
8381
  try {
7501
8382
  handle = await promises.open(candidate, constants.O_RDONLY | O_NOFOLLOW);
7502
8383
  } catch (err) {
7503
- return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage3(err)}` };
8384
+ return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage4(err)}` };
7504
8385
  }
7505
8386
  try {
7506
8387
  const st = await handle.stat();
@@ -7512,7 +8393,7 @@ async function openArtifact(workspaceDir, name) {
7512
8393
  } catch (err) {
7513
8394
  await handle.close().catch(() => {
7514
8395
  });
7515
- return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage3(err)}` };
8396
+ return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage4(err)}` };
7516
8397
  }
7517
8398
  return { ok: true, handle };
7518
8399
  }
@@ -7526,13 +8407,13 @@ var TaskRunner = class {
7526
8407
  * Finding F4 (cancel lost during the offer-processing window): a
7527
8408
  * `task.cancel` for a taskId that hasn't finished `handleOffer` yet (still
7528
8409
  * awaiting adapter detection / instruction resolution / workspace setup /
7529
- * `adapter.start()`) has no `this.tasks` entry to land on — it used to be
8410
+ * prepared operation `start()`) has no `this.tasks` entry to land on — it used to be
7530
8411
  * silently dropped, and the runtime session `handleOffer` was about to
7531
8412
  * register would then run an unsupervised ("zombie") turn nobody asked
7532
8413
  * for anymore. Recording the taskId here lets `handleOffer` consult it at
7533
8414
  * the two points where it can still safely react (see its body): before
7534
8415
  * claiming at all (decline instead of ever starting a session), and right
7535
- * after `adapter.start()` resolves but before this task is registered as
8416
+ * after the prepared operation resolves but before this task is registered as
7536
8417
  * active (tear the just-started session down immediately, before its
7537
8418
  * event loop ever pumps a single event). Consumed (deleted) at whichever
7538
8419
  * checkpoint handles it; a cancel for a taskId that's already active,
@@ -7558,12 +8439,12 @@ var TaskRunner = class {
7558
8439
  * checkpoint-2 cancel-teardown, or successful registration into
7559
8440
  * `this.tasks`). Bounded eviction on `pendingCancelled` (below) must never
7560
8441
  * remove an entry for a taskId in this set: doing so is exactly the bug —
7561
- * block task A in `adapter.start()`, deliver A's own `task.cancel` (so
8442
+ * block task A in prepared-operation `start()`, deliver A's own `task.cancel` (so
7562
8443
  * `pendingCancelled` gets an entry for A while A is still in-flight),
7563
8444
  * then deliver `MAX_TRACKED_TASK_IDS` more cancels for unrelated taskIds
7564
8445
  * nobody ever offered — under naive oldest-wins eviction, A's entry (the
7565
8446
  * single oldest) gets evicted purely because of unrelated churn, so when
7566
- * `adapter.start()` finally resolves, checkpoint 2 finds no cancel marker
8447
+ * the prepared operation finally resolves, checkpoint 2 finds no cancel marker
7567
8448
  * and the already-cancelled task starts a real session. See
7568
8449
  * `evictPendingCancelled` below for the fix, and
7569
8450
  * `task-runner-bounded-collections.test.ts` for a test mirroring this
@@ -7583,7 +8464,7 @@ var TaskRunner = class {
7583
8464
  * explicitly relies on redelivered handlers being idempotent for exactly
7584
8465
  * this reason). `handleOffer` must treat a redelivered offer for a taskId
7585
8466
  * that's already active (`this.tasks`) or already finished (this set) as
7586
- * a no-op — never a second `adapter.start()` call, which would orphan the
8467
+ * a no-op — never a second prepared-operation `start()` call, which would orphan the
7587
8468
  * first session.
7588
8469
  *
7589
8470
  * M3-B: unbounded otherwise — a long-lived daemon that's finished many
@@ -7652,10 +8533,9 @@ var TaskRunner = class {
7652
8533
  this.stoppingOffers = true;
7653
8534
  }
7654
8535
  /**
7655
- * M4 Phase 2: best-effort shutdown of every currently ACTIVE task, for the
7656
- * control socket's `shutdown` RPC. Mirrors `handleCancel`'s best-effort
7657
- * `session.interrupt()` style (an interrupt failure is swallowed; the
7658
- * terminal message is sent either way) but reports `task.fail` rather than
8536
+ * Shutdown of every currently ACTIVE task for the control socket's
8537
+ * `shutdown` RPC. Soft interrupt remains bounded, but each task's
8538
+ * authoritative close receipt must settle successfully. Reports `task.fail` rather than
7659
8539
  * `task.cancelled` — these tasks aren't ending because the SERVER
7660
8540
  * cancelled them, they're ending because this device is shutting down.
7661
8541
  * `retryable: true` throughout: nothing about the task/policy itself was
@@ -7709,28 +8589,13 @@ var TaskRunner = class {
7709
8589
  * unconditionally, so a hung `interrupt()` (a misbehaving adapter) can
7710
8590
  * never block `task.fail` from being sent at all.
7711
8591
  *
7712
- * New in this batch hard-kill escalation: when `interrupt()` does NOT
7713
- * settle within that same grace window, `session.close()` is tried next
7714
- * (ALSO raced against `timeoutMs`, for the identical reason: a hung
7715
- * `close()` must not be able to block this forever either — which matters
7716
- * far more here than it used to for the pre-existing graceful-shutdown-only
7717
- * caller, since THAT path is additionally bounded by an outer deadline
7718
- * (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`/`DaemonConfig.shutdownGraceMs`,
7719
- * `create-daemon.ts`), while resource-limit enforcement fires during
7720
- * ordinary operation with no such outer bound watching it). `close()` is
7721
- * every adapter's harder teardown primitive — an actual process-level kill
7722
- * (SIGTERM, or `taskkill /F` on Windows — see e.g.
7723
- * `ClaudeProcessClient.kill()`/`PiRpcClient.kill()`) as opposed to pi's own
7724
- * soft in-band `interrupt()` (an RPC `abort` message that leaves the
7725
- * process alive and resumable) — so escalating to it is the closest thing
7726
- * to a "hard kill" the `Session` interface exposes. `finish()` below calls
7727
- * `session.close()` again regardless (documented idempotent) — this isn't
7728
- * a substitute for that, only an earlier, bounded attempt at actually
7729
- * stopping a stuck runtime before this method gives up and reports failure
7730
- * anyway.
8592
+ * After the bounded soft interrupt, `finish()` always awaits the authoritative
8593
+ * `Session.close()` receipt. A failed receipt retains active/Git ownership;
8594
+ * shutdown surfaces the rejection while resource enforcement leaves local
8595
+ * evidence for a later retry.
7731
8596
  *
7732
8597
  * Re-checks task identity (`this.tasks.get(...) === active`) immediately
7733
- * before sending `task.fail`: the interrupt/hard-kill race above has await
8598
+ * before sending `task.fail`: the interrupt race above has await
7734
8599
  * points during which a DIFFERENT path (a racing `task.cancel`/
7735
8600
  * `task.reject`, or the session completing normally on its own) may have
7736
8601
  * already finished this exact task and sent its own terminal message.
@@ -7739,20 +8604,25 @@ var TaskRunner = class {
7739
8604
  * identity-check guard for the same class of race.
7740
8605
  */
7741
8606
  async teardownActiveTask(active, reason, retryable) {
8607
+ if (active.finalizationStarted) return this.finish(active.taskId);
8608
+ if (!this.reserveSemanticTerminal(active)) return active.semanticTerminalSettled ?? false;
7742
8609
  active.beingTornDown = true;
7743
8610
  await this.observeGit(active, "salvage");
7744
8611
  const timeoutMs = this.deps.shutdownInterruptTimeoutMs ?? DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS;
7745
- const interrupted = await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
7746
- if (!interrupted) {
7747
- await raceSettleFirst(() => active.session.close(), timeoutMs);
7748
- }
7749
- if (this.tasks.get(active.taskId) !== active) return;
8612
+ await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
8613
+ if (this.tasks.get(active.taskId) !== active) return true;
7750
8614
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId: active.taskId }));
7751
- await this.finish(active.taskId);
8615
+ return this.finish(active.taskId);
7752
8616
  }
7753
8617
  /** Graceful-shutdown caller of {@link teardownActiveTask} — see `shutdownActiveTasks`'s own doc comment. `retryable: true`: nothing about the task/policy itself was ever at fault, only this device's own availability right now. */
7754
8618
  async shutdownTask(active, reason) {
7755
- await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8619
+ const disposed = await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8620
+ if (!disposed) {
8621
+ throw new RuntimeDisposalFailure({
8622
+ stage: "quiescence",
8623
+ reason: `${active.adapter.descriptor.id} runtime ownership remains active after shutdown disposal failed`
8624
+ });
8625
+ }
7756
8626
  }
7757
8627
  /**
7758
8628
  * M5 batch-3 (workstream 2): shared entry point for both resource-limit
@@ -7872,15 +8742,33 @@ var TaskRunner = class {
7872
8742
  this.decline(taskId, resolvedMcp.reason, true);
7873
8743
  return;
7874
8744
  }
8745
+ const decision = computeEffectivePolicy(payload.policy, this.deps.permissionDefaults);
8746
+ if (!decision.ok) {
8747
+ this.decline(taskId, decision.reason ?? "policy rejected", false);
8748
+ return;
8749
+ }
8750
+ const offered = withoutRequiredToolsets(payload);
7875
8751
  const requestedRuntime = payload.dispatchSelection?.runtimeId ?? payload.runtime;
7876
8752
  const pick = await this.pickAdapter(requestedRuntime, payload.policy.mode, requiredToolsets !== void 0);
7877
8753
  if (!pick.ok) {
7878
8754
  this.decline(taskId, pick.reason, pick.retryable);
7879
8755
  return;
7880
8756
  }
7881
- const decision = computeEffectivePolicy(payload.policy, this.deps.permissionDefaults);
7882
- if (!decision.ok) {
7883
- this.decline(taskId, decision.reason ?? "policy rejected", false);
8757
+ let prepared;
8758
+ try {
8759
+ prepared = await pick.adapter.prepare({
8760
+ offer: offered,
8761
+ policy: decision.policy,
8762
+ descriptor: pick.descriptor,
8763
+ requiredToolsetIds: requiredToolsets ?? [],
8764
+ ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {}
8765
+ });
8766
+ } catch (error) {
8767
+ this.decline(taskId, `runtime preparation failed: ${errorMessage4(error)}`, true);
8768
+ return;
8769
+ }
8770
+ if (prepared.kind === "reject") {
8771
+ this.decline(taskId, prepared.reason, prepared.retryable);
7884
8772
  return;
7885
8773
  }
7886
8774
  let known = void 0;
@@ -7915,6 +8803,7 @@ var TaskRunner = class {
7915
8803
  }
7916
8804
  } else {
7917
8805
  workspaceDir = path17.join(this.deps.workspaceRoot, taskId);
8806
+ gitWorkspaceId = randomUUID();
7918
8807
  }
7919
8808
  try {
7920
8809
  gitLease = await gitManager.acquireLease(workspaceDir, payload.sessionRef);
@@ -7930,6 +8819,26 @@ var TaskRunner = class {
7930
8819
  this.decline(taskId, "workspace mode is unavailable", true);
7931
8820
  return;
7932
8821
  }
8822
+ const env = buildRuntimeEnv({
8823
+ ambient: process.env,
8824
+ requirements: pick.descriptor.environmentRequirements,
8825
+ locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.descriptor.id]?.allow
8826
+ });
8827
+ const manifest = sealRuntimeOperationManifest({
8828
+ taskId,
8829
+ runtimeId: pick.descriptor.id,
8830
+ descriptor: pick.descriptor,
8831
+ policy: decision.policy,
8832
+ requiredToolsetIds: requiredToolsets ?? [],
8833
+ ...offered.dispatchSelection === void 0 ? {} : { dispatchSelection: offered.dispatchSelection },
8834
+ ...known === void 0 || payload.sessionRef === void 0 ? {} : { sessionRef: payload.sessionRef },
8835
+ workspace: {
8836
+ workspaceDir,
8837
+ ...gitWorkspaceId === void 0 ? {} : { workspaceId: gitWorkspaceId },
8838
+ ...gitBaseline === void 0 ? {} : { baseline: gitBaseline }
8839
+ },
8840
+ forwardedEnvironmentNames: Object.freeze(Object.keys(env).sort())
8841
+ });
7933
8842
  this.deps.send(
7934
8843
  createEnvelope(
7935
8844
  "task.claim",
@@ -7945,7 +8854,7 @@ var TaskRunner = class {
7945
8854
  // (the merely REQUESTED runtime): this is what closes the gap
7946
8855
  // where an auto-selected task left the server never learning
7947
8856
  // which runtime actually ran.
7948
- runtime: isKnownRuntimeId(pick.adapter.id) ? pick.adapter.id : void 0,
8857
+ runtime: isKnownRuntimeId(manifest.descriptor.id) ? manifest.descriptor.id : void 0,
7949
8858
  // S0/D-4 (`task.claim.capabilities`, docs/protocol.md §2.4): the
7950
8859
  // selected adapter's own capability self-report, carried on the
7951
8860
  // same message that establishes the task↔runtime binding. The
@@ -7962,7 +8871,7 @@ var TaskRunner = class {
7962
8871
  // Gating them would silently strip a custom steer-capable
7963
8872
  // adapter's own truth and leave the server fail-closing on it
7964
8873
  // forever.
7965
- capabilities: toRuntimeInfoCapabilities(pick.adapter.capabilities())
8874
+ capabilities: toRuntimeInfoCapabilities(manifest.descriptor.capabilities)
7966
8875
  },
7967
8876
  { taskId }
7968
8877
  )
@@ -7973,7 +8882,7 @@ var TaskRunner = class {
7973
8882
  if (plainWorkspaceNeedsResolve) workspaceDir = await this.resolveWorkspaceDir(taskId, known?.workspaceDir);
7974
8883
  } catch (err) {
7975
8884
  gitLease?.release();
7976
- await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage3(err)}`, true);
8885
+ await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage4(err)}`, true);
7977
8886
  return;
7978
8887
  }
7979
8888
  if (this.deps.gitWorkspaceManager && gitLease) {
@@ -7982,7 +8891,7 @@ var TaskRunner = class {
7982
8891
  if (gitExisting) {
7983
8892
  observation = await this.deps.gitWorkspaceManager.validateExisting(workspaceDir);
7984
8893
  } else {
7985
- const workspaceId2 = randomUUID();
8894
+ const workspaceId2 = gitWorkspaceId ?? randomUUID();
7986
8895
  const now2 = (/* @__PURE__ */ new Date()).toISOString();
7987
8896
  gitWorkspaceId = workspaceId2;
7988
8897
  await this.deps.gitWorkspaceStore?.upsert({
@@ -8033,30 +8942,11 @@ var TaskRunner = class {
8033
8942
  return;
8034
8943
  }
8035
8944
  }
8036
- const ctx = {
8037
- workspaceDir,
8038
- policy: decision.policy,
8945
+ const startInput = {
8946
+ manifest,
8947
+ instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
8948
+ env,
8039
8949
  ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {},
8040
- ...gitWorkspaceId ? { gitWorkspace: { workspaceId: gitWorkspaceId, baseline: gitBaseline } } : {},
8041
- // M5: no longer `process.env` verbatim (see `environment.ts`'s own
8042
- // module doc comment for the credential-leak gap that closed) —
8043
- // built fresh per task from the SPECIFIC adapter `pickAdapter`
8044
- // above already selected, so this always runs after adapter
8045
- // selection: `pick.adapter.environmentRequirements?.()` (undefined
8046
- // ⇒ platform baseline only, fail-closed) plus this device's own
8047
- // `runtimeEnvironment` override, keyed by that same adapter's `id`.
8048
- env: buildRuntimeEnv({
8049
- ambient: process.env,
8050
- requirements: pick.adapter.environmentRequirements?.(),
8051
- locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.adapter.id]?.allow
8052
- }),
8053
- // M4 Phase 3: adapter-agnostic and cheap to always populate — only an
8054
- // adapter whose runtime genuinely supports an out-of-band approval
8055
- // pause (claude, today) ever reads this. `resolve` is a closure over
8056
- // `taskId` (not a pre-bound approvalId): it looks up whichever
8057
- // approval is CURRENTLY pending for this task at call time, since one
8058
- // task/session can face several approval requests, one at a time,
8059
- // over its life. See `types.ts`'s `ApprovalChannel` doc comment.
8060
8950
  approvalChannel: {
8061
8951
  taskId,
8062
8952
  storeDir: this.deps.storeDir,
@@ -8072,45 +8962,19 @@ var TaskRunner = class {
8072
8962
  }
8073
8963
  }
8074
8964
  };
8075
- const effectiveOffer = {
8076
- ...withoutRequiredToolsets(payload),
8077
- instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
8078
- // Never forward a sessionRef this device has no recorded workspace
8079
- // for (stale, from another device, or simply made up) — an adapter
8080
- // that tries to resume an id it never minted fails outright (pi:
8081
- // "No session found matching '<id>'", exit 1, empirically confirmed)
8082
- // instead of silently starting fresh, so an unresolvable sessionRef
8083
- // must look identical to "none supplied" by the time it reaches the
8084
- // adapter, not get forwarded as a resume attempt doomed to fail.
8085
- sessionRef: known ? payload.sessionRef : void 0
8086
- };
8087
8965
  let session;
8088
8966
  try {
8089
- session = await pick.adapter.start(effectiveOffer, ctx);
8967
+ session = await prepared.operation.start(startInput);
8090
8968
  } catch (err) {
8091
- const retryable = !(err instanceof PolicyUnsupportedError);
8969
+ const failure = projectRuntimeBoundaryFailure(err, "start");
8970
+ if (failure.contractViolation) {
8971
+ console.error("[byok/client] runtime adapter start() returned an untyped failure", err);
8972
+ }
8092
8973
  await this.updateGitPhaseBestEffort(gitWorkspaceId, "failed", "repository-invalid");
8093
8974
  gitLease?.release();
8094
- await this.fail(taskId, `adapter failed to start: ${errorMessage3(err)}`, retryable);
8975
+ await this.fail(taskId, failure.reason, failure.retryable);
8095
8976
  return;
8096
8977
  }
8097
- if (this.pendingCancelled.has(taskId)) {
8098
- const reason = this.pendingCancelled.get(taskId);
8099
- this.pendingCancelled.delete(taskId);
8100
- try {
8101
- await session.interrupt();
8102
- } catch {
8103
- }
8104
- try {
8105
- await session.close();
8106
- } catch {
8107
- }
8108
- gitLease?.release();
8109
- await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
8110
- this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
8111
- return;
8112
- }
8113
- this.deps.send(createEnvelope("task.started", {}, { taskId }));
8114
8978
  const active = {
8115
8979
  taskId,
8116
8980
  adapter: pick.adapter,
@@ -8127,6 +8991,21 @@ var TaskRunner = class {
8127
8991
  approvalQueue: [],
8128
8992
  outputBytesSoFar: 0
8129
8993
  };
8994
+ if (this.pendingCancelled.has(taskId)) {
8995
+ const reason = this.pendingCancelled.get(taskId);
8996
+ this.pendingCancelled.delete(taskId);
8997
+ this.tasks.set(taskId, active);
8998
+ this.reserveSemanticTerminal(active);
8999
+ try {
9000
+ await session.interrupt();
9001
+ } catch {
9002
+ }
9003
+ await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
9004
+ this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
9005
+ await this.finish(taskId);
9006
+ return;
9007
+ }
9008
+ this.deps.send(createEnvelope("task.started", {}, { taskId }));
8130
9009
  this.tasks.set(taskId, active);
8131
9010
  if (payload.limits?.maxDurationMs !== void 0) {
8132
9011
  this.armMaxDurationTimer(active, payload.limits.maxDurationMs);
@@ -8184,6 +9063,7 @@ var TaskRunner = class {
8184
9063
  async pump(active) {
8185
9064
  try {
8186
9065
  for await (const event of active.session.events) {
9066
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8187
9067
  if (this.tasks.get(active.taskId) !== active) return;
8188
9068
  active.outputBytesSoFar += estimateEventBytes(event);
8189
9069
  if (active.outputBytesSoFar > this.maxTaskOutputBytes) {
@@ -8206,7 +9086,7 @@ var TaskRunner = class {
8206
9086
  try {
8207
9087
  await active.session.resolveApproval(approved, reason);
8208
9088
  } catch (err) {
8209
- await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage3(err)}`, false);
9089
+ await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage4(err)}`, false);
8210
9090
  }
8211
9091
  });
8212
9092
  continue;
@@ -8218,6 +9098,7 @@ var TaskRunner = class {
8218
9098
  const outcome = await this.resolveResultDocument(active, finalOutput);
8219
9099
  if (!outcome.deliver) return;
8220
9100
  await this.observeGit(active, "completed");
9101
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8221
9102
  if (outcome.document !== void 0 && !this.hasResultDocumentCapability()) {
8222
9103
  await this.fail(
8223
9104
  active.taskId,
@@ -8226,6 +9107,7 @@ var TaskRunner = class {
8226
9107
  );
8227
9108
  return;
8228
9109
  }
9110
+ if (!this.reserveSemanticTerminal(active)) return;
8229
9111
  this.deps.send(
8230
9112
  createEnvelope(
8231
9113
  "task.complete",
@@ -8261,11 +9143,17 @@ var TaskRunner = class {
8261
9143
  active.batcher.push(event);
8262
9144
  }
8263
9145
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8264
- await this.fail(active.taskId, "runtime session ended without completing the task", true);
9146
+ const failure = projectRuntimeBoundaryFailure(void 0, "run");
9147
+ console.error("[byok/client] runtime adapter events iterable ended without terminal authority");
9148
+ await this.fail(active.taskId, failure.reason, failure.retryable);
8265
9149
  } catch (err) {
8266
9150
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
8267
9151
  active.batcher.flush();
8268
- await this.fail(active.taskId, `runtime error: ${errorMessage3(err)}`, true);
9152
+ const failure = projectRuntimeBoundaryFailure(err, "run");
9153
+ if (failure.contractViolation) {
9154
+ console.error("[byok/client] runtime adapter events iterable returned an untyped failure", err);
9155
+ }
9156
+ await this.fail(active.taskId, failure.reason, failure.retryable);
8269
9157
  }
8270
9158
  }
8271
9159
  /**
@@ -8304,7 +9192,7 @@ var TaskRunner = class {
8304
9192
  try {
8305
9193
  bytes = await opened.handle.readFile();
8306
9194
  } catch (err) {
8307
- this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage3(err)}`);
9195
+ this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage4(err)}`);
8308
9196
  return;
8309
9197
  } finally {
8310
9198
  await opened.handle.close().catch(() => {
@@ -8321,7 +9209,7 @@ var TaskRunner = class {
8321
9209
  const blobRef = await this.deps.blobClient.uploadArtifact(bytes, contentType);
8322
9210
  this.deps.send(createEnvelope("task.artifact", { name, contentType, blobRef }, { taskId: active.taskId }));
8323
9211
  } catch (err) {
8324
- this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage3(err)}`);
9212
+ this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage4(err)}`);
8325
9213
  }
8326
9214
  }
8327
9215
  /** Loud, non-silent artifact failure (finding F7): logged, and folded into this task's own progress stream as an `error` AgentEvent rather than swallowed — the task itself can still complete normally, but the omission is now visible. */
@@ -8335,6 +9223,14 @@ var TaskRunner = class {
8335
9223
  this.setPendingCancelled(taskId, reason);
8336
9224
  return;
8337
9225
  }
9226
+ if (active.finalizationStarted) {
9227
+ await this.finish(taskId);
9228
+ return;
9229
+ }
9230
+ if (!this.reserveSemanticTerminal(active)) {
9231
+ await active.semanticTerminalSettled;
9232
+ return;
9233
+ }
8338
9234
  try {
8339
9235
  await active.session.interrupt();
8340
9236
  } catch {
@@ -8363,7 +9259,7 @@ var TaskRunner = class {
8363
9259
  *
8364
9260
  * `inFlightOffers` is naturally tiny (bounded by this device's real
8365
9261
  * concurrent-offer-processing count — normally single digits, driven by
8366
- * how many `task.offer`s are simultaneously mid-`adapter.start()` — nowhere
9262
+ * how many `task.offer`s are simultaneously mid-prepared-operation start() — nowhere
8367
9263
  * near `MAX_TRACKED_TASK_IDS`), so this scan is cheap in practice: it
8368
9264
  * finds a safe entry at or near the front almost always. The only case
8369
9265
  * where NO entry is safe to evict is every single tracked cancel
@@ -8716,7 +9612,7 @@ var TaskRunner = class {
8716
9612
  this.deps.onStaleApprovalDecision?.(taskId, "approve");
8717
9613
  return;
8718
9614
  }
8719
- await this.fail(taskId, `failed to resume session after approval: ${errorMessage3(err)}`, false);
9615
+ await this.fail(taskId, `failed to resume session after approval: ${errorMessage4(err)}`, false);
8720
9616
  return;
8721
9617
  }
8722
9618
  this.clearPendingApproval(resolvedId, "approve", void 0);
@@ -8747,6 +9643,10 @@ var TaskRunner = class {
8747
9643
  async handleReject(taskId, reason, approvalId) {
8748
9644
  const active = this.tasks.get(taskId);
8749
9645
  if (!active) return;
9646
+ if (active.finalizationStarted) {
9647
+ await this.finish(taskId);
9648
+ return;
9649
+ }
8750
9650
  if (approvalId !== void 0 && approvalId !== active.pendingApprovalId) {
8751
9651
  this.deps.onStaleApprovalDecision?.(
8752
9652
  taskId,
@@ -8765,6 +9665,10 @@ var TaskRunner = class {
8765
9665
  }
8766
9666
  }
8767
9667
  this.clearPendingApproval(resolvedId, "reject", reason);
9668
+ if (!this.reserveSemanticTerminal(active)) {
9669
+ await active.semanticTerminalSettled;
9670
+ return;
9671
+ }
8768
9672
  try {
8769
9673
  await active.session.interrupt();
8770
9674
  } catch {
@@ -8779,6 +9683,14 @@ var TaskRunner = class {
8779
9683
  }
8780
9684
  async fail(taskId, reason, retryable) {
8781
9685
  const active = this.tasks.get(taskId);
9686
+ if (active?.finalizationStarted) {
9687
+ await this.finish(taskId);
9688
+ return;
9689
+ }
9690
+ if (active && !this.reserveSemanticTerminal(active)) {
9691
+ await active.semanticTerminalSettled;
9692
+ return;
9693
+ }
8782
9694
  if (active) await this.observeGit(active, "salvage");
8783
9695
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId }));
8784
9696
  await this.finish(taskId);
@@ -8838,7 +9750,7 @@ var TaskRunner = class {
8838
9750
  } catch (err) {
8839
9751
  await this.fail(
8840
9752
  active.taskId,
8841
- `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage3(err)}`,
9753
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage4(err)}`,
8842
9754
  false
8843
9755
  );
8844
9756
  return { deliver: false };
@@ -8871,9 +9783,9 @@ var TaskRunner = class {
8871
9783
  /**
8872
9784
  * Whether the CURRENTLY connected server advertised `result-document` —
8873
9785
  * read fresh on every call, never captured, because the answer changes
8874
- * across a reconnect (`ConnectionManager.getServerCapabilities` returns
8875
- * `[]` from the moment an acked connection closes until a fresh
8876
- * `conn.ack` repopulates it). An absent `getServerCapabilities` seam is
9786
+ * across a reconnect or transport switch (`ConnectionManager` clears the
9787
+ * old advertisement at the boundary, then repopulates it from a fresh WS
9788
+ * ack or successful poll response). An absent `getServerCapabilities` seam is
8877
9789
  * "no capabilities", the fail-closed reading.
8878
9790
  */
8879
9791
  hasResultDocumentCapability() {
@@ -8913,32 +9825,64 @@ var TaskRunner = class {
8913
9825
  }
8914
9826
  async finish(taskId) {
8915
9827
  const active = this.tasks.get(taskId);
8916
- if (!active) return;
8917
- if (active.maxDurationTimer) {
8918
- clearTimeout(active.maxDurationTimer);
8919
- active.maxDurationTimer = void 0;
8920
- }
8921
- active.batcher.stop();
8922
- this.tasks.delete(taskId);
8923
- this.addFinishedTaskId(taskId);
8924
- const queued = active.approvalQueue.splice(0);
8925
- for (const request of queued) {
8926
- request.resolve({
8927
- approved: false,
8928
- reason: `task ${taskId} finished before this queued approval request could be dispatched`
8929
- });
8930
- }
8931
- if (active.pendingApprovalId !== void 0) {
8932
- try {
8933
- this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
8934
- } catch {
9828
+ if (!active) return true;
9829
+ if (!active.finalizationStarted) {
9830
+ active.finalizationStarted = true;
9831
+ active.beingTornDown = true;
9832
+ if (active.maxDurationTimer) {
9833
+ clearTimeout(active.maxDurationTimer);
9834
+ active.maxDurationTimer = void 0;
9835
+ }
9836
+ active.batcher.stop();
9837
+ this.addFinishedTaskId(taskId);
9838
+ const queued = active.approvalQueue.splice(0);
9839
+ for (const request of queued) {
9840
+ request.resolve({
9841
+ approved: false,
9842
+ reason: `task ${taskId} finished before this queued approval request could be dispatched`
9843
+ });
9844
+ }
9845
+ if (active.pendingApprovalId !== void 0) {
9846
+ try {
9847
+ this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
9848
+ } catch {
9849
+ }
8935
9850
  }
8936
9851
  }
8937
- if (active.gitLease) active.gitLease.release();
9852
+ const attempt = active.disposalAttempt ?? active.session.close();
9853
+ active.disposalAttempt = attempt;
8938
9854
  try {
8939
- await active.session.close();
8940
- } catch {
9855
+ await attempt;
9856
+ } catch (caught) {
9857
+ if (active.disposalAttempt === attempt) active.disposalAttempt = void 0;
9858
+ const failure = isRuntimeDisposalFailure(caught) ? caught : new RuntimeDisposalFailure({
9859
+ stage: "quiescence",
9860
+ reason: `${active.adapter.descriptor.id} session.close() returned an untyped disposal failure`
9861
+ }, { cause: caught });
9862
+ console.error(`[byok/client] runtime disposal failed for task ${taskId}: ${failure.message}`);
9863
+ this.deps.onRuntimeDisposalFailure?.({
9864
+ taskId,
9865
+ runtimeId: active.adapter.descriptor.id,
9866
+ stage: failure.stage,
9867
+ reason: failure.message
9868
+ });
9869
+ active.resolveSemanticTerminalSettled?.(false);
9870
+ return false;
8941
9871
  }
9872
+ if (this.tasks.get(taskId) !== active) return true;
9873
+ active.gitLease?.release();
9874
+ this.tasks.delete(taskId);
9875
+ active.resolveSemanticTerminalSettled?.(true);
9876
+ return true;
9877
+ }
9878
+ reserveSemanticTerminal(active) {
9879
+ if (active.semanticTerminalReserved || active.finalizationStarted) return false;
9880
+ active.semanticTerminalReserved = true;
9881
+ active.beingTornDown = true;
9882
+ active.semanticTerminalSettled = new Promise((resolve) => {
9883
+ active.resolveSemanticTerminalSettled = resolve;
9884
+ });
9885
+ return true;
8942
9886
  }
8943
9887
  /** M3-B: bounded insert for `finishedTaskIds` — see its class-level doc comment and `MAX_TRACKED_TASK_IDS`. Evicts the oldest (first-inserted) entry once over cap, same idiom as `ConnectionHub.checkAndRecordDuplicate` (packages/server/src/hub.ts). */
8944
9888
  addFinishedTaskId(taskId) {
@@ -8991,18 +9935,19 @@ var TaskRunner = class {
8991
9935
  retryable: false
8992
9936
  };
8993
9937
  }
8994
- const adapter = this.deps.adapters.find((a) => a.id === requestedRuntime);
9938
+ const adapter = this.deps.adapters.find((a) => a.descriptor.id === requestedRuntime);
8995
9939
  if (!adapter) {
8996
9940
  return { ok: false, reason: `unknown runtime "${requestedRuntime}"`, retryable: false };
8997
9941
  }
8998
- if (!adapterSupportsMode(adapter, policyMode)) {
9942
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
9943
+ if (!adapterSupportsMode(descriptor, policyMode)) {
8999
9944
  return {
9000
9945
  ok: false,
9001
9946
  reason: `runtime "${requestedRuntime}" cannot express permission mode "${policyMode}"`,
9002
9947
  retryable: false
9003
9948
  };
9004
9949
  }
9005
- if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) {
9950
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) {
9006
9951
  return {
9007
9952
  ok: false,
9008
9953
  reason: `runtime "${requestedRuntime}" cannot project required MCP toolsets`,
@@ -9017,15 +9962,16 @@ var TaskRunner = class {
9017
9962
  retryable: true
9018
9963
  };
9019
9964
  }
9020
- return { ok: true, adapter };
9965
+ return { ok: true, adapter, descriptor };
9021
9966
  }
9022
- const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.id)) : this.deps.adapters;
9967
+ const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.descriptor.id)) : this.deps.adapters;
9023
9968
  const candidates = orderByPreference(eligible, this.deps.runtimePreference ?? DEFAULT_RUNTIME_PREFERENCE);
9024
9969
  for (const adapter of candidates) {
9025
- if (!adapterSupportsMode(adapter, policyMode)) continue;
9026
- if (requiresMcpToolsets && !adapterSupportsMcpToolsets(adapter)) continue;
9970
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
9971
+ if (!adapterSupportsMode(descriptor, policyMode)) continue;
9972
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) continue;
9027
9973
  const detected = await adapter.detect();
9028
- if (detected.present) return { ok: true, adapter };
9974
+ if (detected.present) return { ok: true, adapter, descriptor };
9029
9975
  }
9030
9976
  return {
9031
9977
  ok: false,
@@ -9070,27 +10016,27 @@ async function detectRuntimes(adapters) {
9070
10016
  const detections = await Promise.all(adapters.map(async (adapter) => ({ adapter, detected: await adapter.detect() })));
9071
10017
  const runtimes = [];
9072
10018
  for (const { adapter, detected } of detections) {
9073
- if (!detected.present || !isRuntimeId(adapter.id)) continue;
9074
- const info = { id: adapter.id };
10019
+ if (!detected.present || !isRuntimeId(adapter.descriptor.id)) continue;
10020
+ const info = { id: adapter.descriptor.id };
9075
10021
  if (detected.version !== void 0) info.version = detected.version;
9076
10022
  if (detected.authPresent !== void 0) info.authPresent = detected.authPresent;
9077
- info.capabilities = toRuntimeInfoCapabilities(adapter.capabilities());
10023
+ info.capabilities = toRuntimeInfoCapabilities(adapter.descriptor.capabilities);
9078
10024
  runtimes.push(info);
9079
10025
  }
9080
10026
  return runtimes;
9081
10027
  }
9082
10028
  function computeCapabilities(adapters) {
9083
10029
  const flags = [];
9084
- if (adapters.some((adapter) => adapter.capabilities().steer)) flags.push("steer");
10030
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.steer)) flags.push("steer");
9085
10031
  flags.push("blob-upload");
9086
10032
  flags.push("approval-targeting");
9087
10033
  const selectionAdapters = adapters.filter(
9088
- (adapter) => ALL_RUNTIME_IDS.includes(adapter.id)
10034
+ (adapter) => ALL_RUNTIME_IDS.includes(adapter.descriptor.id)
9089
10035
  );
9090
- if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.supportsDispatchSelection === true)) {
10036
+ if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.descriptor.supportsDispatchSelection === true)) {
9091
10037
  flags.push("dispatch-selection");
9092
10038
  }
9093
- if (adapters.some((adapter) => adapter.capabilities().mcpToolsets === true)) {
10039
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.mcpToolsets === true)) {
9094
10040
  flags.push("toolset-selection");
9095
10041
  }
9096
10042
  return flags;
@@ -9150,7 +10096,6 @@ function validatePiByokLauncherConfig(launcher) {
9150
10096
  throw new Error("DaemonConfig.piByokLauncher.args must contain only non-empty single-line strings");
9151
10097
  }
9152
10098
  }
9153
- var MAX_LOCAL_MCP_TOOLSETS = 64;
9154
10099
  var MAX_LOCAL_MCP_SERVERS_PER_TOOLSET = 16;
9155
10100
  var MAX_LOCAL_MCP_ARGS = 64;
9156
10101
  var MAX_LOCAL_MCP_TOKEN_CHARS = 4096;
@@ -9163,8 +10108,10 @@ function resolveMcpToolsets(configured) {
9163
10108
  throw new Error("DaemonConfig.mcpToolsets must be an object keyed by logical toolset id");
9164
10109
  }
9165
10110
  const toolsetEntries = Object.entries(configured);
9166
- if (toolsetEntries.length > MAX_LOCAL_MCP_TOOLSETS) {
9167
- throw new Error(`DaemonConfig.mcpToolsets may contain at most ${MAX_LOCAL_MCP_TOOLSETS} toolsets`);
10111
+ if (toolsetEntries.length > CONFIGURED_TOOLSETS_MAX_ITEMS) {
10112
+ throw new Error(
10113
+ `DaemonConfig.mcpToolsets may contain at most ${CONFIGURED_TOOLSETS_MAX_ITEMS} toolsets`
10114
+ );
9168
10115
  }
9169
10116
  const resolved = /* @__PURE__ */ new Map();
9170
10117
  for (const [toolsetId, rawToolset] of toolsetEntries) {
@@ -9277,6 +10224,9 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9277
10224
  }
9278
10225
  function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProbe) {
9279
10226
  const mcpToolsets = resolveMcpToolsets(config.mcpToolsets);
10227
+ const configuredToolsets = Object.freeze(
10228
+ [...mcpToolsets?.keys() ?? []].sort()
10229
+ );
9280
10230
  if (config.piByokLauncher !== void 0) {
9281
10231
  validatePiByokLauncherConfig(config.piByokLauncher);
9282
10232
  }
@@ -9583,12 +10533,13 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9583
10533
  dirty: event.observation ? { staged: event.observation.staged, unstaged: event.observation.unstaged, untracked: event.observation.untracked, conflicted: event.observation.conflicted } : void 0,
9584
10534
  errorCategory: event.errorCategory
9585
10535
  }),
10536
+ onRuntimeDisposalFailure: (event) => observer.noteRuntimeDisposalFailure(event),
9586
10537
  // M4 Phase 3: the SAME `ApprovalRegistry` instance the control
9587
10538
  // socket's own `approvals.list`/`approvals.resolve` methods already
9588
10539
  // share (see that field's own construction above) — `TaskRunner
9589
10540
  // .requestApproval` registers into it directly, so a decision arriving
9590
10541
  // via either the server wire or the local CLI resolves the identical
9591
- // entry. `storeDir`/`productId` let `TaskContext.approvalChannel`
10542
+ // entry. `storeDir`/`productId` let the prepared operation approval channel
9592
10543
  // (populated per-task by `TaskRunner`) tell an out-of-process helper
9593
10544
  // (`bin/byok-approval-mcp.ts`) exactly which control socket to dial.
9594
10545
  approvalRegistry,
@@ -9640,6 +10591,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9640
10591
  productId: config.productId,
9641
10592
  capabilities,
9642
10593
  runtimes,
10594
+ configuredToolsets,
9643
10595
  auth,
9644
10596
  cursorStore,
9645
10597
  // Finding F3: return (not void-and-forget) so ConnectionManager can
@@ -9726,6 +10678,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9726
10678
  presencePublisher ??= new PresencePublisher({
9727
10679
  serverUrl: config.serverUrl,
9728
10680
  auth,
10681
+ configuredToolsets,
9729
10682
  ...presenceCadence,
9730
10683
  onDegraded: (reason) => console.warn(`[byok/client] ${reason}`)
9731
10684
  });
@@ -9819,8 +10772,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9819
10772
  await attempt(() => auth.stop(), true);
9820
10773
  connectionState = "closed";
9821
10774
  await attempt(async () => {
9822
- await controlServerHandle?.close();
9823
- controlServerHandle = void 0;
10775
+ await controlServerHandle?.stopServing();
9824
10776
  }, true);
9825
10777
  if (mutationBarrierComplete && daemonOwnerLease) {
9826
10778
  await attempt(() => operationalHealth.markCleanStop(), false);
@@ -9829,6 +10781,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9829
10781
  daemonOwnerLease = void 0;
9830
10782
  }, false);
9831
10783
  }
10784
+ if (daemonOwnerLease === void 0) {
10785
+ await attempt(async () => {
10786
+ await controlServerHandle?.close();
10787
+ controlServerHandle = void 0;
10788
+ }, false);
10789
+ }
9832
10790
  if (errors.length === 1) throw errors[0];
9833
10791
  if (errors.length > 1) {
9834
10792
  throw new AggregateError(errors, "daemon shutdown completed with errors");
@@ -9908,7 +10866,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
9908
10866
  deviceId: auth.deviceId,
9909
10867
  transport: connectionState,
9910
10868
  activeTasks,
9911
- runtimeIds: adapters.map((adapter) => adapter.id),
10869
+ runtimeIds: adapters.map((adapter) => adapter.descriptor.id),
9912
10870
  // M4 Phase 4 (part B.3): queue watermarks come from TaskRunner's own
9913
10871
  // active-task map (distinct from `observer.tasks()` above, which is
9914
10872
  // derived from the envelope feed) — see `TaskRunner.getQueueWatermarks`'s
@@ -10116,7 +11074,7 @@ function createDaemon(config) {
10116
11074
  return createDaemonWithAdapters(config, buildDefaultAdapters(config));
10117
11075
  }
10118
11076
  var MAX_CONTROL_TOKEN_BYTES = 256;
10119
- function errorMessage4(err) {
11077
+ function errorMessage5(err) {
10120
11078
  return err instanceof Error ? err.message : String(err);
10121
11079
  }
10122
11080
  function sameFileState3(left, right) {
@@ -10169,7 +11127,7 @@ async function connectControlClient(opts) {
10169
11127
  }
10170
11128
  token = read;
10171
11129
  } catch (err) {
10172
- return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
11130
+ return { ok: false, reason: `could not read the control token: ${errorMessage5(err)}` };
10173
11131
  }
10174
11132
  if (!token) {
10175
11133
  return { ok: false, reason: "control token file is empty" };
@@ -10179,7 +11137,7 @@ async function connectControlClient(opts) {
10179
11137
  const client = await connectAndHandshake(endpoint, token, opts);
10180
11138
  return { ok: true, client };
10181
11139
  } catch (err) {
10182
- return { ok: false, reason: `daemon control socket not reachable: ${errorMessage4(err)}` };
11140
+ return { ok: false, reason: `daemon control socket not reachable: ${errorMessage5(err)}` };
10183
11141
  }
10184
11142
  }
10185
11143
  function connectAndHandshake(endpoint, token, opts) {
@@ -10356,7 +11314,7 @@ function createControlClient(socket, reader, opts) {
10356
11314
  }
10357
11315
 
10358
11316
  // src/daemon/assertion-client.ts
10359
- function errorMessage5(err) {
11317
+ function errorMessage6(err) {
10360
11318
  return err instanceof Error ? err.message : String(err);
10361
11319
  }
10362
11320
  async function requestDeviceAssertion(options) {
@@ -10378,7 +11336,7 @@ async function requestDeviceAssertion(options) {
10378
11336
  return { ok: true, assertion, expiresAt: result.expiresAt };
10379
11337
  } catch (err) {
10380
11338
  if (err instanceof ControlError) return { ok: false, code: err.code, reason: err.message };
10381
- return { ok: false, code: "bad_response", reason: errorMessage5(err) };
11339
+ return { ok: false, code: "bad_response", reason: errorMessage6(err) };
10382
11340
  } finally {
10383
11341
  connected.client.close();
10384
11342
  }
@@ -11614,6 +12572,6 @@ function createServiceLifecycle(def, opts = {}) {
11614
12572
  }
11615
12573
  }
11616
12574
 
11617
- export { AuthManager, BlobClient, ClaudeAdapter, CodexAdapter, DEFAULT_ACK_CRITICAL_RESERVE_BYTES, DEFAULT_CLEANUP_BATCH_LIMIT, DEFAULT_HARD_BUDGET_RATIO, DEFAULT_INCREMENTAL_VACUUM_PAGES, DEFAULT_LOG_ROTATION, DEFAULT_NORMAL_COMPACTION_INTERVAL_MS, DEFAULT_PRESSURE_COMPACTION_INTERVAL_MS, DEFAULT_RETENTION_MS, DEFAULT_SOFT_BUDGET_RATIO, DaemonObserver, DeviceRevokedError, GitWorkspaceError, GitWorkspaceManager, GitWorkspaceStore, JOURNAL_DB_FILENAME, JOURNAL_QUARANTINE_DIRNAME, JOURNAL_TASK_REF_PREFIX, JournalClosedError, JournalCorruptError, JournalRecordTooLargeError, JournalUnavailableError, JournalUnknownTaskError, LocalStorageEmergencyError, LocalStoragePolicyError, LocalStoragePressureEngine, PI_PACKAGE_NAME, PiAdapter, PolicyUnsupportedError, SKILL_PACKS_CAPABILITY, SKILL_PACKS_DIRNAME, SKILL_PACK_AUDIT_FILENAME, SKILL_PACK_INSTALL_ERROR_CODES, SKILL_PACK_LOCK_FILENAME, SKILL_PACK_LOCK_SCHEMA, SKILL_PACK_RESPONSE_MAX_BYTES, SecureDirHardeningError, SkillPackInstallError, SqliteLocalTaskJournal, SteerUnsupportedError, StoredDeviceProofSigner, TruthMemoryClient, TruthMemoryClientError, UnsupportedServicePlatformError, buildIcaclsArgs, cleanupEligibleAt, cleanupOrderFor, computePressureState, createDaemon, createDaemonWithAdapters, createFilesystemCleanupExecutor, createServiceLifecycle, createStatfsFreeBytesProvider, ensureSecureDir, generateLaunchdPlist, generateSystemdUnit, generateWinswXml, installSkillPacks, isGitWorkspaceConfig, journalHash, listInstalledSkillPacks, nodeAgentProgram, prependGitWorkspaceGuidance, projectSkillPack, requestDeviceAssertion, resolveLocalStoragePolicy, sanitizeServiceName, skillPacksRoot };
12575
+ export { AuthManager, BlobClient, ClaudeAdapter, CodexAdapter, DEFAULT_ACK_CRITICAL_RESERVE_BYTES, DEFAULT_CLEANUP_BATCH_LIMIT, DEFAULT_HARD_BUDGET_RATIO, DEFAULT_INCREMENTAL_VACUUM_PAGES, DEFAULT_LOG_ROTATION, DEFAULT_NORMAL_COMPACTION_INTERVAL_MS, DEFAULT_PRESSURE_COMPACTION_INTERVAL_MS, DEFAULT_RETENTION_MS, DEFAULT_SOFT_BUDGET_RATIO, DaemonObserver, DeviceRevokedError, GitWorkspaceError, GitWorkspaceManager, GitWorkspaceStore, JOURNAL_DB_FILENAME, JOURNAL_QUARANTINE_DIRNAME, JOURNAL_TASK_REF_PREFIX, JournalClosedError, JournalCorruptError, JournalRecordTooLargeError, JournalUnavailableError, JournalUnknownTaskError, LocalStorageEmergencyError, LocalStoragePolicyError, LocalStoragePressureEngine, PI_PACKAGE_NAME, PiAdapter, PolicyUnsupportedError, RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON, RuntimeDisposalFailure, RuntimeExecutionFailure, SKILL_PACKS_CAPABILITY, SKILL_PACKS_DIRNAME, SKILL_PACK_AUDIT_FILENAME, SKILL_PACK_INSTALL_ERROR_CODES, SKILL_PACK_LOCK_FILENAME, SKILL_PACK_LOCK_SCHEMA, SKILL_PACK_RESPONSE_MAX_BYTES, SecureDirHardeningError, SkillPackInstallError, SqliteLocalTaskJournal, SteerUnsupportedError, StoredDeviceProofSigner, TruthMemoryClient, TruthMemoryClientError, UnsupportedServicePlatformError, buildIcaclsArgs, cleanupEligibleAt, cleanupOrderFor, computePressureState, createDaemon, createDaemonWithAdapters, createFilesystemCleanupExecutor, createServiceLifecycle, createStatfsFreeBytesProvider, ensureSecureDir, freezeRuntimeAdapterDescriptor, generateLaunchdPlist, generateSystemdUnit, generateWinswXml, installSkillPacks, isGitWorkspaceConfig, isRuntimeDisposalFailure, isRuntimeExecutionFailure, journalHash, listInstalledSkillPacks, nodeAgentProgram, prependGitWorkspaceGuidance, projectRuntimeBoundaryFailure, projectRuntimeExecutionFailure, projectSkillPack, requestDeviceAssertion, resolveLocalStoragePolicy, sanitizeServiceName, sealRuntimeOperationManifest, skillPacksRoot };
11618
12576
  //# sourceMappingURL=index.js.map
11619
12577
  //# sourceMappingURL=index.js.map