@byok-sdk/client 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +58 -5
  2. package/dist/adapters/claude/claude-adapter.d.ts +6 -19
  3. package/dist/adapters/claude/events.d.ts +3 -0
  4. package/dist/adapters/claude/process-client.d.ts +9 -1
  5. package/dist/adapters/codex/codex-adapter.d.ts +4 -15
  6. package/dist/adapters/codex/process-runner.d.ts +4 -1
  7. package/dist/adapters/index.d.ts +4 -2
  8. package/dist/adapters/index.js +1081 -258
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/adapters/pi/pi-adapter.d.ts +24 -15
  11. package/dist/adapters/pi/rpc-client.d.ts +9 -1
  12. package/dist/adapters/process-tree.d.ts +19 -0
  13. package/dist/adapters/provider-credential-environment.d.ts +18 -0
  14. package/dist/bin/audit-log.d.ts +12 -0
  15. package/dist/bin/byok-agent.js +2686 -912
  16. package/dist/bin/byok-agent.js.map +1 -1
  17. package/dist/bin/byok-approval-mcp.js +2 -2
  18. package/dist/bin/byok-approval-mcp.js.map +1 -1
  19. package/dist/bin/commands/workspaces.d.ts +11 -0
  20. package/dist/bin/format.d.ts +13 -0
  21. package/dist/bin/runtime-probe.d.ts +1 -1
  22. package/dist/bin/tasks-view.d.ts +13 -0
  23. package/dist/daemon/approvals.d.ts +2 -2
  24. package/dist/daemon/assertion-client.d.ts +68 -0
  25. package/dist/daemon/capabilities-client.d.ts +48 -0
  26. package/dist/daemon/connection-manager.d.ts +4 -2
  27. package/dist/daemon/control-protocol.d.ts +81 -4
  28. package/dist/daemon/control-server.d.ts +18 -1
  29. package/dist/daemon/create-daemon.d.ts +171 -3
  30. package/dist/daemon/daemon-owner.d.ts +37 -0
  31. package/dist/daemon/device-assertion-signer.d.ts +41 -0
  32. package/dist/daemon/device-keys.d.ts +15 -13
  33. package/dist/daemon/environment.d.ts +9 -9
  34. package/dist/daemon/git-workspace.d.ts +21 -0
  35. package/dist/daemon/observer.d.ts +81 -3
  36. package/dist/daemon/presence-publisher.d.ts +98 -0
  37. package/dist/daemon/runtime-capabilities.d.ts +1 -1
  38. package/dist/daemon/skill-pack-installer.d.ts +116 -0
  39. package/dist/daemon/task-runner.d.ts +156 -37
  40. package/dist/daemon/ws-transport.d.ts +3 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +2972 -597
  43. package/dist/index.js.map +1 -1
  44. package/dist/runtime-failure.d.ts +64 -0
  45. package/dist/types.d.ts +114 -58
  46. package/package.json +4 -4
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile, spawn, spawnSync } from 'child_process';
3
- import { randomUUID, createHash, randomBytes, timingSafeEqual, createPrivateKey, generateKeyPairSync, sign, createHmac } from 'crypto';
3
+ import { randomUUID, createHash, randomBytes, timingSafeEqual, createHmac, createPrivateKey, generateKeyPairSync, sign } from 'crypto';
4
4
  import { readFileSync, promises, linkSync, fstatSync, lstatSync, unlinkSync, constants, readSync, openSync, writeFileSync, fchmodSync, fsyncSync, closeSync, opendirSync, existsSync, realpathSync, mkdirSync, renameSync, chmodSync, statSync, readdirSync } from 'fs';
5
- import path20, { join, isAbsolute } from 'path';
5
+ import path20, { isAbsolute, join } from 'path';
6
6
  import os from 'os';
7
- import { TASK_STATES, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, createEnvelope, MAX_MESSAGES_PER_BATCH, RuntimeIdSchema, PROTOCOL_VERSION, decodeEnvelope, MessagesSendResponseSchema, parseMessage, UnknownMessageTypeError } from '@byok-sdk/protocol';
7
+ import { DEVICE_ASSERTION_AUDIENCE_MAX_BYTES, DEVICE_ASSERTION_DEFAULT_TTL_MS, DEVICE_ASSERTION_MAX_TTL_MS, nonceSigningBytes, CapabilityDeclarationSchema, hasCapability, DeviceAssertionClaimsSchema, deviceAssertionSigningInput, DEVICE_ASSERTION_SCHEMA_ID } from '@byok-sdk/core';
8
+ import { TASK_STATES, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, BYOK_PAIR_PATH, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, createEnvelope, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, RuntimeIdSchema, RESULT_DOCUMENT_MAX_BYTES, PROTOCOL_VERSION, decodeEnvelope, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, BYOK_EVENTS_PATH, parseMessage, UnknownMessageTypeError, BYOK_WS_PATH } from '@byok-sdk/protocol';
8
9
  import { promisify } from 'util';
9
10
  import { fileURLToPath } from 'url';
10
11
  import 'readline';
@@ -14,6 +15,52 @@ import { createRequire } from 'module';
14
15
  import { createInterface } from 'readline/promises';
15
16
 
16
17
  // src/types.ts
18
+ function frozenStrings(values) {
19
+ return values === void 0 ? void 0 : Object.freeze([...values]);
20
+ }
21
+ function frozenPolicy(policy) {
22
+ const allowTools = policy.allowTools === void 0 ? void 0 : Object.freeze([...policy.allowTools]);
23
+ const denyTools = policy.denyTools === void 0 ? void 0 : Object.freeze([...policy.denyTools]);
24
+ return Object.freeze({
25
+ mode: policy.mode,
26
+ ...allowTools === void 0 ? {} : { allowTools },
27
+ ...denyTools === void 0 ? {} : { denyTools },
28
+ ...policy.workspaceRoot === void 0 ? {} : { workspaceRoot: policy.workspaceRoot },
29
+ ...policy.network === void 0 ? {} : { network: policy.network }
30
+ });
31
+ }
32
+ function freezeRuntimeAdapterDescriptor(descriptor) {
33
+ const baseNames = frozenStrings(descriptor.environmentRequirements.baseNames);
34
+ const credentialNames = frozenStrings(descriptor.environmentRequirements.credentialNames);
35
+ return Object.freeze({
36
+ id: descriptor.id,
37
+ supportsDispatchSelection: descriptor.supportsDispatchSelection === true,
38
+ capabilities: Object.freeze({
39
+ steer: descriptor.capabilities.steer === true,
40
+ resume: descriptor.capabilities.resume === true,
41
+ approvalInteractive: descriptor.capabilities.approvalInteractive === true,
42
+ ...descriptor.capabilities.mcpToolsets === void 0 ? {} : { mcpToolsets: descriptor.capabilities.mcpToolsets === true },
43
+ permissionModes: Object.freeze([...descriptor.capabilities.permissionModes])
44
+ }),
45
+ environmentRequirements: Object.freeze({
46
+ ...baseNames === void 0 ? {} : { baseNames },
47
+ ...credentialNames === void 0 ? {} : { credentialNames }
48
+ })
49
+ });
50
+ }
51
+ function sealRuntimeOperationManifest(manifest) {
52
+ return Object.freeze({
53
+ taskId: manifest.taskId,
54
+ runtimeId: manifest.runtimeId,
55
+ descriptor: freezeRuntimeAdapterDescriptor(manifest.descriptor),
56
+ policy: frozenPolicy(manifest.policy),
57
+ requiredToolsetIds: Object.freeze([...manifest.requiredToolsetIds]),
58
+ ...manifest.dispatchSelection === void 0 ? {} : { dispatchSelection: Object.freeze({ ...manifest.dispatchSelection }) },
59
+ ...manifest.sessionRef === void 0 ? {} : { sessionRef: manifest.sessionRef },
60
+ workspace: Object.freeze({ ...manifest.workspace }),
61
+ forwardedEnvironmentNames: Object.freeze([...manifest.forwardedEnvironmentNames])
62
+ });
63
+ }
17
64
  var PolicyUnsupportedError = class extends Error {
18
65
  constructor(message) {
19
66
  super(message);
@@ -21,7 +68,7 @@ var PolicyUnsupportedError = class extends Error {
21
68
  }
22
69
  };
23
70
  var SteerUnsupportedError = class extends Error {
24
- /** The `RuntimeAdapter.id` that cannot steer (e.g. `claude`, `codex`). */
71
+ /** The `RuntimeAdapter.descriptor.id` that cannot steer (e.g. `claude`, `codex`). */
25
72
  runtimeId;
26
73
  constructor(runtimeId, message) {
27
74
  super(message);
@@ -29,6 +76,112 @@ var SteerUnsupportedError = class extends Error {
29
76
  this.runtimeId = runtimeId;
30
77
  }
31
78
  };
79
+
80
+ // src/runtime-failure.ts
81
+ var RUNTIME_EXECUTION_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeExecutionFailure/v1");
82
+ var RUNTIME_DISPOSAL_FAILURE_BRAND = /* @__PURE__ */ Symbol.for("@byok-sdk/client/RuntimeDisposalFailure/v1");
83
+ var RuntimeDisposalFailure = class extends Error {
84
+ stage;
85
+ constructor(input, options) {
86
+ if (!isRuntimeDisposalStage(input.stage) || typeof input.reason !== "string" || input.reason.length === 0) {
87
+ throw new TypeError("invalid RuntimeDisposalFailure input");
88
+ }
89
+ super(input.reason, options);
90
+ this.name = "RuntimeDisposalFailure";
91
+ this.stage = input.stage;
92
+ Object.defineProperty(this, RUNTIME_DISPOSAL_FAILURE_BRAND, { value: true });
93
+ Object.freeze(this);
94
+ }
95
+ };
96
+ function isRuntimeDisposalStage(value) {
97
+ return value === "signal" || value === "quiescence" || value === "cleanup";
98
+ }
99
+ function isRuntimeDisposalFailure(value) {
100
+ if (typeof value !== "object" || value === null) return false;
101
+ const candidate = value;
102
+ return candidate[RUNTIME_DISPOSAL_FAILURE_BRAND] === true && isRuntimeDisposalStage(candidate.stage) && typeof candidate.message === "string" && candidate.message.length > 0;
103
+ }
104
+ var RuntimeExecutionFailure = class extends Error {
105
+ phase;
106
+ category;
107
+ retry;
108
+ constructor(input, options) {
109
+ if (!isRuntimeFailurePhase(input.phase) || !isRuntimeFailureCategory(input.category) || !isRuntimeRetryDisposition(input.retry) || typeof input.reason !== "string" || input.reason.length === 0) {
110
+ throw new TypeError("invalid RuntimeExecutionFailure input");
111
+ }
112
+ super(input.reason, options);
113
+ this.name = "RuntimeExecutionFailure";
114
+ this.phase = input.phase;
115
+ this.category = input.category;
116
+ this.retry = input.retry;
117
+ Object.defineProperty(this, RUNTIME_EXECUTION_FAILURE_BRAND, { value: true });
118
+ Object.freeze(this);
119
+ }
120
+ };
121
+ function isRuntimeFailurePhase(value) {
122
+ return value === "start" || value === "run";
123
+ }
124
+ function isRuntimeFailureCategory(value) {
125
+ return value === "semantic" || value === "infrastructure" || value === "authority";
126
+ }
127
+ function isRuntimeRetryDisposition(value) {
128
+ return value === "retryable" || value === "non-retryable";
129
+ }
130
+ function isRuntimeExecutionFailure(value) {
131
+ if (typeof value !== "object" || value === null) return false;
132
+ const candidate = value;
133
+ return candidate[RUNTIME_EXECUTION_FAILURE_BRAND] === true && isRuntimeFailurePhase(candidate.phase) && isRuntimeFailureCategory(candidate.category) && isRuntimeRetryDisposition(candidate.retry) && typeof candidate.message === "string" && candidate.message.length > 0;
134
+ }
135
+ function retryableFromDisposition(disposition) {
136
+ switch (disposition) {
137
+ case "retryable":
138
+ return true;
139
+ case "non-retryable":
140
+ return false;
141
+ }
142
+ }
143
+ function projectRuntimeExecutionFailure(failure) {
144
+ return {
145
+ reason: failure.message,
146
+ retryable: retryableFromDisposition(failure.retry)
147
+ };
148
+ }
149
+ var RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON = Object.freeze({
150
+ start: "runtime adapter contract violation during start",
151
+ run: "runtime adapter contract violation during run"
152
+ });
153
+ function projectRuntimeBoundaryFailure(value, expectedPhase) {
154
+ if (isRuntimeExecutionFailure(value) && value.phase === expectedPhase) {
155
+ return { ...projectRuntimeExecutionFailure(value), contractViolation: false };
156
+ }
157
+ return {
158
+ reason: RUNTIME_ADAPTER_CONTRACT_VIOLATION_REASON[expectedPhase],
159
+ retryable: false,
160
+ contractViolation: true
161
+ };
162
+ }
163
+ var GIT_ERROR_CATEGORIES = [
164
+ "git-unavailable",
165
+ "git-timeout",
166
+ "git-output-limit",
167
+ "git-command-failed",
168
+ "workspace-root-invalid",
169
+ "workspace-root-conflict",
170
+ "workspace-not-owned",
171
+ "repository-root-mismatch",
172
+ "repository-invalid",
173
+ "lease-busy",
174
+ "ledger-invalid"
175
+ ];
176
+ var GIT_WORKSPACE_PHASES = [
177
+ "preparing",
178
+ "active",
179
+ "completed",
180
+ "failed",
181
+ "cancelled",
182
+ "interrupted",
183
+ "salvage"
184
+ ];
32
185
  var GitWorkspaceError = class extends Error {
33
186
  constructor(category, message = category) {
34
187
  super(message);
@@ -944,6 +1097,155 @@ var AsyncQueue = class {
944
1097
  };
945
1098
  }
946
1099
  };
1100
+ var DEFAULT_TERM_GRACE_MS = 750;
1101
+ var DEFAULT_KILL_GRACE_MS = 2e3;
1102
+ var POLL_MS = 20;
1103
+ var terminationRequested = /* @__PURE__ */ new WeakSet();
1104
+ var terminationRequestFailed = /* @__PURE__ */ new WeakSet();
1105
+ function withOwnedProcessTree(options) {
1106
+ return {
1107
+ ...options,
1108
+ ...process.platform === "win32" ? { windowsHide: true } : { detached: true }
1109
+ };
1110
+ }
1111
+ function positivePid(child, label) {
1112
+ const pid = child.pid;
1113
+ if (pid === void 0) return void 0;
1114
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
1115
+ throw new RuntimeDisposalFailure({
1116
+ stage: "signal",
1117
+ reason: `${label} runtime process has an unsafe owned pid`
1118
+ });
1119
+ }
1120
+ return pid;
1121
+ }
1122
+ function groupExists(pid, label) {
1123
+ try {
1124
+ process.kill(-pid, 0);
1125
+ return true;
1126
+ } catch (cause) {
1127
+ const code = cause.code;
1128
+ if (code === "ESRCH") return false;
1129
+ if (code === "EPERM") return true;
1130
+ throw new RuntimeDisposalFailure({
1131
+ stage: "quiescence",
1132
+ reason: `${label} runtime process-group state could not be verified`
1133
+ }, { cause });
1134
+ }
1135
+ }
1136
+ function signalGroup(pid, signal, label) {
1137
+ try {
1138
+ process.kill(-pid, signal);
1139
+ } catch (cause) {
1140
+ const code = cause.code;
1141
+ if (code === "ESRCH" || code === "EPERM") return;
1142
+ throw new RuntimeDisposalFailure({
1143
+ stage: "signal",
1144
+ reason: `${label} runtime process group ${pid} could not receive ${signal} (${code ?? "unknown"})`
1145
+ }, { cause });
1146
+ }
1147
+ }
1148
+ async function waitUntil(predicate, timeoutMs) {
1149
+ const deadline = Date.now() + timeoutMs;
1150
+ while (predicate()) {
1151
+ if (Date.now() >= deadline) return false;
1152
+ await new Promise((resolve) => {
1153
+ setTimeout(resolve, POLL_MS);
1154
+ });
1155
+ }
1156
+ return true;
1157
+ }
1158
+ async function waitWithDeadline(promise, timeoutMs) {
1159
+ return new Promise((resolve) => {
1160
+ let settled = false;
1161
+ const timer = setTimeout(() => {
1162
+ if (!settled) {
1163
+ settled = true;
1164
+ resolve(false);
1165
+ }
1166
+ }, timeoutMs);
1167
+ void promise.then(
1168
+ () => {
1169
+ if (!settled) {
1170
+ settled = true;
1171
+ clearTimeout(timer);
1172
+ resolve(true);
1173
+ }
1174
+ },
1175
+ () => {
1176
+ if (!settled) {
1177
+ settled = true;
1178
+ clearTimeout(timer);
1179
+ resolve(false);
1180
+ }
1181
+ }
1182
+ );
1183
+ });
1184
+ }
1185
+ function requestOwnedProcessTreeTermination(options) {
1186
+ if (options.isClosed()) return;
1187
+ const pid = positivePid(options.child, options.label);
1188
+ if (pid === void 0) return;
1189
+ if (process.platform === "win32") {
1190
+ const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
1191
+ if (result.error) {
1192
+ throw new RuntimeDisposalFailure({
1193
+ stage: "signal",
1194
+ reason: `${options.label} runtime process tree could not be terminated`
1195
+ }, { cause: result.error });
1196
+ }
1197
+ terminationRequested.add(options.child);
1198
+ if (result.status !== 0) terminationRequestFailed.add(options.child);
1199
+ return;
1200
+ }
1201
+ signalGroup(pid, "SIGTERM", options.label);
1202
+ terminationRequested.add(options.child);
1203
+ }
1204
+ async function disposeOwnedProcessTree(options) {
1205
+ const pid = positivePid(options.child, options.label);
1206
+ const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
1207
+ const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
1208
+ if (pid === void 0) {
1209
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1210
+ throw new RuntimeDisposalFailure({
1211
+ stage: "quiescence",
1212
+ reason: `${options.label} runtime process did not settle after spawn failure`
1213
+ });
1214
+ }
1215
+ if (process.platform === "win32") {
1216
+ if (!options.isClosed() && !terminationRequested.has(options.child)) requestOwnedProcessTreeTermination(options);
1217
+ if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1218
+ if (terminationRequestFailed.has(options.child)) {
1219
+ throw new RuntimeDisposalFailure({
1220
+ stage: "signal",
1221
+ reason: `${options.label} runtime process tree could not be terminated`
1222
+ });
1223
+ }
1224
+ throw new RuntimeDisposalFailure({
1225
+ stage: "quiescence",
1226
+ reason: `${options.label} runtime process tree did not close before the disposal deadline`
1227
+ });
1228
+ }
1229
+ if (groupExists(pid, options.label) && !terminationRequested.has(options.child)) {
1230
+ signalGroup(pid, "SIGTERM", options.label);
1231
+ terminationRequested.add(options.child);
1232
+ }
1233
+ if (!await waitUntil(() => groupExists(pid, options.label), termGraceMs)) {
1234
+ signalGroup(pid, "SIGKILL", options.label);
1235
+ if (!await waitUntil(() => groupExists(pid, options.label), killGraceMs)) {
1236
+ throw new RuntimeDisposalFailure({
1237
+ stage: "quiescence",
1238
+ reason: `${options.label} runtime process group remained live after SIGKILL`
1239
+ });
1240
+ }
1241
+ }
1242
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1243
+ throw new RuntimeDisposalFailure({
1244
+ stage: "quiescence",
1245
+ reason: `${options.label} runtime root did not emit close after its process group exited`
1246
+ });
1247
+ }
1248
+ }
947
1249
 
948
1250
  // src/adapters/pi/rpc-client.ts
949
1251
  var STDERR_RING_CAPACITY = 20;
@@ -956,16 +1258,22 @@ var PiRpcClient = class {
956
1258
  eventQueue = new AsyncQueue();
957
1259
  closed = false;
958
1260
  exitError;
1261
+ closedPromise;
1262
+ resolveClosed;
1263
+ disposalAttempt;
959
1264
  /** 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`. */
960
1265
  stderrRing = [];
961
1266
  /** Count of pi RPC message types `PiSession` (pi-adapter.ts) has told us have no `AgentEvent` mapping and aren't routine bookkeeping — see `recordUnmappedFrame`. */
962
1267
  unmappedFrameCounts = /* @__PURE__ */ new Map();
963
1268
  constructor(options) {
964
1269
  const spawnFn = options.spawnFn ?? spawn;
965
- this.child = spawnFn(options.command, options.args, {
1270
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
966
1271
  cwd: options.cwd,
967
1272
  env: options.env,
968
1273
  stdio: ["pipe", "pipe", "pipe"]
1274
+ }));
1275
+ this.closedPromise = new Promise((resolve) => {
1276
+ this.resolveClosed = resolve;
969
1277
  });
970
1278
  this.child.stdout.setEncoding("utf8");
971
1279
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1000,6 +1308,10 @@ var PiRpcClient = class {
1000
1308
  get events() {
1001
1309
  return this.eventQueue;
1002
1310
  }
1311
+ /** Local transport diagnostic retained when the process closes; consumers must classify it explicitly. */
1312
+ get terminalError() {
1313
+ return this.exitError;
1314
+ }
1003
1315
  /**
1004
1316
  * Record a pi RPC message `type` that `PiSession` (pi-adapter.ts) decided
1005
1317
  * has no `AgentEvent` mapping and isn't routine bookkeeping (see
@@ -1018,15 +1330,30 @@ var PiRpcClient = class {
1018
1330
  );
1019
1331
  }
1020
1332
  }
1021
- /** Best-effort teardown. SIGTERM on POSIX; `taskkill /T /F` on Windows to also reap child processes pi itself spawned (e.g. bash). */
1333
+ /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
1022
1334
  kill() {
1023
- if (this.closed) return;
1024
- const pid = this.child.pid;
1025
- if (process.platform === "win32" && pid !== void 0) {
1026
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1027
- } else {
1028
- this.child.kill("SIGTERM");
1335
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
1336
+ }
1337
+ waitClosed() {
1338
+ return this.closedPromise;
1339
+ }
1340
+ dispose() {
1341
+ if (!this.disposalAttempt) {
1342
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
1343
+ this.disposalAttempt = attempt.catch((error) => {
1344
+ this.disposalAttempt = void 0;
1345
+ throw error;
1346
+ });
1029
1347
  }
1348
+ return this.disposalAttempt;
1349
+ }
1350
+ processTreeOptions() {
1351
+ return {
1352
+ child: this.child,
1353
+ waitClosed: () => this.closedPromise,
1354
+ isClosed: () => this.closed,
1355
+ label: "pi"
1356
+ };
1030
1357
  }
1031
1358
  onData(chunk) {
1032
1359
  this.buffer += chunk;
@@ -1112,19 +1439,15 @@ var PiRpcClient = class {
1112
1439
  if (this.closed) return;
1113
1440
  this.closed = true;
1114
1441
  this.exitError = err;
1442
+ this.resolveClosed();
1115
1443
  for (const [, waiter] of this.pending) waiter.reject(err);
1116
1444
  this.pending.clear();
1117
1445
  this.eventQueue.end();
1118
1446
  }
1119
1447
  };
1120
1448
 
1121
- // src/adapters/pi/pi-adapter.ts
1122
- var execFileAsync = promisify(execFile);
1123
- var DETECT_TIMEOUT_MS = 5e3;
1124
- function errorMessage(err) {
1125
- return err instanceof Error ? err.message : String(err);
1126
- }
1127
- var KNOWN_PROVIDER_ENV_VARS = [
1449
+ // src/adapters/provider-credential-environment.ts
1450
+ var PROVIDER_CREDENTIAL_ENV_NAMES = [
1128
1451
  "ANTHROPIC_API_KEY",
1129
1452
  "ANTHROPIC_OAUTH_TOKEN",
1130
1453
  "OPENAI_API_KEY",
@@ -1135,123 +1458,288 @@ var KNOWN_PROVIDER_ENV_VARS = [
1135
1458
  "MISTRAL_API_KEY",
1136
1459
  "OPENROUTER_API_KEY",
1137
1460
  "XAI_API_KEY",
1138
- // Confirmed against the installed pi's own docs/providers.md ("ZAI |
1139
- // `ZAI_API_KEY` | `zai`") and exercised live against real GLM traffic
1140
- // during this task's acceptance run — omitting it made `authPresent`
1141
- // silently false for a perfectly valid, working z.ai/GLM setup.
1142
1461
  "ZAI_API_KEY"
1143
1462
  ];
1463
+ var PROVIDER_CREDENTIAL_ENV_DENY_NAMES = [
1464
+ ...PROVIDER_CREDENTIAL_ENV_NAMES,
1465
+ "ANT_LING_API_KEY",
1466
+ "NVIDIA_API_KEY",
1467
+ "CEREBRAS_API_KEY",
1468
+ "CLOUDFLARE_API_KEY",
1469
+ "AI_GATEWAY_API_KEY",
1470
+ "ZAI_CODING_CN_API_KEY",
1471
+ "OPENCODE_API_KEY",
1472
+ "RADIUS_API_KEY",
1473
+ "FIREWORKS_API_KEY",
1474
+ "TOGETHER_API_KEY",
1475
+ "BASETEN_API_KEY",
1476
+ "KIMI_API_KEY",
1477
+ "MINIMAX_API_KEY",
1478
+ "MINIMAX_CN_API_KEY",
1479
+ "QWEN_TOKEN_PLAN_API_KEY",
1480
+ "QWEN_TOKEN_PLAN_CN_API_KEY",
1481
+ "XIAOMI_API_KEY",
1482
+ "XIAOMI_TOKEN_PLAN_CN_API_KEY",
1483
+ "XIAOMI_TOKEN_PLAN_AMS_API_KEY",
1484
+ "XIAOMI_TOKEN_PLAN_SGP_API_KEY",
1485
+ "AWS_ACCESS_KEY_ID",
1486
+ "AWS_SECRET_ACCESS_KEY",
1487
+ "AWS_SESSION_TOKEN",
1488
+ "GOOGLE_APPLICATION_CREDENTIALS",
1489
+ // Reserved by the keys-owned Pi projection. It must never be inherited
1490
+ // from the daemon; the launcher deletes any ambient copy and injects only
1491
+ // the exact credential it just resolved from OS custody.
1492
+ "PI_PROVIDER_API_KEY"
1493
+ ];
1494
+ function withoutProviderCredentials(env) {
1495
+ const sanitized = { ...env };
1496
+ for (const name of PROVIDER_CREDENTIAL_ENV_DENY_NAMES) {
1497
+ delete sanitized[name];
1498
+ }
1499
+ return sanitized;
1500
+ }
1501
+
1502
+ // src/adapters/pi/pi-adapter.ts
1503
+ var execFileAsync = promisify(execFile);
1504
+ var DETECT_TIMEOUT_MS = 5e3;
1505
+ function errorMessage(err) {
1506
+ return err instanceof Error ? err.message : String(err);
1507
+ }
1144
1508
  var PiAdapter = class {
1145
1509
  constructor(options = {}) {
1146
1510
  this.options = options;
1147
1511
  }
1148
1512
  options;
1149
- id = "pi";
1513
+ descriptor = freezeRuntimeAdapterDescriptor({
1514
+ id: "pi",
1515
+ supportsDispatchSelection: true,
1516
+ capabilities: {
1517
+ steer: true,
1518
+ resume: true,
1519
+ approvalInteractive: false,
1520
+ permissionModes: ["auto", "readonly"]
1521
+ },
1522
+ environmentRequirements: { credentialNames: PROVIDER_CREDENTIAL_ENV_NAMES }
1523
+ });
1150
1524
  async detect() {
1151
1525
  try {
1152
1526
  const bin = this.resolveBin();
1153
1527
  const { stdout, stderr } = await execFileAsync(bin.command, ["--version"], { timeout: DETECT_TIMEOUT_MS });
1154
1528
  const version = stdout.trim() || stderr.trim();
1155
- const authPresent = KNOWN_PROVIDER_ENV_VARS.some((name) => process.env[name] !== void 0);
1529
+ const authPresent = PROVIDER_CREDENTIAL_ENV_NAMES.some((name) => process.env[name] !== void 0);
1156
1530
  return { present: true, version, authPresent };
1157
1531
  } catch {
1158
1532
  return { present: false };
1159
1533
  }
1160
1534
  }
1161
- capabilities() {
1162
- return { steer: true, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
1163
- }
1164
- /**
1165
- * M5: pi authenticates to its ~30 supported providers via env-var API
1166
- * keys — `detect()`'s own `authPresent` probe above checks this identical
1167
- * list — so these MUST keep flowing into pi's spawned process or pi auth
1168
- * breaks entirely. `KNOWN_PROVIDER_ENV_VARS` above is the single source
1169
- * of truth, reused here rather than duplicated. No `baseNames`: nothing
1170
- * in this adapter or `rpc-client.ts` reads a pi-specific config-discovery
1171
- * variable beyond the platform baseline (`daemon/environment.ts`).
1172
- */
1173
- environmentRequirements() {
1174
- return { credentialNames: KNOWN_PROVIDER_ENV_VARS };
1175
- }
1176
- async start(task, ctx) {
1177
- if (typeof task.instruction !== "string") {
1178
- throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
1179
- }
1180
- const mapping = mapPermissionPolicyToPiArgs(ctx.policy);
1535
+ async prepare(input) {
1536
+ const mapping = mapPermissionPolicyToPiArgs(input.policy);
1181
1537
  if (!mapping.ok) {
1182
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by pi adapter");
1538
+ return { kind: "reject", reason: mapping.reason ?? "policy rejected by pi adapter", retryable: false };
1183
1539
  }
1184
1540
  const bin = this.resolveBin();
1185
- const resumeSessionId = task.sessionRef;
1186
- const args = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1187
- const rpc = new PiRpcClient({
1188
- command: bin.command,
1189
- args,
1190
- cwd: ctx.workspaceDir,
1191
- env: ctx.env,
1192
- spawnFn: this.options.spawnFn
1193
- });
1194
- const response = await rpc.send({ type: "prompt", message: task.instruction });
1195
- if (response.success === false) {
1196
- rpc.kill();
1197
- throw new Error(typeof response.error === "string" ? response.error : "pi rejected the initial prompt");
1541
+ const selection = input.offer.dispatchSelection;
1542
+ const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
1543
+ let command = bin.command;
1544
+ let launcherArgs;
1545
+ if (pinnedSelection !== void 0) {
1546
+ if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
1547
+ return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
1548
+ }
1549
+ const launcher = this.options.byokLauncher;
1550
+ if (launcher === void 0) {
1551
+ return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
1552
+ }
1553
+ command = launcher.command;
1554
+ launcherArgs = [
1555
+ ...launcher.args ?? [],
1556
+ "--pi-bin",
1557
+ bin.command,
1558
+ "--profile-db",
1559
+ launcher.profileDbPath,
1560
+ "--session-dir",
1561
+ launcher.sessionDir,
1562
+ ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
1563
+ "--provider",
1564
+ pinnedSelection.providerId,
1565
+ "--model",
1566
+ pinnedSelection.modelId
1567
+ ];
1198
1568
  }
1199
- let sessionRef;
1200
- if (resumeSessionId) {
1201
- sessionRef = resumeSessionId;
1202
- } else {
1203
- try {
1204
- sessionRef = await resolveFreshSessionId(rpc);
1205
- } catch (err) {
1206
- rpc.kill();
1207
- throw err;
1569
+ return {
1570
+ kind: "prepared",
1571
+ operation: {
1572
+ start: async (startInput) => {
1573
+ const manifestSelection = startInput.manifest.dispatchSelection;
1574
+ if (!sameDispatchSelection(manifestSelection, pinnedSelection)) {
1575
+ throw new RuntimeExecutionFailure({
1576
+ phase: "start",
1577
+ category: "authority",
1578
+ retry: "non-retryable",
1579
+ reason: "prepared pi operation received a manifest with different runtime selection"
1580
+ });
1581
+ }
1582
+ if (typeof startInput.instruction !== "string") {
1583
+ throw new RuntimeExecutionFailure({
1584
+ phase: "start",
1585
+ category: "authority",
1586
+ retry: "non-retryable",
1587
+ reason: "prepared pi operation requires a resolved string instruction"
1588
+ });
1589
+ }
1590
+ const resumeSessionId = startInput.manifest.sessionRef;
1591
+ const piArgs = ["--mode", "rpc", ...resumeSessionId ? ["--session", resumeSessionId] : [], ...mapping.args];
1592
+ const args = launcherArgs === void 0 ? piArgs : [...launcherArgs, "--", ...piArgs];
1593
+ let rpc;
1594
+ try {
1595
+ rpc = new PiRpcClient({
1596
+ command,
1597
+ args,
1598
+ cwd: startInput.manifest.workspace.workspaceDir,
1599
+ env: manifestSelection === void 0 ? startInput.env : withoutProviderCredentials(startInput.env),
1600
+ spawnFn: this.options.spawnFn
1601
+ });
1602
+ } catch (cause) {
1603
+ throw new RuntimeExecutionFailure({
1604
+ phase: "start",
1605
+ category: "infrastructure",
1606
+ retry: "retryable",
1607
+ reason: "pi runtime process could not be spawned"
1608
+ }, { cause });
1609
+ }
1610
+ let response;
1611
+ try {
1612
+ response = await rpc.send({ type: "prompt", message: startInput.instruction });
1613
+ } catch (cause) {
1614
+ rpc.kill();
1615
+ throw new RuntimeExecutionFailure({
1616
+ phase: "start",
1617
+ category: "infrastructure",
1618
+ retry: "retryable",
1619
+ reason: `pi initial prompt transport failed: ${errorMessage(cause)}`
1620
+ }, { cause });
1621
+ }
1622
+ if (response.success === false) {
1623
+ rpc.kill();
1624
+ throw new RuntimeExecutionFailure({
1625
+ phase: "start",
1626
+ category: "semantic",
1627
+ retry: "non-retryable",
1628
+ reason: typeof response.error === "string" ? response.error : "pi rejected the initial prompt"
1629
+ });
1630
+ }
1631
+ let sessionRef;
1632
+ try {
1633
+ sessionRef = await resolveAuthoritativeSessionId(rpc);
1634
+ } catch (err) {
1635
+ rpc.kill();
1636
+ throw err;
1637
+ }
1638
+ if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1639
+ rpc.kill();
1640
+ throw new RuntimeExecutionFailure({
1641
+ phase: "start",
1642
+ category: "authority",
1643
+ retry: "non-retryable",
1644
+ reason: "pi resumed a different authoritative session than requested"
1645
+ });
1646
+ }
1647
+ return new PiSession(sessionRef, rpc, manifestSelection);
1648
+ }
1208
1649
  }
1209
- }
1210
- return new PiSession(sessionRef, rpc);
1650
+ };
1211
1651
  }
1212
1652
  resolveBin() {
1213
1653
  return (this.options.resolveBin ?? resolvePiBin)();
1214
1654
  }
1215
1655
  };
1216
- async function resolveFreshSessionId(rpc) {
1656
+ function sameDispatchSelection(left, right) {
1657
+ if (left === void 0 || right === void 0) return left === right;
1658
+ return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
1659
+ }
1660
+ async function resolveAuthoritativeSessionId(rpc) {
1217
1661
  let state;
1218
1662
  try {
1219
1663
  state = await rpc.send({ type: "get_state" });
1220
1664
  } catch (err) {
1221
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${errorMessage(err)}`, {
1665
+ if (isRuntimeExecutionFailure(err)) throw err;
1666
+ throw new RuntimeExecutionFailure({
1667
+ phase: "start",
1668
+ category: "infrastructure",
1669
+ retry: "retryable",
1670
+ reason: `pi transport ended before yielding an authoritative session id: ${errorMessage(err)}`
1671
+ }, {
1222
1672
  cause: err
1223
1673
  });
1224
1674
  }
1225
1675
  if (state.success === false) {
1226
1676
  const reason = typeof state.error === "string" ? state.error : "get_state reported failure";
1227
- throw new Error(`pi did not yield an authoritative session id (get_state failed): ${reason}`);
1677
+ throw new RuntimeExecutionFailure({
1678
+ phase: "start",
1679
+ category: "authority",
1680
+ retry: "non-retryable",
1681
+ reason: `pi did not yield an authoritative session id: ${reason}`
1682
+ });
1228
1683
  }
1229
1684
  const data = state.data;
1230
1685
  if (typeof data?.sessionId === "string" && data.sessionId.length > 0) {
1231
1686
  return data.sessionId;
1232
1687
  }
1233
- throw new Error(
1234
- "pi did not yield an authoritative session id (get_state succeeded but reported no sessionId) \u2014 cannot mint a resumable session"
1235
- );
1688
+ throw new RuntimeExecutionFailure({
1689
+ phase: "start",
1690
+ category: "authority",
1691
+ retry: "non-retryable",
1692
+ reason: "pi get_state reported no authoritative session id"
1693
+ });
1236
1694
  }
1237
1695
  var PiSession = class {
1238
- constructor(sessionRef, rpc) {
1696
+ constructor(sessionRef, rpc, selection) {
1239
1697
  this.sessionRef = sessionRef;
1240
1698
  this.rpc = rpc;
1699
+ this.selection = selection;
1241
1700
  }
1242
1701
  sessionRef;
1243
1702
  rpc;
1703
+ selection;
1244
1704
  get events() {
1245
1705
  const rpc = this.rpc;
1246
1706
  return {
1247
1707
  [Symbol.asyncIterator]() {
1248
1708
  const inner = rpc.events[Symbol.asyncIterator]();
1709
+ let terminalFailure;
1249
1710
  return {
1250
1711
  async next() {
1251
1712
  for (; ; ) {
1252
- const { value, done } = await inner.next();
1253
- if (done) return { value: void 0, done: true };
1713
+ if (terminalFailure) throw terminalFailure;
1714
+ let result;
1715
+ try {
1716
+ result = await inner.next();
1717
+ } catch (cause) {
1718
+ throw new RuntimeExecutionFailure({
1719
+ phase: "run",
1720
+ category: "infrastructure",
1721
+ retry: "retryable",
1722
+ reason: "pi runtime event transport failed"
1723
+ }, { cause });
1724
+ }
1725
+ const { value, done } = result;
1726
+ if (done) {
1727
+ throw new RuntimeExecutionFailure({
1728
+ phase: "run",
1729
+ category: "infrastructure",
1730
+ retry: "retryable",
1731
+ reason: "pi runtime process ended before agent_settled"
1732
+ }, { cause: rpc.terminalError });
1733
+ }
1254
1734
  const mapped = mapPiMessageToAgentEvent(value);
1735
+ if (value.type === "auto_retry_end" && value.success === false) {
1736
+ terminalFailure = new RuntimeExecutionFailure({
1737
+ phase: "run",
1738
+ category: "semantic",
1739
+ retry: "non-retryable",
1740
+ reason: "pi exhausted its native retry policy"
1741
+ });
1742
+ }
1255
1743
  if (mapped) return { value: mapped, done: false };
1256
1744
  if (!ROUTINE_PI_EVENT_TYPES.has(value.type)) {
1257
1745
  rpc.recordUnmappedFrame(value.type);
@@ -1269,13 +1757,19 @@ var PiSession = class {
1269
1757
  if (typeof task.instruction !== "string") {
1270
1758
  throw new PolicyUnsupportedError("pi adapter only supports string instructions in M0 (no blob-ref fetch yet)");
1271
1759
  }
1760
+ const requestedSelection = task.dispatchSelection;
1761
+ if (requestedSelection !== void 0 && (this.selection?.lane !== "byok" || requestedSelection.lane !== "byok" || requestedSelection.runtimeId !== "pi" || requestedSelection.providerId !== this.selection.providerId || requestedSelection.modelId !== this.selection.modelId)) {
1762
+ throw new PolicyUnsupportedError(
1763
+ "pi persistent session cannot change its authoritative BYOK provider/model selection"
1764
+ );
1765
+ }
1272
1766
  await this.rpc.send({ type: "prompt", message: task.instruction, streamingBehavior: "followUp" });
1273
1767
  }
1274
1768
  async interrupt() {
1275
1769
  await this.rpc.send({ type: "abort" });
1276
1770
  }
1277
1771
  async close() {
1278
- this.rpc.kill();
1772
+ await this.rpc.dispose();
1279
1773
  }
1280
1774
  async resolveApproval() {
1281
1775
  throw new Error("pi adapter does not support approval resume: pi never emits needs_approval in M0/M1");
@@ -1487,7 +1981,15 @@ function mapResult(msg) {
1487
1981
  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`
1488
1982
  );
1489
1983
  const events = usageEvent ? [usageEvent, { type: "error", message }] : [{ type: "error", message }];
1490
- return { events };
1984
+ return {
1985
+ events,
1986
+ terminalFailure: new RuntimeExecutionFailure({
1987
+ phase: "run",
1988
+ category: msg.is_error === true ? "semantic" : "authority",
1989
+ retry: "non-retryable",
1990
+ reason: msg.is_error === true ? "claude reported terminal task failure" : "claude emitted a malformed terminal result frame"
1991
+ })
1992
+ };
1491
1993
  }
1492
1994
  var RESULT_DIAGNOSTIC_MAX_CHARS = 2e3;
1493
1995
  function truncateResultDiagnostic(text) {
@@ -1539,16 +2041,22 @@ var ClaudeProcessClient = class {
1539
2041
  eventQueue = new AsyncQueue();
1540
2042
  closed = false;
1541
2043
  exitError;
2044
+ closedPromise;
2045
+ resolveClosed;
2046
+ disposalAttempt;
1542
2047
  stderrRing = [];
1543
2048
  unmappedFrameCounts = /* @__PURE__ */ new Map();
1544
2049
  sessionId;
1545
2050
  initWaiter;
1546
2051
  constructor(options) {
1547
2052
  const spawnFn = options.spawnFn ?? spawn;
1548
- this.child = spawnFn(options.command, options.args, {
2053
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
1549
2054
  cwd: options.cwd,
1550
2055
  env: options.env,
1551
2056
  stdio: ["pipe", "pipe", "pipe"]
2057
+ }));
2058
+ this.closedPromise = new Promise((resolve) => {
2059
+ this.resolveClosed = resolve;
1552
2060
  });
1553
2061
  this.child.stdout.setEncoding("utf8");
1554
2062
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
@@ -1604,6 +2112,10 @@ var ClaudeProcessClient = class {
1604
2112
  get events() {
1605
2113
  return this.eventQueue;
1606
2114
  }
2115
+ /** Local transport diagnostic retained when the process closes; consumers classify it at the session boundary. */
2116
+ get terminalError() {
2117
+ return this.exitError;
2118
+ }
1607
2119
  /**
1608
2120
  * Record a claude stream-json frame/subtype/content-block label that
1609
2121
  * `ClaudeSession`'s event iterator (`../claude-adapter.ts`) decided has
@@ -1623,15 +2135,30 @@ var ClaudeProcessClient = class {
1623
2135
  );
1624
2136
  }
1625
2137
  }
1626
- /** 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). */
2138
+ /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
1627
2139
  kill() {
1628
- if (this.closed) return;
1629
- const pid = this.child.pid;
1630
- if (process.platform === "win32" && pid !== void 0) {
1631
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
1632
- } else {
1633
- this.child.kill("SIGTERM");
2140
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
2141
+ }
2142
+ waitClosed() {
2143
+ return this.closedPromise;
2144
+ }
2145
+ dispose() {
2146
+ if (!this.disposalAttempt) {
2147
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2148
+ this.disposalAttempt = attempt.catch((error) => {
2149
+ this.disposalAttempt = void 0;
2150
+ throw error;
2151
+ });
1634
2152
  }
2153
+ return this.disposalAttempt;
2154
+ }
2155
+ processTreeOptions() {
2156
+ return {
2157
+ child: this.child,
2158
+ waitClosed: () => this.closedPromise,
2159
+ isClosed: () => this.closed,
2160
+ label: "claude"
2161
+ };
1635
2162
  }
1636
2163
  onData(chunk) {
1637
2164
  this.buffer += chunk;
@@ -1682,6 +2209,7 @@ var ClaudeProcessClient = class {
1682
2209
  if (this.closed) return;
1683
2210
  this.closed = true;
1684
2211
  this.exitError = err;
2212
+ this.resolveClosed();
1685
2213
  this.initWaiter?.reject(err);
1686
2214
  this.initWaiter = void 0;
1687
2215
  this.eventQueue.end();
@@ -1693,17 +2221,37 @@ var APPROVAL_TOOL_NAME = "approval_prompt";
1693
2221
  var APPROVAL_MCP_SERVER_NAME = "byokapproval";
1694
2222
  var execFileAsync2 = promisify(execFile);
1695
2223
  var DETECT_TIMEOUT_MS2 = 5e3;
1696
- async function cleanupApprovalMcpConfigDir(dir) {
2224
+ function errorMessage2(err) {
2225
+ return err instanceof Error ? err.message : String(err);
2226
+ }
2227
+ async function cleanupMcpConfigDir(dir) {
1697
2228
  if (!dir) return;
1698
- await promises.rm(dir, { recursive: true, force: true }).catch(() => {
1699
- });
2229
+ try {
2230
+ await promises.rm(dir, { recursive: true, force: true });
2231
+ } catch (cause) {
2232
+ throw new RuntimeDisposalFailure({
2233
+ stage: "cleanup",
2234
+ reason: "claude task-scoped MCP configuration could not be removed"
2235
+ }, { cause });
2236
+ }
1700
2237
  }
1701
2238
  var ClaudeAdapter = class {
1702
2239
  constructor(options = {}) {
1703
2240
  this.options = options;
1704
2241
  }
1705
2242
  options;
1706
- id = "claude";
2243
+ descriptor = freezeRuntimeAdapterDescriptor({
2244
+ id: "claude",
2245
+ supportsDispatchSelection: true,
2246
+ capabilities: {
2247
+ steer: false,
2248
+ resume: true,
2249
+ approvalInteractive: true,
2250
+ mcpToolsets: true,
2251
+ permissionModes: ["auto", "readonly", "plan", "confirm"]
2252
+ },
2253
+ environmentRequirements: { credentialNames: [] }
2254
+ });
1707
2255
  async detect() {
1708
2256
  const bin = this.resolveBin();
1709
2257
  try {
@@ -1715,75 +2263,138 @@ var ClaudeAdapter = class {
1715
2263
  return { present: false };
1716
2264
  }
1717
2265
  }
1718
- capabilities() {
1719
- return { steer: false, resume: true, approvalInteractive: true, permissionModes: ["auto", "readonly", "plan", "confirm"] };
1720
- }
1721
- /**
1722
- * M5: deliberate product-boundary decision, not an oversight — byok's
1723
- * current ToS posture for claude is login-state-only (`claude auth
1724
- * login`'s own OAuth session — see `probeAuthPresent` below), so this
1725
- * adapter declares NO credential env vars at all; env-based API-key
1726
- * passthrough for claude is a separate, still-pending product decision.
1727
- * A product that genuinely needs it can opt in locally per-device via
1728
- * `DaemonConfig.runtimeEnvironment.claude.allow` (`create-daemon.ts`).
1729
- * `baseNames` is empty too: nothing in this adapter reads a
1730
- * claude-specific config-discovery variable (e.g. `CLAUDE_CONFIG_DIR`)
1731
- * today — if a future version of this adapter starts reading one, it
1732
- * belongs here, not left to rely on the platform baseline alone.
1733
- */
1734
- environmentRequirements() {
1735
- return { credentialNames: [] };
1736
- }
1737
- async start(task, ctx) {
1738
- if (typeof task.instruction !== "string") {
1739
- throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2266
+ async prepare(input) {
2267
+ const mapping = mapPermissionPolicyToClaudeArgs(input.policy);
2268
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by claude adapter", retryable: false };
2269
+ let modelId;
2270
+ try {
2271
+ modelId = subscriptionModel(input.offer.dispatchSelection, "claude");
2272
+ } catch (error) {
2273
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
1740
2274
  }
1741
- const mapping = mapPermissionPolicyToClaudeArgs(ctx.policy);
1742
- if (!mapping.ok) {
1743
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by claude adapter");
2275
+ if (mapping.needsApprovalMcp && Object.prototype.hasOwnProperty.call(input.mcpServers ?? {}, APPROVAL_MCP_SERVER_NAME)) {
2276
+ return { kind: "reject", reason: `MCP server name "${APPROVAL_MCP_SERVER_NAME}" is reserved by the claude approval channel`, retryable: false };
1744
2277
  }
1745
- let approvalMcpConfigDir;
2278
+ let bin;
2279
+ try {
2280
+ bin = this.resolveBin();
2281
+ } catch (error) {
2282
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2283
+ }
2284
+ let approvalMcpBin;
1746
2285
  if (mapping.needsApprovalMcp) {
1747
- if (!ctx.approvalChannel) {
1748
- throw new PolicyUnsupportedError(
1749
- 'claude adapter requires policy.mode "confirm" to be started with an approval channel (TaskContext.approvalChannel) \u2014 none was provided'
1750
- );
2286
+ try {
2287
+ approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
2288
+ } catch (error) {
2289
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
2290
+ }
2291
+ }
2292
+ return {
2293
+ kind: "prepared",
2294
+ operation: {
2295
+ start: (startInput) => this.startPrepared(startInput, mapping, modelId, bin, approvalMcpBin)
2296
+ }
2297
+ };
2298
+ }
2299
+ async startPrepared(startInput, initialMapping, modelId, bin, approvalMcpBin) {
2300
+ if (!initialMapping.ok) throw new RuntimeExecutionFailure({
2301
+ phase: "start",
2302
+ category: "authority",
2303
+ retry: "non-retryable",
2304
+ reason: "prepared claude permission mapping was invalid"
2305
+ });
2306
+ if (typeof startInput.instruction !== "string") {
2307
+ throw new RuntimeExecutionFailure({
2308
+ phase: "start",
2309
+ category: "authority",
2310
+ retry: "non-retryable",
2311
+ reason: "prepared claude operation requires a resolved string instruction"
2312
+ });
2313
+ }
2314
+ const mapping = { ...initialMapping, args: [...initialMapping.args] };
2315
+ let mcpConfigDir;
2316
+ const taskMcpServers = startInput.mcpServers ?? {};
2317
+ const needsMcpConfig = mapping.needsApprovalMcp || Object.keys(taskMcpServers).length > 0;
2318
+ if (mapping.needsApprovalMcp) {
2319
+ if (!startInput.approvalChannel) {
2320
+ throw new RuntimeExecutionFailure({
2321
+ phase: "start",
2322
+ category: "authority",
2323
+ retry: "non-retryable",
2324
+ reason: 'claude adapter requires policy.mode "confirm" to be started with an approval channel'
2325
+ });
1751
2326
  }
1752
- const approvalMcpBin = (this.options.resolveApprovalMcpBin ?? resolveApprovalMcpBin)();
1753
- approvalMcpConfigDir = await promises.mkdtemp(path20.join(os.tmpdir(), "byok-approval-mcp-"));
1754
- await promises.chmod(approvalMcpConfigDir, 448).catch(() => {
2327
+ if (!approvalMcpBin) throw new RuntimeExecutionFailure({
2328
+ phase: "start",
2329
+ category: "authority",
2330
+ retry: "non-retryable",
2331
+ reason: "prepared claude approval MCP binary was not resolved"
1755
2332
  });
1756
- const mcpConfigPath = path20.join(approvalMcpConfigDir, "mcp-config.json");
1757
- const mcpConfig = {
1758
- mcpServers: {
1759
- [APPROVAL_MCP_SERVER_NAME]: {
1760
- command: approvalMcpBin.command,
1761
- args: approvalMcpBin.args,
1762
- env: {
1763
- BYOK_STORE_DIR: ctx.approvalChannel.storeDir,
1764
- BYOK_PRODUCT_ID: ctx.approvalChannel.productId,
1765
- BYOK_TASK_ID: ctx.approvalChannel.taskId,
1766
- BYOK_APPROVAL_TIMEOUT_MS: String(ctx.approvalChannel.timeoutMs)
1767
- }
2333
+ }
2334
+ if (needsMcpConfig) {
2335
+ mcpConfigDir = await promises.mkdtemp(path20.join(os.tmpdir(), "byok-mcp-"));
2336
+ await promises.chmod(mcpConfigDir, 448).catch(() => {
2337
+ });
2338
+ const mcpConfigPath = path20.join(mcpConfigDir, "mcp-config.json");
2339
+ const mcpServers = { ...taskMcpServers };
2340
+ if (mapping.needsApprovalMcp) {
2341
+ const approvalChannel = startInput.approvalChannel;
2342
+ if (!approvalChannel) throw new RuntimeExecutionFailure({
2343
+ phase: "start",
2344
+ category: "authority",
2345
+ retry: "non-retryable",
2346
+ reason: "prepared claude approval channel was not available"
2347
+ });
2348
+ const preparedApprovalMcpBin = approvalMcpBin;
2349
+ if (!preparedApprovalMcpBin) throw new RuntimeExecutionFailure({
2350
+ phase: "start",
2351
+ category: "authority",
2352
+ retry: "non-retryable",
2353
+ reason: "prepared claude approval MCP binary was not resolved"
2354
+ });
2355
+ mcpServers[APPROVAL_MCP_SERVER_NAME] = {
2356
+ command: preparedApprovalMcpBin.command,
2357
+ args: preparedApprovalMcpBin.args,
2358
+ env: {
2359
+ BYOK_STORE_DIR: approvalChannel.storeDir,
2360
+ BYOK_PRODUCT_ID: approvalChannel.productId,
2361
+ BYOK_TASK_ID: approvalChannel.taskId,
2362
+ BYOK_APPROVAL_TIMEOUT_MS: String(approvalChannel.timeoutMs)
1768
2363
  }
1769
- }
1770
- };
1771
- await promises.writeFile(mcpConfigPath, JSON.stringify(mcpConfig), { mode: 384 });
2364
+ };
2365
+ }
2366
+ await promises.writeFile(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 384 });
1772
2367
  mapping.args = [
1773
2368
  ...mapping.args,
1774
- "--permission-prompt-tool",
1775
- `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`,
2369
+ ...mapping.needsApprovalMcp ? ["--permission-prompt-tool", `mcp__${APPROVAL_MCP_SERVER_NAME}__${APPROVAL_TOOL_NAME}`] : [],
1776
2370
  "--mcp-config",
1777
2371
  mcpConfigPath,
1778
- // Never let this task's confirm-mode run pick up some OTHER MCP
1779
- // server from ambient project/user config the approval channel is
1780
- // the only MCP server this invocation should ever see.
2372
+ // The generated file is the complete task-scoped MCP authority.
2373
+ // Never merge ambient user/project MCP configuration into it.
1781
2374
  "--strict-mcp-config"
1782
2375
  ];
1783
2376
  }
1784
- const bin = this.resolveBin();
1785
- const resumeSessionId = task.sessionRef;
1786
- const args = [
2377
+ const resumeSessionId = startInput.manifest.sessionRef;
2378
+ let manifestModelId;
2379
+ try {
2380
+ manifestModelId = subscriptionModel(startInput.manifest.dispatchSelection, "claude");
2381
+ } catch (cause) {
2382
+ throw new RuntimeExecutionFailure({
2383
+ phase: "start",
2384
+ category: "authority",
2385
+ retry: "non-retryable",
2386
+ reason: "prepared claude operation received an invalid runtime selection manifest"
2387
+ }, { cause });
2388
+ }
2389
+ if (manifestModelId !== modelId) {
2390
+ throw new RuntimeExecutionFailure({
2391
+ phase: "start",
2392
+ category: "authority",
2393
+ retry: "non-retryable",
2394
+ reason: "prepared claude operation received a manifest with different runtime selection"
2395
+ });
2396
+ }
2397
+ const args = [
1787
2398
  "-p",
1788
2399
  "--input-format",
1789
2400
  "stream-json",
@@ -1794,33 +2405,72 @@ var ClaudeAdapter = class {
1794
2405
  // "Error: When using --print, --output-format=stream-json requires
1795
2406
  // --verbose", before spawning any model call.
1796
2407
  "--verbose",
2408
+ ...manifestModelId ? ["--model", manifestModelId] : [],
1797
2409
  ...resumeSessionId ? ["--resume", resumeSessionId] : [],
1798
2410
  ...mapping.args
1799
2411
  ];
1800
- const client = new ClaudeProcessClient({
1801
- command: bin.command,
1802
- args,
1803
- cwd: ctx.workspaceDir,
1804
- env: ctx.env,
1805
- spawnFn: this.options.spawnFn
1806
- });
1807
- client.writeUserMessage(task.instruction);
2412
+ let client;
2413
+ try {
2414
+ client = new ClaudeProcessClient({
2415
+ command: bin.command,
2416
+ args,
2417
+ cwd: startInput.manifest.workspace.workspaceDir,
2418
+ env: withoutProviderCredentials(startInput.env),
2419
+ spawnFn: this.options.spawnFn
2420
+ });
2421
+ } catch (cause) {
2422
+ await cleanupMcpConfigDir(mcpConfigDir);
2423
+ throw new RuntimeExecutionFailure({
2424
+ phase: "start",
2425
+ category: "infrastructure",
2426
+ retry: "retryable",
2427
+ reason: "claude runtime process could not be spawned"
2428
+ }, { cause });
2429
+ }
2430
+ try {
2431
+ client.writeUserMessage(startInput.instruction);
2432
+ } catch (cause) {
2433
+ client.kill();
2434
+ await cleanupMcpConfigDir(mcpConfigDir);
2435
+ throw new RuntimeExecutionFailure({
2436
+ phase: "start",
2437
+ category: "infrastructure",
2438
+ retry: "retryable",
2439
+ reason: "claude initial instruction transport failed"
2440
+ }, { cause });
2441
+ }
1808
2442
  let sessionRef;
1809
2443
  try {
1810
2444
  sessionRef = await client.waitForInit();
1811
2445
  } catch (err) {
1812
2446
  client.kill();
1813
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1814
- throw err;
2447
+ await cleanupMcpConfigDir(mcpConfigDir);
2448
+ if (isRuntimeExecutionFailure(err)) throw err;
2449
+ throw new RuntimeExecutionFailure({
2450
+ phase: "start",
2451
+ category: "infrastructure",
2452
+ retry: "retryable",
2453
+ reason: `claude exited before yielding an authoritative session id: ${errorMessage2(err)}`
2454
+ }, { cause: err });
1815
2455
  }
1816
2456
  if (resumeSessionId !== void 0 && sessionRef !== resumeSessionId) {
1817
2457
  client.kill();
1818
- await cleanupApprovalMcpConfigDir(approvalMcpConfigDir);
1819
- throw new Error(
1820
- `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef}) \u2014 refusing to continue in a possibly-wrong session (fail-closed)`
1821
- );
2458
+ await cleanupMcpConfigDir(mcpConfigDir);
2459
+ throw new RuntimeExecutionFailure({
2460
+ phase: "start",
2461
+ category: "authority",
2462
+ retry: "non-retryable",
2463
+ reason: `claude --resume echoed a different session id than requested (requested ${resumeSessionId}, got ${sessionRef})`
2464
+ });
1822
2465
  }
1823
- return new ClaudeSession(sessionRef, client, ctx.workspaceDir, ctx.approvalChannel, approvalMcpConfigDir);
2466
+ return new ClaudeSession(
2467
+ sessionRef,
2468
+ client,
2469
+ startInput.manifest.workspace.workspaceDir,
2470
+ startInput.approvalChannel,
2471
+ mcpConfigDir,
2472
+ manifestModelId
2473
+ );
1824
2474
  }
1825
2475
  /**
1826
2476
  * `claude auth status --json` is claude's OWN non-secret login-state
@@ -1854,20 +2504,32 @@ var ClaudeAdapter = class {
1854
2504
  return (this.options.resolveBin ?? resolveClaudeBin)();
1855
2505
  }
1856
2506
  };
2507
+ function subscriptionModel(selection, runtimeId) {
2508
+ if (selection === void 0) return void 0;
2509
+ if (selection.lane !== "subscription" || selection.runtimeId !== runtimeId) {
2510
+ throw new PolicyUnsupportedError(
2511
+ `claude adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
2512
+ );
2513
+ }
2514
+ return selection.modelId;
2515
+ }
1857
2516
  var ClaudeSession = class {
1858
- constructor(sessionRef, client, workspaceDir, approvalChannel, approvalMcpConfigDir) {
2517
+ constructor(sessionRef, client, workspaceDir, approvalChannel, mcpConfigDir, modelId) {
1859
2518
  this.sessionRef = sessionRef;
1860
2519
  this.client = client;
1861
2520
  this.workspaceDir = workspaceDir;
1862
2521
  this.approvalChannel = approvalChannel;
1863
- this.approvalMcpConfigDir = approvalMcpConfigDir;
2522
+ this.mcpConfigDir = mcpConfigDir;
2523
+ this.modelId = modelId;
1864
2524
  }
1865
2525
  sessionRef;
1866
2526
  client;
1867
2527
  workspaceDir;
1868
2528
  approvalChannel;
1869
- approvalMcpConfigDir;
2529
+ mcpConfigDir;
2530
+ modelId;
1870
2531
  correlation = createToolUseCorrelation();
2532
+ closeAttempt;
1871
2533
  get events() {
1872
2534
  const client = this.client;
1873
2535
  const correlation = this.correlation;
@@ -1876,17 +2538,40 @@ var ClaudeSession = class {
1876
2538
  [Symbol.asyncIterator]() {
1877
2539
  const inner = client.events[Symbol.asyncIterator]();
1878
2540
  let pending = [];
2541
+ let terminalFailure;
1879
2542
  let turnSettled = false;
1880
2543
  return {
1881
2544
  async next() {
1882
2545
  for (; ; ) {
1883
2546
  const buffered = pending.shift();
1884
2547
  if (buffered) return { value: buffered, done: false };
1885
- if (turnSettled) return { value: void 0, done: true };
1886
- const { value, done } = await inner.next();
1887
- if (done) return { value: void 0, done: true };
2548
+ if (turnSettled) {
2549
+ if (terminalFailure) throw terminalFailure;
2550
+ return { value: void 0, done: true };
2551
+ }
2552
+ let raw;
2553
+ try {
2554
+ raw = await inner.next();
2555
+ } catch (cause) {
2556
+ throw new RuntimeExecutionFailure({
2557
+ phase: "run",
2558
+ category: "infrastructure",
2559
+ retry: "retryable",
2560
+ reason: "claude runtime event transport failed"
2561
+ }, { cause });
2562
+ }
2563
+ const { value, done } = raw;
2564
+ if (done) {
2565
+ throw new RuntimeExecutionFailure({
2566
+ phase: "run",
2567
+ category: "infrastructure",
2568
+ retry: "retryable",
2569
+ reason: "claude runtime process ended before a terminal result frame"
2570
+ }, { cause: client.terminalError });
2571
+ }
1888
2572
  if (value.type === "result") turnSettled = true;
1889
2573
  const mapped = mapClaudeMessageToAgentEvents(value, correlation, { workspaceDir });
2574
+ terminalFailure = mapped.terminalFailure ?? terminalFailure;
1890
2575
  if (mapped.unmappedLabel) {
1891
2576
  client.recordUnmappedFrame(mapped.unmappedLabel);
1892
2577
  }
@@ -1917,6 +2602,12 @@ var ClaudeSession = class {
1917
2602
  if (typeof task.instruction !== "string") {
1918
2603
  throw new PolicyUnsupportedError("claude adapter only supports string instructions in M2 (no blob-ref fetch yet)");
1919
2604
  }
2605
+ const requestedModel = subscriptionModel(task.dispatchSelection, "claude");
2606
+ if (requestedModel !== void 0 && requestedModel !== this.modelId) {
2607
+ throw new PolicyUnsupportedError(
2608
+ `claude persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
2609
+ );
2610
+ }
1920
2611
  this.client.writeUserMessage(task.instruction);
1921
2612
  }
1922
2613
  /**
@@ -1935,8 +2626,17 @@ var ClaudeSession = class {
1935
2626
  this.client.kill();
1936
2627
  }
1937
2628
  async close() {
1938
- this.client.kill();
1939
- await cleanupApprovalMcpConfigDir(this.approvalMcpConfigDir);
2629
+ if (!this.closeAttempt) {
2630
+ const attempt = (async () => {
2631
+ await this.client.dispose();
2632
+ await cleanupMcpConfigDir(this.mcpConfigDir);
2633
+ })();
2634
+ this.closeAttempt = attempt.catch((error) => {
2635
+ this.closeAttempt = void 0;
2636
+ throw error;
2637
+ });
2638
+ }
2639
+ await this.closeAttempt;
1940
2640
  }
1941
2641
  /**
1942
2642
  * M4 Phase 3: routes into the out-of-band approval channel `start()`
@@ -2153,14 +2853,15 @@ var CodexProcessRunner = class {
2153
2853
  exitSignal = null;
2154
2854
  closedPromise;
2155
2855
  resolveClosed;
2856
+ disposalAttempt;
2156
2857
  constructor(options) {
2157
2858
  this.onEvent = options.onEvent;
2158
2859
  const spawnFn = options.spawnFn ?? spawn;
2159
- this.child = spawnFn(options.command, options.args, {
2860
+ this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
2160
2861
  cwd: options.cwd,
2161
2862
  env: options.env,
2162
2863
  stdio: ["ignore", "pipe", "pipe"]
2163
- });
2864
+ }));
2164
2865
  this.closedPromise = new Promise((resolve) => {
2165
2866
  this.resolveClosed = resolve;
2166
2867
  });
@@ -2190,7 +2891,7 @@ var CodexProcessRunner = class {
2190
2891
  return this.closed;
2191
2892
  }
2192
2893
  /**
2193
- * Best-effort teardown. SIGTERM on POSIX: SIGINT was empirically confirmed
2894
+ * Immediate tree termination request. SIGTERM on POSIX: SIGINT was empirically confirmed
2194
2895
  * to be silently ignored by `codex exec` (a real, direct test — a 60s
2195
2896
  * shell `sleep` ran to full, unaffected completion despite SIGINT sent at
2196
2897
  * t=4s) — a genuine, evidence-based correction to this task's own initial
@@ -2203,13 +2904,25 @@ var CodexProcessRunner = class {
2203
2904
  * `../pi/rpc-client.ts`'s own cross-platform convention.
2204
2905
  */
2205
2906
  kill() {
2206
- if (this.closed) return;
2207
- const pid = this.child.pid;
2208
- if (process.platform === "win32" && pid !== void 0) {
2209
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"]);
2210
- } else {
2211
- this.child.kill("SIGTERM");
2907
+ requestOwnedProcessTreeTermination(this.processTreeOptions());
2908
+ }
2909
+ dispose() {
2910
+ if (!this.disposalAttempt) {
2911
+ const attempt = disposeOwnedProcessTree(this.processTreeOptions());
2912
+ this.disposalAttempt = attempt.catch((error) => {
2913
+ this.disposalAttempt = void 0;
2914
+ throw error;
2915
+ });
2212
2916
  }
2917
+ return this.disposalAttempt;
2918
+ }
2919
+ processTreeOptions() {
2920
+ return {
2921
+ child: this.child,
2922
+ waitClosed: () => this.closedPromise,
2923
+ isClosed: () => this.closed,
2924
+ label: "codex"
2925
+ };
2213
2926
  }
2214
2927
  /** 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. */
2215
2928
  buildExitError(context) {
@@ -2260,7 +2973,17 @@ var CodexAdapter = class {
2260
2973
  this.options = options;
2261
2974
  }
2262
2975
  options;
2263
- id = "codex";
2976
+ descriptor = freezeRuntimeAdapterDescriptor({
2977
+ id: "codex",
2978
+ supportsDispatchSelection: true,
2979
+ capabilities: {
2980
+ steer: false,
2981
+ resume: true,
2982
+ approvalInteractive: false,
2983
+ permissionModes: ["auto", "readonly"]
2984
+ },
2985
+ environmentRequirements: { credentialNames: [] }
2986
+ });
2264
2987
  async detect() {
2265
2988
  const bin = this.resolveBin();
2266
2989
  try {
@@ -2309,57 +3032,100 @@ ${result.stderr}`);
2309
3032
  ${withStreams.stderr ?? ""}`);
2310
3033
  }
2311
3034
  }
2312
- capabilities() {
2313
- return { steer: false, resume: true, approvalInteractive: false, permissionModes: ["auto", "readonly"] };
2314
- }
2315
- /**
2316
- * M5: same deliberate posture as the claude adapter (see its own doc
2317
- * comment) — codex authenticates via its own `codex login`-managed
2318
- * ChatGPT OAuth session (`probeAuthPresent` above), not an env var, so
2319
- * there is no credential env var this adapter needs forwarded; env-based
2320
- * API-key passthrough remains a separate, pending product decision. No
2321
- * `baseNames` either: nothing in this adapter reads a codex-specific
2322
- * config-discovery variable (e.g. `CODEX_HOME`) today.
2323
- */
2324
- environmentRequirements() {
2325
- return { credentialNames: [] };
2326
- }
2327
- async start(task, ctx) {
2328
- if (typeof task.instruction !== "string") {
2329
- throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
3035
+ async prepare(input) {
3036
+ const mapping = mapPermissionPolicyToCodexArgs(input.policy);
3037
+ if (!mapping.ok) return { kind: "reject", reason: mapping.reason ?? "policy rejected by codex adapter", retryable: false };
3038
+ let modelId;
3039
+ try {
3040
+ modelId = subscriptionModel2(input.offer.dispatchSelection);
3041
+ } catch (error) {
3042
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: false };
2330
3043
  }
2331
- const mapping = mapPermissionPolicyToCodexArgs(ctx.policy);
2332
- if (!mapping.ok) {
2333
- throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
3044
+ let command;
3045
+ try {
3046
+ command = this.resolveBin().command;
3047
+ } catch (error) {
3048
+ return { kind: "reject", reason: error instanceof Error ? error.message : String(error), retryable: true };
3049
+ }
3050
+ return {
3051
+ kind: "prepared",
3052
+ operation: {
3053
+ start: (startInput) => this.startPrepared(startInput, mapping.args, modelId, command)
3054
+ }
3055
+ };
3056
+ }
3057
+ async startPrepared(startInput, policyArgs, modelId, command) {
3058
+ if (typeof startInput.instruction !== "string") {
3059
+ throw new RuntimeExecutionFailure({
3060
+ phase: "start",
3061
+ category: "authority",
3062
+ retry: "non-retryable",
3063
+ reason: "prepared codex operation requires a resolved string instruction"
3064
+ });
2334
3065
  }
2335
- const bin = this.resolveBin();
2336
3066
  const queue = new AsyncQueue();
3067
+ const terminal = {};
2337
3068
  const recordUnmapped = makeUnmappedFrameRecorder(/* @__PURE__ */ new Map());
2338
- const workspaceDir = await resolveRealWorkspaceDir(ctx.workspaceDir);
3069
+ let workspaceDir;
3070
+ try {
3071
+ workspaceDir = await resolveRealWorkspaceDir(startInput.manifest.workspace.workspaceDir);
3072
+ } catch (cause) {
3073
+ throw new RuntimeExecutionFailure({
3074
+ phase: "start",
3075
+ category: "infrastructure",
3076
+ retry: "retryable",
3077
+ reason: "codex runtime workspace could not be resolved"
3078
+ }, { cause });
3079
+ }
3080
+ const runtimeEnv = withoutProviderCredentials(startInput.env);
3081
+ let manifestModelId;
3082
+ try {
3083
+ manifestModelId = subscriptionModel2(startInput.manifest.dispatchSelection);
3084
+ } catch (cause) {
3085
+ throw new RuntimeExecutionFailure({
3086
+ phase: "start",
3087
+ category: "authority",
3088
+ retry: "non-retryable",
3089
+ reason: "prepared codex operation received an invalid runtime selection manifest"
3090
+ }, { cause });
3091
+ }
3092
+ if (manifestModelId !== modelId) {
3093
+ throw new RuntimeExecutionFailure({
3094
+ phase: "start",
3095
+ category: "authority",
3096
+ retry: "non-retryable",
3097
+ reason: "prepared codex operation received a manifest with different runtime selection"
3098
+ });
3099
+ }
2339
3100
  const { sessionRef, runner } = await runCodexTurn({
2340
- command: bin.command,
2341
- resumeRef: task.sessionRef,
2342
- instruction: task.instruction,
2343
- policyArgs: mapping.args,
2344
- cwd: ctx.workspaceDir,
2345
- env: ctx.env,
3101
+ command,
3102
+ resumeRef: startInput.manifest.sessionRef,
3103
+ instruction: startInput.instruction,
3104
+ modelId: manifestModelId,
3105
+ policyArgs: [...policyArgs],
3106
+ cwd: startInput.manifest.workspace.workspaceDir,
3107
+ env: runtimeEnv,
2346
3108
  spawnFn: this.options.spawnFn,
2347
3109
  workspaceDir,
2348
3110
  queue,
2349
3111
  recordUnmapped,
2350
- expectedSessionRef: task.sessionRef,
2351
- preparedGit: ctx.gitWorkspace !== void 0
3112
+ expectedSessionRef: startInput.manifest.sessionRef,
3113
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3114
+ failurePhase: "start",
3115
+ terminal
2352
3116
  });
2353
3117
  return new CodexSession({
2354
3118
  sessionRef,
2355
- command: bin.command,
3119
+ command,
2356
3120
  workspaceDir,
2357
- env: ctx.env,
3121
+ env: startInput.env,
2358
3122
  spawnFn: this.options.spawnFn,
2359
3123
  queue,
2360
3124
  recordUnmapped,
2361
3125
  initialRunner: runner,
2362
- preparedGit: ctx.gitWorkspace !== void 0
3126
+ preparedGit: startInput.manifest.workspace.workspaceId !== void 0,
3127
+ modelId: manifestModelId,
3128
+ terminal
2363
3129
  });
2364
3130
  }
2365
3131
  resolveBin() {
@@ -2380,12 +3146,25 @@ function makeUnmappedFrameRecorder(counts) {
2380
3146
  }
2381
3147
  };
2382
3148
  }
2383
- function buildArgv(resumeRef, policyArgs, instruction, preparedGit = false) {
3149
+ function buildArgv(resumeRef, policyArgs, instruction, modelId, preparedGit = false) {
2384
3150
  const base = resumeRef !== void 0 ? ["exec", "resume", resumeRef] : ["exec"];
2385
- return [...base, "--json", ...preparedGit ? [] : ["--skip-git-repo-check"], ...policyArgs, instruction];
3151
+ return [
3152
+ ...base,
3153
+ "--json",
3154
+ ...modelId ? ["--model", modelId] : [],
3155
+ ...preparedGit ? [] : ["--skip-git-repo-check"],
3156
+ ...policyArgs,
3157
+ instruction
3158
+ ];
2386
3159
  }
2387
3160
  async function runCodexTurn(params) {
2388
- const argv = buildArgv(params.resumeRef, params.policyArgs, params.instruction, params.preparedGit);
3161
+ const argv = buildArgv(
3162
+ params.resumeRef,
3163
+ params.policyArgs,
3164
+ params.instruction,
3165
+ params.modelId,
3166
+ params.preparedGit
3167
+ );
2389
3168
  let firstLineSettled = false;
2390
3169
  let resolveFirstLine;
2391
3170
  let rejectFirstLine;
@@ -2394,37 +3173,69 @@ async function runCodexTurn(params) {
2394
3173
  rejectFirstLine = reject;
2395
3174
  });
2396
3175
  let turnEnded = false;
2397
- const runner = new CodexProcessRunner({
2398
- command: params.command,
2399
- args: argv,
2400
- cwd: params.cwd,
2401
- env: params.env,
2402
- spawnFn: params.spawnFn,
2403
- onEvent: (evt) => {
2404
- if (!firstLineSettled) {
2405
- firstLineSettled = true;
2406
- if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
2407
- resolveFirstLine(evt.thread_id);
2408
- } else {
2409
- rejectFirstLine(
2410
- new Error(`codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`)
2411
- );
3176
+ let runner;
3177
+ try {
3178
+ runner = new CodexProcessRunner({
3179
+ command: params.command,
3180
+ args: argv,
3181
+ cwd: params.cwd,
3182
+ env: params.env,
3183
+ spawnFn: params.spawnFn,
3184
+ onEvent: (evt) => {
3185
+ if (!firstLineSettled) {
3186
+ firstLineSettled = true;
3187
+ if (evt.type === "thread.started" && typeof evt.thread_id === "string" && evt.thread_id.length > 0) {
3188
+ resolveFirstLine(evt.thread_id);
3189
+ } else {
3190
+ rejectFirstLine(
3191
+ new RuntimeExecutionFailure({
3192
+ phase: params.failurePhase,
3193
+ category: "authority",
3194
+ retry: "non-retryable",
3195
+ reason: `codex did not yield thread.started as its first event (got ${JSON.stringify(evt).slice(0, 200)})`
3196
+ })
3197
+ );
3198
+ }
3199
+ return;
3200
+ }
3201
+ const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
3202
+ for (const agentEvent of mapped) {
3203
+ if (agentEvent.type === "turn_end") turnEnded = true;
3204
+ params.queue.push(agentEvent);
3205
+ }
3206
+ if (evt.type === "turn.failed") {
3207
+ params.terminal.failure = new RuntimeExecutionFailure({
3208
+ phase: "run",
3209
+ category: "semantic",
3210
+ retry: "non-retryable",
3211
+ reason: "codex reported terminal task failure"
3212
+ });
3213
+ params.queue.end();
3214
+ }
3215
+ if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
3216
+ params.recordUnmapped(unmappedFrameKey(evt));
2412
3217
  }
2413
- return;
2414
- }
2415
- const mapped = mapCodexEventToAgentEvents(evt, params.workspaceDir);
2416
- for (const agentEvent of mapped) {
2417
- if (agentEvent.type === "turn_end") turnEnded = true;
2418
- params.queue.push(agentEvent);
2419
- }
2420
- if (mapped.length === 0 && !isRoutineCodexEvent(evt)) {
2421
- params.recordUnmapped(unmappedFrameKey(evt));
2422
3218
  }
2423
- }
2424
- });
3219
+ });
3220
+ } catch (cause) {
3221
+ throw new RuntimeExecutionFailure({
3222
+ phase: params.failurePhase,
3223
+ category: "infrastructure",
3224
+ retry: "retryable",
3225
+ reason: "codex runtime process could not be spawned"
3226
+ }, { cause });
3227
+ }
3228
+ params.onRunnerCreated?.(runner);
2425
3229
  void runner.waitClosed().then(() => {
2426
- if (turnEnded) return;
2427
- params.queue.push({ type: "error", message: runner.buildExitError("codex exited without completing the turn").message });
3230
+ if (turnEnded || params.terminal.failure) return;
3231
+ const cause = runner.buildExitError("codex exited without completing the turn");
3232
+ params.terminal.failure = new RuntimeExecutionFailure({
3233
+ phase: "run",
3234
+ category: "infrastructure",
3235
+ retry: "retryable",
3236
+ reason: cause.message
3237
+ }, { cause });
3238
+ params.queue.push({ type: "error", message: cause.message });
2428
3239
  params.queue.end();
2429
3240
  });
2430
3241
  let sessionRef;
@@ -2448,7 +3259,13 @@ async function runCodexTurn(params) {
2448
3259
  void runner.waitClosed().then(() => {
2449
3260
  if (!settled) {
2450
3261
  settled = true;
2451
- reject(runner.buildExitError("codex exited before yielding an authoritative thread id"));
3262
+ const cause = runner.buildExitError("codex exited before yielding an authoritative thread id");
3263
+ reject(new RuntimeExecutionFailure({
3264
+ phase: params.failurePhase,
3265
+ category: "infrastructure",
3266
+ retry: "retryable",
3267
+ reason: cause.message
3268
+ }, { cause }));
2452
3269
  }
2453
3270
  });
2454
3271
  });
@@ -2458,9 +3275,12 @@ async function runCodexTurn(params) {
2458
3275
  }
2459
3276
  if (params.expectedSessionRef !== void 0 && sessionRef !== params.expectedSessionRef) {
2460
3277
  runner.kill();
2461
- throw new Error(
2462
- `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)`
2463
- );
3278
+ throw new RuntimeExecutionFailure({
3279
+ phase: params.failurePhase,
3280
+ category: "authority",
3281
+ retry: "non-retryable",
3282
+ reason: `codex exec resume echoed a different thread id than requested (requested ${params.expectedSessionRef}, got ${sessionRef})`
3283
+ });
2464
3284
  }
2465
3285
  return { sessionRef, runner };
2466
3286
  }
@@ -2486,8 +3306,13 @@ var CodexSession = class {
2486
3306
  queue;
2487
3307
  recordUnmapped;
2488
3308
  preparedGit;
3309
+ modelId;
3310
+ terminal;
2489
3311
  currentRunner;
3312
+ ownedRunners = /* @__PURE__ */ new Set();
3313
+ followUpAttempts = /* @__PURE__ */ new Set();
2490
3314
  closed = false;
3315
+ closeAttempt;
2491
3316
  constructor(options) {
2492
3317
  this.sessionRef = options.sessionRef;
2493
3318
  this.command = options.command;
@@ -2497,11 +3322,37 @@ var CodexSession = class {
2497
3322
  this.queue = options.queue;
2498
3323
  this.recordUnmapped = options.recordUnmapped;
2499
3324
  this.preparedGit = options.preparedGit;
3325
+ this.modelId = options.modelId;
3326
+ this.terminal = options.terminal;
2500
3327
  this.currentRunner = options.initialRunner;
3328
+ this.ownedRunners.add(options.initialRunner);
2501
3329
  void this.forgetRunnerOnceClosed(options.initialRunner);
2502
3330
  }
2503
3331
  get events() {
2504
- return this.queue;
3332
+ const queue = this.queue;
3333
+ const session = this;
3334
+ return {
3335
+ [Symbol.asyncIterator]() {
3336
+ const inner = queue[Symbol.asyncIterator]();
3337
+ return {
3338
+ async next() {
3339
+ let result;
3340
+ try {
3341
+ result = await inner.next();
3342
+ } catch (cause) {
3343
+ throw new RuntimeExecutionFailure({
3344
+ phase: "run",
3345
+ category: "infrastructure",
3346
+ retry: "retryable",
3347
+ reason: "codex runtime event transport failed"
3348
+ }, { cause });
3349
+ }
3350
+ if (result.done && session.terminal.failure) throw session.terminal.failure;
3351
+ return result;
3352
+ }
3353
+ };
3354
+ }
3355
+ };
2505
3356
  }
2506
3357
  async forgetRunnerOnceClosed(runner) {
2507
3358
  await runner.waitClosed();
@@ -2546,7 +3397,14 @@ var CodexSession = class {
2546
3397
  * stale id even after codex had moved on) — it just can now only ever be
2547
3398
  * the SAME id this call asked to resume, never a silently-different one.
2548
3399
  */
2549
- async followUp(task) {
3400
+ followUp(task) {
3401
+ const attempt = this.runFollowUp(task);
3402
+ this.followUpAttempts.add(attempt);
3403
+ void attempt.finally(() => this.followUpAttempts.delete(attempt)).catch(() => {
3404
+ });
3405
+ return attempt;
3406
+ }
3407
+ async runFollowUp(task) {
2550
3408
  if (typeof task.instruction !== "string") {
2551
3409
  throw new PolicyUnsupportedError("codex adapter only supports string instructions in M2 (no blob-ref fetch yet)");
2552
3410
  }
@@ -2557,41 +3415,79 @@ var CodexSession = class {
2557
3415
  if (!mapping.ok) {
2558
3416
  throw new PolicyUnsupportedError(mapping.reason ?? "policy rejected by codex adapter");
2559
3417
  }
3418
+ const requestedModel = subscriptionModel2(task.dispatchSelection);
3419
+ if (requestedModel !== void 0 && requestedModel !== this.modelId) {
3420
+ throw new PolicyUnsupportedError(
3421
+ `codex persistent session cannot change model from ${this.modelId ?? "(legacy default)"} to ${requestedModel}`
3422
+ );
3423
+ }
3424
+ const modelId = this.modelId;
2560
3425
  const resumeRef = this.sessionRef;
2561
3426
  let sessionRef;
2562
3427
  let runner;
3428
+ const terminal = {};
2563
3429
  try {
2564
3430
  ({ sessionRef, runner } = await runCodexTurn({
2565
3431
  command: this.command,
2566
3432
  resumeRef,
2567
3433
  instruction: task.instruction,
3434
+ modelId,
2568
3435
  policyArgs: mapping.args,
2569
3436
  cwd: this.workspaceDir,
2570
- env: this.env,
3437
+ env: withoutProviderCredentials(this.env),
2571
3438
  spawnFn: this.spawnFn,
2572
3439
  workspaceDir: this.workspaceDir,
2573
3440
  queue: this.queue,
2574
3441
  recordUnmapped: this.recordUnmapped,
2575
3442
  expectedSessionRef: resumeRef,
2576
- preparedGit: this.preparedGit
3443
+ preparedGit: this.preparedGit,
3444
+ failurePhase: "run",
3445
+ terminal,
3446
+ onRunnerCreated: (created) => {
3447
+ this.ownedRunners.add(created);
3448
+ void this.forgetRunnerOnceClosed(created);
3449
+ }
2577
3450
  }));
2578
3451
  } catch (err) {
2579
3452
  this.queue.end();
3453
+ this.terminal = {
3454
+ failure: isRuntimeExecutionFailure(err) ? err : new RuntimeExecutionFailure({
3455
+ phase: "run",
3456
+ category: "authority",
3457
+ retry: "non-retryable",
3458
+ reason: "codex follow-up violated the runtime adapter contract"
3459
+ }, { cause: err })
3460
+ };
2580
3461
  throw err;
2581
3462
  }
3463
+ if (this.closed) {
3464
+ await runner.dispose();
3465
+ throw new Error("codex session closed while follow-up was starting");
3466
+ }
3467
+ this.terminal = terminal;
2582
3468
  this.sessionRef = sessionRef;
2583
3469
  this.currentRunner = runner;
2584
- void this.forgetRunnerOnceClosed(runner);
2585
3470
  }
2586
3471
  /** 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. */
2587
3472
  async interrupt() {
2588
3473
  this.currentRunner?.kill();
2589
3474
  }
2590
3475
  async close() {
2591
- if (this.closed) return;
2592
- this.closed = true;
2593
- this.currentRunner?.kill();
2594
- this.queue.end();
3476
+ if (!this.closeAttempt) {
3477
+ this.closed = true;
3478
+ this.queue.end();
3479
+ const attempt = (async () => {
3480
+ const runners = [...this.ownedRunners];
3481
+ await Promise.all(runners.map((runner) => runner.dispose()));
3482
+ for (const runner of runners) this.ownedRunners.delete(runner);
3483
+ await Promise.allSettled([...this.followUpAttempts]);
3484
+ })();
3485
+ this.closeAttempt = attempt.catch((error) => {
3486
+ this.closeAttempt = void 0;
3487
+ throw error;
3488
+ });
3489
+ }
3490
+ await this.closeAttempt;
2595
3491
  }
2596
3492
  /**
2597
3493
  * `codex exec` has no in-band channel to inject text into an already-
@@ -2627,6 +3523,15 @@ var CodexSession = class {
2627
3523
  );
2628
3524
  }
2629
3525
  };
3526
+ function subscriptionModel2(selection) {
3527
+ if (selection === void 0) return void 0;
3528
+ if (selection.lane !== "subscription" || selection.runtimeId !== "codex") {
3529
+ throw new PolicyUnsupportedError(
3530
+ `codex adapter cannot execute ${selection.lane} selection for runtime ${selection.runtimeId}`
3531
+ );
3532
+ }
3533
+ return selection.modelId;
3534
+ }
2630
3535
 
2631
3536
  // src/daemon/approvals.ts
2632
3537
  var ApprovalNotFoundError = class extends Error {
@@ -2683,7 +3588,7 @@ var ApprovalRegistry = class {
2683
3588
  * `requestApproval` timeout and `finish()` fail-closed cleanup
2684
3589
  * (`task-runner.ts`) — resolves a decision this device made on its own.
2685
3590
  * The one exception, a server-sent wire `task.approve`/`task.reject`
2686
- * relayed through `TaskContext.approvalChannel.resolve`
3591
+ * relayed through `RuntimeOperationStartInput.approvalChannel.resolve`
2687
3592
  * (`task-runner.ts`'s `handleOffer`), passes `'wire'` explicitly.
2688
3593
  */
2689
3594
  resolve(approvalId, decision, reason, origin = "local") {
@@ -2851,13 +3756,10 @@ function exportPrivateKeyPem(privateKey) {
2851
3756
  function importPrivateKeyPem(pem) {
2852
3757
  return createPrivateKey(pem);
2853
3758
  }
2854
- var NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
2855
3759
  function signNonce(privateKey, nonce) {
2856
- const signature = sign(null, Buffer.from(NONCE_SIGNING_DOMAIN + nonce, "utf8"), privateKey);
3760
+ const signature = sign(null, nonceSigningBytes(nonce), privateKey);
2857
3761
  return signature.toString("base64url");
2858
3762
  }
2859
-
2860
- // src/daemon/url.ts
2861
3763
  function toHttpBase(serverUrl) {
2862
3764
  const url = new URL(serverUrl);
2863
3765
  if (url.protocol === "ws:") url.protocol = "http:";
@@ -2867,7 +3769,7 @@ function toHttpBase(serverUrl) {
2867
3769
  return url.toString();
2868
3770
  }
2869
3771
  function toWsUrl(serverUrl) {
2870
- const url = new URL("/byok/ws", toHttpBase(serverUrl));
3772
+ const url = new URL(BYOK_WS_PATH, toHttpBase(serverUrl));
2871
3773
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
2872
3774
  return url.toString();
2873
3775
  }
@@ -2955,7 +3857,7 @@ var AuthManager = class {
2955
3857
  return await this.runCredentialMutation(async () => {
2956
3858
  const existing = this.record ?? await this.opts.store.load();
2957
3859
  const keyPair = existing ? { privateKey: importPrivateKeyPem(existing.devicePrivateKeyPem), publicKeyBase64Url: existing.devicePublicKey } : generateDeviceKeyPair();
2958
- const url = new URL("/byok/pair", toHttpBase(this.opts.serverUrl));
3860
+ const url = new URL(BYOK_PAIR_PATH, toHttpBase(this.opts.serverUrl));
2959
3861
  const res = await fetch(url, {
2960
3862
  method: "POST",
2961
3863
  headers: { "content-type": "application/json" },
@@ -2989,6 +3891,7 @@ var AuthManager = class {
2989
3891
  async getValidAccessToken() {
2990
3892
  if (!this.record) throw new Error("device is not paired yet; call pair(pairingCode) first");
2991
3893
  if (this.revoked) throw new DeviceRevokedError();
3894
+ if (this.renewing) return this.renewing;
2992
3895
  if (msUntilExpiry(this.record.expiresAt) > RENEW_MARGIN_MS) return this.record.accessToken;
2993
3896
  return this.renew();
2994
3897
  }
@@ -3017,7 +3920,7 @@ var AuthManager = class {
3017
3920
  const record = this.record;
3018
3921
  const base = toHttpBase(this.opts.serverUrl);
3019
3922
  const privateKey = importPrivateKeyPem(record.devicePrivateKeyPem);
3020
- const challengeRes = await fetch(new URL("/byok/challenge", base), {
3923
+ const challengeRes = await fetch(new URL(BYOK_CHALLENGE_PATH, base), {
3021
3924
  method: "POST",
3022
3925
  headers: { "content-type": "application/json" },
3023
3926
  body: JSON.stringify({ deviceId: record.deviceId })
@@ -3030,7 +3933,7 @@ var AuthManager = class {
3030
3933
  }
3031
3934
  const { nonce } = await challengeRes.json();
3032
3935
  const signature = signNonce(privateKey, nonce);
3033
- const tokenRes = await fetch(new URL("/byok/token", base), {
3936
+ const tokenRes = await fetch(new URL(BYOK_TOKEN_PATH, base), {
3034
3937
  method: "POST",
3035
3938
  headers: { "content-type": "application/json" },
3036
3939
  body: JSON.stringify({ deviceId: record.deviceId, nonce, signature })
@@ -3128,7 +4031,7 @@ var BlobClient = class {
3128
4031
  async resolveInstruction(blobRef) {
3129
4032
  const base = toHttpBase(this.serverUrl);
3130
4033
  const urlRes = await authedFetch(
3131
- new URL(`/byok/blobs/${encodeURIComponent(blobRef.blobId)}/url`, base),
4034
+ new URL(byokBlobUrlPath(blobRef.blobId), base),
3132
4035
  { method: "GET" },
3133
4036
  this.auth
3134
4037
  );
@@ -3156,7 +4059,7 @@ var BlobClient = class {
3156
4059
  const base = toHttpBase(this.serverUrl);
3157
4060
  const reservationId = `blob_${randomUUID()}`;
3158
4061
  const createRes = await authedFetch(
3159
- new URL("/byok/blobs", base),
4062
+ new URL(BYOK_BLOBS_PATH, base),
3160
4063
  {
3161
4064
  method: "POST",
3162
4065
  headers: {
@@ -3188,7 +4091,7 @@ var BlobClient = class {
3188
4091
  let response;
3189
4092
  try {
3190
4093
  response = await authedFetch(
3191
- new URL(`/byok/blobs/${encodeURIComponent(blobId)}/finalize`, base),
4094
+ new URL(byokBlobFinalizePath(blobId), base),
3192
4095
  {
3193
4096
  method: "POST",
3194
4097
  headers: { "idempotency-key": reservationId }
@@ -3211,16 +4114,180 @@ var BlobClient = class {
3211
4114
  throw lastFailure;
3212
4115
  }
3213
4116
  };
4117
+ var PRESENCE_HINTS_CAPABILITY = "presence.hints";
4118
+ var CapabilityDiscoveryError = class extends Error {
4119
+ constructor(message, options) {
4120
+ super(message, options);
4121
+ this.name = "CapabilityDiscoveryError";
4122
+ }
4123
+ };
4124
+ async function fetchCapabilityDeclaration(serverUrl, options = {}) {
4125
+ const url = new URL(BYOK_CAPABILITIES_PATH, toHttpBase(serverUrl));
4126
+ let response;
4127
+ try {
4128
+ response = await fetch(url, {
4129
+ method: "GET",
4130
+ ...options.signal === void 0 ? {} : { signal: options.signal }
4131
+ });
4132
+ } catch (err) {
4133
+ throw new CapabilityDiscoveryError(
4134
+ `failed to read the capability declaration from ${url.toString()}: ${err instanceof Error ? err.message : String(err)}`,
4135
+ { cause: err }
4136
+ );
4137
+ }
4138
+ if (!response.ok) {
4139
+ throw new CapabilityDiscoveryError(
4140
+ `failed to read the capability declaration from ${url.toString()}: HTTP ${response.status}`
4141
+ );
4142
+ }
4143
+ let body;
4144
+ try {
4145
+ body = await response.json();
4146
+ } catch (err) {
4147
+ throw new CapabilityDiscoveryError(
4148
+ `the capability declaration at ${url.toString()} is not JSON: ${err instanceof Error ? err.message : String(err)}`,
4149
+ { cause: err }
4150
+ );
4151
+ }
4152
+ const parsed = CapabilityDeclarationSchema.safeParse(body);
4153
+ if (!parsed.success) {
4154
+ throw new CapabilityDiscoveryError(
4155
+ `the capability declaration at ${url.toString()} is not a valid ADR-010 declaration: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ")}`,
4156
+ { cause: parsed.error }
4157
+ );
4158
+ }
4159
+ return parsed.data;
4160
+ }
4161
+ function declares(declaration, capability) {
4162
+ return hasCapability(declaration, capability);
4163
+ }
4164
+ var DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS = 3e4;
4165
+ var DEFAULT_PRESENCE_TTL_MS = 9e4;
4166
+ var DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS = 5e3;
4167
+ function assertPresenceHeartbeatCadence(cadence) {
4168
+ const { intervalMs, ttlMs, minimumIntervalMs } = cadence;
4169
+ if (!(minimumIntervalMs < intervalMs && intervalMs < ttlMs)) {
4170
+ throw new Error(
4171
+ `presence heartbeat interval must satisfy minimumIntervalMs < intervalMs < ttlMs \u2014 got ${minimumIntervalMs} < ${intervalMs} < ${ttlMs}`
4172
+ );
4173
+ }
4174
+ }
4175
+ var PresencePublisher = class {
4176
+ constructor(opts) {
4177
+ this.opts = opts;
4178
+ const intervalMs = opts.intervalMs ?? DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS;
4179
+ const ttlMs = opts.ttlMs ?? DEFAULT_PRESENCE_TTL_MS;
4180
+ const minimumIntervalMs = opts.minimumIntervalMs ?? DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS;
4181
+ assertPresenceHeartbeatCadence({ intervalMs, ttlMs, minimumIntervalMs });
4182
+ this.intervalMs = intervalMs;
4183
+ this.url = new URL(BYOK_PRESENCE_PATH, toHttpBase(opts.serverUrl));
4184
+ }
4185
+ opts;
4186
+ url;
4187
+ intervalMs;
4188
+ timer;
4189
+ running = false;
4190
+ /** Set once a revoked device is observed. Terminal: `start()` will not restart this instance. */
4191
+ stoppedPermanently = false;
4192
+ /** Publishes immediately, then every `intervalMs`. Idempotent; a no-op after a permanent stop. */
4193
+ start() {
4194
+ if (this.running || this.stoppedPermanently) return;
4195
+ this.running = true;
4196
+ void this.beat();
4197
+ }
4198
+ /** Stops the cadence. Idempotent, and the only "offline" signal this producer emits — the hint's TTL does the rest. */
4199
+ stop() {
4200
+ this.running = false;
4201
+ if (this.timer !== void 0) {
4202
+ clearTimeout(this.timer);
4203
+ this.timer = void 0;
4204
+ }
4205
+ }
4206
+ schedule() {
4207
+ if (!this.running) return;
4208
+ this.timer = setTimeout(() => {
4209
+ this.timer = void 0;
4210
+ void this.beat();
4211
+ }, this.intervalMs);
4212
+ this.timer.unref?.();
4213
+ }
4214
+ async beat() {
4215
+ if (!this.running) return;
4216
+ try {
4217
+ const response = await authedFetch(
4218
+ this.url,
4219
+ {
4220
+ method: "PUT",
4221
+ headers: { "content-type": "application/json" },
4222
+ body: JSON.stringify({
4223
+ level: "online",
4224
+ ...this.opts.configuredToolsets === void 0 ? {} : { configuredToolsets: this.opts.configuredToolsets }
4225
+ })
4226
+ },
4227
+ this.opts.auth
4228
+ );
4229
+ if (!response.ok) {
4230
+ if (response.status === 401) {
4231
+ this.stopPermanently(`presence heartbeat unauthorized after token renewal (HTTP 401)`);
4232
+ return;
4233
+ }
4234
+ this.opts.onDegraded?.(`presence heartbeat failed: HTTP ${response.status}`);
4235
+ }
4236
+ } catch (err) {
4237
+ if (err instanceof DeviceRevokedError) {
4238
+ this.stopPermanently("presence heartbeat stopped: device has been revoked; re-pair required");
4239
+ return;
4240
+ }
4241
+ this.opts.onDegraded?.(`presence heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
4242
+ }
4243
+ this.schedule();
4244
+ }
4245
+ stopPermanently(reason) {
4246
+ this.stoppedPermanently = true;
4247
+ this.stop();
4248
+ this.opts.onDegraded?.(reason);
4249
+ }
4250
+ };
4251
+ function freshJti() {
4252
+ return randomBytes(16).toString("base64url");
4253
+ }
4254
+ function mintDeviceAssertion(input) {
4255
+ const issuedAtMs = input.now.getTime();
4256
+ const expiresAt = new Date(issuedAtMs + input.ttlMs).toISOString();
4257
+ const claims = DeviceAssertionClaimsSchema.parse({
4258
+ version: 1,
4259
+ issuer: input.issuer,
4260
+ productId: input.productId,
4261
+ deviceId: input.record.deviceId,
4262
+ audience: input.audience,
4263
+ jti: freshJti(),
4264
+ issuedAt: new Date(issuedAtMs).toISOString(),
4265
+ expiresAt
4266
+ });
4267
+ const privateKey = importPrivateKeyPem(input.record.devicePrivateKeyPem);
4268
+ const signature = sign(null, deviceAssertionSigningInput(claims), privateKey).toString("base64url");
4269
+ return {
4270
+ envelope: {
4271
+ schema: DEVICE_ASSERTION_SCHEMA_ID,
4272
+ algorithm: "ed25519",
4273
+ protected: claims,
4274
+ signature
4275
+ },
4276
+ claims,
4277
+ expiresAt
4278
+ };
4279
+ }
3214
4280
  var CONTROL_PROTOCOL_VERSION = 1;
3215
4281
  var HANDSHAKE_TIMEOUT_MS = 3e3;
3216
4282
  var UNIX_SOCKET_PATH_SOFT_LIMIT = 100;
4283
+ var CONTROL_SOCKET_FALLBACK_ROOT = "/tmp";
3217
4284
  function shortHash(input) {
3218
4285
  return createHash("sha256").update(input, "utf8").digest("hex").slice(0, 16);
3219
4286
  }
3220
4287
  function controlSocketPath(storeDir) {
3221
4288
  const candidate = path20.join(storeDir, "control.sock");
3222
4289
  if (Buffer.byteLength(candidate, "utf8") <= UNIX_SOCKET_PATH_SOFT_LIMIT) return candidate;
3223
- return path20.join(os.tmpdir(), `byok-${shortHash(storeDir)}`, "sock");
4290
+ return path20.join(CONTROL_SOCKET_FALLBACK_ROOT, `byok-${shortHash(storeDir)}`, "sock");
3224
4291
  }
3225
4292
  function controlPipeName(productId, storeDir) {
3226
4293
  const id = shortHash(`${productId}|${path20.resolve(storeDir)}`);
@@ -3326,6 +4393,16 @@ function parseApprovalsRequestParams(value) {
3326
4393
  if (typeof value.summary !== "string") return void 0;
3327
4394
  return { taskId: value.taskId, summary: value.summary };
3328
4395
  }
4396
+ var ASSERTION_AUDIENCE_MAX_BYTES = 256;
4397
+ function parseAssertionIssueParams(value) {
4398
+ if (!isRecord2(value)) return void 0;
4399
+ const keys = Object.keys(value);
4400
+ if (keys.length !== 1 || keys[0] !== "audience") return void 0;
4401
+ const { audience } = value;
4402
+ if (typeof audience !== "string" || audience.length === 0) return void 0;
4403
+ if (Buffer.byteLength(audience, "utf8") > ASSERTION_AUDIENCE_MAX_BYTES) return void 0;
4404
+ return { audience };
4405
+ }
3329
4406
  function parseShutdownParams(value) {
3330
4407
  if (!isRecord2(value)) return {};
3331
4408
  return value.reason === "unpair" || value.reason === "operator" ? { reason: value.reason } : {};
@@ -3339,12 +4416,12 @@ var AnotherControlServerRunningError = class extends Error {
3339
4416
  }
3340
4417
  };
3341
4418
  var MAX_HALF_OPEN_CONNECTIONS = 8;
3342
- function errorMessage2(err) {
4419
+ function errorMessage3(err) {
3343
4420
  return err instanceof Error ? err.message : String(err);
3344
4421
  }
3345
4422
  function toControlErrorShape(err) {
3346
4423
  if (err instanceof ControlError) return { code: err.code, message: err.message };
3347
- return { code: "internal_error", message: errorMessage2(err) };
4424
+ return { code: "internal_error", message: errorMessage3(err) };
3348
4425
  }
3349
4426
  function probeUnixSocketAlive(socketPath) {
3350
4427
  return new Promise((resolve) => {
@@ -3551,12 +4628,19 @@ async function startControlServer(opts) {
3551
4628
  }
3552
4629
  throw err;
3553
4630
  }
4631
+ let stopServingPromise;
4632
+ async function stopServing() {
4633
+ stopServingPromise ??= (async () => {
4634
+ for (const socket of sockets) socket.destroy();
4635
+ await new Promise((resolve) => server.close(() => resolve()));
4636
+ })();
4637
+ await stopServingPromise;
4638
+ }
3554
4639
  let closed = false;
3555
4640
  async function close() {
3556
4641
  if (closed) return;
3557
4642
  closed = true;
3558
- for (const socket of sockets) socket.destroy();
3559
- await new Promise((resolve) => server.close(() => resolve()));
4643
+ await stopServing();
3560
4644
  if (process.platform !== "win32") {
3561
4645
  await promises.rm(endpoint, { force: true }).catch(() => {
3562
4646
  });
@@ -3564,7 +4648,7 @@ async function startControlServer(opts) {
3564
4648
  await promises.rm(tokenPath, { force: true }).catch(() => {
3565
4649
  });
3566
4650
  }
3567
- return { endpoint, close };
4651
+ return { endpoint, stopServing, close };
3568
4652
  }
3569
4653
  function deterministicJitterMs(input) {
3570
4654
  const ratio = input.ratio ?? 0.2;
@@ -3658,7 +4742,7 @@ var LongPollClient = class {
3658
4742
  try {
3659
4743
  const base = toHttpBase(this.opts.serverUrl);
3660
4744
  const res = await authedFetch(
3661
- new URL("/byok/messages", base),
4745
+ new URL(BYOK_MESSAGES_PATH, base),
3662
4746
  {
3663
4747
  method: "POST",
3664
4748
  headers: { "content-type": "application/json" },
@@ -3682,7 +4766,7 @@ var LongPollClient = class {
3682
4766
  while (this.running) {
3683
4767
  try {
3684
4768
  const base = toHttpBase(this.opts.serverUrl);
3685
- const url = new URL("/byok/events", base);
4769
+ const url = new URL(BYOK_EVENTS_PATH, base);
3686
4770
  const cursor = this.opts.getCursor();
3687
4771
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
3688
4772
  const res = await authedFetch(url, { method: "GET" }, this.opts.auth);
@@ -3858,6 +4942,7 @@ var WsTransport = class {
3858
4942
  deviceId: this.opts.deviceId,
3859
4943
  productId: this.opts.productId,
3860
4944
  runtimes: this.opts.runtimes,
4945
+ configuredToolsets: this.opts.configuredToolsets === void 0 ? void 0 : [...this.opts.configuredToolsets],
3861
4946
  cursor: this.opts.getCursor?.()
3862
4947
  });
3863
4948
  socket.send(encodeEnvelope(hello));
@@ -3947,6 +5032,7 @@ var ConnectionManager = class {
3947
5032
  productId: opts.productId,
3948
5033
  capabilities: opts.capabilities,
3949
5034
  runtimes: opts.runtimes,
5035
+ configuredToolsets: opts.configuredToolsets,
3950
5036
  getCursor: () => this.cursor,
3951
5037
  onEnvelope: (envelope) => this.deliver(envelope),
3952
5038
  onStateChange: (state) => {
@@ -4069,7 +5155,7 @@ var ConnectionManager = class {
4069
5155
  * already-delivered seqs (see its own doc comment) so the failed seq's own
4070
5156
  * redelivery can get through — but that same frozen watermark also means
4071
5157
  * every OTHER seq above it rides along on every re-poll too. Without this,
4072
- * a seq already mid-flight (e.g. a `task.offer` whose `adapter.start()`
5158
+ * a seq already mid-flight (e.g. a `task.offer` whose prepared operation start()
4073
5159
  * hasn't resolved yet) would be re-enqueued into `processingChain` on
4074
5160
  * every such re-poll, piling up duplicate copies that — once the first
4075
5161
  * finally resolves and the chain unwinds through them — run its handler
@@ -4945,19 +6031,25 @@ var RECLAIM_FILENAME = `${DAEMON_OWNER_FILENAME}.reclaim`;
4945
6031
  var MAX_OWNER_BYTES = 4096;
4946
6032
  var RECLAIM_MALFORMED_GRACE_MS = 3e4;
4947
6033
  var SELF_PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1e3).toISOString();
4948
- var STORE_MUTEX_PORT_BASE = 1e4;
4949
- var STORE_MUTEX_PORT_COUNT = 2e4;
4950
- var STORE_MUTEX_PORT_CANDIDATES = 32;
6034
+ function endOwnershipProbe(socket, response) {
6035
+ socket.on("error", () => {
6036
+ });
6037
+ if (response === void 0) socket.end();
6038
+ else socket.end(response);
6039
+ }
4951
6040
  var STORE_MUTEX_ID_PREFIX = "byok-store-mutex-v1:";
4952
6041
  var STORE_MUTEX_PROBE_TIMEOUT_MS = 1e3;
6042
+ var STORE_MUTEX_SOCKET_FILENAME = "mutex.sock";
6043
+ var UNIX_SOCKET_PATH_SOFT_LIMIT2 = 100;
6044
+ var STORE_MUTEX_FALLBACK_ROOT = "/tmp";
4953
6045
  function storeMutexIdentity(canonicalStoreDir) {
4954
6046
  return createHash("sha256").update(canonicalStoreDir).digest("hex");
4955
6047
  }
4956
- function storeMutexPort(identity, attempt) {
4957
- const digest = Buffer.from(identity, "hex");
4958
- const start = digest.readUInt32BE(0) % STORE_MUTEX_PORT_COUNT;
4959
- const step = 1 + digest.readUInt32BE(4) % (STORE_MUTEX_PORT_COUNT - 1);
4960
- return STORE_MUTEX_PORT_BASE + (start + attempt * step) % STORE_MUTEX_PORT_COUNT;
6048
+ function storeMutexEndpoint(canonicalStoreDir, identity, platform = process.platform) {
6049
+ if (platform === "win32") return `\\\\.\\pipe\\byok-store-mutex-${identity.slice(0, 16)}`;
6050
+ const candidate = path20.join(canonicalStoreDir, STORE_MUTEX_SOCKET_FILENAME);
6051
+ if (Buffer.byteLength(candidate, "utf8") <= UNIX_SOCKET_PATH_SOFT_LIMIT2) return candidate;
6052
+ return path20.join(STORE_MUTEX_FALLBACK_ROOT, `byok-store-mutex-${identity.slice(0, 16)}`, "sock");
4961
6053
  }
4962
6054
  var DaemonOwnerActiveError = class extends Error {
4963
6055
  constructor(role) {
@@ -5051,7 +6143,7 @@ async function portIsBound(port) {
5051
6143
  });
5052
6144
  }
5053
6145
  async function createLivenessListener() {
5054
- const server = createServer((socket) => socket.end());
6146
+ const server = createServer((socket) => endOwnershipProbe(socket));
5055
6147
  const port = await new Promise((resolve, reject) => {
5056
6148
  server.once("error", reject);
5057
6149
  server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
@@ -5078,70 +6170,90 @@ async function createLivenessListener() {
5078
6170
  })
5079
6171
  };
5080
6172
  }
5081
- async function probeStoreMutex(port) {
6173
+ async function probeStoreMutex(endpoint, identity) {
5082
6174
  return new Promise((resolve) => {
5083
- const socket = createConnection({ host: "127.0.0.1", port });
6175
+ const socket = createConnection(endpoint);
5084
6176
  let settled = false;
5085
6177
  let raw = "";
5086
6178
  const finish = (result) => {
5087
6179
  if (settled) return;
5088
6180
  settled = true;
6181
+ clearTimeout(timer);
6182
+ socket.removeAllListeners();
5089
6183
  socket.destroy();
5090
6184
  resolve(result);
5091
6185
  };
6186
+ const timer = setTimeout(() => finish({ kind: "occupied" }), STORE_MUTEX_PROBE_TIMEOUT_MS);
5092
6187
  socket.setEncoding("utf8");
5093
- socket.setTimeout(STORE_MUTEX_PROBE_TIMEOUT_MS, () => finish({ kind: "uncertain" }));
5094
6188
  socket.on("data", (chunk) => {
5095
6189
  raw += chunk;
5096
- if (raw.length > STORE_MUTEX_ID_PREFIX.length + 64 + 1) finish({ kind: "foreign-or-gone" });
5097
- });
5098
- socket.once("end", () => {
5099
- const line = raw.trimEnd();
5100
- const identity = line.startsWith(STORE_MUTEX_ID_PREFIX) ? line.slice(STORE_MUTEX_ID_PREFIX.length) : void 0;
5101
- finish(
5102
- identity && /^[a-f0-9]{64}$/.test(identity) ? { kind: "identity", identity } : { kind: "foreign-or-gone" }
5103
- );
6190
+ if (raw.length > STORE_MUTEX_ID_PREFIX.length + 64 + 1) finish({ kind: "occupied" });
5104
6191
  });
5105
- socket.once("error", () => finish({ kind: "foreign-or-gone" }));
6192
+ socket.once("end", () => finish(raw.trimEnd() === `${STORE_MUTEX_ID_PREFIX}${identity}` ? { kind: "holder" } : { kind: "occupied" }));
6193
+ socket.once(
6194
+ "error",
6195
+ (err) => finish(err.code === "ECONNREFUSED" || err.code === "ENOENT" ? { kind: "unbound" } : { kind: "occupied" })
6196
+ );
5106
6197
  });
5107
6198
  }
6199
+ async function clearStaleStoreMutexSocket(endpoint, identity) {
6200
+ let stat;
6201
+ try {
6202
+ stat = await promises.lstat(endpoint);
6203
+ } catch (err) {
6204
+ if (err.code === "ENOENT") return;
6205
+ throw err;
6206
+ }
6207
+ if (!stat.isSocket()) throw new Error("store mutation lock path exists but is not a socket");
6208
+ if ((await probeStoreMutex(endpoint, identity)).kind !== "unbound") throw new DaemonOwnerActiveError("unknown");
6209
+ await promises.rm(endpoint, { force: true });
6210
+ }
6211
+ async function assertOwnedPrivateDir2(dir) {
6212
+ const uid = process.getuid?.();
6213
+ if (uid === void 0) return;
6214
+ const stat = await promises.lstat(dir);
6215
+ if (stat.isSymbolicLink() || stat.uid !== uid) {
6216
+ throw new Error(`refusing to bind the store mutation lock under "${dir}": not a real directory owned by this process's own uid`);
6217
+ }
6218
+ }
5108
6219
  async function acquireStoreMutex(canonicalStoreDir) {
5109
6220
  const identity = storeMutexIdentity(canonicalStoreDir);
5110
- for (let attempt = 0; attempt < STORE_MUTEX_PORT_CANDIDATES; attempt += 1) {
5111
- const server = createServer((socket) => socket.end(`${STORE_MUTEX_ID_PREFIX}${identity}
6221
+ const endpoint = storeMutexEndpoint(canonicalStoreDir, identity);
6222
+ const isPipe = process.platform === "win32";
6223
+ if (!isPipe) {
6224
+ const endpointDir = path20.dirname(endpoint);
6225
+ if (endpointDir !== canonicalStoreDir) {
6226
+ await ensureSecureDir(endpointDir);
6227
+ await assertOwnedPrivateDir2(endpointDir);
6228
+ }
6229
+ await clearStaleStoreMutexSocket(endpoint, identity);
6230
+ }
6231
+ const server = createServer((socket) => endOwnershipProbe(socket, `${STORE_MUTEX_ID_PREFIX}${identity}
5112
6232
  `));
5113
- const port = storeMutexPort(identity, attempt);
5114
- try {
5115
- await new Promise((resolve, reject) => {
5116
- server.once("error", reject);
5117
- server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
5118
- server.removeListener("error", reject);
5119
- resolve();
5120
- });
6233
+ try {
6234
+ await new Promise((resolve, reject) => {
6235
+ server.once("error", reject);
6236
+ server.listen(endpoint, () => {
6237
+ server.removeListener("error", reject);
6238
+ resolve();
5121
6239
  });
5122
- } catch (err) {
5123
- if (err.code !== "EADDRINUSE") throw err;
5124
- const probe = await probeStoreMutex(port);
5125
- if (probe.kind === "uncertain" || probe.kind === "identity" && probe.identity === identity) {
5126
- throw new DaemonOwnerActiveError("unknown");
5127
- }
5128
- continue;
5129
- }
5130
- server.unref();
5131
- let closed = false;
5132
- return {
5133
- port,
5134
- close: () => new Promise((resolve, reject) => {
5135
- if (closed) {
5136
- resolve();
5137
- return;
5138
- }
5139
- closed = true;
5140
- server.close((err) => err ? reject(err) : resolve());
5141
- })
5142
- };
6240
+ });
6241
+ } catch (err) {
6242
+ if (err.code === "EADDRINUSE") throw new DaemonOwnerActiveError("unknown");
6243
+ throw err;
5143
6244
  }
5144
- throw new DaemonOwnerActiveError("unknown");
6245
+ if (!isPipe) await promises.chmod(endpoint, 384).catch(() => void 0);
6246
+ server.unref();
6247
+ let closed = false;
6248
+ return {
6249
+ endpoint,
6250
+ close: async () => {
6251
+ if (closed) return;
6252
+ closed = true;
6253
+ await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
6254
+ if (!isPipe) await promises.rm(endpoint, { force: true }).catch(() => void 0);
6255
+ }
6256
+ };
5145
6257
  }
5146
6258
  async function reclaimExistsAndIsActive(reclaimPath) {
5147
6259
  let stat;
@@ -5257,7 +6369,8 @@ function toRuntimeInfoCapabilities(caps) {
5257
6369
  steer: caps.steer,
5258
6370
  resume: caps.resume,
5259
6371
  approvalInteractive: caps.approvalInteractive,
5260
- permissionModes: caps.permissionModes
6372
+ ...caps.mcpToolsets === void 0 ? {} : { mcpToolsets: caps.mcpToolsets },
6373
+ permissionModes: [...caps.permissionModes]
5261
6374
  };
5262
6375
  }
5263
6376
  var CursorStore = class {
@@ -5330,7 +6443,7 @@ var DaemonObserver = class {
5330
6443
  }
5331
6444
  /**
5332
6445
  * Feed a raw INBOUND (server -> daemon) envelope. Deliberately narrow: only
5333
- * `task.offer` produces a local event here — every other inbound type
6446
+ * either offer variant produces a local event here — every other inbound type
5334
6447
  * (`task.cancel`/`task.steer`/`task.approve`/`task.reject`) is a
5335
6448
  * best-effort notification whose OWN observable effect already surfaces
5336
6449
  * through the daemon's outbound envelopes (`task.cancelled`, `task.progress`
@@ -5338,7 +6451,7 @@ var DaemonObserver = class {
5338
6451
  * where those are actually reported from.
5339
6452
  */
5340
6453
  handleInboundEnvelope(envelope) {
5341
- if (envelope.type !== "task.offer") return;
6454
+ if (envelope.type !== "task.offer" && envelope.type !== "task.offer_with_toolsets") return;
5342
6455
  const taskId = envelope.task_id;
5343
6456
  if (this.taskInfo.has(taskId)) return;
5344
6457
  this.upsertTask(taskId, { state: "Offered", runtime: envelope.payload.runtime });
@@ -5459,6 +6572,37 @@ var DaemonObserver = class {
5459
6572
  noteShutdownComplete(reason, undeliveredOutboxCount) {
5460
6573
  this.emit({ kind: "shutdown-complete", ts: nowIso(), reason, undeliveredOutboxCount });
5461
6574
  }
6575
+ /**
6576
+ * Plan `device-assertion-broker`: see the `device-assertion` `DaemonEvent`
6577
+ * variant's own doc comment. The parameter type is what keeps the signature
6578
+ * out — there is no field to pass one through.
6579
+ *
6580
+ * codex round-2 F4: the DENIED caller can pass its raw `audience` here, but
6581
+ * it is converted to a byte SIZE the instant the event is constructed and the
6582
+ * raw string is dropped — it is never placed on the emitted `DaemonEvent`, so
6583
+ * it cannot reach a subscriber, `format.ts`, stdout, or the audit file. The
6584
+ * ISSUED `audience` came from the allowlist and is kept verbatim.
6585
+ */
6586
+ noteDeviceAssertion(event) {
6587
+ if (event.result === "issued") {
6588
+ this.emit({
6589
+ kind: "device-assertion",
6590
+ ts: nowIso(),
6591
+ result: "issued",
6592
+ audience: event.audience,
6593
+ jti: event.jti,
6594
+ expiresAt: event.expiresAt
6595
+ });
6596
+ return;
6597
+ }
6598
+ this.emit({
6599
+ kind: "device-assertion",
6600
+ ts: nowIso(),
6601
+ result: "denied",
6602
+ reason: event.reason,
6603
+ audienceSize: event.audience === void 0 ? void 0 : Buffer.byteLength(event.audience, "utf8")
6604
+ });
6605
+ }
5462
6606
  /** M4 Phase 3 hardening: see the `stale-approval-decision` `DaemonEvent` variant's own doc comment. */
5463
6607
  noteStaleApprovalDecision(taskId, decision, reason) {
5464
6608
  this.emit({ kind: "stale-approval-decision", ts: nowIso(), taskId, decision, reason });
@@ -5466,6 +6610,9 @@ var DaemonObserver = class {
5466
6610
  noteGitWorkspace(event) {
5467
6611
  this.emit({ kind: "git-workspace", ts: nowIso(), ...event });
5468
6612
  }
6613
+ noteRuntimeDisposalFailure(event) {
6614
+ this.emit({ kind: "runtime-disposal-failed", ts: nowIso(), ...event });
6615
+ }
5469
6616
  /**
5470
6617
  * Finding F4: wired from `TaskRunnerDeps.onApprovalDispatched`, called
5471
6618
  * synchronously by `TaskRunner.dispatchApproval` BEFORE its own
@@ -7097,6 +8244,21 @@ var MAX_INLINE_ARTIFACT_BYTES = 64 * 1024;
7097
8244
  var MAX_TRACKED_TASK_IDS = 2e3;
7098
8245
  var MAX_DURATION_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxDurationMs";
7099
8246
  var MAX_OUTPUT_BYTES_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxTaskOutputBytes";
8247
+ var RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX = "result document undeliverable";
8248
+ function resultDocumentRejectionDetail(check) {
8249
+ switch (check.reason) {
8250
+ case "over-cap":
8251
+ return `${check.bytes} bytes as canonical JSON, over the ${RESULT_DOCUMENT_MAX_BYTES}-byte limit (it is never truncated \u2014 a truncated JSON document is not valid JSON; use artifactRefs for a result this size)`;
8252
+ case "not-serializable":
8253
+ return "not JSON-serializable (JSON.stringify threw, or produced no output at all)";
8254
+ case "not-plain-json":
8255
+ return "not plain JSON data: it does not equal its own JSON round trip, so serializing it would silently change it (an undefined-valued key, NaN, a function or symbol value, a Date, a toJSON that rewrites the value, or a getter that answers differently on a second read)";
8256
+ default: {
8257
+ const exhaustive = check;
8258
+ throw new Error(`unhandled result document rejection: ${JSON.stringify(exhaustive)}`);
8259
+ }
8260
+ }
8261
+ }
7100
8262
  var DEFAULT_MAX_TASK_OUTPUT_BYTES = 64 * 1024 * 1024;
7101
8263
  function isKnownRuntimeId(id) {
7102
8264
  return RuntimeIdSchema.safeParse(id).success;
@@ -7104,12 +8266,20 @@ function isKnownRuntimeId(id) {
7104
8266
  var DEFAULT_RUNTIME_PREFERENCE = ["claude", "codex", "pi"];
7105
8267
  function orderByPreference(candidates, preference) {
7106
8268
  const rank = new Map(preference.map((id, index) => [id, index]));
7107
- return [...candidates].sort((a, b) => (rank.get(a.id) ?? preference.length) - (rank.get(b.id) ?? preference.length));
8269
+ return [...candidates].sort((a, b) => (rank.get(a.descriptor.id) ?? preference.length) - (rank.get(b.descriptor.id) ?? preference.length));
7108
8270
  }
7109
- function adapterSupportsMode(adapter, mode) {
7110
- return adapter.capabilities().permissionModes.includes(mode);
8271
+ function adapterSupportsMode(descriptor, mode) {
8272
+ return descriptor.capabilities.permissionModes.includes(mode);
7111
8273
  }
7112
- function errorMessage3(err) {
8274
+ function adapterSupportsMcpToolsets(descriptor) {
8275
+ return descriptor.capabilities.mcpToolsets === true;
8276
+ }
8277
+ function withoutRequiredToolsets(payload) {
8278
+ if (!("requiredToolsets" in payload)) return payload;
8279
+ const { requiredToolsets, ...offer } = payload;
8280
+ return offer;
8281
+ }
8282
+ function errorMessage4(err) {
7113
8283
  return err instanceof Error ? err.message : String(err);
7114
8284
  }
7115
8285
  function raceSettleFirst(fn, timeoutMs) {
@@ -7162,7 +8332,7 @@ async function openArtifact(workspaceDir, name) {
7162
8332
  try {
7163
8333
  handle = await promises.open(candidate, constants.O_RDONLY | O_NOFOLLOW);
7164
8334
  } catch (err) {
7165
- return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage3(err)}` };
8335
+ return { ok: false, reason: `artifact "${name}" could not be opened: ${errorMessage4(err)}` };
7166
8336
  }
7167
8337
  try {
7168
8338
  const st = await handle.stat();
@@ -7174,7 +8344,7 @@ async function openArtifact(workspaceDir, name) {
7174
8344
  } catch (err) {
7175
8345
  await handle.close().catch(() => {
7176
8346
  });
7177
- return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage3(err)}` };
8347
+ return { ok: false, reason: `artifact "${name}" could not be verified: ${errorMessage4(err)}` };
7178
8348
  }
7179
8349
  return { ok: true, handle };
7180
8350
  }
@@ -7188,13 +8358,13 @@ var TaskRunner = class {
7188
8358
  * Finding F4 (cancel lost during the offer-processing window): a
7189
8359
  * `task.cancel` for a taskId that hasn't finished `handleOffer` yet (still
7190
8360
  * awaiting adapter detection / instruction resolution / workspace setup /
7191
- * `adapter.start()`) has no `this.tasks` entry to land on — it used to be
8361
+ * prepared operation `start()`) has no `this.tasks` entry to land on — it used to be
7192
8362
  * silently dropped, and the runtime session `handleOffer` was about to
7193
8363
  * register would then run an unsupervised ("zombie") turn nobody asked
7194
8364
  * for anymore. Recording the taskId here lets `handleOffer` consult it at
7195
8365
  * the two points where it can still safely react (see its body): before
7196
8366
  * claiming at all (decline instead of ever starting a session), and right
7197
- * after `adapter.start()` resolves but before this task is registered as
8367
+ * after the prepared operation resolves but before this task is registered as
7198
8368
  * active (tear the just-started session down immediately, before its
7199
8369
  * event loop ever pumps a single event). Consumed (deleted) at whichever
7200
8370
  * checkpoint handles it; a cancel for a taskId that's already active,
@@ -7220,12 +8390,12 @@ var TaskRunner = class {
7220
8390
  * checkpoint-2 cancel-teardown, or successful registration into
7221
8391
  * `this.tasks`). Bounded eviction on `pendingCancelled` (below) must never
7222
8392
  * remove an entry for a taskId in this set: doing so is exactly the bug —
7223
- * block task A in `adapter.start()`, deliver A's own `task.cancel` (so
8393
+ * block task A in prepared-operation `start()`, deliver A's own `task.cancel` (so
7224
8394
  * `pendingCancelled` gets an entry for A while A is still in-flight),
7225
8395
  * then deliver `MAX_TRACKED_TASK_IDS` more cancels for unrelated taskIds
7226
8396
  * nobody ever offered — under naive oldest-wins eviction, A's entry (the
7227
8397
  * single oldest) gets evicted purely because of unrelated churn, so when
7228
- * `adapter.start()` finally resolves, checkpoint 2 finds no cancel marker
8398
+ * the prepared operation finally resolves, checkpoint 2 finds no cancel marker
7229
8399
  * and the already-cancelled task starts a real session. See
7230
8400
  * `evictPendingCancelled` below for the fix, and
7231
8401
  * `task-runner-bounded-collections.test.ts` for a test mirroring this
@@ -7245,7 +8415,7 @@ var TaskRunner = class {
7245
8415
  * explicitly relies on redelivered handlers being idempotent for exactly
7246
8416
  * this reason). `handleOffer` must treat a redelivered offer for a taskId
7247
8417
  * that's already active (`this.tasks`) or already finished (this set) as
7248
- * a no-op — never a second `adapter.start()` call, which would orphan the
8418
+ * a no-op — never a second prepared-operation `start()` call, which would orphan the
7249
8419
  * first session.
7250
8420
  *
7251
8421
  * M3-B: unbounded otherwise — a long-lived daemon that's finished many
@@ -7314,10 +8484,9 @@ var TaskRunner = class {
7314
8484
  this.stoppingOffers = true;
7315
8485
  }
7316
8486
  /**
7317
- * M4 Phase 2: best-effort shutdown of every currently ACTIVE task, for the
7318
- * control socket's `shutdown` RPC. Mirrors `handleCancel`'s best-effort
7319
- * `session.interrupt()` style (an interrupt failure is swallowed; the
7320
- * terminal message is sent either way) but reports `task.fail` rather than
8487
+ * Shutdown of every currently ACTIVE task for the control socket's
8488
+ * `shutdown` RPC. Soft interrupt remains bounded, but each task's
8489
+ * authoritative close receipt must settle successfully. Reports `task.fail` rather than
7321
8490
  * `task.cancelled` — these tasks aren't ending because the SERVER
7322
8491
  * cancelled them, they're ending because this device is shutting down.
7323
8492
  * `retryable: true` throughout: nothing about the task/policy itself was
@@ -7371,28 +8540,13 @@ var TaskRunner = class {
7371
8540
  * unconditionally, so a hung `interrupt()` (a misbehaving adapter) can
7372
8541
  * never block `task.fail` from being sent at all.
7373
8542
  *
7374
- * New in this batch hard-kill escalation: when `interrupt()` does NOT
7375
- * settle within that same grace window, `session.close()` is tried next
7376
- * (ALSO raced against `timeoutMs`, for the identical reason: a hung
7377
- * `close()` must not be able to block this forever either — which matters
7378
- * far more here than it used to for the pre-existing graceful-shutdown-only
7379
- * caller, since THAT path is additionally bounded by an outer deadline
7380
- * (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`/`DaemonConfig.shutdownGraceMs`,
7381
- * `create-daemon.ts`), while resource-limit enforcement fires during
7382
- * ordinary operation with no such outer bound watching it). `close()` is
7383
- * every adapter's harder teardown primitive — an actual process-level kill
7384
- * (SIGTERM, or `taskkill /F` on Windows — see e.g.
7385
- * `ClaudeProcessClient.kill()`/`PiRpcClient.kill()`) as opposed to pi's own
7386
- * soft in-band `interrupt()` (an RPC `abort` message that leaves the
7387
- * process alive and resumable) — so escalating to it is the closest thing
7388
- * to a "hard kill" the `Session` interface exposes. `finish()` below calls
7389
- * `session.close()` again regardless (documented idempotent) — this isn't
7390
- * a substitute for that, only an earlier, bounded attempt at actually
7391
- * stopping a stuck runtime before this method gives up and reports failure
7392
- * anyway.
8543
+ * After the bounded soft interrupt, `finish()` always awaits the authoritative
8544
+ * `Session.close()` receipt. A failed receipt retains active/Git ownership;
8545
+ * shutdown surfaces the rejection while resource enforcement leaves local
8546
+ * evidence for a later retry.
7393
8547
  *
7394
8548
  * Re-checks task identity (`this.tasks.get(...) === active`) immediately
7395
- * before sending `task.fail`: the interrupt/hard-kill race above has await
8549
+ * before sending `task.fail`: the interrupt race above has await
7396
8550
  * points during which a DIFFERENT path (a racing `task.cancel`/
7397
8551
  * `task.reject`, or the session completing normally on its own) may have
7398
8552
  * already finished this exact task and sent its own terminal message.
@@ -7401,20 +8555,25 @@ var TaskRunner = class {
7401
8555
  * identity-check guard for the same class of race.
7402
8556
  */
7403
8557
  async teardownActiveTask(active, reason, retryable) {
8558
+ if (active.finalizationStarted) return this.finish(active.taskId);
8559
+ if (!this.reserveSemanticTerminal(active)) return active.semanticTerminalSettled ?? false;
7404
8560
  active.beingTornDown = true;
7405
8561
  await this.observeGit(active, "salvage");
7406
8562
  const timeoutMs = this.deps.shutdownInterruptTimeoutMs ?? DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS;
7407
- const interrupted = await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
7408
- if (!interrupted) {
7409
- await raceSettleFirst(() => active.session.close(), timeoutMs);
7410
- }
7411
- if (this.tasks.get(active.taskId) !== active) return;
8563
+ await raceSettleFirst(() => active.session.interrupt(), timeoutMs);
8564
+ if (this.tasks.get(active.taskId) !== active) return true;
7412
8565
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId: active.taskId }));
7413
- await this.finish(active.taskId);
8566
+ return this.finish(active.taskId);
7414
8567
  }
7415
8568
  /** 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. */
7416
8569
  async shutdownTask(active, reason) {
7417
- await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8570
+ const disposed = await this.teardownActiveTask(active, `daemon shutting down: ${reason}`, true);
8571
+ if (!disposed) {
8572
+ throw new RuntimeDisposalFailure({
8573
+ stage: "quiescence",
8574
+ reason: `${active.adapter.descriptor.id} runtime ownership remains active after shutdown disposal failed`
8575
+ });
8576
+ }
7418
8577
  }
7419
8578
  /**
7420
8579
  * M5 batch-3 (workstream 2): shared entry point for both resource-limit
@@ -7464,6 +8623,9 @@ var TaskRunner = class {
7464
8623
  case "task.offer":
7465
8624
  await this.handleOffer(envelope.task_id, envelope.payload);
7466
8625
  return;
8626
+ case "task.offer_with_toolsets":
8627
+ await this.handleOffer(envelope.task_id, envelope.payload);
8628
+ return;
7467
8629
  case "task.cancel":
7468
8630
  await this.handleCancel(envelope.task_id, envelope.payload.reason);
7469
8631
  return;
@@ -7517,16 +8679,49 @@ var TaskRunner = class {
7517
8679
  );
7518
8680
  return;
7519
8681
  }
7520
- const pick = await this.pickAdapter(payload.runtime, payload.policy.mode);
7521
- if (!pick.ok) {
7522
- this.decline(taskId, pick.reason, pick.retryable);
7523
- return;
7524
- }
8682
+ if (payload.dispatchSelection !== void 0 && payload.runtime !== void 0 && payload.runtime !== payload.dispatchSelection.runtimeId) {
8683
+ this.decline(
8684
+ taskId,
8685
+ `offer runtime ${payload.runtime} does not match dispatchSelection.runtimeId ${payload.dispatchSelection.runtimeId}`,
8686
+ false
8687
+ );
8688
+ return;
8689
+ }
8690
+ const requiredToolsets = "requiredToolsets" in payload ? payload.requiredToolsets : void 0;
8691
+ const resolvedMcp = requiredToolsets ? this.resolveMcpServers(requiredToolsets) : void 0;
8692
+ if (resolvedMcp && !resolvedMcp.ok) {
8693
+ this.decline(taskId, resolvedMcp.reason, true);
8694
+ return;
8695
+ }
7525
8696
  const decision = computeEffectivePolicy(payload.policy, this.deps.permissionDefaults);
7526
8697
  if (!decision.ok) {
7527
8698
  this.decline(taskId, decision.reason ?? "policy rejected", false);
7528
8699
  return;
7529
8700
  }
8701
+ const offered = withoutRequiredToolsets(payload);
8702
+ const requestedRuntime = payload.dispatchSelection?.runtimeId ?? payload.runtime;
8703
+ const pick = await this.pickAdapter(requestedRuntime, payload.policy.mode, requiredToolsets !== void 0);
8704
+ if (!pick.ok) {
8705
+ this.decline(taskId, pick.reason, pick.retryable);
8706
+ return;
8707
+ }
8708
+ let prepared;
8709
+ try {
8710
+ prepared = await pick.adapter.prepare({
8711
+ offer: offered,
8712
+ policy: decision.policy,
8713
+ descriptor: pick.descriptor,
8714
+ requiredToolsetIds: requiredToolsets ?? [],
8715
+ ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {}
8716
+ });
8717
+ } catch (error) {
8718
+ this.decline(taskId, `runtime preparation failed: ${errorMessage4(error)}`, true);
8719
+ return;
8720
+ }
8721
+ if (prepared.kind === "reject") {
8722
+ this.decline(taskId, prepared.reason, prepared.retryable);
8723
+ return;
8724
+ }
7530
8725
  let known = void 0;
7531
8726
  let workspaceDir;
7532
8727
  let gitLease;
@@ -7559,6 +8754,7 @@ var TaskRunner = class {
7559
8754
  }
7560
8755
  } else {
7561
8756
  workspaceDir = path20.join(this.deps.workspaceRoot, taskId);
8757
+ gitWorkspaceId = randomUUID();
7562
8758
  }
7563
8759
  try {
7564
8760
  gitLease = await gitManager.acquireLease(workspaceDir, payload.sessionRef);
@@ -7574,6 +8770,26 @@ var TaskRunner = class {
7574
8770
  this.decline(taskId, "workspace mode is unavailable", true);
7575
8771
  return;
7576
8772
  }
8773
+ const env = buildRuntimeEnv({
8774
+ ambient: process.env,
8775
+ requirements: pick.descriptor.environmentRequirements,
8776
+ locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.descriptor.id]?.allow
8777
+ });
8778
+ const manifest = sealRuntimeOperationManifest({
8779
+ taskId,
8780
+ runtimeId: pick.descriptor.id,
8781
+ descriptor: pick.descriptor,
8782
+ policy: decision.policy,
8783
+ requiredToolsetIds: requiredToolsets ?? [],
8784
+ ...offered.dispatchSelection === void 0 ? {} : { dispatchSelection: offered.dispatchSelection },
8785
+ ...known === void 0 || payload.sessionRef === void 0 ? {} : { sessionRef: payload.sessionRef },
8786
+ workspace: {
8787
+ workspaceDir,
8788
+ ...gitWorkspaceId === void 0 ? {} : { workspaceId: gitWorkspaceId },
8789
+ ...gitBaseline === void 0 ? {} : { baseline: gitBaseline }
8790
+ },
8791
+ forwardedEnvironmentNames: Object.freeze(Object.keys(env).sort())
8792
+ });
7577
8793
  this.deps.send(
7578
8794
  createEnvelope(
7579
8795
  "task.claim",
@@ -7589,7 +8805,7 @@ var TaskRunner = class {
7589
8805
  // (the merely REQUESTED runtime): this is what closes the gap
7590
8806
  // where an auto-selected task left the server never learning
7591
8807
  // which runtime actually ran.
7592
- runtime: isKnownRuntimeId(pick.adapter.id) ? pick.adapter.id : void 0,
8808
+ runtime: isKnownRuntimeId(manifest.descriptor.id) ? manifest.descriptor.id : void 0,
7593
8809
  // S0/D-4 (`task.claim.capabilities`, docs/protocol.md §2.4): the
7594
8810
  // selected adapter's own capability self-report, carried on the
7595
8811
  // same message that establishes the task↔runtime binding. The
@@ -7606,7 +8822,7 @@ var TaskRunner = class {
7606
8822
  // Gating them would silently strip a custom steer-capable
7607
8823
  // adapter's own truth and leave the server fail-closing on it
7608
8824
  // forever.
7609
- capabilities: toRuntimeInfoCapabilities(pick.adapter.capabilities())
8825
+ capabilities: toRuntimeInfoCapabilities(manifest.descriptor.capabilities)
7610
8826
  },
7611
8827
  { taskId }
7612
8828
  )
@@ -7617,7 +8833,7 @@ var TaskRunner = class {
7617
8833
  if (plainWorkspaceNeedsResolve) workspaceDir = await this.resolveWorkspaceDir(taskId, known?.workspaceDir);
7618
8834
  } catch (err) {
7619
8835
  gitLease?.release();
7620
- await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage3(err)}`, true);
8836
+ await this.fail(taskId, `failed to resolve instruction blob: ${errorMessage4(err)}`, true);
7621
8837
  return;
7622
8838
  }
7623
8839
  if (this.deps.gitWorkspaceManager && gitLease) {
@@ -7626,7 +8842,7 @@ var TaskRunner = class {
7626
8842
  if (gitExisting) {
7627
8843
  observation = await this.deps.gitWorkspaceManager.validateExisting(workspaceDir);
7628
8844
  } else {
7629
- const workspaceId2 = randomUUID();
8845
+ const workspaceId2 = gitWorkspaceId ?? randomUUID();
7630
8846
  const now2 = (/* @__PURE__ */ new Date()).toISOString();
7631
8847
  gitWorkspaceId = workspaceId2;
7632
8848
  await this.deps.gitWorkspaceStore?.upsert({
@@ -7677,29 +8893,11 @@ var TaskRunner = class {
7677
8893
  return;
7678
8894
  }
7679
8895
  }
7680
- const ctx = {
7681
- workspaceDir,
7682
- policy: decision.policy,
7683
- ...gitWorkspaceId ? { gitWorkspace: { workspaceId: gitWorkspaceId, baseline: gitBaseline } } : {},
7684
- // M5: no longer `process.env` verbatim (see `environment.ts`'s own
7685
- // module doc comment for the credential-leak gap that closed) —
7686
- // built fresh per task from the SPECIFIC adapter `pickAdapter`
7687
- // above already selected, so this always runs after adapter
7688
- // selection: `pick.adapter.environmentRequirements?.()` (undefined
7689
- // ⇒ platform baseline only, fail-closed) plus this device's own
7690
- // `runtimeEnvironment` override, keyed by that same adapter's `id`.
7691
- env: buildRuntimeEnv({
7692
- ambient: process.env,
7693
- requirements: pick.adapter.environmentRequirements?.(),
7694
- locallyAllowedNames: this.deps.runtimeEnvironment?.[pick.adapter.id]?.allow
7695
- }),
7696
- // M4 Phase 3: adapter-agnostic and cheap to always populate — only an
7697
- // adapter whose runtime genuinely supports an out-of-band approval
7698
- // pause (claude, today) ever reads this. `resolve` is a closure over
7699
- // `taskId` (not a pre-bound approvalId): it looks up whichever
7700
- // approval is CURRENTLY pending for this task at call time, since one
7701
- // task/session can face several approval requests, one at a time,
7702
- // over its life. See `types.ts`'s `ApprovalChannel` doc comment.
8896
+ const startInput = {
8897
+ manifest,
8898
+ instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
8899
+ env,
8900
+ ...resolvedMcp?.ok ? { mcpServers: resolvedMcp.servers } : {},
7703
8901
  approvalChannel: {
7704
8902
  taskId,
7705
8903
  storeDir: this.deps.storeDir,
@@ -7715,45 +8913,19 @@ var TaskRunner = class {
7715
8913
  }
7716
8914
  }
7717
8915
  };
7718
- const effectiveOffer = {
7719
- ...payload,
7720
- instruction: gitWorkspaceId ? prependGitWorkspaceGuidance(resolvedInstruction) : resolvedInstruction,
7721
- // Never forward a sessionRef this device has no recorded workspace
7722
- // for (stale, from another device, or simply made up) — an adapter
7723
- // that tries to resume an id it never minted fails outright (pi:
7724
- // "No session found matching '<id>'", exit 1, empirically confirmed)
7725
- // instead of silently starting fresh, so an unresolvable sessionRef
7726
- // must look identical to "none supplied" by the time it reaches the
7727
- // adapter, not get forwarded as a resume attempt doomed to fail.
7728
- sessionRef: known ? payload.sessionRef : void 0
7729
- };
7730
8916
  let session;
7731
8917
  try {
7732
- session = await pick.adapter.start(effectiveOffer, ctx);
8918
+ session = await prepared.operation.start(startInput);
7733
8919
  } catch (err) {
7734
- const retryable = !(err instanceof PolicyUnsupportedError);
7735
- await this.updateGitPhaseBestEffort(gitWorkspaceId, "failed", "repository-invalid");
7736
- gitLease?.release();
7737
- await this.fail(taskId, `adapter failed to start: ${errorMessage3(err)}`, retryable);
7738
- return;
7739
- }
7740
- if (this.pendingCancelled.has(taskId)) {
7741
- const reason = this.pendingCancelled.get(taskId);
7742
- this.pendingCancelled.delete(taskId);
7743
- try {
7744
- await session.interrupt();
7745
- } catch {
7746
- }
7747
- try {
7748
- await session.close();
7749
- } catch {
8920
+ const failure = projectRuntimeBoundaryFailure(err, "start");
8921
+ if (failure.contractViolation) {
8922
+ console.error("[byok/client] runtime adapter start() returned an untyped failure", err);
7750
8923
  }
8924
+ await this.updateGitPhaseBestEffort(gitWorkspaceId, "failed", "repository-invalid");
7751
8925
  gitLease?.release();
7752
- await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
7753
- this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
8926
+ await this.fail(taskId, failure.reason, failure.retryable);
7754
8927
  return;
7755
8928
  }
7756
- this.deps.send(createEnvelope("task.started", {}, { taskId }));
7757
8929
  const active = {
7758
8930
  taskId,
7759
8931
  adapter: pick.adapter,
@@ -7770,6 +8942,21 @@ var TaskRunner = class {
7770
8942
  approvalQueue: [],
7771
8943
  outputBytesSoFar: 0
7772
8944
  };
8945
+ if (this.pendingCancelled.has(taskId)) {
8946
+ const reason = this.pendingCancelled.get(taskId);
8947
+ this.pendingCancelled.delete(taskId);
8948
+ this.tasks.set(taskId, active);
8949
+ this.reserveSemanticTerminal(active);
8950
+ try {
8951
+ await session.interrupt();
8952
+ } catch {
8953
+ }
8954
+ await this.updateGitPhaseBestEffort(gitWorkspaceId, "cancelled");
8955
+ this.deps.send(createEnvelope("task.cancelled", { reason }, { taskId }));
8956
+ await this.finish(taskId);
8957
+ return;
8958
+ }
8959
+ this.deps.send(createEnvelope("task.started", {}, { taskId }));
7773
8960
  this.tasks.set(taskId, active);
7774
8961
  if (payload.limits?.maxDurationMs !== void 0) {
7775
8962
  this.armMaxDurationTimer(active, payload.limits.maxDurationMs);
@@ -7794,9 +8981,40 @@ var TaskRunner = class {
7794
8981
  if (typeof instruction === "string") return instruction;
7795
8982
  return this.deps.blobClient.resolveInstruction(instruction.blobRef);
7796
8983
  }
8984
+ /** Resolve every requested logical id locally and reject missing/colliding server authority before claim. */
8985
+ resolveMcpServers(requiredToolsets) {
8986
+ const registry = this.deps.mcpToolsets;
8987
+ if (!registry) {
8988
+ return { ok: false, reason: "offer requires MCP toolsets, but this device has no local mcpToolsets registry" };
8989
+ }
8990
+ const servers = /* @__PURE__ */ Object.create(null);
8991
+ for (const toolsetId of requiredToolsets) {
8992
+ const toolset = registry.get(toolsetId);
8993
+ if (!toolset) {
8994
+ return { ok: false, reason: `required MCP toolset "${toolsetId}" is not configured on this device` };
8995
+ }
8996
+ for (const [serverName, server] of Object.entries(toolset.mcpServers)) {
8997
+ if (Object.prototype.hasOwnProperty.call(servers, serverName)) {
8998
+ return {
8999
+ ok: false,
9000
+ reason: `required MCP toolsets collide on server name "${serverName}"; refusing ambiguous projection`
9001
+ };
9002
+ }
9003
+ servers[serverName] = Object.freeze({
9004
+ command: server.command,
9005
+ ...server.args ? { args: Object.freeze([...server.args]) } : {}
9006
+ });
9007
+ }
9008
+ }
9009
+ if (Object.keys(servers).length === 0) {
9010
+ return { ok: false, reason: "required MCP toolsets resolved to no servers; refusing to run without tools" };
9011
+ }
9012
+ return { ok: true, servers: Object.freeze(servers) };
9013
+ }
7797
9014
  async pump(active) {
7798
9015
  try {
7799
9016
  for await (const event of active.session.events) {
9017
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
7800
9018
  if (this.tasks.get(active.taskId) !== active) return;
7801
9019
  active.outputBytesSoFar += estimateEventBytes(event);
7802
9020
  if (active.outputBytesSoFar > this.maxTaskOutputBytes) {
@@ -7819,7 +9037,7 @@ var TaskRunner = class {
7819
9037
  try {
7820
9038
  await active.session.resolveApproval(approved, reason);
7821
9039
  } catch (err) {
7822
- await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage3(err)}`, false);
9040
+ await this.fail(taskId, `failed to resume session after approval decision: ${errorMessage4(err)}`, false);
7823
9041
  }
7824
9042
  });
7825
9043
  continue;
@@ -7827,11 +9045,40 @@ var TaskRunner = class {
7827
9045
  if (event.type === "turn_end") {
7828
9046
  active.batcher.push(event);
7829
9047
  active.batcher.flush();
9048
+ const finalOutput = active.summaryParts.join("");
9049
+ const outcome = await this.resolveResultDocument(active, finalOutput);
9050
+ if (!outcome.deliver) return;
7830
9051
  await this.observeGit(active, "completed");
9052
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
9053
+ if (outcome.document !== void 0 && !this.hasResultDocumentCapability()) {
9054
+ await this.fail(
9055
+ active.taskId,
9056
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the connected server stopped advertising the result-document capability before this completion could be sent (a reconnect to an older server), so it would silently discard this document`,
9057
+ false
9058
+ );
9059
+ return;
9060
+ }
9061
+ if (!this.reserveSemanticTerminal(active)) return;
7831
9062
  this.deps.send(
7832
9063
  createEnvelope(
7833
9064
  "task.complete",
7834
- { summary: active.summaryParts.join(""), sessionRef: active.session.sessionRef },
9065
+ {
9066
+ summary: finalOutput,
9067
+ sessionRef: active.session.sessionRef,
9068
+ // Spread rather than `document: outcome.document`, so a
9069
+ // completion with no document is the exact same payload it
9070
+ // was before this field existed — not one carrying an
9071
+ // explicit `document: undefined` key.
9072
+ //
9073
+ // `outcome.document` is the protocol's CANONICAL SNAPSHOT
9074
+ // (`checkResultDocument`), never the object the extractor
9075
+ // returned: pure data serializes identically at the root
9076
+ // (where it was measured) and nested inside this payload
9077
+ // (where the codec actually serializes it), so a contextual
9078
+ // `toJSON(key)` or an unstable getter cannot make the wire
9079
+ // bytes differ from what the cap gate approved.
9080
+ ...outcome.document !== void 0 ? { document: outcome.document } : {}
9081
+ },
7835
9082
  { taskId: active.taskId, sessionRef: active.session.sessionRef }
7836
9083
  )
7837
9084
  );
@@ -7847,11 +9094,17 @@ var TaskRunner = class {
7847
9094
  active.batcher.push(event);
7848
9095
  }
7849
9096
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
7850
- await this.fail(active.taskId, "runtime session ended without completing the task", true);
9097
+ const failure = projectRuntimeBoundaryFailure(void 0, "run");
9098
+ console.error("[byok/client] runtime adapter events iterable ended without terminal authority");
9099
+ await this.fail(active.taskId, failure.reason, failure.retryable);
7851
9100
  } catch (err) {
7852
9101
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
7853
9102
  active.batcher.flush();
7854
- await this.fail(active.taskId, `runtime error: ${errorMessage3(err)}`, true);
9103
+ const failure = projectRuntimeBoundaryFailure(err, "run");
9104
+ if (failure.contractViolation) {
9105
+ console.error("[byok/client] runtime adapter events iterable returned an untyped failure", err);
9106
+ }
9107
+ await this.fail(active.taskId, failure.reason, failure.retryable);
7855
9108
  }
7856
9109
  }
7857
9110
  /**
@@ -7890,7 +9143,7 @@ var TaskRunner = class {
7890
9143
  try {
7891
9144
  bytes = await opened.handle.readFile();
7892
9145
  } catch (err) {
7893
- this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage3(err)}`);
9146
+ this.reportArtifactError(active, name, `failed to read artifact "${name}": ${errorMessage4(err)}`);
7894
9147
  return;
7895
9148
  } finally {
7896
9149
  await opened.handle.close().catch(() => {
@@ -7907,7 +9160,7 @@ var TaskRunner = class {
7907
9160
  const blobRef = await this.deps.blobClient.uploadArtifact(bytes, contentType);
7908
9161
  this.deps.send(createEnvelope("task.artifact", { name, contentType, blobRef }, { taskId: active.taskId }));
7909
9162
  } catch (err) {
7910
- this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage3(err)}`);
9163
+ this.reportArtifactError(active, name, `failed to upload artifact "${name}": ${errorMessage4(err)}`);
7911
9164
  }
7912
9165
  }
7913
9166
  /** 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. */
@@ -7921,6 +9174,14 @@ var TaskRunner = class {
7921
9174
  this.setPendingCancelled(taskId, reason);
7922
9175
  return;
7923
9176
  }
9177
+ if (active.finalizationStarted) {
9178
+ await this.finish(taskId);
9179
+ return;
9180
+ }
9181
+ if (!this.reserveSemanticTerminal(active)) {
9182
+ await active.semanticTerminalSettled;
9183
+ return;
9184
+ }
7924
9185
  try {
7925
9186
  await active.session.interrupt();
7926
9187
  } catch {
@@ -7949,7 +9210,7 @@ var TaskRunner = class {
7949
9210
  *
7950
9211
  * `inFlightOffers` is naturally tiny (bounded by this device's real
7951
9212
  * concurrent-offer-processing count — normally single digits, driven by
7952
- * how many `task.offer`s are simultaneously mid-`adapter.start()` — nowhere
9213
+ * how many `task.offer`s are simultaneously mid-prepared-operation start() — nowhere
7953
9214
  * near `MAX_TRACKED_TASK_IDS`), so this scan is cheap in practice: it
7954
9215
  * finds a safe entry at or near the front almost always. The only case
7955
9216
  * where NO entry is safe to evict is every single tracked cancel
@@ -8302,7 +9563,7 @@ var TaskRunner = class {
8302
9563
  this.deps.onStaleApprovalDecision?.(taskId, "approve");
8303
9564
  return;
8304
9565
  }
8305
- await this.fail(taskId, `failed to resume session after approval: ${errorMessage3(err)}`, false);
9566
+ await this.fail(taskId, `failed to resume session after approval: ${errorMessage4(err)}`, false);
8306
9567
  return;
8307
9568
  }
8308
9569
  this.clearPendingApproval(resolvedId, "approve", void 0);
@@ -8333,6 +9594,10 @@ var TaskRunner = class {
8333
9594
  async handleReject(taskId, reason, approvalId) {
8334
9595
  const active = this.tasks.get(taskId);
8335
9596
  if (!active) return;
9597
+ if (active.finalizationStarted) {
9598
+ await this.finish(taskId);
9599
+ return;
9600
+ }
8336
9601
  if (approvalId !== void 0 && approvalId !== active.pendingApprovalId) {
8337
9602
  this.deps.onStaleApprovalDecision?.(
8338
9603
  taskId,
@@ -8351,6 +9616,10 @@ var TaskRunner = class {
8351
9616
  }
8352
9617
  }
8353
9618
  this.clearPendingApproval(resolvedId, "reject", reason);
9619
+ if (!this.reserveSemanticTerminal(active)) {
9620
+ await active.semanticTerminalSettled;
9621
+ return;
9622
+ }
8354
9623
  try {
8355
9624
  await active.session.interrupt();
8356
9625
  } catch {
@@ -8365,10 +9634,114 @@ var TaskRunner = class {
8365
9634
  }
8366
9635
  async fail(taskId, reason, retryable) {
8367
9636
  const active = this.tasks.get(taskId);
9637
+ if (active?.finalizationStarted) {
9638
+ await this.finish(taskId);
9639
+ return;
9640
+ }
9641
+ if (active && !this.reserveSemanticTerminal(active)) {
9642
+ await active.semanticTerminalSettled;
9643
+ return;
9644
+ }
8368
9645
  if (active) await this.observeGit(active, "salvage");
8369
9646
  this.deps.send(createEnvelope("task.fail", { reason, retryable }, { taskId }));
8370
9647
  await this.finish(taskId);
8371
9648
  }
9649
+ /**
9650
+ * additive-minor (`task.complete.document`): the whole daemon-side gate
9651
+ * between a configured {@link ResultDocumentExtractor} and the wire —
9652
+ * called once, from the `turn_end` completion path, immediately before
9653
+ * `task.complete` is built.
9654
+ *
9655
+ * `{deliver: true}` means "go on and send `task.complete`", carrying the
9656
+ * document when there is one. `{deliver: false}` means this method has
9657
+ * ALREADY reported `task.fail` and finished the task; the caller must
9658
+ * return without sending anything further.
9659
+ *
9660
+ * Four fail-closed branches, all `retryable: false` (see
9661
+ * {@link RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX} for why none of them
9662
+ * can succeed on a retry):
9663
+ *
9664
+ * 1. The extractor threw — its error is surfaced, never swallowed.
9665
+ * 2. The extractor returned a thenable, violating the synchronous
9666
+ * contract in the one way that would otherwise ship a wrong answer.
9667
+ * 3. The document is over the cap, not JSON-serializable, or not plain
9668
+ * JSON data, per `checkResultDocument` — the protocol's OWN check,
9669
+ * imported rather than reimplemented, so this gate and the server's
9670
+ * schema validation can never disagree about what is legal.
9671
+ * 4. The connected server never advertised `result-document`. Its
9672
+ * tolerant `z.object()` would silently strip the field on arrival
9673
+ * (`version.ts`'s own flag doc comment), so "send anyway" is not a
9674
+ * degraded-but-working path — it is the task's primary structured
9675
+ * result being deleted in transit with nothing reported anywhere.
9676
+ *
9677
+ * The capability is checked LAST, deliberately: a document that is itself
9678
+ * invalid is the host's own bug and is worth reporting as such even when
9679
+ * the connected server could not have accepted any document at all. It is
9680
+ * then re-checked once more by the caller after its own last await, since
9681
+ * a reconnect can invalidate this answer in between (F3).
9682
+ *
9683
+ * **Residual window (bounded, deliberately not hacked around).** Even the
9684
+ * caller's re-check happens before `ConnectionManager.send` hands the
9685
+ * envelope to a transport, and a queued envelope can outlive the
9686
+ * connection it was queued for: a reconnect between `send()` and the
9687
+ * outbox actually draining could still deliver this `task.complete` to a
9688
+ * rolled-back N-1 server that strips the document. Closing that would
9689
+ * mean teaching the transport outbox to inspect payload semantics and
9690
+ * mint a substitute `task.fail` for a task this runner already finished —
9691
+ * a second authority over terminal outcomes living in the queue, which is
9692
+ * worse than the window it closes. Documented instead, here and in
9693
+ * docs/protocol.md §7.2.
9694
+ */
9695
+ async resolveResultDocument(active, finalOutput) {
9696
+ const extract = this.deps.resultDocument?.extract;
9697
+ if (!extract) return { deliver: true };
9698
+ let document;
9699
+ try {
9700
+ document = extract(finalOutput, { taskId: active.taskId, sessionRef: active.session.sessionRef });
9701
+ } catch (err) {
9702
+ await this.fail(
9703
+ active.taskId,
9704
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract threw: ${errorMessage4(err)}`,
9705
+ false
9706
+ );
9707
+ return { deliver: false };
9708
+ }
9709
+ if (typeof document?.then === "function") {
9710
+ await this.fail(
9711
+ active.taskId,
9712
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the configured resultDocument.extract returned a promise; the contract is synchronous (an awaited value is never read, and a promise encodes to an empty document)`,
9713
+ false
9714
+ );
9715
+ return { deliver: false };
9716
+ }
9717
+ if (document === void 0) return { deliver: true };
9718
+ const check = checkResultDocument(document);
9719
+ if (!check.ok) {
9720
+ const detail = resultDocumentRejectionDetail(check);
9721
+ await this.fail(active.taskId, `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: ${detail}`, false);
9722
+ return { deliver: false };
9723
+ }
9724
+ if (!this.hasResultDocumentCapability()) {
9725
+ await this.fail(
9726
+ active.taskId,
9727
+ `${RESULT_DOCUMENT_UNDELIVERABLE_REASON_PREFIX}: the connected server did not advertise the result-document capability, so it would silently discard this ${check.bytes}-byte document`,
9728
+ false
9729
+ );
9730
+ return { deliver: false };
9731
+ }
9732
+ return { deliver: true, document: check.canonical };
9733
+ }
9734
+ /**
9735
+ * Whether the CURRENTLY connected server advertised `result-document` —
9736
+ * read fresh on every call, never captured, because the answer changes
9737
+ * across a reconnect (`ConnectionManager.getServerCapabilities` returns
9738
+ * `[]` from the moment an acked connection closes until a fresh
9739
+ * `conn.ack` repopulates it). An absent `getServerCapabilities` seam is
9740
+ * "no capabilities", the fail-closed reading.
9741
+ */
9742
+ hasResultDocumentCapability() {
9743
+ return (this.deps.getServerCapabilities?.() ?? []).includes("result-document");
9744
+ }
8372
9745
  async observeGit(active, phase) {
8373
9746
  if (!active.gitWorkspaceId || !this.deps.gitWorkspaceManager || !this.deps.gitWorkspaceStore) return;
8374
9747
  try {
@@ -8403,32 +9776,64 @@ var TaskRunner = class {
8403
9776
  }
8404
9777
  async finish(taskId) {
8405
9778
  const active = this.tasks.get(taskId);
8406
- if (!active) return;
8407
- if (active.maxDurationTimer) {
8408
- clearTimeout(active.maxDurationTimer);
8409
- active.maxDurationTimer = void 0;
8410
- }
8411
- active.batcher.stop();
8412
- this.tasks.delete(taskId);
8413
- this.addFinishedTaskId(taskId);
8414
- const queued = active.approvalQueue.splice(0);
8415
- for (const request of queued) {
8416
- request.resolve({
8417
- approved: false,
8418
- reason: `task ${taskId} finished before this queued approval request could be dispatched`
8419
- });
8420
- }
8421
- if (active.pendingApprovalId !== void 0) {
8422
- try {
8423
- this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
8424
- } catch {
9779
+ if (!active) return true;
9780
+ if (!active.finalizationStarted) {
9781
+ active.finalizationStarted = true;
9782
+ active.beingTornDown = true;
9783
+ if (active.maxDurationTimer) {
9784
+ clearTimeout(active.maxDurationTimer);
9785
+ active.maxDurationTimer = void 0;
9786
+ }
9787
+ active.batcher.stop();
9788
+ this.addFinishedTaskId(taskId);
9789
+ const queued = active.approvalQueue.splice(0);
9790
+ for (const request of queued) {
9791
+ request.resolve({
9792
+ approved: false,
9793
+ reason: `task ${taskId} finished before this queued approval request could be dispatched`
9794
+ });
9795
+ }
9796
+ if (active.pendingApprovalId !== void 0) {
9797
+ try {
9798
+ this.deps.approvalRegistry.resolve(active.pendingApprovalId, "reject", `task ${taskId} finished`);
9799
+ } catch {
9800
+ }
8425
9801
  }
8426
9802
  }
8427
- if (active.gitLease) active.gitLease.release();
9803
+ const attempt = active.disposalAttempt ?? active.session.close();
9804
+ active.disposalAttempt = attempt;
8428
9805
  try {
8429
- await active.session.close();
8430
- } catch {
9806
+ await attempt;
9807
+ } catch (caught) {
9808
+ if (active.disposalAttempt === attempt) active.disposalAttempt = void 0;
9809
+ const failure = isRuntimeDisposalFailure(caught) ? caught : new RuntimeDisposalFailure({
9810
+ stage: "quiescence",
9811
+ reason: `${active.adapter.descriptor.id} session.close() returned an untyped disposal failure`
9812
+ }, { cause: caught });
9813
+ console.error(`[byok/client] runtime disposal failed for task ${taskId}: ${failure.message}`);
9814
+ this.deps.onRuntimeDisposalFailure?.({
9815
+ taskId,
9816
+ runtimeId: active.adapter.descriptor.id,
9817
+ stage: failure.stage,
9818
+ reason: failure.message
9819
+ });
9820
+ active.resolveSemanticTerminalSettled?.(false);
9821
+ return false;
8431
9822
  }
9823
+ if (this.tasks.get(taskId) !== active) return true;
9824
+ active.gitLease?.release();
9825
+ this.tasks.delete(taskId);
9826
+ active.resolveSemanticTerminalSettled?.(true);
9827
+ return true;
9828
+ }
9829
+ reserveSemanticTerminal(active) {
9830
+ if (active.semanticTerminalReserved || active.finalizationStarted) return false;
9831
+ active.semanticTerminalReserved = true;
9832
+ active.beingTornDown = true;
9833
+ active.semanticTerminalSettled = new Promise((resolve) => {
9834
+ active.resolveSemanticTerminalSettled = resolve;
9835
+ });
9836
+ return true;
8432
9837
  }
8433
9838
  /** 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). */
8434
9839
  addFinishedTaskId(taskId) {
@@ -8471,7 +9876,7 @@ var TaskRunner = class {
8471
9876
  * is device-specific (which runtimes happen to be installed here), so a
8472
9877
  * different device's installed runtime set might satisfy it.
8473
9878
  */
8474
- async pickAdapter(requestedRuntime, policyMode) {
9879
+ async pickAdapter(requestedRuntime, policyMode, requiresMcpToolsets) {
8475
9880
  const allowlist = this.deps.runtimeAllowlist;
8476
9881
  if (requestedRuntime) {
8477
9882
  if (allowlist && !allowlist.includes(requestedRuntime)) {
@@ -8481,17 +9886,25 @@ var TaskRunner = class {
8481
9886
  retryable: false
8482
9887
  };
8483
9888
  }
8484
- const adapter = this.deps.adapters.find((a) => a.id === requestedRuntime);
9889
+ const adapter = this.deps.adapters.find((a) => a.descriptor.id === requestedRuntime);
8485
9890
  if (!adapter) {
8486
9891
  return { ok: false, reason: `unknown runtime "${requestedRuntime}"`, retryable: false };
8487
9892
  }
8488
- if (!adapterSupportsMode(adapter, policyMode)) {
9893
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
9894
+ if (!adapterSupportsMode(descriptor, policyMode)) {
8489
9895
  return {
8490
9896
  ok: false,
8491
9897
  reason: `runtime "${requestedRuntime}" cannot express permission mode "${policyMode}"`,
8492
9898
  retryable: false
8493
9899
  };
8494
9900
  }
9901
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) {
9902
+ return {
9903
+ ok: false,
9904
+ reason: `runtime "${requestedRuntime}" cannot project required MCP toolsets`,
9905
+ retryable: false
9906
+ };
9907
+ }
8495
9908
  const detected = await adapter.detect();
8496
9909
  if (!detected.present) {
8497
9910
  return {
@@ -8500,18 +9913,20 @@ var TaskRunner = class {
8500
9913
  retryable: true
8501
9914
  };
8502
9915
  }
8503
- return { ok: true, adapter };
9916
+ return { ok: true, adapter, descriptor };
8504
9917
  }
8505
- const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.id)) : this.deps.adapters;
9918
+ const eligible = allowlist ? this.deps.adapters.filter((a) => allowlist.includes(a.descriptor.id)) : this.deps.adapters;
8506
9919
  const candidates = orderByPreference(eligible, this.deps.runtimePreference ?? DEFAULT_RUNTIME_PREFERENCE);
8507
9920
  for (const adapter of candidates) {
8508
- if (!adapterSupportsMode(adapter, policyMode)) continue;
9921
+ const descriptor = freezeRuntimeAdapterDescriptor(adapter.descriptor);
9922
+ if (!adapterSupportsMode(descriptor, policyMode)) continue;
9923
+ if (requiresMcpToolsets && !adapterSupportsMcpToolsets(descriptor)) continue;
8509
9924
  const detected = await adapter.detect();
8510
- if (detected.present) return { ok: true, adapter };
9925
+ if (detected.present) return { ok: true, adapter, descriptor };
8511
9926
  }
8512
9927
  return {
8513
9928
  ok: false,
8514
- reason: `no available runtime on this device can express permission mode "${policyMode}"`,
9929
+ reason: requiresMcpToolsets ? `no available runtime on this device can express permission mode "${policyMode}" with required MCP toolsets` : `no available runtime on this device can express permission mode "${policyMode}"`,
8515
9930
  retryable: true
8516
9931
  };
8517
9932
  }
@@ -8542,7 +9957,7 @@ function toJournalEnvelopeRecord(envelope, identity) {
8542
9957
  bytes,
8543
9958
  bytesHash: journalHash(bytes),
8544
9959
  receivedAt: (/* @__PURE__ */ new Date()).toISOString(),
8545
- opensTask: envelope.type === "task.offer"
9960
+ opensTask: envelope.type === "task.offer" || envelope.type === "task.offer_with_toolsets"
8546
9961
  };
8547
9962
  }
8548
9963
  function isRuntimeId(id) {
@@ -8552,43 +9967,234 @@ async function detectRuntimes(adapters) {
8552
9967
  const detections = await Promise.all(adapters.map(async (adapter) => ({ adapter, detected: await adapter.detect() })));
8553
9968
  const runtimes = [];
8554
9969
  for (const { adapter, detected } of detections) {
8555
- if (!detected.present || !isRuntimeId(adapter.id)) continue;
8556
- const info = { id: adapter.id };
9970
+ if (!detected.present || !isRuntimeId(adapter.descriptor.id)) continue;
9971
+ const info = { id: adapter.descriptor.id };
8557
9972
  if (detected.version !== void 0) info.version = detected.version;
8558
9973
  if (detected.authPresent !== void 0) info.authPresent = detected.authPresent;
8559
- info.capabilities = toRuntimeInfoCapabilities(adapter.capabilities());
9974
+ info.capabilities = toRuntimeInfoCapabilities(adapter.descriptor.capabilities);
8560
9975
  runtimes.push(info);
8561
9976
  }
8562
9977
  return runtimes;
8563
9978
  }
8564
9979
  function computeCapabilities(adapters) {
8565
9980
  const flags = [];
8566
- if (adapters.some((adapter) => adapter.capabilities().steer)) flags.push("steer");
9981
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.steer)) flags.push("steer");
8567
9982
  flags.push("blob-upload");
8568
9983
  flags.push("approval-targeting");
9984
+ const selectionAdapters = adapters.filter(
9985
+ (adapter) => ALL_RUNTIME_IDS.includes(adapter.descriptor.id)
9986
+ );
9987
+ if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.descriptor.supportsDispatchSelection === true)) {
9988
+ flags.push("dispatch-selection");
9989
+ }
9990
+ if (adapters.some((adapter) => adapter.descriptor.capabilities.mcpToolsets === true)) {
9991
+ flags.push("toolset-selection");
9992
+ }
8569
9993
  return flags;
8570
9994
  }
8571
9995
  var ALL_RUNTIME_IDS = ["pi", "claude", "codex"];
8572
- function buildAdapter(id) {
9996
+ function buildAdapter(id, config) {
8573
9997
  switch (id) {
8574
9998
  case "pi":
8575
- return new PiAdapter();
9999
+ return new PiAdapter({ byokLauncher: config.piByokLauncher });
8576
10000
  case "claude":
8577
10001
  return new ClaudeAdapter();
8578
10002
  case "codex":
8579
10003
  return new CodexAdapter();
8580
10004
  }
8581
10005
  }
8582
- function buildDefaultAdapters(runtimeAllowlist) {
8583
- const ids = runtimeAllowlist ? ALL_RUNTIME_IDS.filter((id) => runtimeAllowlist.includes(id)) : ALL_RUNTIME_IDS;
8584
- return ids.map(buildAdapter);
10006
+ function buildDefaultAdapters(config) {
10007
+ const ids = config.runtimeAllowlist ? ALL_RUNTIME_IDS.filter((id) => config.runtimeAllowlist?.includes(id)) : ALL_RUNTIME_IDS;
10008
+ return ids.map((id) => buildAdapter(id, config));
10009
+ }
10010
+ function validatePiByokLauncherConfig(launcher) {
10011
+ for (const [field, value] of [
10012
+ ["command", launcher.command],
10013
+ ["profileDbPath", launcher.profileDbPath],
10014
+ ["sessionDir", launcher.sessionDir]
10015
+ ]) {
10016
+ if (value.trim().length === 0 || /[\u0000\r\n]/u.test(value)) {
10017
+ throw new Error(`DaemonConfig.piByokLauncher.${field} must be a non-empty single-line string`);
10018
+ }
10019
+ }
10020
+ if (!isAbsolute(launcher.profileDbPath) || !isAbsolute(launcher.sessionDir)) {
10021
+ throw new Error(
10022
+ "DaemonConfig.piByokLauncher profileDbPath and sessionDir must be absolute paths"
10023
+ );
10024
+ }
10025
+ if (launcher.secretServicePrefix !== void 0 && (launcher.secretServicePrefix.trim().length === 0 || /[\u0000\r\n]/u.test(launcher.secretServicePrefix))) {
10026
+ throw new Error(
10027
+ "DaemonConfig.piByokLauncher.secretServicePrefix must be a non-empty single-line string"
10028
+ );
10029
+ }
10030
+ const reserved = /* @__PURE__ */ new Set([
10031
+ "--",
10032
+ "--pi-bin",
10033
+ "--profile-db",
10034
+ "--session-dir",
10035
+ "--secret-service-prefix",
10036
+ "--provider",
10037
+ "--model"
10038
+ ]);
10039
+ const conflicting = launcher.args?.find((arg) => reserved.has(arg));
10040
+ if (conflicting !== void 0) {
10041
+ throw new Error(
10042
+ `DaemonConfig.piByokLauncher.args must not override reserved launcher argument ${conflicting}`
10043
+ );
10044
+ }
10045
+ const invalidArg = launcher.args?.find((arg) => arg.length === 0 || /[\u0000\r\n]/u.test(arg));
10046
+ if (invalidArg !== void 0) {
10047
+ throw new Error("DaemonConfig.piByokLauncher.args must contain only non-empty single-line strings");
10048
+ }
10049
+ }
10050
+ var MAX_LOCAL_MCP_SERVERS_PER_TOOLSET = 16;
10051
+ var MAX_LOCAL_MCP_ARGS = 64;
10052
+ var MAX_LOCAL_MCP_TOKEN_CHARS = 4096;
10053
+ function isNonEmptySingleLine(value) {
10054
+ return typeof value === "string" && value.trim().length > 0 && value.length <= MAX_LOCAL_MCP_TOKEN_CHARS && !/[\u0000\r\n]/u.test(value);
10055
+ }
10056
+ function resolveMcpToolsets(configured) {
10057
+ if (configured === void 0) return void 0;
10058
+ if (configured === null || typeof configured !== "object" || Array.isArray(configured)) {
10059
+ throw new Error("DaemonConfig.mcpToolsets must be an object keyed by logical toolset id");
10060
+ }
10061
+ const toolsetEntries = Object.entries(configured);
10062
+ if (toolsetEntries.length > CONFIGURED_TOOLSETS_MAX_ITEMS) {
10063
+ throw new Error(
10064
+ `DaemonConfig.mcpToolsets may contain at most ${CONFIGURED_TOOLSETS_MAX_ITEMS} toolsets`
10065
+ );
10066
+ }
10067
+ const resolved = /* @__PURE__ */ new Map();
10068
+ for (const [toolsetId, rawToolset] of toolsetEntries) {
10069
+ const parsedId = ToolsetIdSchema.safeParse(toolsetId);
10070
+ if (!parsedId.success) {
10071
+ throw new Error(`DaemonConfig.mcpToolsets contains invalid toolset id ${JSON.stringify(toolsetId)}`);
10072
+ }
10073
+ if (rawToolset === null || typeof rawToolset !== "object" || Array.isArray(rawToolset)) {
10074
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId} must be an object`);
10075
+ }
10076
+ const toolsetKeys = Object.keys(rawToolset);
10077
+ if (toolsetKeys.some((key) => key !== "mcpServers")) {
10078
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId} accepts only the mcpServers field`);
10079
+ }
10080
+ const rawServers = rawToolset.mcpServers;
10081
+ if (rawServers === null || typeof rawServers !== "object" || Array.isArray(rawServers)) {
10082
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId}.mcpServers must be an object`);
10083
+ }
10084
+ const serverEntries = Object.entries(rawServers);
10085
+ if (serverEntries.length === 0 || serverEntries.length > MAX_LOCAL_MCP_SERVERS_PER_TOOLSET) {
10086
+ throw new Error(
10087
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers must contain 1-${MAX_LOCAL_MCP_SERVERS_PER_TOOLSET} servers`
10088
+ );
10089
+ }
10090
+ const servers = {};
10091
+ for (const [serverName, rawServer] of serverEntries) {
10092
+ if (!ToolsetIdSchema.safeParse(serverName).success) {
10093
+ throw new Error(
10094
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers contains invalid server name ${JSON.stringify(serverName)}`
10095
+ );
10096
+ }
10097
+ if (serverName === APPROVAL_MCP_SERVER_NAME) {
10098
+ throw new Error(
10099
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName} uses a server name reserved by the daemon`
10100
+ );
10101
+ }
10102
+ if (rawServer === null || typeof rawServer !== "object" || Array.isArray(rawServer)) {
10103
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName} must be an object`);
10104
+ }
10105
+ const serverKeys = Object.keys(rawServer);
10106
+ if (serverKeys.some((key) => key !== "command" && key !== "args")) {
10107
+ throw new Error(
10108
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName} accepts only command and args; env, headers, and remote task data are not supported`
10109
+ );
10110
+ }
10111
+ const server = rawServer;
10112
+ if (!isNonEmptySingleLine(server.command)) {
10113
+ throw new Error(
10114
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName}.command must be a non-empty single-line string no longer than ${MAX_LOCAL_MCP_TOKEN_CHARS} characters`
10115
+ );
10116
+ }
10117
+ if (server.args !== void 0 && !Array.isArray(server.args)) {
10118
+ throw new Error(`DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName}.args must be an array`);
10119
+ }
10120
+ const args = server.args ?? [];
10121
+ if (args.length > MAX_LOCAL_MCP_ARGS || args.some((arg) => !isNonEmptySingleLine(arg))) {
10122
+ throw new Error(
10123
+ `DaemonConfig.mcpToolsets.${toolsetId}.mcpServers.${serverName}.args must contain at most ${MAX_LOCAL_MCP_ARGS} non-empty single-line strings`
10124
+ );
10125
+ }
10126
+ servers[serverName] = Object.freeze({
10127
+ command: server.command,
10128
+ ...args.length > 0 ? { args: Object.freeze([...args]) } : {}
10129
+ });
10130
+ }
10131
+ resolved.set(toolsetId, Object.freeze({ mcpServers: Object.freeze(servers) }));
10132
+ }
10133
+ return resolved;
10134
+ }
10135
+ function resolveDeviceAssertionAudiences(config) {
10136
+ if (config === void 0) return void 0;
10137
+ if (!Array.isArray(config.audiences)) {
10138
+ throw new Error(
10139
+ `DaemonConfig.deviceAssertion.audiences must be an array of exact audience strings \u2014 got ${JSON.stringify(config.audiences)}. Omit the deviceAssertion section (or pass an empty array) to leave the assertion broker disabled.`
10140
+ );
10141
+ }
10142
+ if (config.audiences.length === 0) return void 0;
10143
+ const audiences = /* @__PURE__ */ new Set();
10144
+ for (const audience of config.audiences) {
10145
+ if (typeof audience !== "string" || audience.length === 0) {
10146
+ throw new Error(
10147
+ `DaemonConfig.deviceAssertion.audiences entries must be non-empty strings \u2014 got ${JSON.stringify(audience)}`
10148
+ );
10149
+ }
10150
+ if (Buffer.byteLength(audience, "utf8") > DEVICE_ASSERTION_AUDIENCE_MAX_BYTES) {
10151
+ throw new Error(
10152
+ `DaemonConfig.deviceAssertion.audiences entry ${JSON.stringify(audience)} exceeds ${DEVICE_ASSERTION_AUDIENCE_MAX_BYTES} UTF-8 bytes`
10153
+ );
10154
+ }
10155
+ if (audiences.has(audience)) {
10156
+ throw new Error(
10157
+ `DaemonConfig.deviceAssertion.audiences contains ${JSON.stringify(audience)} twice \u2014 rejected rather than de-duplicated, because a duplicate is usually a copy-paste that hid a typo in the entry that was meant to be different`
10158
+ );
10159
+ }
10160
+ audiences.add(audience);
10161
+ }
10162
+ return audiences;
10163
+ }
10164
+ function resolveDeviceAssertionTtlMs(config) {
10165
+ const ttlMs = config?.ttlMs ?? DEVICE_ASSERTION_DEFAULT_TTL_MS;
10166
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > DEVICE_ASSERTION_MAX_TTL_MS) {
10167
+ throw new Error(
10168
+ `DaemonConfig.deviceAssertion.ttlMs must be a positive integer no greater than ${DEVICE_ASSERTION_MAX_TTL_MS} ms \u2014 got ${JSON.stringify(config?.ttlMs)}`
10169
+ );
10170
+ }
10171
+ return ttlMs;
8585
10172
  }
8586
10173
  function createDaemonWithAdapters(config, adapters, overrides = {}) {
10174
+ return buildDaemonWithAdapters(config, adapters, overrides);
10175
+ }
10176
+ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProbe) {
10177
+ const mcpToolsets = resolveMcpToolsets(config.mcpToolsets);
10178
+ const configuredToolsets = Object.freeze(
10179
+ [...mcpToolsets?.keys() ?? []].sort()
10180
+ );
10181
+ if (config.piByokLauncher !== void 0) {
10182
+ validatePiByokLauncherConfig(config.piByokLauncher);
10183
+ }
8587
10184
  if (config.maxTaskOutputBytes !== void 0 && !(config.maxTaskOutputBytes > 0)) {
8588
10185
  throw new Error(
8589
10186
  `DaemonConfig.maxTaskOutputBytes must be a positive number (or omitted to use the ${DEFAULT_MAX_TASK_OUTPUT_BYTES}-byte default) \u2014 got ${config.maxTaskOutputBytes}. Pass Number.POSITIVE_INFINITY to explicitly disable the cap; 0 or a negative number is rejected rather than silently treated as "disabled".`
8590
10187
  );
8591
10188
  }
10189
+ const presenceCadence = {
10190
+ intervalMs: config.presence?.intervalMs ?? DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS,
10191
+ ttlMs: config.presence?.ttlMs ?? DEFAULT_PRESENCE_TTL_MS,
10192
+ minimumIntervalMs: config.presence?.minimumIntervalMs ?? DEFAULT_PRESENCE_MINIMUM_INTERVAL_MS
10193
+ };
10194
+ assertPresenceHeartbeatCadence(presenceCadence);
10195
+ const deviceAssertionAudiences = resolveDeviceAssertionAudiences(config.deviceAssertion);
10196
+ const deviceAssertionTtlMs = resolveDeviceAssertionTtlMs(config.deviceAssertion);
10197
+ let shuttingDown = false;
8592
10198
  const storeDir = DeviceStore.resolveDir(config.productId, config.storeDir);
8593
10199
  const store = new DeviceStore(storeDir);
8594
10200
  const operationalHealth = new OperationalHealthTracker(storeDir);
@@ -8679,6 +10285,9 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8679
10285
  let runner;
8680
10286
  let controlServerHandle;
8681
10287
  let daemonOwnerLease;
10288
+ let presencePublisher;
10289
+ let presenceDiscovery;
10290
+ let presenceDiscoveryInFlight = false;
8682
10291
  let shutdownPromise;
8683
10292
  const pendingLateMutationBarriers = /* @__PURE__ */ new Set();
8684
10293
  let startedAt;
@@ -8851,6 +10460,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8851
10460
  deviceId: record.deviceId,
8852
10461
  // M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment above.
8853
10462
  runtimeEnvironment: config.runtimeEnvironment,
10463
+ ...mcpToolsets ? { mcpToolsets } : {},
8854
10464
  // M3-2a: `send` is already this file's OWN closure (not something
8855
10465
  // `TaskRunner` builds) — every `task.claim`/`task.started`/
8856
10466
  // `task.progress`/`task.artifact`/`task.await_approval`/
@@ -8874,12 +10484,13 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8874
10484
  dirty: event.observation ? { staged: event.observation.staged, unstaged: event.observation.unstaged, untracked: event.observation.untracked, conflicted: event.observation.conflicted } : void 0,
8875
10485
  errorCategory: event.errorCategory
8876
10486
  }),
10487
+ onRuntimeDisposalFailure: (event) => observer.noteRuntimeDisposalFailure(event),
8877
10488
  // M4 Phase 3: the SAME `ApprovalRegistry` instance the control
8878
10489
  // socket's own `approvals.list`/`approvals.resolve` methods already
8879
10490
  // share (see that field's own construction above) — `TaskRunner
8880
10491
  // .requestApproval` registers into it directly, so a decision arriving
8881
10492
  // via either the server wire or the local CLI resolves the identical
8882
- // entry. `storeDir`/`productId` let `TaskContext.approvalChannel`
10493
+ // entry. `storeDir`/`productId` let the prepared operation approval channel
8883
10494
  // (populated per-task by `TaskRunner`) tell an out-of-process helper
8884
10495
  // (`bin/byok-approval-mcp.ts`) exactly which control socket to dial.
8885
10496
  approvalRegistry,
@@ -8890,6 +10501,11 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8890
10501
  shutdownInterruptTimeoutMs: overrides.shutdown?.taskInterruptTimeoutMs,
8891
10502
  // M5 batch-3 (workstream 2): see DaemonConfig.maxTaskOutputBytes's own doc comment — already validated above.
8892
10503
  maxTaskOutputBytes: config.maxTaskOutputBytes,
10504
+ // additive-minor (`task.complete.document`): passed through verbatim,
10505
+ // absent when unconfigured — see `DaemonConfig.resultDocument`'s own
10506
+ // doc comment. Spread rather than assigned so an unconfigured daemon
10507
+ // builds the exact `deps` object it did before this seam existed.
10508
+ ...config.resultDocument ? { resultDocument: config.resultDocument } : {},
8893
10509
  // M4 Phase 3 hardening: bridges TaskRunner's stale-approval-race
8894
10510
  // finding out to the SAME local observability seam every other
8895
10511
  // daemon-local event already uses (see observer.ts's own module doc
@@ -8926,6 +10542,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8926
10542
  productId: config.productId,
8927
10543
  capabilities,
8928
10544
  runtimes,
10545
+ configuredToolsets,
8929
10546
  auth,
8930
10547
  cursorStore,
8931
10548
  // Finding F3: return (not void-and-forget) so ConnectionManager can
@@ -8965,8 +10582,10 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8965
10582
  return runner?.handleEnvelope(envelope) ?? Promise.resolve();
8966
10583
  },
8967
10584
  onStateChange: (state) => {
10585
+ const wasSettled = connectionState === "open" || connectionState === "degraded";
8968
10586
  connectionState = state;
8969
10587
  observer.noteConnectionState(state);
10588
+ if (!wasSettled && (state === "open" || state === "degraded")) runPresenceDiscovery();
8970
10589
  },
8971
10590
  backoff: overrides.backoff,
8972
10591
  liveness: overrides.liveness,
@@ -8984,6 +10603,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8984
10603
  });
8985
10604
  await connection.start();
8986
10605
  await connection.waitForAck();
10606
+ startPresenceProducer();
8987
10607
  } catch (err) {
8988
10608
  try {
8989
10609
  await runShutdownSequence("startup failed", { drainTimeoutMs: 0 });
@@ -8993,7 +10613,42 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
8993
10613
  throw err;
8994
10614
  }
8995
10615
  }
10616
+ function startPresenceProducer() {
10617
+ presenceDiscovery = new AbortController();
10618
+ runPresenceDiscovery();
10619
+ }
10620
+ function runPresenceDiscovery() {
10621
+ const discovery = presenceDiscovery;
10622
+ if (!discovery || presenceDiscoveryInFlight) return;
10623
+ presenceDiscoveryInFlight = true;
10624
+ void (async () => {
10625
+ try {
10626
+ const declaration = await fetchCapabilityDeclaration(config.serverUrl, { signal: discovery.signal });
10627
+ if (discovery.signal.aborted) return;
10628
+ if (declares(declaration, PRESENCE_HINTS_CAPABILITY)) {
10629
+ presencePublisher ??= new PresencePublisher({
10630
+ serverUrl: config.serverUrl,
10631
+ auth,
10632
+ configuredToolsets,
10633
+ ...presenceCadence,
10634
+ onDegraded: (reason) => console.warn(`[byok/client] ${reason}`)
10635
+ });
10636
+ presencePublisher.start();
10637
+ } else {
10638
+ presencePublisher?.stop();
10639
+ }
10640
+ } catch (err) {
10641
+ if (discovery.signal.aborted) return;
10642
+ console.warn(
10643
+ `[byok/client] capability discovery failed; presence publishing stays off until the next reconnect: ${err instanceof Error ? err.message : String(err)}`
10644
+ );
10645
+ } finally {
10646
+ presenceDiscoveryInFlight = false;
10647
+ }
10648
+ })();
10649
+ }
8996
10650
  async function runShutdownSequence(reason, opts = {}) {
10651
+ shuttingDown = true;
8997
10652
  const errors = [];
8998
10653
  let mutationBarrierComplete = hostedStorageInitializationBarrierComplete;
8999
10654
  if (!hostedStorageInitializationBarrierComplete) {
@@ -9022,6 +10677,11 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9022
10677
  errors.push(new Error("a prior active task teardown remains unsettled; ownership lease retained"));
9023
10678
  }
9024
10679
  }
10680
+ presenceDiscovery?.abort();
10681
+ presenceDiscovery = void 0;
10682
+ presenceDiscoveryInFlight = false;
10683
+ presencePublisher?.stop();
10684
+ presencePublisher = void 0;
9025
10685
  const stoppingOwnedPressureEngine = ownedPressureEngine;
9026
10686
  const stoppingOwnedJournal = ownedJournal;
9027
10687
  const maintenanceStopped = stoppingOwnedPressureEngine?.stop() ?? Promise.resolve();
@@ -9063,8 +10723,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9063
10723
  await attempt(() => auth.stop(), true);
9064
10724
  connectionState = "closed";
9065
10725
  await attempt(async () => {
9066
- await controlServerHandle?.close();
9067
- controlServerHandle = void 0;
10726
+ await controlServerHandle?.stopServing();
9068
10727
  }, true);
9069
10728
  if (mutationBarrierComplete && daemonOwnerLease) {
9070
10729
  await attempt(() => operationalHealth.markCleanStop(), false);
@@ -9073,6 +10732,12 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9073
10732
  daemonOwnerLease = void 0;
9074
10733
  }, false);
9075
10734
  }
10735
+ if (daemonOwnerLease === void 0) {
10736
+ await attempt(async () => {
10737
+ await controlServerHandle?.close();
10738
+ controlServerHandle = void 0;
10739
+ }, false);
10740
+ }
9076
10741
  if (errors.length === 1) throw errors[0];
9077
10742
  if (errors.length > 1) {
9078
10743
  throw new AggregateError(errors, "daemon shutdown completed with errors");
@@ -9082,6 +10747,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9082
10747
  }
9083
10748
  }
9084
10749
  async function stop(opts = {}) {
10750
+ shuttingDown = true;
9085
10751
  await requestShutdown(opts.reason ?? "operator", { drainTimeoutMs: opts.drainTimeoutMs });
9086
10752
  }
9087
10753
  function requestShutdown(reason, opts = {}) {
@@ -9094,6 +10760,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9094
10760
  return current;
9095
10761
  }
9096
10762
  async function unpair() {
10763
+ shuttingDown = true;
9097
10764
  await runLifecycleMutation(unpairUnderLease);
9098
10765
  }
9099
10766
  async function unpairUnderLease() {
@@ -9150,7 +10817,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9150
10817
  deviceId: auth.deviceId,
9151
10818
  transport: connectionState,
9152
10819
  activeTasks,
9153
- runtimeIds: adapters.map((adapter) => adapter.id),
10820
+ runtimeIds: adapters.map((adapter) => adapter.descriptor.id),
9154
10821
  // M4 Phase 4 (part B.3): queue watermarks come from TaskRunner's own
9155
10822
  // active-task map (distinct from `observer.tasks()` above, which is
9156
10823
  // derived from the envelope feed) — see `TaskRunner.getQueueWatermarks`'s
@@ -9172,6 +10839,7 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9172
10839
  }
9173
10840
  async function performControlShutdown(reason) {
9174
10841
  const effectiveReason = reason ?? "operator";
10842
+ shuttingDown = true;
9175
10843
  observer.noteShutdownRequested(effectiveReason);
9176
10844
  try {
9177
10845
  await requestShutdown(`control socket shutdown (${effectiveReason})`);
@@ -9209,7 +10877,107 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9209
10877
  if (!runner) throw new ControlError("not_found", "daemon is not started");
9210
10878
  return runner.requestApproval(parsed.taskId, parsed.summary);
9211
10879
  },
10880
+ /**
10881
+ * Plan `device-assertion-broker`: mint one short-lived, audience-scoped
10882
+ * device assertion for a sibling local process.
10883
+ *
10884
+ * SIX fail-closed gates, in this exact order, none of which signs
10885
+ * anything on the way out. The order is part of the contract, not an
10886
+ * implementation detail:
10887
+ *
10888
+ * 1. `assertion_disabled` — before anything else, because a daemon that
10889
+ * was never configured for this must not reveal, by answering
10890
+ * differently for different inputs, that it even validates params.
10891
+ * 2. `bad_request` — shape/length, checked before the allowlist so a
10892
+ * malformed request cannot be used to probe membership.
10893
+ * 3. `audience_denied` — EXACT `Set.has`, never a prefix/suffix rule
10894
+ * (`salesko-api.evil.com` and `salesko-ap` both fail against an entry
10895
+ * of `salesko-api`). The message deliberately does not echo the
10896
+ * allowlist: a refusal must not be an enumeration oracle.
10897
+ * 4. `shutting_down` — see `performControlShutdown`'s own comment for
10898
+ * the minting window this closes.
10899
+ * 5. `revoked` — the server-side revocation this daemon already knows
10900
+ * about.
10901
+ * 6. `not_paired` — the on-disk record, re-read on EVERY call (never
10902
+ * cached), so clearing `device.json` removes local signing authority
10903
+ * immediately.
10904
+ *
10905
+ * Only after all six does the private key get imported, used once, and
10906
+ * dropped (`device-assertion-signer.ts`).
10907
+ *
10908
+ * Honest limit, and it must stay in the docs as well as here: gates 4-6
10909
+ * are only HALF of revocation. They make this daemon stop minting
10910
+ * promptly, but an assertion already in a caller's hands is not recalled
10911
+ * by any of them. The other half is the host's own recheck at exchange
10912
+ * time, which is why core's `verifyDeviceAssertion` makes the device
10913
+ * row's `revoked` state a REQUIRED parameter. Nothing here entitles
10914
+ * anyone to claim this daemon delivers synchronous invalidation on its
10915
+ * own.
10916
+ */
10917
+ "assertion.issue": async (params) => {
10918
+ if (deviceAssertionAudiences === void 0) {
10919
+ observer.noteDeviceAssertion({ result: "denied", reason: "assertion_disabled" });
10920
+ throw new ControlError(
10921
+ "assertion_disabled",
10922
+ "this daemon is not configured to issue device assertions (DaemonConfig.deviceAssertion.audiences is absent or empty)"
10923
+ );
10924
+ }
10925
+ const parsed = parseAssertionIssueParams(params);
10926
+ if (!parsed) {
10927
+ const rawAudience = typeof params === "object" && params !== null && typeof params.audience === "string" ? params.audience : void 0;
10928
+ observer.noteDeviceAssertion({ result: "denied", reason: "bad_request", audience: rawAudience });
10929
+ throw new ControlError(
10930
+ "bad_request",
10931
+ `assertion.issue requires exactly {audience} where audience is a non-empty string of at most ${DEVICE_ASSERTION_AUDIENCE_MAX_BYTES} UTF-8 bytes`
10932
+ );
10933
+ }
10934
+ if (!deviceAssertionAudiences.has(parsed.audience)) {
10935
+ observer.noteDeviceAssertion({ result: "denied", reason: "audience_denied", audience: parsed.audience });
10936
+ throw new ControlError("audience_denied", "the requested audience is not allowed by this daemon");
10937
+ }
10938
+ if (shuttingDown) {
10939
+ observer.noteDeviceAssertion({ result: "denied", reason: "shutting_down", audience: parsed.audience });
10940
+ throw new ControlError("shutting_down", "this daemon is shutting down and will not issue new assertions");
10941
+ }
10942
+ if (auth.isRevoked()) {
10943
+ observer.noteDeviceAssertion({ result: "denied", reason: "revoked", audience: parsed.audience });
10944
+ throw new ControlError("revoked", "this device has been revoked by the server; re-pair required");
10945
+ }
10946
+ const record = await store.load();
10947
+ if (record === void 0) {
10948
+ observer.noteDeviceAssertion({ result: "denied", reason: "not_paired", audience: parsed.audience });
10949
+ throw new ControlError("not_paired", "this device is not paired; nothing can be asserted about it");
10950
+ }
10951
+ if (shuttingDown) {
10952
+ observer.noteDeviceAssertion({ result: "denied", reason: "shutting_down", audience: parsed.audience });
10953
+ throw new ControlError("shutting_down", "this daemon is shutting down and will not issue new assertions");
10954
+ }
10955
+ if (auth.isRevoked()) {
10956
+ observer.noteDeviceAssertion({ result: "denied", reason: "revoked", audience: parsed.audience });
10957
+ throw new ControlError("revoked", "this device has been revoked by the server; re-pair required");
10958
+ }
10959
+ const minted = mintDeviceAssertion({
10960
+ record,
10961
+ // `toHttpBase` is the one place a configured serverUrl is normalized
10962
+ // (ws:->http:, wss:->https:, path stripped), so an operator who
10963
+ // configured the websocket spelling and one who configured the HTTP
10964
+ // spelling of the same deployment produce the same issuer.
10965
+ issuer: new URL(toHttpBase(config.serverUrl)).origin,
10966
+ productId: config.productId,
10967
+ audience: parsed.audience,
10968
+ ttlMs: deviceAssertionTtlMs,
10969
+ now: /* @__PURE__ */ new Date()
10970
+ });
10971
+ observer.noteDeviceAssertion({
10972
+ result: "issued",
10973
+ audience: minted.claims.audience,
10974
+ jti: minted.claims.jti,
10975
+ expiresAt: minted.expiresAt
10976
+ });
10977
+ return { assertion: minted.envelope, expiresAt: minted.expiresAt };
10978
+ },
9212
10979
  shutdown: (params) => {
10980
+ shuttingDown = true;
9213
10981
  const { reason } = parseShutdownParams(params);
9214
10982
  setImmediate(() => {
9215
10983
  void performControlShutdown(reason).catch((err) => {
@@ -9254,28 +11022,285 @@ function createDaemonWithAdapters(config, adapters, overrides = {}) {
9254
11022
  return { pair, start, stop, status, subscribe, tasks, unpair, approve, reject };
9255
11023
  }
9256
11024
  function createDaemon(config) {
9257
- return createDaemonWithAdapters(config, buildDefaultAdapters(config.runtimeAllowlist));
11025
+ return createDaemonWithAdapters(config, buildDefaultAdapters(config));
9258
11026
  }
9259
-
9260
- // src/lifecycle/service-types.ts
9261
- function nodeAgentProgram(opts) {
9262
- const program = {
9263
- command: opts.nodeBin ?? process.execPath,
9264
- args: [opts.agentBin, "start", "--config", opts.configPath]
9265
- };
9266
- if (opts.cwd !== void 0) program.cwd = opts.cwd;
9267
- return program;
11027
+ var MAX_CONTROL_TOKEN_BYTES = 256;
11028
+ function errorMessage5(err) {
11029
+ return err instanceof Error ? err.message : String(err);
9268
11030
  }
9269
- function sanitizeServiceName(name) {
9270
- const cleaned = name.trim().replace(/[^A-Za-z0-9._-]+/g, "-");
9271
- const safe = cleaned.replace(/^-+/, "");
9272
- if (!safe) {
9273
- throw new Error(`service name "${name}" has no valid characters left after sanitizing (allowed: letters, digits, ".", "-", "_"; cannot consist only of leading "-")`);
9274
- }
9275
- return safe;
11031
+ function sameFileState3(left, right) {
11032
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
9276
11033
  }
9277
-
9278
- // src/lifecycle/launchd.ts
11034
+ async function readControlToken(tokenPath) {
11035
+ let namedBefore;
11036
+ try {
11037
+ namedBefore = await promises.lstat(tokenPath, { bigint: true });
11038
+ } catch (err) {
11039
+ if (err.code === "ENOENT") return void 0;
11040
+ throw err;
11041
+ }
11042
+ if (!namedBefore.isFile() || namedBefore.isSymbolicLink()) {
11043
+ throw new Error("control token is not a real regular file");
11044
+ }
11045
+ const handle = await promises.open(
11046
+ tokenPath,
11047
+ constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0)
11048
+ );
11049
+ try {
11050
+ const opened = await handle.stat({ bigint: true });
11051
+ const namedAfterOpen = await promises.lstat(tokenPath, { bigint: true });
11052
+ if (!opened.isFile() || !namedAfterOpen.isFile() || namedAfterOpen.isSymbolicLink() || !sameFileState3(namedBefore, opened) || !sameFileState3(opened, namedAfterOpen)) {
11053
+ throw new Error("control token pathname changed before safe open");
11054
+ }
11055
+ if (opened.size < 0 || opened.size > BigInt(MAX_CONTROL_TOKEN_BYTES)) {
11056
+ throw new Error("control token exceeds the bounded read limit");
11057
+ }
11058
+ const size = Number(opened.size);
11059
+ const bytes = Buffer.alloc(size);
11060
+ const { bytesRead } = await handle.read(bytes, 0, size, 0);
11061
+ const afterRead = await handle.stat({ bigint: true });
11062
+ const namedAfterRead = await promises.lstat(tokenPath, { bigint: true });
11063
+ if (bytesRead !== size || namedAfterRead.isSymbolicLink() || !sameFileState3(opened, afterRead) || !sameFileState3(afterRead, namedAfterRead)) {
11064
+ throw new Error("control token changed during bounded read");
11065
+ }
11066
+ return bytes.toString("utf8").trim();
11067
+ } finally {
11068
+ await handle.close();
11069
+ }
11070
+ }
11071
+ async function connectControlClient(opts) {
11072
+ const tokenPath = controlTokenPath(opts.storeDir);
11073
+ let token;
11074
+ try {
11075
+ const read = await readControlToken(tokenPath);
11076
+ if (read === void 0) {
11077
+ return { ok: false, reason: "daemon is not running (no control.token found)" };
11078
+ }
11079
+ token = read;
11080
+ } catch (err) {
11081
+ return { ok: false, reason: `could not read the control token: ${errorMessage5(err)}` };
11082
+ }
11083
+ if (!token) {
11084
+ return { ok: false, reason: "control token file is empty" };
11085
+ }
11086
+ const endpoint = controlEndpointPath(opts.productId, opts.storeDir);
11087
+ try {
11088
+ const client = await connectAndHandshake(endpoint, token, opts);
11089
+ return { ok: true, client };
11090
+ } catch (err) {
11091
+ return { ok: false, reason: `daemon control socket not reachable: ${errorMessage5(err)}` };
11092
+ }
11093
+ }
11094
+ function connectAndHandshake(endpoint, token, opts) {
11095
+ return new Promise((resolve, reject) => {
11096
+ const socket = net.createConnection(endpoint);
11097
+ const reader = new NdjsonLineReader();
11098
+ let phase = "server-hello";
11099
+ let settled = false;
11100
+ const clientNonce = randomNonceHex();
11101
+ const timer = setTimeout(() => {
11102
+ fail(new Error("handshake timed out"));
11103
+ }, opts.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
11104
+ timer.unref?.();
11105
+ function fail(err) {
11106
+ if (settled) return;
11107
+ settled = true;
11108
+ clearTimeout(timer);
11109
+ socket.removeAllListeners();
11110
+ socket.destroy();
11111
+ reject(err instanceof Error ? err : new Error(String(err)));
11112
+ }
11113
+ function succeed() {
11114
+ settled = true;
11115
+ clearTimeout(timer);
11116
+ socket.removeListener("error", onError);
11117
+ socket.removeListener("data", onData);
11118
+ resolve(createControlClient(socket, reader, opts));
11119
+ }
11120
+ function onData(chunk) {
11121
+ let lines;
11122
+ try {
11123
+ lines = reader.push(chunk);
11124
+ } catch (err) {
11125
+ fail(err);
11126
+ return;
11127
+ }
11128
+ for (const line of lines) {
11129
+ let parsed;
11130
+ try {
11131
+ parsed = JSON.parse(line);
11132
+ } catch {
11133
+ fail(new Error("malformed handshake frame"));
11134
+ return;
11135
+ }
11136
+ if (phase === "server-hello") {
11137
+ const hello = parseServerHello(parsed);
11138
+ if (!hello) {
11139
+ fail(new Error("malformed or unexpected server hello"));
11140
+ return;
11141
+ }
11142
+ if (!timingSafeEqualHex(hello.proof, computeServerProof(token, clientNonce))) {
11143
+ fail(new Error("server failed to prove it holds the control token"));
11144
+ return;
11145
+ }
11146
+ socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, auth: computeClientAuth(token, hello.nonce) }));
11147
+ phase = "ready";
11148
+ continue;
11149
+ }
11150
+ if (!parseServerReady(parsed)) {
11151
+ fail(new Error("server did not confirm readiness"));
11152
+ return;
11153
+ }
11154
+ succeed();
11155
+ return;
11156
+ }
11157
+ }
11158
+ function onError(err) {
11159
+ fail(err);
11160
+ }
11161
+ socket.once("error", onError);
11162
+ socket.once("connect", () => {
11163
+ socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, hello: "client", nonce: clientNonce }));
11164
+ socket.on("data", onData);
11165
+ });
11166
+ });
11167
+ }
11168
+ function withTimeout(promise, ms, message) {
11169
+ return new Promise((resolve, reject) => {
11170
+ const timer = setTimeout(() => reject(new Error(message)), ms);
11171
+ timer.unref?.();
11172
+ promise.then(
11173
+ (value) => {
11174
+ clearTimeout(timer);
11175
+ resolve(value);
11176
+ },
11177
+ (err) => {
11178
+ clearTimeout(timer);
11179
+ reject(err);
11180
+ }
11181
+ );
11182
+ });
11183
+ }
11184
+ function createControlClient(socket, reader, opts) {
11185
+ const pending = /* @__PURE__ */ new Map();
11186
+ let idSeq = 0;
11187
+ let closed = false;
11188
+ function handleFrame(parsed) {
11189
+ if (!isRecord2(parsed) || typeof parsed.id !== "string") return;
11190
+ const entry = pending.get(parsed.id);
11191
+ if (!entry) return;
11192
+ if ("event" in parsed) {
11193
+ entry.onEvent?.(parsed.event);
11194
+ return;
11195
+ }
11196
+ if (parsed.ok === true) {
11197
+ pending.delete(parsed.id);
11198
+ entry.resolve(parsed.done === true ? void 0 : parsed.result);
11199
+ return;
11200
+ }
11201
+ pending.delete(parsed.id);
11202
+ const shape = parsed.error;
11203
+ entry.reject(
11204
+ new ControlError(
11205
+ typeof shape?.code === "string" ? shape.code : "internal_error",
11206
+ typeof shape?.message === "string" ? shape.message : "unknown control error"
11207
+ )
11208
+ );
11209
+ }
11210
+ socket.on("data", (chunk) => {
11211
+ let lines;
11212
+ try {
11213
+ lines = reader.push(chunk);
11214
+ } catch {
11215
+ socket.destroy();
11216
+ return;
11217
+ }
11218
+ for (const line of lines) {
11219
+ let parsed;
11220
+ try {
11221
+ parsed = JSON.parse(line);
11222
+ } catch {
11223
+ continue;
11224
+ }
11225
+ handleFrame(parsed);
11226
+ }
11227
+ });
11228
+ socket.on("close", () => {
11229
+ closed = true;
11230
+ for (const entry of pending.values()) entry.reject(new Error("control connection closed"));
11231
+ pending.clear();
11232
+ });
11233
+ socket.on("error", () => {
11234
+ });
11235
+ function send(method, params, onEvent) {
11236
+ const id = `c${++idSeq}`;
11237
+ const promise = new Promise((resolve, reject) => {
11238
+ pending.set(id, { resolve, reject, onEvent });
11239
+ });
11240
+ socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, id, method, params }));
11241
+ return { id, promise };
11242
+ }
11243
+ return {
11244
+ async request(method, params) {
11245
+ if (closed) throw new Error("control connection is closed");
11246
+ const { promise } = send(method, params);
11247
+ const result = await withTimeout(promise, opts.requestTimeoutMs ?? 1e4, `control request "${method}" timed out`);
11248
+ return result;
11249
+ },
11250
+ subscribe(method, params, onEvent) {
11251
+ const { id, promise } = send(method, params, onEvent);
11252
+ promise.catch(() => {
11253
+ });
11254
+ return {
11255
+ close: () => {
11256
+ pending.delete(id);
11257
+ socket.destroy();
11258
+ }
11259
+ };
11260
+ },
11261
+ close() {
11262
+ socket.destroy();
11263
+ }
11264
+ };
11265
+ }
11266
+ async function isControlDaemonGone(storeDir, productId) {
11267
+ const tokenGone = await promises.stat(controlTokenPath(storeDir)).then(
11268
+ () => false,
11269
+ (err) => err.code === "ENOENT"
11270
+ );
11271
+ if (!tokenGone) return false;
11272
+ const endpoint = controlEndpointPath(productId, storeDir);
11273
+ return new Promise((resolve) => {
11274
+ const socket = net.createConnection(endpoint);
11275
+ const finish = (gone) => {
11276
+ socket.removeAllListeners();
11277
+ socket.destroy();
11278
+ resolve(gone);
11279
+ };
11280
+ socket.once("connect", () => finish(false));
11281
+ socket.once("error", (err) => finish(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
11282
+ });
11283
+ }
11284
+
11285
+ // src/lifecycle/service-types.ts
11286
+ function nodeAgentProgram(opts) {
11287
+ const program = {
11288
+ command: opts.nodeBin ?? process.execPath,
11289
+ args: [opts.agentBin, "start", "--config", opts.configPath]
11290
+ };
11291
+ if (opts.cwd !== void 0) program.cwd = opts.cwd;
11292
+ return program;
11293
+ }
11294
+ function sanitizeServiceName(name) {
11295
+ const cleaned = name.trim().replace(/[^A-Za-z0-9._-]+/g, "-");
11296
+ const safe = cleaned.replace(/^-+/, "");
11297
+ if (!safe) {
11298
+ throw new Error(`service name "${name}" has no valid characters left after sanitizing (allowed: letters, digits, ".", "-", "_"; cannot consist only of leading "-")`);
11299
+ }
11300
+ return safe;
11301
+ }
11302
+
11303
+ // src/lifecycle/launchd.ts
9279
11304
  var LAUNCHD_CONNECTIVITY_OR_PERMISSION_FAILURE = [
9280
11305
  /operation not permitted/i,
9281
11306
  /could not find domain/i,
@@ -9577,359 +11602,102 @@ function createWinswLifecycle(def, deps = {}) {
9577
11602
  if (!await fileExists(xmlPath)) {
9578
11603
  throw new Error(`service "${id}" is not installed (no config at ${xmlPath}) \u2014 call install() first`);
9579
11604
  }
9580
- await runOrThrow(run, exePath, ["start"], "winsw start");
9581
- }
9582
- async function stop() {
9583
- await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_ALREADY_STOPPED);
9584
- }
9585
- async function status() {
9586
- const installed = await fileExists(xmlPath);
9587
- const result = await run("sc.exe", ["query", id]);
9588
- const detail = (result.stdout || result.stderr).trim();
9589
- const running = result.code === 0 && /\bSTATE\b.*\bRUNNING\b/i.test(detail);
9590
- const determinate = running || !WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
9591
- return { installed, running, determinate, detail };
9592
- }
9593
- return { install, uninstall, start, stop, status };
9594
- }
9595
-
9596
- // src/lifecycle/create-service-lifecycle.ts
9597
- var UnsupportedServicePlatformError = class extends Error {
9598
- constructor(platform) {
9599
- super(`no OS service lifecycle for platform "${platform}" \u2014 supported: darwin (launchd), linux (systemd --user), win32 (WinSW)`);
9600
- this.name = "UnsupportedServicePlatformError";
9601
- }
9602
- };
9603
- function createServiceLifecycle(def, opts = {}) {
9604
- const platform = opts.platform ?? process.platform;
9605
- switch (platform) {
9606
- case "darwin":
9607
- return createLaunchdLifecycle(def, opts.deps);
9608
- case "linux":
9609
- return createSystemdLifecycle(def, opts.deps);
9610
- case "win32":
9611
- return createWinswLifecycle(def, opts.deps);
9612
- default:
9613
- throw new UnsupportedServicePlatformError(platform);
9614
- }
9615
- }
9616
- var REQUIRED_FIELDS = ["productName", "productId", "serverUrl", "workspaceRoot"];
9617
- var ConfigError = class extends Error {
9618
- constructor(message) {
9619
- super(message);
9620
- this.name = "ConfigError";
9621
- }
9622
- };
9623
- function loadConfig(configPath, overrides = {}) {
9624
- let base = {};
9625
- if (configPath) {
9626
- let raw;
9627
- try {
9628
- raw = readFileSync(configPath, "utf8");
9629
- } catch (err) {
9630
- throw new ConfigError(`could not read config at "${configPath}": ${err instanceof Error ? err.message : String(err)}`);
9631
- }
9632
- try {
9633
- base = JSON.parse(raw);
9634
- } catch (err) {
9635
- throw new ConfigError(`config at "${configPath}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
9636
- }
9637
- }
9638
- const merged = { ...base, ...overrides };
9639
- if (merged.gitWorkspace !== void 0) {
9640
- try {
9641
- GitWorkspaceManager.validateConfig(merged.gitWorkspace);
9642
- } catch (error) {
9643
- throw new ConfigError(error instanceof Error ? error.message : "invalid gitWorkspace configuration");
9644
- }
9645
- }
9646
- for (const field of REQUIRED_FIELDS) {
9647
- if (!merged[field]) {
9648
- throw new ConfigError(`config is missing required field "${field}"`);
9649
- }
9650
- }
9651
- return merged;
9652
- }
9653
- function resolveStoreDir(config) {
9654
- return DeviceStore.resolveDir(config.productId, config.storeDir);
9655
- }
9656
- function argValue(args, flag) {
9657
- const idx = args.indexOf(flag);
9658
- const value = idx >= 0 ? args[idx + 1] : void 0;
9659
- return value !== void 0 && !value.startsWith("--") ? value : void 0;
9660
- }
9661
- function hasFlag(args, flag) {
9662
- return args.includes(flag);
9663
- }
9664
- function positionalArgs(args, valueFlags = []) {
9665
- const result = [];
9666
- for (let i = 0; i < args.length; i++) {
9667
- const arg = args[i];
9668
- if (arg === void 0) continue;
9669
- if (valueFlags.includes(arg)) {
9670
- i++;
9671
- continue;
9672
- }
9673
- result.push(arg);
9674
- }
9675
- return result;
9676
- }
9677
- var MAX_CONTROL_TOKEN_BYTES = 256;
9678
- function errorMessage4(err) {
9679
- return err instanceof Error ? err.message : String(err);
9680
- }
9681
- function sameFileState3(left, right) {
9682
- return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
9683
- }
9684
- async function readControlToken(tokenPath) {
9685
- let namedBefore;
9686
- try {
9687
- namedBefore = await promises.lstat(tokenPath, { bigint: true });
9688
- } catch (err) {
9689
- if (err.code === "ENOENT") return void 0;
9690
- throw err;
9691
- }
9692
- if (!namedBefore.isFile() || namedBefore.isSymbolicLink()) {
9693
- throw new Error("control token is not a real regular file");
9694
- }
9695
- const handle = await promises.open(
9696
- tokenPath,
9697
- constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0)
9698
- );
9699
- try {
9700
- const opened = await handle.stat({ bigint: true });
9701
- const namedAfterOpen = await promises.lstat(tokenPath, { bigint: true });
9702
- if (!opened.isFile() || !namedAfterOpen.isFile() || namedAfterOpen.isSymbolicLink() || !sameFileState3(namedBefore, opened) || !sameFileState3(opened, namedAfterOpen)) {
9703
- throw new Error("control token pathname changed before safe open");
9704
- }
9705
- if (opened.size < 0 || opened.size > BigInt(MAX_CONTROL_TOKEN_BYTES)) {
9706
- throw new Error("control token exceeds the bounded read limit");
9707
- }
9708
- const size = Number(opened.size);
9709
- const bytes = Buffer.alloc(size);
9710
- const { bytesRead } = await handle.read(bytes, 0, size, 0);
9711
- const afterRead = await handle.stat({ bigint: true });
9712
- const namedAfterRead = await promises.lstat(tokenPath, { bigint: true });
9713
- if (bytesRead !== size || namedAfterRead.isSymbolicLink() || !sameFileState3(opened, afterRead) || !sameFileState3(afterRead, namedAfterRead)) {
9714
- throw new Error("control token changed during bounded read");
9715
- }
9716
- return bytes.toString("utf8").trim();
9717
- } finally {
9718
- await handle.close();
9719
- }
9720
- }
9721
- async function connectControlClient(opts) {
9722
- const tokenPath = controlTokenPath(opts.storeDir);
9723
- let token;
9724
- try {
9725
- const read = await readControlToken(tokenPath);
9726
- if (read === void 0) {
9727
- return { ok: false, reason: "daemon is not running (no control.token found)" };
9728
- }
9729
- token = read;
9730
- } catch (err) {
9731
- return { ok: false, reason: `could not read the control token: ${errorMessage4(err)}` };
9732
- }
9733
- if (!token) {
9734
- return { ok: false, reason: "control token file is empty" };
9735
- }
9736
- const endpoint = controlEndpointPath(opts.productId, opts.storeDir);
9737
- try {
9738
- const client = await connectAndHandshake(endpoint, token, opts);
9739
- return { ok: true, client };
9740
- } catch (err) {
9741
- return { ok: false, reason: `daemon control socket not reachable: ${errorMessage4(err)}` };
9742
- }
9743
- }
9744
- function connectAndHandshake(endpoint, token, opts) {
9745
- return new Promise((resolve, reject) => {
9746
- const socket = net.createConnection(endpoint);
9747
- const reader = new NdjsonLineReader();
9748
- let phase = "server-hello";
9749
- let settled = false;
9750
- const clientNonce = randomNonceHex();
9751
- const timer = setTimeout(() => {
9752
- fail(new Error("handshake timed out"));
9753
- }, opts.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
9754
- timer.unref?.();
9755
- function fail(err) {
9756
- if (settled) return;
9757
- settled = true;
9758
- clearTimeout(timer);
9759
- socket.removeAllListeners();
9760
- socket.destroy();
9761
- reject(err instanceof Error ? err : new Error(String(err)));
9762
- }
9763
- function succeed() {
9764
- settled = true;
9765
- clearTimeout(timer);
9766
- socket.removeListener("error", onError);
9767
- socket.removeListener("data", onData);
9768
- resolve(createControlClient(socket, reader, opts));
9769
- }
9770
- function onData(chunk) {
9771
- let lines;
9772
- try {
9773
- lines = reader.push(chunk);
9774
- } catch (err) {
9775
- fail(err);
9776
- return;
9777
- }
9778
- for (const line of lines) {
9779
- let parsed;
9780
- try {
9781
- parsed = JSON.parse(line);
9782
- } catch {
9783
- fail(new Error("malformed handshake frame"));
9784
- return;
9785
- }
9786
- if (phase === "server-hello") {
9787
- const hello = parseServerHello(parsed);
9788
- if (!hello) {
9789
- fail(new Error("malformed or unexpected server hello"));
9790
- return;
9791
- }
9792
- if (!timingSafeEqualHex(hello.proof, computeServerProof(token, clientNonce))) {
9793
- fail(new Error("server failed to prove it holds the control token"));
9794
- return;
9795
- }
9796
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, auth: computeClientAuth(token, hello.nonce) }));
9797
- phase = "ready";
9798
- continue;
9799
- }
9800
- if (!parseServerReady(parsed)) {
9801
- fail(new Error("server did not confirm readiness"));
9802
- return;
9803
- }
9804
- succeed();
9805
- return;
9806
- }
9807
- }
9808
- function onError(err) {
9809
- fail(err);
9810
- }
9811
- socket.once("error", onError);
9812
- socket.once("connect", () => {
9813
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, hello: "client", nonce: clientNonce }));
9814
- socket.on("data", onData);
9815
- });
9816
- });
11605
+ await runOrThrow(run, exePath, ["start"], "winsw start");
11606
+ }
11607
+ async function stop() {
11608
+ await runIdempotent(run, exePath, ["stop"], "winsw stop", WINSW_ALREADY_STOPPED);
11609
+ }
11610
+ async function status() {
11611
+ const installed = await fileExists(xmlPath);
11612
+ const result = await run("sc.exe", ["query", id]);
11613
+ const detail = (result.stdout || result.stderr).trim();
11614
+ const running = result.code === 0 && /\bSTATE\b.*\bRUNNING\b/i.test(detail);
11615
+ const determinate = running || !WINSW_CONNECTIVITY_OR_PERMISSION_FAILURE.some((pattern) => pattern.test(detail));
11616
+ return { installed, running, determinate, detail };
11617
+ }
11618
+ return { install, uninstall, start, stop, status };
9817
11619
  }
9818
- function withTimeout(promise, ms, message) {
9819
- return new Promise((resolve, reject) => {
9820
- const timer = setTimeout(() => reject(new Error(message)), ms);
9821
- timer.unref?.();
9822
- promise.then(
9823
- (value) => {
9824
- clearTimeout(timer);
9825
- resolve(value);
9826
- },
9827
- (err) => {
9828
- clearTimeout(timer);
9829
- reject(err);
9830
- }
9831
- );
9832
- });
11620
+
11621
+ // src/lifecycle/create-service-lifecycle.ts
11622
+ var UnsupportedServicePlatformError = class extends Error {
11623
+ constructor(platform) {
11624
+ super(`no OS service lifecycle for platform "${platform}" \u2014 supported: darwin (launchd), linux (systemd --user), win32 (WinSW)`);
11625
+ this.name = "UnsupportedServicePlatformError";
11626
+ }
11627
+ };
11628
+ function createServiceLifecycle(def, opts = {}) {
11629
+ const platform = opts.platform ?? process.platform;
11630
+ switch (platform) {
11631
+ case "darwin":
11632
+ return createLaunchdLifecycle(def, opts.deps);
11633
+ case "linux":
11634
+ return createSystemdLifecycle(def, opts.deps);
11635
+ case "win32":
11636
+ return createWinswLifecycle(def, opts.deps);
11637
+ default:
11638
+ throw new UnsupportedServicePlatformError(platform);
11639
+ }
9833
11640
  }
9834
- function createControlClient(socket, reader, opts) {
9835
- const pending = /* @__PURE__ */ new Map();
9836
- let idSeq = 0;
9837
- let closed = false;
9838
- function handleFrame(parsed) {
9839
- if (!isRecord2(parsed) || typeof parsed.id !== "string") return;
9840
- const entry = pending.get(parsed.id);
9841
- if (!entry) return;
9842
- if ("event" in parsed) {
9843
- entry.onEvent?.(parsed.event);
9844
- return;
11641
+ var REQUIRED_FIELDS = ["productName", "productId", "serverUrl", "workspaceRoot"];
11642
+ var ConfigError = class extends Error {
11643
+ constructor(message) {
11644
+ super(message);
11645
+ this.name = "ConfigError";
11646
+ }
11647
+ };
11648
+ function loadConfig(configPath, overrides = {}) {
11649
+ let base = {};
11650
+ if (configPath) {
11651
+ let raw;
11652
+ try {
11653
+ raw = readFileSync(configPath, "utf8");
11654
+ } catch (err) {
11655
+ throw new ConfigError(`could not read config at "${configPath}": ${err instanceof Error ? err.message : String(err)}`);
9845
11656
  }
9846
- if (parsed.ok === true) {
9847
- pending.delete(parsed.id);
9848
- entry.resolve(parsed.done === true ? void 0 : parsed.result);
9849
- return;
11657
+ try {
11658
+ base = JSON.parse(raw);
11659
+ } catch (err) {
11660
+ throw new ConfigError(`config at "${configPath}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
9850
11661
  }
9851
- pending.delete(parsed.id);
9852
- const shape = parsed.error;
9853
- entry.reject(
9854
- new ControlError(
9855
- typeof shape?.code === "string" ? shape.code : "internal_error",
9856
- typeof shape?.message === "string" ? shape.message : "unknown control error"
9857
- )
9858
- );
9859
11662
  }
9860
- socket.on("data", (chunk) => {
9861
- let lines;
11663
+ const merged = { ...base, ...overrides };
11664
+ if (merged.gitWorkspace !== void 0) {
9862
11665
  try {
9863
- lines = reader.push(chunk);
9864
- } catch {
9865
- socket.destroy();
9866
- return;
9867
- }
9868
- for (const line of lines) {
9869
- let parsed;
9870
- try {
9871
- parsed = JSON.parse(line);
9872
- } catch {
9873
- continue;
9874
- }
9875
- handleFrame(parsed);
11666
+ GitWorkspaceManager.validateConfig(merged.gitWorkspace);
11667
+ } catch (error) {
11668
+ throw new ConfigError(error instanceof Error ? error.message : "invalid gitWorkspace configuration");
9876
11669
  }
9877
- });
9878
- socket.on("close", () => {
9879
- closed = true;
9880
- for (const entry of pending.values()) entry.reject(new Error("control connection closed"));
9881
- pending.clear();
9882
- });
9883
- socket.on("error", () => {
9884
- });
9885
- function send(method, params, onEvent) {
9886
- const id = `c${++idSeq}`;
9887
- const promise = new Promise((resolve, reject) => {
9888
- pending.set(id, { resolve, reject, onEvent });
9889
- });
9890
- socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, id, method, params }));
9891
- return { id, promise };
9892
11670
  }
9893
- return {
9894
- async request(method, params) {
9895
- if (closed) throw new Error("control connection is closed");
9896
- const { promise } = send(method, params);
9897
- const result = await withTimeout(promise, opts.requestTimeoutMs ?? 1e4, `control request "${method}" timed out`);
9898
- return result;
9899
- },
9900
- subscribe(method, params, onEvent) {
9901
- const { id, promise } = send(method, params, onEvent);
9902
- promise.catch(() => {
9903
- });
9904
- return {
9905
- close: () => {
9906
- pending.delete(id);
9907
- socket.destroy();
9908
- }
9909
- };
9910
- },
9911
- close() {
9912
- socket.destroy();
11671
+ for (const field of REQUIRED_FIELDS) {
11672
+ if (!merged[field]) {
11673
+ throw new ConfigError(`config is missing required field "${field}"`);
9913
11674
  }
9914
- };
11675
+ }
11676
+ return merged;
9915
11677
  }
9916
- async function isControlDaemonGone(storeDir, productId) {
9917
- const tokenGone = await promises.stat(controlTokenPath(storeDir)).then(
9918
- () => false,
9919
- (err) => err.code === "ENOENT"
9920
- );
9921
- if (!tokenGone) return false;
9922
- const endpoint = controlEndpointPath(productId, storeDir);
9923
- return new Promise((resolve) => {
9924
- const socket = net.createConnection(endpoint);
9925
- const finish = (gone) => {
9926
- socket.removeAllListeners();
9927
- socket.destroy();
9928
- resolve(gone);
9929
- };
9930
- socket.once("connect", () => finish(false));
9931
- socket.once("error", (err) => finish(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
9932
- });
11678
+ function resolveStoreDir(config) {
11679
+ return DeviceStore.resolveDir(config.productId, config.storeDir);
11680
+ }
11681
+ function argValue(args, flag) {
11682
+ const idx = args.indexOf(flag);
11683
+ const value = idx >= 0 ? args[idx + 1] : void 0;
11684
+ return value !== void 0 && !value.startsWith("--") ? value : void 0;
11685
+ }
11686
+ function hasFlag(args, flag) {
11687
+ return args.includes(flag);
11688
+ }
11689
+ function positionalArgs(args, valueFlags = []) {
11690
+ const result = [];
11691
+ for (let i = 0; i < args.length; i++) {
11692
+ const arg = args[i];
11693
+ if (arg === void 0) continue;
11694
+ if (valueFlags.includes(arg)) {
11695
+ i++;
11696
+ continue;
11697
+ }
11698
+ result.push(arg);
11699
+ }
11700
+ return result;
9933
11701
  }
9934
11702
 
9935
11703
  // src/bin/format.ts
@@ -9939,19 +11707,7 @@ function quote(text) {
9939
11707
  function redactedByteCountPlaceholder(text) {
9940
11708
  return `[redacted: ${Buffer.byteLength(text, "utf8")} bytes]`;
9941
11709
  }
9942
- var STABLE_GIT_ERROR_CATEGORIES = /* @__PURE__ */ new Set([
9943
- "git-unavailable",
9944
- "git-timeout",
9945
- "git-output-limit",
9946
- "git-command-failed",
9947
- "workspace-root-invalid",
9948
- "workspace-root-conflict",
9949
- "workspace-not-owned",
9950
- "repository-root-mismatch",
9951
- "repository-invalid",
9952
- "lease-busy",
9953
- "ledger-invalid"
9954
- ]);
11710
+ var STABLE_GIT_ERROR_CATEGORIES = new Set(GIT_ERROR_CATEGORIES);
9955
11711
  function stableGitErrorCategory(value) {
9956
11712
  return value !== void 0 && STABLE_GIT_ERROR_CATEGORIES.has(value) ? value : void 0;
9957
11713
  }
@@ -10017,6 +11773,8 @@ function formatDaemonEventLine(event, options = {}) {
10017
11773
  return `${prefix} shutdown-complete reason=${quote(event.reason)}${event.undeliveredOutboxCount !== void 0 ? ` undeliveredOutboxCount=${event.undeliveredOutboxCount}` : ""}`;
10018
11774
  case "stale-approval-decision":
10019
11775
  return `${prefix} stale-approval-decision taskId=${event.taskId} decision=${event.decision}${event.reason ? ` reason=${quote(event.reason)}` : ""}`;
11776
+ case "runtime-disposal-failed":
11777
+ return `${prefix} runtime-disposal-failed taskId=${event.taskId} runtime=${event.runtimeId} stage=${event.stage} reason=${quote(event.reason)}`;
10020
11778
  case "git-workspace": {
10021
11779
  const parts = [
10022
11780
  `${prefix} git-workspace taskId=${event.taskId}`,
@@ -10029,6 +11787,19 @@ function formatDaemonEventLine(event, options = {}) {
10029
11787
  ].filter((part) => part !== void 0);
10030
11788
  return parts.join(" ");
10031
11789
  }
11790
+ case "device-assertion": {
11791
+ const parts = event.result === "issued" ? [
11792
+ `${prefix} device-assertion result=issued`,
11793
+ `audience=${quote(event.audience)}`,
11794
+ `jti=${event.jti}`,
11795
+ `expiresAt=${event.expiresAt}`
11796
+ ] : [
11797
+ `${prefix} device-assertion result=denied`,
11798
+ `reason=${event.reason}`,
11799
+ event.audienceSize !== void 0 ? `audienceSize=${event.audienceSize}` : void 0
11800
+ ].filter((part) => part !== void 0);
11801
+ return parts.join(" ");
11802
+ }
10032
11803
  }
10033
11804
  }
10034
11805
  function formatTaskLine(task) {
@@ -10282,8 +12053,8 @@ async function probeRuntimes(adapters, options = {}) {
10282
12053
  let resume = false;
10283
12054
  let permissionModes = [];
10284
12055
  try {
10285
- id = boundedSingleLine(adapter.id, MAX_RUNTIME_ID_CHARS);
10286
- const caps = adapter.capabilities();
12056
+ id = boundedSingleLine(adapter.descriptor.id, MAX_RUNTIME_ID_CHARS);
12057
+ const caps = adapter.descriptor.capabilities;
10287
12058
  steer = caps.steer === true;
10288
12059
  resume = caps.resume === true;
10289
12060
  permissionModes = caps.permissionModes.slice(0, MAX_PERMISSION_MODES).map((mode) => boundedSingleLine(mode, MAX_PERMISSION_MODE_CHARS));
@@ -11148,19 +12919,7 @@ function valueByteSize(value) {
11148
12919
  function placeholderFor(size) {
11149
12920
  return size === void 0 ? "[redacted]" : `[redacted: ${size} bytes]`;
11150
12921
  }
11151
- var STABLE_GIT_ERROR_CATEGORIES2 = /* @__PURE__ */ new Set([
11152
- "git-unavailable",
11153
- "git-timeout",
11154
- "git-output-limit",
11155
- "git-command-failed",
11156
- "workspace-root-invalid",
11157
- "workspace-root-conflict",
11158
- "workspace-not-owned",
11159
- "repository-root-mismatch",
11160
- "repository-invalid",
11161
- "lease-busy",
11162
- "ledger-invalid"
11163
- ]);
12922
+ var STABLE_GIT_ERROR_CATEGORIES2 = new Set(GIT_ERROR_CATEGORIES);
11164
12923
  function stableGitErrorCategory2(value) {
11165
12924
  return typeof value === "string" && STABLE_GIT_ERROR_CATEGORIES2.has(value) ? value : void 0;
11166
12925
  }
@@ -11259,6 +13018,16 @@ function redactForAudit(event) {
11259
13018
  return { ...base, reason: event.reason, undeliveredOutboxCount: event.undeliveredOutboxCount };
11260
13019
  case "stale-approval-decision":
11261
13020
  return { ...base, taskId: event.taskId, decision: event.decision, reasonSize: byteSize(event.reason) };
13021
+ case "runtime-disposal-failed":
13022
+ return { ...base, taskId: event.taskId, runtimeId: event.runtimeId, stage: event.stage, reason: event.reason };
13023
+ case "device-assertion":
13024
+ return event.result === "issued" ? {
13025
+ ...base,
13026
+ result: "issued",
13027
+ audience: event.audience,
13028
+ jti: event.jti,
13029
+ expiresAt: event.expiresAt
13030
+ } : { ...base, result: "denied", reason: event.reason, audienceSize: event.audienceSize };
11262
13031
  case "git-workspace":
11263
13032
  return {
11264
13033
  ...base,
@@ -11399,6 +13168,35 @@ function reconstructDaemonEvent(raw) {
11399
13168
  reason: reasonSize === void 0 ? void 0 : placeholderFor(reasonSize)
11400
13169
  };
11401
13170
  }
13171
+ case "runtime-disposal-failed":
13172
+ return {
13173
+ kind: "runtime-disposal-failed",
13174
+ ts,
13175
+ taskId: str(raw.taskId),
13176
+ runtimeId: str(raw.runtimeId),
13177
+ stage: str(raw.stage),
13178
+ reason: str(raw.reason)
13179
+ };
13180
+ case "device-assertion": {
13181
+ if (raw.result === "issued") {
13182
+ return {
13183
+ kind: "device-assertion",
13184
+ ts,
13185
+ result: "issued",
13186
+ audience: typeof raw.audience === "string" ? raw.audience : "",
13187
+ jti: typeof raw.jti === "string" ? raw.jti : "",
13188
+ expiresAt: typeof raw.expiresAt === "string" ? raw.expiresAt : ""
13189
+ };
13190
+ }
13191
+ const audienceSize = num(raw.audienceSize);
13192
+ return {
13193
+ kind: "device-assertion",
13194
+ ts,
13195
+ result: "denied",
13196
+ reason: typeof raw.reason === "string" ? raw.reason : "",
13197
+ ...audienceSize === void 0 ? {} : { audienceSize }
13198
+ };
13199
+ }
11402
13200
  case "git-workspace": {
11403
13201
  const commitsSinceBaseline = gitCount(raw.commitsSinceBaseline);
11404
13202
  const dirty = gitDirty(raw.dirty);
@@ -11651,20 +13449,8 @@ async function runStartCommand(config, deps) {
11651
13449
  throw err;
11652
13450
  }
11653
13451
  }
11654
- var STABLE_GIT_PHASES = /* @__PURE__ */ new Set(["preparing", "active", "completed", "failed", "cancelled", "interrupted", "salvage"]);
11655
- var STABLE_GIT_ERROR_CATEGORIES3 = /* @__PURE__ */ new Set([
11656
- "git-unavailable",
11657
- "git-timeout",
11658
- "git-output-limit",
11659
- "git-command-failed",
11660
- "workspace-root-invalid",
11661
- "workspace-root-conflict",
11662
- "workspace-not-owned",
11663
- "repository-root-mismatch",
11664
- "repository-invalid",
11665
- "lease-busy",
11666
- "ledger-invalid"
11667
- ]);
13452
+ var STABLE_GIT_PHASES = new Set(GIT_WORKSPACE_PHASES);
13453
+ var STABLE_GIT_ERROR_CATEGORIES3 = new Set(GIT_ERROR_CATEGORIES);
11668
13454
  function nonNegativeSafeInteger(value) {
11669
13455
  return value !== void 0 && Number.isSafeInteger(value) && value >= 0 ? value : void 0;
11670
13456
  }
@@ -12163,19 +13949,7 @@ async function runUnpairCommand(daemon, deps = {}) {
12163
13949
  }
12164
13950
 
12165
13951
  // src/bin/commands/workspaces.ts
12166
- var STABLE_ERROR_CATEGORIES = /* @__PURE__ */ new Set([
12167
- "git-unavailable",
12168
- "git-timeout",
12169
- "git-output-limit",
12170
- "git-command-failed",
12171
- "workspace-root-invalid",
12172
- "workspace-root-conflict",
12173
- "workspace-not-owned",
12174
- "repository-root-mismatch",
12175
- "repository-invalid",
12176
- "lease-busy",
12177
- "ledger-invalid"
12178
- ]);
13952
+ var STABLE_ERROR_CATEGORIES = new Set(GIT_ERROR_CATEGORIES);
12179
13953
  function abbreviateCommit(value) {
12180
13954
  if (!value) return "-";
12181
13955
  return value.length > 8 ? value.slice(0, 8) : value;