@byok-sdk/client 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ export interface ResolvedBin {
25
25
  * only ever constructs `new PiAdapter()` with no options (see `createDaemon`),
26
26
  * so an out-of-process substitution (e.g. examples/basic's e2e run swapping
27
27
  * in the fake-pi fixture, or a single-file product injecting its required
28
- * Node 22.19+ pi sidecar) has no other seam to use.
28
+ * Node 22.22+ pi sidecar) has no other seam to use.
29
29
  *
30
30
  * Deliberately does NOT use `createRequire(...).resolve()`: this package is
31
31
  * pure ESM with no `require` export condition (`exports["."]` only offers
@@ -66,7 +66,13 @@ export declare class PiRpcClient {
66
66
  * a post-mortem on a failed/hung task has it without separate log scraping.
67
67
  */
68
68
  recordUnmappedFrame(type: string): void;
69
- /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
69
+ /**
70
+ * Immediate process-tree termination request. `dispose()` is the settlement
71
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
72
+ * terminator. A request that could not be spawned is left unrecorded, so
73
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
74
+ * swallowing it here loses nothing.
75
+ */
70
76
  kill(): void;
71
77
  waitClosed(): Promise<void>;
72
78
  dispose(): Promise<void>;
@@ -1,4 +1,5 @@
1
- import { type ChildProcess, type SpawnOptions } from 'node:child_process';
1
+ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
2
+ type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
2
3
  export interface OwnedProcessTreeOptions {
3
4
  child: ChildProcess;
4
5
  waitClosed: () => Promise<void>;
@@ -6,6 +7,12 @@ export interface OwnedProcessTreeOptions {
6
7
  label: string;
7
8
  termGraceMs?: number;
8
9
  killGraceMs?: number;
10
+ /** DI seam — defaults to `process.platform`. Lets the win32 branch be exercised from POSIX CI, mirroring `util/secure-dir.ts`'s identical convention. */
11
+ platform?: NodeJS.Platform;
12
+ /** DI seam for the win32 `taskkill` sweep — defaults to `node:child_process`'s `spawn`. */
13
+ spawnFn?: typeof spawn;
14
+ /** DI seam for liveness probing — defaults to `process.kill`. */
15
+ killFn?: KillFn;
9
16
  }
10
17
  /**
11
18
  * Every bundled runtime root is an owned process-group leader on POSIX. Pipes
@@ -13,7 +20,41 @@ export interface OwnedProcessTreeOptions {
13
20
  * the runtime outlive the daemon. Windows uses taskkill's `/T` tree authority.
14
21
  */
15
22
  export declare function withOwnedProcessTree<T extends SpawnOptions>(options: T): T;
16
- /** Immediate termination request used by interrupt paths; close remains the receipt. */
17
- export declare function requestOwnedProcessTreeTermination(options: OwnedProcessTreeOptions): void;
18
- /** Resolve only after the adapter-owned root and descendants are quiescent. */
23
+ /**
24
+ * Immediate termination request used by interrupt paths; close remains the
25
+ * receipt. On win32 the request also RECORDS the pid set taskkill walked,
26
+ * which is what {@link disposeOwnedProcessTree} later measures — so an
27
+ * interrupt that is fired and forgotten still leaves disposal a measurable
28
+ * tree. A request that could not be spawned records nothing, which makes
29
+ * disposal re-issue it and surface `stage:'signal'` itself.
30
+ */
31
+ export declare function requestOwnedProcessTreeTermination(options: OwnedProcessTreeOptions): Promise<void>;
32
+ /**
33
+ * Resolve only after the adapter-owned root and descendants are quiescent.
34
+ *
35
+ * POSIX measures the owned process GROUP; win32 measures the pid set taskkill
36
+ * reported walking (`stage:'quiescence'` names how many of those were still
37
+ * alive at the deadline). Neither platform reads a terminator's exit status:
38
+ * on win32 `stage:'signal'` now means only that taskkill could not be spawned.
39
+ * `close` stays the final receipt on both — it is the stdio-flush guarantee,
40
+ * not the liveness proof.
41
+ *
42
+ * Grace budget: each phase carries its own full grace on both platforms. On
43
+ * win32 the quiescence poll gets `killGraceMs` and the close wait that follows
44
+ * gets a fresh `killGraceMs`; POSIX likewise gives the SIGTERM wait
45
+ * `termGraceMs`, the SIGKILL wait `killGraceMs`, and the close wait another
46
+ * `killGraceMs`. Worst-case disposal is therefore bounded by the sum, never by
47
+ * one shared deadline that could starve the close wait after a slow drain.
48
+ *
49
+ * Residual boundary, stated honestly. Three cases this mechanism cannot cover:
50
+ * a descendant whose intermediate parent died before any sweep observed it is
51
+ * unreachable, because Windows does not re-parent orphans — nothing in the
52
+ * surviving tree links back to it and `taskkill /T` cannot find it. The same
53
+ * window means a recycled pid could read as alive. And if taskkill's output is
54
+ * empty or unparseable, the walked set collapses to `{root}`: disposal then
55
+ * measures the root alone and reports quiescence on that basis, which is a
56
+ * narrower claim than the tree, not a false one. Preventing orphans left behind
57
+ * by a DAEMON crash is a separate job-object concern, explicitly out of scope.
58
+ */
19
59
  export declare function disposeOwnedProcessTree(options: OwnedProcessTreeOptions): Promise<void>;
60
+ export {};
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Windows-only: recovers the process-tree PID set that `taskkill /T /F`
3
+ * reports it walked, so `process-tree.ts` can MEASURE quiescence instead of
4
+ * trusting taskkill's exit status.
5
+ *
6
+ * Two constraints shape this parser:
7
+ *
8
+ * - taskkill's messages are LOCALIZED (the same run prints
9
+ * `SUCCESS: The process with PID ...` on en-US and a translated sentence on
10
+ * de-DE/zh-CN/ja-JP), so no word, prefix, or field label may be matched.
11
+ * Only the integers and their CO-OCCURRENCE on one line are stable: every
12
+ * line taskkill emits for a walked process names that process and its
13
+ * parent together. Seeding with the root pid and closing over co-occurrence
14
+ * therefore reaches exactly the walked tree, in any locale.
15
+ * - the text arrives in the console OEM codepage, not UTF-8. It is decoded as
16
+ * latin1 (byte-preserving) rather than guessed: every OEM codepage taskkill
17
+ * can use encodes ASCII digits as single bytes 0x30-0x39, and no DBCS trail
18
+ * byte (CP932/CP936/CP949/CP950 all start their trail range at 0x40) can
19
+ * land in that range. Undecodable non-ASCII bytes become mojibake, which is
20
+ * irrelevant: they can never manufacture a digit.
21
+ *
22
+ * Line ORDER is deliberately not relied on (taskkill emits children before
23
+ * parents today); the walk iterates to a fixpoint instead.
24
+ */
25
+ /**
26
+ * Returns the PID set reachable from `rootPid` by co-occurrence over `text`.
27
+ *
28
+ * `excludedPids` removes integers that must never enter the set even when
29
+ * they share a line with an accepted pid — taskkill names the ROOT's own
30
+ * parent (this daemon process) on the root's line, and accepting it would
31
+ * make quiescence unreachable by construction. `rootPid` is always seeded and
32
+ * is never subject to exclusion.
33
+ */
34
+ export declare function walkTaskkillPidSet(text: string, rootPid: number, excludedPids?: Iterable<number>): Set<number>;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { execFile, spawn, spawnSync } from 'child_process';
2
+ import { execFile, spawn } from 'child_process';
3
3
  import { randomUUID, createHash, randomBytes, timingSafeEqual, createHmac, createPrivateKey, generateKeyPairSync, sign } from 'crypto';
4
4
  import { readFileSync, promises, linkSync, fstatSync, lstatSync, unlinkSync, constants, readSync, openSync, writeFileSync, fchmodSync, fsyncSync, closeSync, opendirSync, existsSync, realpathSync, mkdirSync, renameSync, chmodSync, statSync, readdirSync } from 'fs';
5
5
  import path20, { isAbsolute, join } from 'path';
@@ -887,12 +887,12 @@ function resolvePiBin() {
887
887
  }
888
888
  } catch (cause) {
889
889
  throw new Error(
890
- `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`,
890
+ `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`,
891
891
  { cause }
892
892
  );
893
893
  }
894
894
  throw new Error(
895
- `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`
895
+ `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`
896
896
  );
897
897
  }
898
898
 
@@ -1097,11 +1097,54 @@ var AsyncQueue = class {
1097
1097
  };
1098
1098
  }
1099
1099
  };
1100
+
1101
+ // src/adapters/taskkill-pid-set.ts
1102
+ var INTEGER_PATTERN = /\d+/g;
1103
+ function isCandidatePid(value) {
1104
+ return Number.isSafeInteger(value) && value > 0;
1105
+ }
1106
+ function walkTaskkillPidSet(text, rootPid, excludedPids = []) {
1107
+ const excluded = new Set(excludedPids);
1108
+ const accepted = /* @__PURE__ */ new Set();
1109
+ if (isCandidatePid(rootPid)) accepted.add(rootPid);
1110
+ const lines = text.split(/\r?\n/).map((line) => {
1111
+ const pids = [];
1112
+ for (const match of line.matchAll(INTEGER_PATTERN)) {
1113
+ const pid = Number(match[0]);
1114
+ if (isCandidatePid(pid) && !excluded.has(pid)) pids.push(pid);
1115
+ }
1116
+ return pids;
1117
+ });
1118
+ let changed = true;
1119
+ while (changed) {
1120
+ changed = false;
1121
+ for (const pids of lines) {
1122
+ if (!pids.some((pid) => accepted.has(pid))) continue;
1123
+ for (const pid of pids) {
1124
+ if (accepted.has(pid)) continue;
1125
+ accepted.add(pid);
1126
+ changed = true;
1127
+ }
1128
+ }
1129
+ }
1130
+ return accepted;
1131
+ }
1132
+
1133
+ // src/adapters/process-tree.ts
1100
1134
  var DEFAULT_TERM_GRACE_MS = 750;
1101
1135
  var DEFAULT_KILL_GRACE_MS = 2e3;
1102
1136
  var POLL_MS = 20;
1103
- var terminationRequested = /* @__PURE__ */ new WeakSet();
1104
- var terminationRequestFailed = /* @__PURE__ */ new WeakSet();
1137
+ var terminationState = /* @__PURE__ */ new WeakMap();
1138
+ function stateFor(child) {
1139
+ const existing = terminationState.get(child);
1140
+ if (existing) return existing;
1141
+ const created = { requested: false, acceptedPids: /* @__PURE__ */ new Set() };
1142
+ terminationState.set(child, created);
1143
+ return created;
1144
+ }
1145
+ function defaultKill(pid, signal) {
1146
+ process.kill(pid, signal);
1147
+ }
1105
1148
  function withOwnedProcessTree(options) {
1106
1149
  return {
1107
1150
  ...options,
@@ -1119,9 +1162,9 @@ function positivePid(child, label) {
1119
1162
  }
1120
1163
  return pid;
1121
1164
  }
1122
- function groupExists(pid, label) {
1165
+ function groupExists(pid, label, kill) {
1123
1166
  try {
1124
- process.kill(-pid, 0);
1167
+ kill(-pid, 0);
1125
1168
  return true;
1126
1169
  } catch (cause) {
1127
1170
  const code = cause.code;
@@ -1133,9 +1176,23 @@ function groupExists(pid, label) {
1133
1176
  }, { cause });
1134
1177
  }
1135
1178
  }
1136
- function signalGroup(pid, signal, label) {
1179
+ function processExists(pid, label, kill) {
1180
+ try {
1181
+ kill(pid, 0);
1182
+ return true;
1183
+ } catch (cause) {
1184
+ const code = cause.code;
1185
+ if (code === "ESRCH") return false;
1186
+ if (code === "EPERM") return true;
1187
+ throw new RuntimeDisposalFailure({
1188
+ stage: "quiescence",
1189
+ reason: `${label} runtime process ${pid} state could not be verified`
1190
+ }, { cause });
1191
+ }
1192
+ }
1193
+ function signalGroup(pid, signal, label, kill) {
1137
1194
  try {
1138
- process.kill(-pid, signal);
1195
+ kill(-pid, signal);
1139
1196
  } catch (cause) {
1140
1197
  const code = cause.code;
1141
1198
  if (code === "ESRCH" || code === "EPERM") return;
@@ -1145,6 +1202,39 @@ function signalGroup(pid, signal, label) {
1145
1202
  }, { cause });
1146
1203
  }
1147
1204
  }
1205
+ async function runTaskkill(pid, options) {
1206
+ const spawnFn = options.spawnFn ?? spawn;
1207
+ return new Promise((resolve, reject) => {
1208
+ const signalFailure = (cause) => {
1209
+ reject(new RuntimeDisposalFailure({
1210
+ stage: "signal",
1211
+ reason: `${options.label} runtime process tree termination could not be requested`
1212
+ }, { cause }));
1213
+ };
1214
+ let taskkill;
1215
+ try {
1216
+ taskkill = spawnFn("taskkill", ["/PID", String(pid), "/T", "/F"], {
1217
+ windowsHide: true,
1218
+ stdio: ["ignore", "pipe", "pipe"]
1219
+ });
1220
+ } catch (cause) {
1221
+ signalFailure(cause);
1222
+ return;
1223
+ }
1224
+ const chunks = [];
1225
+ taskkill.stdout?.on("data", (chunk) => chunks.push(chunk));
1226
+ taskkill.stderr?.on("data", (chunk) => chunks.push(chunk));
1227
+ taskkill.once("error", signalFailure);
1228
+ taskkill.once("close", () => resolve(Buffer.concat(chunks).toString("latin1")));
1229
+ });
1230
+ }
1231
+ function liveAcceptedPids(accepted, label, kill) {
1232
+ const live = [];
1233
+ for (const pid of accepted) {
1234
+ if (processExists(pid, label, kill)) live.push(pid);
1235
+ }
1236
+ return live;
1237
+ }
1148
1238
  async function waitUntil(predicate, timeoutMs) {
1149
1239
  const deadline = Date.now() + timeoutMs;
1150
1240
  while (predicate()) {
@@ -1182,29 +1272,27 @@ async function waitWithDeadline(promise, timeoutMs) {
1182
1272
  );
1183
1273
  });
1184
1274
  }
1185
- function requestOwnedProcessTreeTermination(options) {
1186
- if (options.isClosed()) return;
1275
+ async function requestOwnedProcessTreeTermination(options) {
1276
+ const platform = options.platform ?? process.platform;
1277
+ if (platform !== "win32" && options.isClosed()) return;
1187
1278
  const pid = positivePid(options.child, options.label);
1188
1279
  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);
1280
+ if (platform === "win32") {
1281
+ const output = await runTaskkill(pid, options);
1282
+ const state = stateFor(options.child);
1283
+ for (const walked of walkTaskkillPidSet(output, pid, [process.pid])) state.acceptedPids.add(walked);
1284
+ state.requested = true;
1199
1285
  return;
1200
1286
  }
1201
- signalGroup(pid, "SIGTERM", options.label);
1202
- terminationRequested.add(options.child);
1287
+ signalGroup(pid, "SIGTERM", options.label, options.killFn ?? defaultKill);
1288
+ stateFor(options.child).requested = true;
1203
1289
  }
1204
1290
  async function disposeOwnedProcessTree(options) {
1205
1291
  const pid = positivePid(options.child, options.label);
1206
1292
  const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
1207
1293
  const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
1294
+ const platform = options.platform ?? process.platform;
1295
+ const kill = options.killFn ?? defaultKill;
1208
1296
  if (pid === void 0) {
1209
1297
  if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1210
1298
  throw new RuntimeDisposalFailure({
@@ -1212,27 +1300,50 @@ async function disposeOwnedProcessTree(options) {
1212
1300
  reason: `${options.label} runtime process did not settle after spawn failure`
1213
1301
  });
1214
1302
  }
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)) {
1303
+ if (platform === "win32") {
1304
+ if (!terminationState.get(options.child)?.requested) {
1305
+ await requestOwnedProcessTreeTermination(options);
1306
+ }
1307
+ const accepted = terminationState.get(options.child)?.acceptedPids ?? /* @__PURE__ */ new Set();
1308
+ const deadline = Date.now() + killGraceMs;
1309
+ const resweepAt = Date.now() + Math.floor(killGraceMs / 2);
1310
+ let reswept = false;
1311
+ let live = liveAcceptedPids(accepted, options.label, kill);
1312
+ while (live.length > 0) {
1313
+ if (Date.now() >= deadline) {
1314
+ throw new RuntimeDisposalFailure({
1315
+ stage: "quiescence",
1316
+ reason: `${options.label} runtime process tree did not quiesce: ${live.length} of ${accepted.size} walked process ids were still alive at the disposal deadline`
1317
+ });
1318
+ }
1319
+ if (!reswept && Date.now() >= resweepAt) {
1320
+ reswept = true;
1321
+ for (const livePid of live) {
1322
+ for (const walked of walkTaskkillPidSet(await runTaskkill(livePid, options), livePid, [process.pid])) {
1323
+ accepted.add(walked);
1324
+ }
1325
+ }
1326
+ }
1327
+ await new Promise((resolve) => {
1328
+ setTimeout(resolve, POLL_MS);
1329
+ });
1330
+ live = liveAcceptedPids(accepted, options.label, kill);
1331
+ }
1332
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1219
1333
  throw new RuntimeDisposalFailure({
1220
- stage: "signal",
1221
- reason: `${options.label} runtime process tree could not be terminated`
1334
+ stage: "quiescence",
1335
+ reason: `${options.label} runtime root did not emit close after its process tree quiesced`
1222
1336
  });
1223
1337
  }
1224
- throw new RuntimeDisposalFailure({
1225
- stage: "quiescence",
1226
- reason: `${options.label} runtime process tree did not close before the disposal deadline`
1227
- });
1338
+ return;
1228
1339
  }
1229
- if (groupExists(pid, options.label) && !terminationRequested.has(options.child)) {
1230
- signalGroup(pid, "SIGTERM", options.label);
1231
- terminationRequested.add(options.child);
1340
+ if (groupExists(pid, options.label, kill) && !terminationState.get(options.child)?.requested) {
1341
+ signalGroup(pid, "SIGTERM", options.label, kill);
1342
+ stateFor(options.child).requested = true;
1232
1343
  }
1233
- if (!await waitUntil(() => groupExists(pid, options.label), termGraceMs)) {
1234
- signalGroup(pid, "SIGKILL", options.label);
1235
- if (!await waitUntil(() => groupExists(pid, options.label), killGraceMs)) {
1344
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), termGraceMs)) {
1345
+ signalGroup(pid, "SIGKILL", options.label, kill);
1346
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), killGraceMs)) {
1236
1347
  throw new RuntimeDisposalFailure({
1237
1348
  stage: "quiescence",
1238
1349
  reason: `${options.label} runtime process group remained live after SIGKILL`
@@ -1330,9 +1441,16 @@ var PiRpcClient = class {
1330
1441
  );
1331
1442
  }
1332
1443
  }
1333
- /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
1444
+ /**
1445
+ * Immediate process-tree termination request. `dispose()` is the settlement
1446
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
1447
+ * terminator. A request that could not be spawned is left unrecorded, so
1448
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
1449
+ * swallowing it here loses nothing.
1450
+ */
1334
1451
  kill() {
1335
- requestOwnedProcessTreeTermination(this.processTreeOptions());
1452
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
1453
+ });
1336
1454
  }
1337
1455
  waitClosed() {
1338
1456
  return this.closedPromise;
@@ -2135,9 +2253,16 @@ var ClaudeProcessClient = class {
2135
2253
  );
2136
2254
  }
2137
2255
  }
2138
- /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
2256
+ /**
2257
+ * Immediate process-tree termination request. `dispose()` is the settlement
2258
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
2259
+ * terminator. A request that could not be spawned is left unrecorded, so
2260
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
2261
+ * swallowing it here loses nothing.
2262
+ */
2139
2263
  kill() {
2140
- requestOwnedProcessTreeTermination(this.processTreeOptions());
2264
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
2265
+ });
2141
2266
  }
2142
2267
  waitClosed() {
2143
2268
  return this.closedPromise;
@@ -2902,9 +3027,15 @@ var CodexProcessRunner = class {
2902
3027
  * cleanly resumable afterward via `codex exec resume` (no corruption from
2903
3028
  * killing mid-turn). `taskkill /T /F` on Windows, mirroring
2904
3029
  * `../pi/rpc-client.ts`'s own cross-platform convention.
3030
+ *
3031
+ * Fire-and-forget by design: an interrupt must not block on a terminator,
3032
+ * and `dispose()` is the settlement receipt. A request that could not be
3033
+ * spawned is left unrecorded, so `dispose()` re-issues it and raises the
3034
+ * typed `stage:'signal'` failure — swallowing it here loses nothing.
2905
3035
  */
2906
3036
  kill() {
2907
- requestOwnedProcessTreeTermination(this.processTreeOptions());
3037
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
3038
+ });
2908
3039
  }
2909
3040
  dispose() {
2910
3041
  if (!this.disposalAttempt) {
@@ -4684,14 +4815,17 @@ function parseLooseEventsPollResponse(raw) {
4684
4815
  if (typeof raw !== "object" || raw === null) {
4685
4816
  throw new Error("events poll response is not an object");
4686
4817
  }
4687
- const { events, cursor } = raw;
4818
+ const { events, cursor, capabilities } = raw;
4688
4819
  if (!Array.isArray(events)) {
4689
4820
  throw new Error("events poll response.events is not an array");
4690
4821
  }
4691
4822
  if (typeof cursor !== "number" || !Number.isInteger(cursor)) {
4692
4823
  throw new Error("events poll response.cursor is not an integer");
4693
4824
  }
4694
- return { events, cursor };
4825
+ if (capabilities !== void 0 && (!Array.isArray(capabilities) || capabilities.some((flag) => typeof flag !== "string"))) {
4826
+ throw new Error("events poll response.capabilities is not an array of strings");
4827
+ }
4828
+ return { events, cursor, capabilities: capabilities ?? [] };
4695
4829
  }
4696
4830
  function extractSkippableSeq(raw) {
4697
4831
  if (typeof raw !== "object" || raw === null) return void 0;
@@ -4771,12 +4905,14 @@ var LongPollClient = class {
4771
4905
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
4772
4906
  const res = await authedFetch(url, { method: "GET" }, this.opts.auth);
4773
4907
  if (!res.ok) {
4908
+ this.opts.onServerCapabilities?.([]);
4774
4909
  this.opts.onOperationalOutcome?.("failure");
4775
4910
  const baseMs = this.opts.retryDelayMs ?? 2e3;
4776
4911
  await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
4777
4912
  continue;
4778
4913
  }
4779
4914
  const parsed = parseLooseEventsPollResponse(await res.json());
4915
+ this.opts.onServerCapabilities?.(parsed.capabilities);
4780
4916
  let hadValidationFailureThisBatch = false;
4781
4917
  for (const raw of parsed.events) {
4782
4918
  let envelope;
@@ -4820,6 +4956,7 @@ var LongPollClient = class {
4820
4956
  this.opts.onOperationalOutcome?.("success");
4821
4957
  }
4822
4958
  } catch (err) {
4959
+ this.opts.onServerCapabilities?.([]);
4823
4960
  if (err instanceof DeviceRevokedError) {
4824
4961
  this.running = false;
4825
4962
  this.opts.onRevoked?.();
@@ -5055,6 +5192,9 @@ var ConnectionManager = class {
5055
5192
  // re-attempted.
5056
5193
  getCursor: () => this.dedupWatermark(),
5057
5194
  onEnvelope: (envelope) => this.deliver(envelope),
5195
+ onServerCapabilities: (capabilities) => {
5196
+ if (this.mode === "long-poll") this.serverCapabilities = capabilities;
5197
+ },
5058
5198
  onRevoked: () => this.enterRevoked(),
5059
5199
  // M4 Phase 4 (version-negotiation drill fix): a batch entry
5060
5200
  // LongPollClient couldn't parse into a known Envelope at all (an
@@ -5186,25 +5326,24 @@ var ConnectionManager = class {
5186
5326
  */
5187
5327
  cancelPendingDrainRetry;
5188
5328
  /**
5189
- * The capabilities the CURRENTLY connected server advertised in its
5190
- * `conn.ack` — untyped `string[]` (forward-compat: a server may advertise
5191
- * a flag this build doesn't recognize yet), populated by {@link onAcked}
5192
- * and read by {@link getServerCapabilities}. Empty until the very first
5193
- * successful handshake.
5329
+ * The capabilities the CURRENT transport's server advertised untyped
5330
+ * `string[]` for forward compatibility. WS populates it from `conn.ack`;
5331
+ * long-poll populates it from each successful events response. Empty until
5332
+ * the active transport supplies an advertisement.
5194
5333
  *
5195
5334
  * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
5196
5335
  * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
5197
5336
  * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
5198
5337
  * `acked` branch), `stop()`, or a transport switch to long-poll
5199
- * (`enterLongPoll`) — and only ever repopulated by a FRESH `conn.ack`.
5338
+ * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
5339
+ * the transport that is still current.
5200
5340
  * The previous version of this doc comment claimed long-poll mode simply
5201
5341
  * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
5202
5342
  * a daemon that once learned e.g. `approval_resolved` from an earlier WS
5203
5343
  * session kept believing it applied to whatever it's connected to NOW,
5204
5344
  * even after a disconnect/degrade where nothing has actually confirmed
5205
5345
  * that's still true (a reconnect could land on a DIFFERENT server behind a
5206
- * load balancer; long-poll fallback itself never performs an equivalent
5207
- * handshake at all). Concretely, `TaskRunner.sendApprovalResolved` gates
5346
+ * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
5208
5347
  * `task.approval_resolved` on this list — sending it to a server that
5209
5348
  * doesn't actually understand it over the long-poll path would get a
5210
5349
  * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
@@ -5324,13 +5463,10 @@ var ConnectionManager = class {
5324
5463
  return this.mode === "long-poll";
5325
5464
  }
5326
5465
  /**
5327
- * The capabilities the CURRENTLY connected server advertised in its
5328
- * `conn.ack` e.g. lets a caller gate a daemon->server message on whether
5329
- * THIS server understands it before sending (see `task-runner.ts`'s
5330
- * `sendApprovalResolved`, gated on `approval_resolved`). Empty before the
5331
- * first handshake completes, AND (finding R2) once again empty after any
5332
- * disconnect/degrade — see `serverCapabilities`'s own doc comment for why
5333
- * this is strictly per-connection rather than "sticky" across one.
5466
+ * The capabilities the CURRENT transport's server advertised: from
5467
+ * `conn.ack` on WS, or the latest successful `GET /byok/events` response
5468
+ * on long-poll. Empty before either transport has supplied its current
5469
+ * advertisement, and cleared across disconnect/switch boundaries.
5334
5470
  */
5335
5471
  getServerCapabilities() {
5336
5472
  return this.serverCapabilities;
@@ -9734,9 +9870,9 @@ var TaskRunner = class {
9734
9870
  /**
9735
9871
  * Whether the CURRENTLY connected server advertised `result-document` —
9736
9872
  * 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
9873
+ * across a reconnect or transport switch (`ConnectionManager` clears the
9874
+ * old advertisement at the boundary, then repopulates it from a fresh WS
9875
+ * ack or successful poll response). An absent `getServerCapabilities` seam is
9740
9876
  * "no capabilities", the fail-closed reading.
9741
9877
  */
9742
9878
  hasResultDocumentCapability() {