@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.
@@ -159,25 +159,24 @@ export declare class ConnectionManager {
159
159
  */
160
160
  private cancelPendingDrainRetry;
161
161
  /**
162
- * The capabilities the CURRENTLY connected server advertised in its
163
- * `conn.ack` — untyped `string[]` (forward-compat: a server may advertise
164
- * a flag this build doesn't recognize yet), populated by {@link onAcked}
165
- * and read by {@link getServerCapabilities}. Empty until the very first
166
- * successful handshake.
162
+ * The capabilities the CURRENT transport's server advertised untyped
163
+ * `string[]` for forward compatibility. WS populates it from `conn.ack`;
164
+ * long-poll populates it from each successful events response. Empty until
165
+ * the active transport supplies an advertisement.
167
166
  *
168
167
  * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
169
168
  * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
170
169
  * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
171
170
  * `acked` branch), `stop()`, or a transport switch to long-poll
172
- * (`enterLongPoll`) — and only ever repopulated by a FRESH `conn.ack`.
171
+ * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
172
+ * the transport that is still current.
173
173
  * The previous version of this doc comment claimed long-poll mode simply
174
174
  * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
175
175
  * a daemon that once learned e.g. `approval_resolved` from an earlier WS
176
176
  * session kept believing it applied to whatever it's connected to NOW,
177
177
  * even after a disconnect/degrade where nothing has actually confirmed
178
178
  * that's still true (a reconnect could land on a DIFFERENT server behind a
179
- * load balancer; long-poll fallback itself never performs an equivalent
180
- * handshake at all). Concretely, `TaskRunner.sendApprovalResolved` gates
179
+ * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
181
180
  * `task.approval_resolved` on this list — sending it to a server that
182
181
  * doesn't actually understand it over the long-poll path would get a
183
182
  * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
@@ -242,13 +241,10 @@ export declare class ConnectionManager {
242
241
  private drainRetryDelay;
243
242
  isTransportDegraded(): boolean;
244
243
  /**
245
- * The capabilities the CURRENTLY connected server advertised in its
246
- * `conn.ack` e.g. lets a caller gate a daemon->server message on whether
247
- * THIS server understands it before sending (see `task-runner.ts`'s
248
- * `sendApprovalResolved`, gated on `approval_resolved`). Empty before the
249
- * first handshake completes, AND (finding R2) once again empty after any
250
- * disconnect/degrade — see `serverCapabilities`'s own doc comment for why
251
- * this is strictly per-connection rather than "sticky" across one.
244
+ * The capabilities the CURRENT transport's server advertised: from
245
+ * `conn.ack` on WS, or the latest successful `GET /byok/events` response
246
+ * on long-poll. Empty before either transport has supplied its current
247
+ * advertisement, and cleared across disconnect/switch boundaries.
252
248
  */
253
249
  getServerCapabilities(): readonly string[];
254
250
  isConnected(): boolean;
@@ -5,6 +5,12 @@ export interface LongPollClientOptions {
5
5
  auth: AuthManager;
6
6
  getCursor: () => number | undefined;
7
7
  onEnvelope: (envelope: Envelope) => void;
8
+ /**
9
+ * Capabilities advertised by the server that produced the current poll
10
+ * response. Called before any envelopes from that response are delivered.
11
+ * An older responder omitting the additive field is reported as `[]`.
12
+ */
13
+ onServerCapabilities?: (capabilities: string[]) => void;
8
14
  /** Called once the device is found to be revoked (401 surfaced through {@link AuthManager}) — the loop stops itself rather than retrying. */
9
15
  onRevoked?: () => void;
10
16
  /**
@@ -264,9 +264,10 @@ export interface TaskRunnerDeps {
264
264
  */
265
265
  maxTaskOutputBytes?: number;
266
266
  /**
267
- * M4 (additive-minor, `task.approval_resolved`): the negotiated
268
- * `conn.ack.capabilities` of the CURRENTLY (or most recently) connected
269
- * server — read fresh at call time (mirrors `getCursor`/`getToken`'s own
267
+ * M4 (additive-minor, `task.approval_resolved`): the capabilities advertised
268
+ * by the CURRENT transport's server (`conn.ack` on WS, the latest successful
269
+ * events response on long-poll) — read fresh at call time (mirrors
270
+ * `getCursor`/`getToken`'s own
270
271
  * "read fresh, not captured once" convention elsewhere in this codebase),
271
272
  * since the capability is learned asynchronously, after this `TaskRunner`
272
273
  * is already constructed (`create-daemon.ts`'s `start()` builds `deps`
@@ -935,9 +936,9 @@ export declare class TaskRunner {
935
936
  /**
936
937
  * Whether the CURRENTLY connected server advertised `result-document` —
937
938
  * read fresh on every call, never captured, because the answer changes
938
- * across a reconnect (`ConnectionManager.getServerCapabilities` returns
939
- * `[]` from the moment an acked connection closes until a fresh
940
- * `conn.ack` repopulates it). An absent `getServerCapabilities` seam is
939
+ * across a reconnect or transport switch (`ConnectionManager` clears the
940
+ * old advertisement at the boundary, then repopulates it from a fresh WS
941
+ * ack or successful poll response). An absent `getServerCapabilities` seam is
941
942
  * "no capabilities", the fail-closed reading.
942
943
  */
943
944
  private hasResultDocumentCapability;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execFile, spawn, spawnSync } from 'child_process';
1
+ import { execFile, spawn } from 'child_process';
2
2
  import { createHash, randomUUID, sign, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual, createHmac } from 'crypto';
3
3
  import { promises, mkdirSync, existsSync, renameSync, writeFileSync, chmodSync, statSync, readdirSync, linkSync, fstatSync, lstatSync, unlinkSync, constants, readFileSync, realpathSync } from 'fs';
4
4
  import path17, { join, isAbsolute } from 'path';
@@ -834,12 +834,12 @@ function resolvePiBin() {
834
834
  }
835
835
  } catch (cause) {
836
836
  throw new Error(
837
- `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`,
837
+ `Required ${PI_PACKAGE_NAME} could not be resolved; install @byok-sdk/client dependencies or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`,
838
838
  { cause }
839
839
  );
840
840
  }
841
841
  throw new Error(
842
- `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.19+ pi sidecar`
842
+ `Required ${PI_PACKAGE_NAME} does not expose the pi CLI; reinstall the pinned dependency or set BYOK_PI_BIN to a Node 22.22+ pi sidecar`
843
843
  );
844
844
  }
845
845
 
@@ -1044,11 +1044,54 @@ var AsyncQueue = class {
1044
1044
  };
1045
1045
  }
1046
1046
  };
1047
+
1048
+ // src/adapters/taskkill-pid-set.ts
1049
+ var INTEGER_PATTERN = /\d+/g;
1050
+ function isCandidatePid(value) {
1051
+ return Number.isSafeInteger(value) && value > 0;
1052
+ }
1053
+ function walkTaskkillPidSet(text, rootPid, excludedPids = []) {
1054
+ const excluded = new Set(excludedPids);
1055
+ const accepted = /* @__PURE__ */ new Set();
1056
+ if (isCandidatePid(rootPid)) accepted.add(rootPid);
1057
+ const lines = text.split(/\r?\n/).map((line) => {
1058
+ const pids = [];
1059
+ for (const match of line.matchAll(INTEGER_PATTERN)) {
1060
+ const pid = Number(match[0]);
1061
+ if (isCandidatePid(pid) && !excluded.has(pid)) pids.push(pid);
1062
+ }
1063
+ return pids;
1064
+ });
1065
+ let changed = true;
1066
+ while (changed) {
1067
+ changed = false;
1068
+ for (const pids of lines) {
1069
+ if (!pids.some((pid) => accepted.has(pid))) continue;
1070
+ for (const pid of pids) {
1071
+ if (accepted.has(pid)) continue;
1072
+ accepted.add(pid);
1073
+ changed = true;
1074
+ }
1075
+ }
1076
+ }
1077
+ return accepted;
1078
+ }
1079
+
1080
+ // src/adapters/process-tree.ts
1047
1081
  var DEFAULT_TERM_GRACE_MS = 750;
1048
1082
  var DEFAULT_KILL_GRACE_MS = 2e3;
1049
1083
  var POLL_MS = 20;
1050
- var terminationRequested = /* @__PURE__ */ new WeakSet();
1051
- var terminationRequestFailed = /* @__PURE__ */ new WeakSet();
1084
+ var terminationState = /* @__PURE__ */ new WeakMap();
1085
+ function stateFor(child) {
1086
+ const existing = terminationState.get(child);
1087
+ if (existing) return existing;
1088
+ const created = { requested: false, acceptedPids: /* @__PURE__ */ new Set() };
1089
+ terminationState.set(child, created);
1090
+ return created;
1091
+ }
1092
+ function defaultKill(pid, signal) {
1093
+ process.kill(pid, signal);
1094
+ }
1052
1095
  function withOwnedProcessTree(options) {
1053
1096
  return {
1054
1097
  ...options,
@@ -1066,9 +1109,9 @@ function positivePid(child, label) {
1066
1109
  }
1067
1110
  return pid;
1068
1111
  }
1069
- function groupExists(pid, label) {
1112
+ function groupExists(pid, label, kill) {
1070
1113
  try {
1071
- process.kill(-pid, 0);
1114
+ kill(-pid, 0);
1072
1115
  return true;
1073
1116
  } catch (cause) {
1074
1117
  const code = cause.code;
@@ -1080,9 +1123,23 @@ function groupExists(pid, label) {
1080
1123
  }, { cause });
1081
1124
  }
1082
1125
  }
1083
- function signalGroup(pid, signal, label) {
1126
+ function processExists(pid, label, kill) {
1084
1127
  try {
1085
- process.kill(-pid, signal);
1128
+ kill(pid, 0);
1129
+ return true;
1130
+ } catch (cause) {
1131
+ const code = cause.code;
1132
+ if (code === "ESRCH") return false;
1133
+ if (code === "EPERM") return true;
1134
+ throw new RuntimeDisposalFailure({
1135
+ stage: "quiescence",
1136
+ reason: `${label} runtime process ${pid} state could not be verified`
1137
+ }, { cause });
1138
+ }
1139
+ }
1140
+ function signalGroup(pid, signal, label, kill) {
1141
+ try {
1142
+ kill(-pid, signal);
1086
1143
  } catch (cause) {
1087
1144
  const code = cause.code;
1088
1145
  if (code === "ESRCH" || code === "EPERM") return;
@@ -1092,6 +1149,39 @@ function signalGroup(pid, signal, label) {
1092
1149
  }, { cause });
1093
1150
  }
1094
1151
  }
1152
+ async function runTaskkill(pid, options) {
1153
+ const spawnFn = options.spawnFn ?? spawn;
1154
+ return new Promise((resolve, reject) => {
1155
+ const signalFailure = (cause) => {
1156
+ reject(new RuntimeDisposalFailure({
1157
+ stage: "signal",
1158
+ reason: `${options.label} runtime process tree termination could not be requested`
1159
+ }, { cause }));
1160
+ };
1161
+ let taskkill;
1162
+ try {
1163
+ taskkill = spawnFn("taskkill", ["/PID", String(pid), "/T", "/F"], {
1164
+ windowsHide: true,
1165
+ stdio: ["ignore", "pipe", "pipe"]
1166
+ });
1167
+ } catch (cause) {
1168
+ signalFailure(cause);
1169
+ return;
1170
+ }
1171
+ const chunks = [];
1172
+ taskkill.stdout?.on("data", (chunk) => chunks.push(chunk));
1173
+ taskkill.stderr?.on("data", (chunk) => chunks.push(chunk));
1174
+ taskkill.once("error", signalFailure);
1175
+ taskkill.once("close", () => resolve(Buffer.concat(chunks).toString("latin1")));
1176
+ });
1177
+ }
1178
+ function liveAcceptedPids(accepted, label, kill) {
1179
+ const live = [];
1180
+ for (const pid of accepted) {
1181
+ if (processExists(pid, label, kill)) live.push(pid);
1182
+ }
1183
+ return live;
1184
+ }
1095
1185
  async function waitUntil(predicate, timeoutMs) {
1096
1186
  const deadline = Date.now() + timeoutMs;
1097
1187
  while (predicate()) {
@@ -1129,29 +1219,27 @@ async function waitWithDeadline(promise, timeoutMs) {
1129
1219
  );
1130
1220
  });
1131
1221
  }
1132
- function requestOwnedProcessTreeTermination(options) {
1133
- if (options.isClosed()) return;
1222
+ async function requestOwnedProcessTreeTermination(options) {
1223
+ const platform = options.platform ?? process.platform;
1224
+ if (platform !== "win32" && options.isClosed()) return;
1134
1225
  const pid = positivePid(options.child, options.label);
1135
1226
  if (pid === void 0) return;
1136
- if (process.platform === "win32") {
1137
- const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
1138
- if (result.error) {
1139
- throw new RuntimeDisposalFailure({
1140
- stage: "signal",
1141
- reason: `${options.label} runtime process tree could not be terminated`
1142
- }, { cause: result.error });
1143
- }
1144
- terminationRequested.add(options.child);
1145
- if (result.status !== 0) terminationRequestFailed.add(options.child);
1227
+ if (platform === "win32") {
1228
+ const output = await runTaskkill(pid, options);
1229
+ const state = stateFor(options.child);
1230
+ for (const walked of walkTaskkillPidSet(output, pid, [process.pid])) state.acceptedPids.add(walked);
1231
+ state.requested = true;
1146
1232
  return;
1147
1233
  }
1148
- signalGroup(pid, "SIGTERM", options.label);
1149
- terminationRequested.add(options.child);
1234
+ signalGroup(pid, "SIGTERM", options.label, options.killFn ?? defaultKill);
1235
+ stateFor(options.child).requested = true;
1150
1236
  }
1151
1237
  async function disposeOwnedProcessTree(options) {
1152
1238
  const pid = positivePid(options.child, options.label);
1153
1239
  const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
1154
1240
  const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
1241
+ const platform = options.platform ?? process.platform;
1242
+ const kill = options.killFn ?? defaultKill;
1155
1243
  if (pid === void 0) {
1156
1244
  if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1157
1245
  throw new RuntimeDisposalFailure({
@@ -1159,27 +1247,50 @@ async function disposeOwnedProcessTree(options) {
1159
1247
  reason: `${options.label} runtime process did not settle after spawn failure`
1160
1248
  });
1161
1249
  }
1162
- if (process.platform === "win32") {
1163
- if (!options.isClosed() && !terminationRequested.has(options.child)) requestOwnedProcessTreeTermination(options);
1164
- if (await waitWithDeadline(options.waitClosed(), killGraceMs)) return;
1165
- if (terminationRequestFailed.has(options.child)) {
1250
+ if (platform === "win32") {
1251
+ if (!terminationState.get(options.child)?.requested) {
1252
+ await requestOwnedProcessTreeTermination(options);
1253
+ }
1254
+ const accepted = terminationState.get(options.child)?.acceptedPids ?? /* @__PURE__ */ new Set();
1255
+ const deadline = Date.now() + killGraceMs;
1256
+ const resweepAt = Date.now() + Math.floor(killGraceMs / 2);
1257
+ let reswept = false;
1258
+ let live = liveAcceptedPids(accepted, options.label, kill);
1259
+ while (live.length > 0) {
1260
+ if (Date.now() >= deadline) {
1261
+ throw new RuntimeDisposalFailure({
1262
+ stage: "quiescence",
1263
+ reason: `${options.label} runtime process tree did not quiesce: ${live.length} of ${accepted.size} walked process ids were still alive at the disposal deadline`
1264
+ });
1265
+ }
1266
+ if (!reswept && Date.now() >= resweepAt) {
1267
+ reswept = true;
1268
+ for (const livePid of live) {
1269
+ for (const walked of walkTaskkillPidSet(await runTaskkill(livePid, options), livePid, [process.pid])) {
1270
+ accepted.add(walked);
1271
+ }
1272
+ }
1273
+ }
1274
+ await new Promise((resolve) => {
1275
+ setTimeout(resolve, POLL_MS);
1276
+ });
1277
+ live = liveAcceptedPids(accepted, options.label, kill);
1278
+ }
1279
+ if (!await waitWithDeadline(options.waitClosed(), killGraceMs)) {
1166
1280
  throw new RuntimeDisposalFailure({
1167
- stage: "signal",
1168
- reason: `${options.label} runtime process tree could not be terminated`
1281
+ stage: "quiescence",
1282
+ reason: `${options.label} runtime root did not emit close after its process tree quiesced`
1169
1283
  });
1170
1284
  }
1171
- throw new RuntimeDisposalFailure({
1172
- stage: "quiescence",
1173
- reason: `${options.label} runtime process tree did not close before the disposal deadline`
1174
- });
1285
+ return;
1175
1286
  }
1176
- if (groupExists(pid, options.label) && !terminationRequested.has(options.child)) {
1177
- signalGroup(pid, "SIGTERM", options.label);
1178
- terminationRequested.add(options.child);
1287
+ if (groupExists(pid, options.label, kill) && !terminationState.get(options.child)?.requested) {
1288
+ signalGroup(pid, "SIGTERM", options.label, kill);
1289
+ stateFor(options.child).requested = true;
1179
1290
  }
1180
- if (!await waitUntil(() => groupExists(pid, options.label), termGraceMs)) {
1181
- signalGroup(pid, "SIGKILL", options.label);
1182
- if (!await waitUntil(() => groupExists(pid, options.label), killGraceMs)) {
1291
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), termGraceMs)) {
1292
+ signalGroup(pid, "SIGKILL", options.label, kill);
1293
+ if (!await waitUntil(() => groupExists(pid, options.label, kill), killGraceMs)) {
1183
1294
  throw new RuntimeDisposalFailure({
1184
1295
  stage: "quiescence",
1185
1296
  reason: `${options.label} runtime process group remained live after SIGKILL`
@@ -1277,9 +1388,16 @@ var PiRpcClient = class {
1277
1388
  );
1278
1389
  }
1279
1390
  }
1280
- /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
1391
+ /**
1392
+ * Immediate process-tree termination request. `dispose()` is the settlement
1393
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
1394
+ * terminator. A request that could not be spawned is left unrecorded, so
1395
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
1396
+ * swallowing it here loses nothing.
1397
+ */
1281
1398
  kill() {
1282
- requestOwnedProcessTreeTermination(this.processTreeOptions());
1399
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
1400
+ });
1283
1401
  }
1284
1402
  waitClosed() {
1285
1403
  return this.closedPromise;
@@ -2082,9 +2200,16 @@ var ClaudeProcessClient = class {
2082
2200
  );
2083
2201
  }
2084
2202
  }
2085
- /** Immediate process-tree termination request. `dispose()` is the settlement receipt. */
2203
+ /**
2204
+ * Immediate process-tree termination request. `dispose()` is the settlement
2205
+ * receipt, so this stays fire-and-forget: an interrupt must not block on a
2206
+ * terminator. A request that could not be spawned is left unrecorded, so
2207
+ * `dispose()` re-issues it and raises the typed `stage:'signal'` failure —
2208
+ * swallowing it here loses nothing.
2209
+ */
2086
2210
  kill() {
2087
- requestOwnedProcessTreeTermination(this.processTreeOptions());
2211
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
2212
+ });
2088
2213
  }
2089
2214
  waitClosed() {
2090
2215
  return this.closedPromise;
@@ -2849,9 +2974,15 @@ var CodexProcessRunner = class {
2849
2974
  * cleanly resumable afterward via `codex exec resume` (no corruption from
2850
2975
  * killing mid-turn). `taskkill /T /F` on Windows, mirroring
2851
2976
  * `../pi/rpc-client.ts`'s own cross-platform convention.
2977
+ *
2978
+ * Fire-and-forget by design: an interrupt must not block on a terminator,
2979
+ * and `dispose()` is the settlement receipt. A request that could not be
2980
+ * spawned is left unrecorded, so `dispose()` re-issues it and raises the
2981
+ * typed `stage:'signal'` failure — swallowing it here loses nothing.
2852
2982
  */
2853
2983
  kill() {
2854
- requestOwnedProcessTreeTermination(this.processTreeOptions());
2984
+ void requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
2985
+ });
2855
2986
  }
2856
2987
  dispose() {
2857
2988
  if (!this.disposalAttempt) {
@@ -4631,14 +4762,17 @@ function parseLooseEventsPollResponse(raw) {
4631
4762
  if (typeof raw !== "object" || raw === null) {
4632
4763
  throw new Error("events poll response is not an object");
4633
4764
  }
4634
- const { events, cursor } = raw;
4765
+ const { events, cursor, capabilities } = raw;
4635
4766
  if (!Array.isArray(events)) {
4636
4767
  throw new Error("events poll response.events is not an array");
4637
4768
  }
4638
4769
  if (typeof cursor !== "number" || !Number.isInteger(cursor)) {
4639
4770
  throw new Error("events poll response.cursor is not an integer");
4640
4771
  }
4641
- return { events, cursor };
4772
+ if (capabilities !== void 0 && (!Array.isArray(capabilities) || capabilities.some((flag) => typeof flag !== "string"))) {
4773
+ throw new Error("events poll response.capabilities is not an array of strings");
4774
+ }
4775
+ return { events, cursor, capabilities: capabilities ?? [] };
4642
4776
  }
4643
4777
  function extractSkippableSeq(raw) {
4644
4778
  if (typeof raw !== "object" || raw === null) return void 0;
@@ -4718,12 +4852,14 @@ var LongPollClient = class {
4718
4852
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
4719
4853
  const res = await authedFetch(url, { method: "GET" }, this.opts.auth);
4720
4854
  if (!res.ok) {
4855
+ this.opts.onServerCapabilities?.([]);
4721
4856
  this.opts.onOperationalOutcome?.("failure");
4722
4857
  const baseMs = this.opts.retryDelayMs ?? 2e3;
4723
4858
  await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
4724
4859
  continue;
4725
4860
  }
4726
4861
  const parsed = parseLooseEventsPollResponse(await res.json());
4862
+ this.opts.onServerCapabilities?.(parsed.capabilities);
4727
4863
  let hadValidationFailureThisBatch = false;
4728
4864
  for (const raw of parsed.events) {
4729
4865
  let envelope;
@@ -4767,6 +4903,7 @@ var LongPollClient = class {
4767
4903
  this.opts.onOperationalOutcome?.("success");
4768
4904
  }
4769
4905
  } catch (err) {
4906
+ this.opts.onServerCapabilities?.([]);
4770
4907
  if (err instanceof DeviceRevokedError) {
4771
4908
  this.running = false;
4772
4909
  this.opts.onRevoked?.();
@@ -5002,6 +5139,9 @@ var ConnectionManager = class {
5002
5139
  // re-attempted.
5003
5140
  getCursor: () => this.dedupWatermark(),
5004
5141
  onEnvelope: (envelope) => this.deliver(envelope),
5142
+ onServerCapabilities: (capabilities) => {
5143
+ if (this.mode === "long-poll") this.serverCapabilities = capabilities;
5144
+ },
5005
5145
  onRevoked: () => this.enterRevoked(),
5006
5146
  // M4 Phase 4 (version-negotiation drill fix): a batch entry
5007
5147
  // LongPollClient couldn't parse into a known Envelope at all (an
@@ -5133,25 +5273,24 @@ var ConnectionManager = class {
5133
5273
  */
5134
5274
  cancelPendingDrainRetry;
5135
5275
  /**
5136
- * The capabilities the CURRENTLY connected server advertised in its
5137
- * `conn.ack` — untyped `string[]` (forward-compat: a server may advertise
5138
- * a flag this build doesn't recognize yet), populated by {@link onAcked}
5139
- * and read by {@link getServerCapabilities}. Empty until the very first
5140
- * successful handshake.
5276
+ * The capabilities the CURRENT transport's server advertised untyped
5277
+ * `string[]` for forward compatibility. WS populates it from `conn.ack`;
5278
+ * long-poll populates it from each successful events response. Empty until
5279
+ * the active transport supplies an advertisement.
5141
5280
  *
5142
5281
  * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
5143
5282
  * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
5144
5283
  * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
5145
5284
  * `acked` branch), `stop()`, or a transport switch to long-poll
5146
- * (`enterLongPoll`) — and only ever repopulated by a FRESH `conn.ack`.
5285
+ * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
5286
+ * the transport that is still current.
5147
5287
  * The previous version of this doc comment claimed long-poll mode simply
5148
5288
  * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
5149
5289
  * a daemon that once learned e.g. `approval_resolved` from an earlier WS
5150
5290
  * session kept believing it applied to whatever it's connected to NOW,
5151
5291
  * even after a disconnect/degrade where nothing has actually confirmed
5152
5292
  * that's still true (a reconnect could land on a DIFFERENT server behind a
5153
- * load balancer; long-poll fallback itself never performs an equivalent
5154
- * handshake at all). Concretely, `TaskRunner.sendApprovalResolved` gates
5293
+ * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
5155
5294
  * `task.approval_resolved` on this list — sending it to a server that
5156
5295
  * doesn't actually understand it over the long-poll path would get a
5157
5296
  * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
@@ -5271,13 +5410,10 @@ var ConnectionManager = class {
5271
5410
  return this.mode === "long-poll";
5272
5411
  }
5273
5412
  /**
5274
- * The capabilities the CURRENTLY connected server advertised in its
5275
- * `conn.ack` e.g. lets a caller gate a daemon->server message on whether
5276
- * THIS server understands it before sending (see `task-runner.ts`'s
5277
- * `sendApprovalResolved`, gated on `approval_resolved`). Empty before the
5278
- * first handshake completes, AND (finding R2) once again empty after any
5279
- * disconnect/degrade — see `serverCapabilities`'s own doc comment for why
5280
- * this is strictly per-connection rather than "sticky" across one.
5413
+ * The capabilities the CURRENT transport's server advertised: from
5414
+ * `conn.ack` on WS, or the latest successful `GET /byok/events` response
5415
+ * on long-poll. Empty before either transport has supplied its current
5416
+ * advertisement, and cleared across disconnect/switch boundaries.
5281
5417
  */
5282
5418
  getServerCapabilities() {
5283
5419
  return this.serverCapabilities;
@@ -9647,9 +9783,9 @@ var TaskRunner = class {
9647
9783
  /**
9648
9784
  * Whether the CURRENTLY connected server advertised `result-document` —
9649
9785
  * read fresh on every call, never captured, because the answer changes
9650
- * across a reconnect (`ConnectionManager.getServerCapabilities` returns
9651
- * `[]` from the moment an acked connection closes until a fresh
9652
- * `conn.ack` repopulates it). An absent `getServerCapabilities` seam is
9786
+ * across a reconnect or transport switch (`ConnectionManager` clears the
9787
+ * old advertisement at the boundary, then repopulates it from a fresh WS
9788
+ * ack or successful poll response). An absent `getServerCapabilities` seam is
9653
9789
  * "no capabilities", the fail-closed reading.
9654
9790
  */
9655
9791
  hasResultDocumentCapability() {