@byok-sdk/client 0.12.0 → 0.13.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.
@@ -3,7 +3,7 @@ import { randomUUID, createHash, randomBytes, timingSafeEqual, createHmac, creat
3
3
  import { readFileSync, promises, linkSync, fstatSync, lstatSync, unlinkSync, constants, readSync, openSync, writeFileSync, fchmodSync, fsyncSync, closeSync, opendirSync, existsSync, realpathSync, mkdirSync, renameSync, chmodSync, statSync, readdirSync } from 'fs';
4
4
  import * as path3 from 'path';
5
5
  import path3__default, { isAbsolute, join } from 'path';
6
- import { AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, TASK_STATES, AgentEgressPolicySchema, AgentHomeProjectionPayloadSchema, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, AgentRefSchema, AgentContentReceiptPayloadSchema, BYOK_PAIR_PATH, PairResponseSchema, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, PROTOCOL_VERSION, createEnvelope, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, TERMINAL_PROJECTION_SELECTION_CAPABILITY, STRICT_AGENT_ONLY_CAPABILITY, AGENT_HOME_PROJECTION_CAPABILITY, AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_FRESH_SESSION_CAPABILITY, AGENT_MESSAGE_EGRESS_CAPABILITY, AgentHomeProjectionCompletionRequestSchema, byokAgentHomeProjectionCompletionPath, AgentHomeProjectionReadbackSchema, parseMessage, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, AgentMessagePublishPayloadSchema, AgentMessageDispositionPayloadSchema, RuntimeIdSchema, AGENT_MEMORY_PROJECTION_CAPABILITY, TERMINAL_INFERENCE_USAGE_MAX_DURATION_MS, TERMINAL_INFERENCE_USAGE_MAX_TOKENS, RESULT_DOCUMENT_MAX_BYTES, decodeEnvelope, BYOK_EVENTS_PATH, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, UnknownMessageTypeError, BYOK_WS_PATH, AGENT_MEMORY_PROJECTION_MAX_ORDERING_VALUE, AgentMemoryProjectionMutationSchema, AGENT_MEMORY_PROJECTION_MAX_REDACTED_BYTES } from '@byok-sdk/protocol';
6
+ import { AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, TASK_STATES, AgentEgressPolicySchema, AgentHomeProjectionPayloadSchema, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, AgentRefSchema, AgentContentReceiptPayloadSchema, BYOK_PAIR_PATH, PairResponseSchema, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, partitionAgentEvents, TASK_TRANSITIONS, encodeEnvelope, PROTOCOL_VERSION, createEnvelope, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, TERMINAL_PROJECTION_SELECTION_CAPABILITY, PROVIDER_PROFILE_BINDING_CAPABILITY, STRICT_AGENT_ONLY_CAPABILITY, AGENT_HOME_PROJECTION_CAPABILITY, AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_FRESH_SESSION_CAPABILITY, AGENT_MESSAGE_EGRESS_CAPABILITY, AgentHomeProjectionCompletionRequestSchema, byokAgentHomeProjectionCompletionPath, AgentHomeProjectionReadbackSchema, parseMessage, checkResultDocument, MAX_MESSAGES_PER_BATCH, BYOK_CAPABILITIES_PATH, BYOK_PRESENCE_PATH, AgentMessagePublishPayloadSchema, AgentMessageDispositionPayloadSchema, RuntimeIdSchema, AGENT_MEMORY_PROJECTION_CAPABILITY, TERMINAL_INFERENCE_USAGE_MAX_DURATION_MS, TERMINAL_INFERENCE_USAGE_MAX_TOKENS, RESULT_DOCUMENT_MAX_BYTES, BYOK_EVENTS_PATH, BYOK_MESSAGES_PATH, MessagesSendResponseSchema, UnknownMessageTypeError, MESSAGE_TYPES, AGENT_MEMORY_PROJECTION_MAX_ORDERING_VALUE, AgentMemoryProjectionMutationSchema, AGENT_MEMORY_PROJECTION_MAX_REDACTED_BYTES } from '@byok-sdk/protocol';
7
7
  import net, { createServer, createConnection } from 'net';
8
8
  import * as os from 'os';
9
9
  import os__default from 'os';
@@ -13,7 +13,6 @@ import 'readline';
13
13
  import { isTenantId, 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';
14
14
  import { promisify } from 'util';
15
15
  import * as fs13 from 'fs/promises';
16
- import { WebSocket } from 'ws';
17
16
  import { createRequire } from 'module';
18
17
  import { createInterface } from 'readline/promises';
19
18
 
@@ -445,6 +444,23 @@ async function resolveExistingAncestor(inputPath) {
445
444
  }
446
445
  }
447
446
  }
447
+ async function canonicalPath(inputPath) {
448
+ const { canonical: canonical2, tail } = await resolveExistingAncestor(inputPath);
449
+ return path3__default.resolve(canonical2, ...tail);
450
+ }
451
+ async function assertRealDirectoryIfPresent(target) {
452
+ let stat;
453
+ try {
454
+ stat = await promises.lstat(target);
455
+ } catch (error) {
456
+ const code = error.code;
457
+ if (code === "ENOENT" || code === "ENOTDIR") return;
458
+ throw error;
459
+ }
460
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
461
+ throw new AgentHomeResolutionError(`Agent home path component is not a real directory: ${target}`);
462
+ }
463
+ }
448
464
  async function materializeDirectory(inputPath) {
449
465
  const { canonical: canonical2, tail } = await resolveExistingAncestor(inputPath);
450
466
  let cursor = canonical2;
@@ -539,6 +555,39 @@ var AgentHomeLayout = class {
539
555
  await gate.release();
540
556
  }
541
557
  }
558
+ /**
559
+ * Pure canonical-home derivation for read-only callers, such as the
560
+ * pre-admission single-writer count. It validates the AgentRef and joins
561
+ * exactly the same `<hostStorageRoot>/agents/<agentId>` segments
562
+ * {@link AgentHomeLayout.resolve} would, canonicalizing only the components
563
+ * that already exist.
564
+ *
565
+ * It deliberately creates no directory, takes no cross-process mutation
566
+ * gate and records no Agent binding, so an offer the host vetoes after the
567
+ * count leaves nothing behind on disk. `resolve()` stays the only path that
568
+ * may materialize a home or bind it to an Agent identity.
569
+ *
570
+ * An `agents` root or `agents/<agentId>` leaf that already exists but is a
571
+ * symlink (or any non-directory) is rejected here with the same error class
572
+ * and message `resolve()` raises for it, so an in-root `two -> one` link
573
+ * fails closed instead of silently keying the count of `one`. A leaf that
574
+ * does not exist yet is not an error: this derivation runs before the home
575
+ * is materialized. A home canonicalizing outside the `agents` root stays
576
+ * rejected as before.
577
+ */
578
+ async canonicalHomePath(agentRefInput) {
579
+ const agentRef = validateAgentRef(agentRefInput);
580
+ const hostStorageRoot = this.canonicalRoot ?? await canonicalPath(this.hostStorageRootInput);
581
+ const agentsRoot = path3__default.join(hostStorageRoot, AGENT_HOME_DIRECTORY);
582
+ await assertRealDirectoryIfPresent(agentsRoot);
583
+ const lexicalHome = path3__default.join(agentsRoot, agentRef.agentId);
584
+ await assertRealDirectoryIfPresent(lexicalHome);
585
+ const canonicalHome = await canonicalPath(lexicalHome);
586
+ if (canonicalHome === agentsRoot || !isWithin(agentsRoot, canonicalHome)) {
587
+ throw new AgentHomeResolutionError(`Agent home resolves outside the Agent home root: ${canonicalHome}`);
588
+ }
589
+ return canonicalHome;
590
+ }
542
591
  /**
543
592
  * Prove the canonical root is materializable and writable before the daemon
544
593
  * advertises Agent-home capability. No Agent identity or persistent Agent
@@ -855,6 +904,44 @@ var AgentHomeExecutionLeaseManager = class _AgentHomeExecutionLeaseManager {
855
904
  });
856
905
  });
857
906
  }
907
+ /**
908
+ * WP0: Attempts currently holding an execution lease on this exact
909
+ * canonical home, across every lane and every session. This is the number
910
+ * the daemon's admission gate reads before any side effect — see
911
+ * `TaskRunner.handleOffer`'s per-home busy gate.
912
+ *
913
+ * Derived from the one lease registry above rather than a second tally, so
914
+ * it inherits the lease lifecycle exactly: an entry appears at `acquire()`,
915
+ * survives `bindSession()` (which rekeys in place), and disappears only at
916
+ * `release()`, which the task runner calls after the attempt is terminal
917
+ * AND `Session.close()` resolved. A failed disposal never reaches
918
+ * `release()`, so the slot stays held — fail closed, the same posture as
919
+ * `runtime-disposal-failed`. Crash residue needs nothing extra here: a
920
+ * restarted daemon starts with an empty registry and reclaims the on-disk
921
+ * marker only under the same stable owner identity (`openLeaseMarker`).
922
+ *
923
+ * Counted regardless of which lease manager owns the group: the invariant
924
+ * being protected is the filesystem path (`MEMORY.md`, `notes/`, `.git`),
925
+ * not the owner identity.
926
+ */
927
+ activeAttemptCount(canonicalHome) {
928
+ return _AgentHomeExecutionLeaseManager.groups.get(canonicalHome)?.leasesByKey.size ?? 0;
929
+ }
930
+ /**
931
+ * Counts-only readback for daemon/control status. Scoped to this manager's
932
+ * own leases, so the number describes this daemon rather than every home
933
+ * any manager in the process happens to hold. Never exposes a home path.
934
+ */
935
+ activeAttemptSummary() {
936
+ let homes = 0;
937
+ let attempts = 0;
938
+ for (const group of _AgentHomeExecutionLeaseManager.groups.values()) {
939
+ if (group.manager !== this.manager) continue;
940
+ homes += 1;
941
+ attempts += group.leasesByKey.size;
942
+ }
943
+ return { homes, attempts };
944
+ }
858
945
  async mutate(binding, operation) {
859
946
  return this.exclusive(binding.resolution.canonicalHome, async () => {
860
947
  const group = _AgentHomeExecutionLeaseManager.groups.get(binding.resolution.canonicalHome);
@@ -1818,13 +1905,22 @@ function freezeRuntimeAdapterDescriptor(descriptor) {
1818
1905
  });
1819
1906
  }
1820
1907
  function sealRuntimeOperationManifest(manifest) {
1908
+ const dispatchSelection = manifest.dispatchSelection === void 0 ? void 0 : manifest.dispatchSelection.lane === "byok-profile" ? Object.freeze({
1909
+ ...manifest.dispatchSelection,
1910
+ providerProfile: Object.freeze({
1911
+ ...manifest.dispatchSelection.providerProfile,
1912
+ requiredCapabilities: Object.freeze([
1913
+ ...manifest.dispatchSelection.providerProfile.requiredCapabilities
1914
+ ])
1915
+ })
1916
+ }) : Object.freeze({ ...manifest.dispatchSelection });
1821
1917
  return Object.freeze({
1822
1918
  taskId: manifest.taskId,
1823
1919
  runtimeId: manifest.runtimeId,
1824
1920
  descriptor: freezeRuntimeAdapterDescriptor(manifest.descriptor),
1825
1921
  policy: frozenPolicy(manifest.policy),
1826
1922
  requiredToolsetIds: Object.freeze([...manifest.requiredToolsetIds]),
1827
- ...manifest.dispatchSelection === void 0 ? {} : { dispatchSelection: Object.freeze({ ...manifest.dispatchSelection }) },
1923
+ ...dispatchSelection === void 0 ? {} : { dispatchSelection },
1828
1924
  ...manifest.sessionRef === void 0 ? {} : { sessionRef: manifest.sessionRef },
1829
1925
  ...manifest.agentRef === void 0 ? {} : { agentRef: Object.freeze({ agentId: manifest.agentRef.agentId, profileRevision: manifest.agentRef.profileRevision }) },
1830
1926
  cwd: manifest.cwd ?? manifest.workspace.workspaceDir,
@@ -3852,7 +3948,11 @@ function validatePiByokLauncherConfig(launcher) {
3852
3948
  "--macos-keychain-path",
3853
3949
  "--secret-service-prefix",
3854
3950
  "--provider",
3855
- "--model"
3951
+ "--model",
3952
+ "--profile-revision",
3953
+ "--profile-hash",
3954
+ "--required-capabilities",
3955
+ "--validate-only"
3856
3956
  ]);
3857
3957
  const conflicting = launcher.args?.find((arg) => reserved.has(arg));
3858
3958
  if (conflicting !== void 0) {
@@ -3914,11 +4014,17 @@ var PiAdapter = class {
3914
4014
  const bin = this.resolveBin();
3915
4015
  const extensions = (this.options.resolveExtensions ?? resolvePiExtensions)();
3916
4016
  const selection = input.offer.dispatchSelection;
3917
- const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
4017
+ const pinnedSelection = selection === void 0 ? void 0 : selection.lane === "byok-profile" ? Object.freeze({
4018
+ ...selection,
4019
+ providerProfile: Object.freeze({
4020
+ ...selection.providerProfile,
4021
+ requiredCapabilities: Object.freeze([...selection.providerProfile.requiredCapabilities])
4022
+ })
4023
+ }) : Object.freeze({ ...selection });
3918
4024
  let command = bin.command;
3919
4025
  let launcherArgs;
3920
4026
  if (pinnedSelection !== void 0) {
3921
- if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
4027
+ if (pinnedSelection.lane !== "byok" && pinnedSelection.lane !== "byok-profile" || pinnedSelection.runtimeId !== "pi") {
3922
4028
  return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
3923
4029
  }
3924
4030
  const launcher = this.options.byokLauncher;
@@ -3926,6 +4032,21 @@ var PiAdapter = class {
3926
4032
  return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
3927
4033
  }
3928
4034
  command = launcher.command;
4035
+ const providerProfile = pinnedSelection.lane === "byok-profile" ? pinnedSelection.providerProfile : void 0;
4036
+ if (providerProfile !== void 0) {
4037
+ try {
4038
+ await (this.options.validateProviderProfileBinding ?? validateProviderProfileBindingWithLauncher)(
4039
+ providerProfile,
4040
+ launcher
4041
+ );
4042
+ } catch (error) {
4043
+ return {
4044
+ kind: "reject",
4045
+ reason: `provider profile admission failed: ${errorMessage2(error)}`,
4046
+ retryable: false
4047
+ };
4048
+ }
4049
+ }
3929
4050
  launcherArgs = [
3930
4051
  ...launcher.args ?? [],
3931
4052
  "--pi-bin",
@@ -3937,9 +4058,19 @@ var PiAdapter = class {
3937
4058
  ...launcher.macosKeychainPath !== void 0 ? ["--macos-keychain-path", launcher.macosKeychainPath] : [],
3938
4059
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
3939
4060
  "--provider",
3940
- pinnedSelection.providerId,
4061
+ providerProfile?.profileRef ?? (pinnedSelection.lane === "byok" ? pinnedSelection.providerId : ""),
3941
4062
  "--model",
3942
- pinnedSelection.modelId
4063
+ providerProfile?.modelId ?? (pinnedSelection.lane === "byok" ? pinnedSelection.modelId : ""),
4064
+ ...providerProfile === void 0 ? [] : [
4065
+ "--profile-revision",
4066
+ providerProfile.profileRevision,
4067
+ "--profile-hash",
4068
+ providerProfile.profileHash,
4069
+ "--required-capabilities",
4070
+ JSON.stringify(providerProfile.requiredCapabilities),
4071
+ "--validate-only",
4072
+ "false"
4073
+ ]
3943
4074
  ];
3944
4075
  }
3945
4076
  return {
@@ -4082,8 +4213,42 @@ var PiAdapter = class {
4082
4213
  return (this.options.resolveBin ?? resolvePiBin)();
4083
4214
  }
4084
4215
  };
4216
+ async function validateProviderProfileBindingWithLauncher(binding, launcher) {
4217
+ await execFileAsync(launcher.command, [
4218
+ ...launcher.args ?? [],
4219
+ "--pi-bin",
4220
+ process.execPath,
4221
+ "--profile-db",
4222
+ launcher.profileDbPath,
4223
+ "--session-dir",
4224
+ launcher.sessionDir,
4225
+ "--provider",
4226
+ binding.profileRef,
4227
+ "--model",
4228
+ binding.modelId,
4229
+ "--profile-revision",
4230
+ binding.profileRevision,
4231
+ "--profile-hash",
4232
+ binding.profileHash,
4233
+ "--required-capabilities",
4234
+ JSON.stringify(binding.requiredCapabilities),
4235
+ "--validate-only",
4236
+ "true",
4237
+ ...launcher.macosKeychainPath !== void 0 ? ["--macos-keychain-path", launcher.macosKeychainPath] : [],
4238
+ ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : []
4239
+ ], { timeout: DETECT_TIMEOUT_MS });
4240
+ }
4085
4241
  function sameDispatchSelection(left, right) {
4086
4242
  if (left === void 0 || right === void 0) return left === right;
4243
+ if (left.lane === "byok-profile" || right.lane === "byok-profile") {
4244
+ if (left.lane !== "byok-profile" || right.lane !== "byok-profile") return false;
4245
+ if (left.runtimeId !== right.runtimeId) return false;
4246
+ const leftProfile = left.providerProfile;
4247
+ const rightProfile = right.providerProfile;
4248
+ return leftProfile.profileRef === rightProfile.profileRef && leftProfile.profileRevision === rightProfile.profileRevision && leftProfile.profileHash === rightProfile.profileHash && leftProfile.modelId === rightProfile.modelId && leftProfile.requiredCapabilities.length === rightProfile.requiredCapabilities.length && leftProfile.requiredCapabilities.every(
4249
+ (capability, index) => capability === rightProfile.requiredCapabilities[index]
4250
+ );
4251
+ }
4087
4252
  return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
4088
4253
  }
4089
4254
  async function resolveAuthoritativeSessionId(rpc) {
@@ -6976,19 +7141,14 @@ function signNonce(privateKey, nonce) {
6976
7141
  const signature = sign(null, nonceSigningBytes(nonce), privateKey);
6977
7142
  return signature.toString("base64url");
6978
7143
  }
7144
+
7145
+ // src/daemon/url.ts
6979
7146
  function toHttpBase(serverUrl) {
6980
7147
  const url = new URL(serverUrl);
6981
- if (url.protocol === "ws:") url.protocol = "http:";
6982
- else if (url.protocol === "wss:") url.protocol = "https:";
6983
7148
  url.pathname = "/";
6984
7149
  url.search = "";
6985
7150
  return url.toString();
6986
7151
  }
6987
- function toWsUrl(serverUrl) {
6988
- const url = new URL(BYOK_WS_PATH, toHttpBase(serverUrl));
6989
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
6990
- return url.toString();
6991
- }
6992
7152
  function describeEndpoint(transport, url) {
6993
7153
  const parsed = typeof url === "string" ? new URL(url) : url;
6994
7154
  return { transport, host: parsed.host, path: parsed.pathname };
@@ -7021,17 +7181,15 @@ function assertServerUrlAllowed(rawUrl, opts = {}) {
7021
7181
  const endpoint = formatServerUrl(url);
7022
7182
  switch (url.protocol) {
7023
7183
  case "https:":
7024
- case "wss:":
7025
7184
  return;
7026
7185
  case "http:":
7027
- case "ws:":
7028
7186
  if (opts.dangerouslyAllowInsecureRemote || isLoopbackHostname(url.hostname)) return;
7029
7187
  throw new InsecureServerUrlError(
7030
- `refusing to connect to "${endpoint}" over plaintext ${url.protocol.replace(":", "")} \u2014 "${url.hostname}" is not a loopback host. Use wss:/https: for any non-loopback server, or pass { dangerouslyAllowInsecureRemote: true } (DaemonConfig) if you understand and accept the risk of sending device credentials over an unencrypted connection.`
7188
+ `refusing to connect to "${endpoint}" over plaintext ${url.protocol.replace(":", "")} \u2014 "${url.hostname}" is not a loopback host. Use https: for any non-loopback server, or pass { dangerouslyAllowInsecureRemote: true } (DaemonConfig) if you understand and accept the risk of sending device credentials over an unencrypted connection.`
7031
7189
  );
7032
7190
  default:
7033
7191
  throw new InsecureServerUrlError(
7034
- `refusing to connect to "${endpoint}" \u2014 unsupported scheme "${url.protocol}" (expected http:, https:, ws:, or wss:).`
7192
+ `refusing to connect to "${endpoint}" \u2014 unsupported scheme "${url.protocol}" (expected http: or https:).`
7035
7193
  );
7036
7194
  }
7037
7195
  }
@@ -9083,7 +9241,7 @@ var LongPollRouteError = class extends Error {
9083
9241
  };
9084
9242
  var MAX_TRACKED_VALIDATION_FAILURE_WARNINGS = 1e3;
9085
9243
  var MAX_TRACKED_ROUTE_FAILURE_WARNINGS = 1e3;
9086
- function parseLooseEventsPollResponse(raw) {
9244
+ function parseLooseEventsPollResponse(raw, requestedCursor) {
9087
9245
  if (typeof raw !== "object" || raw === null) {
9088
9246
  throw new Error("events poll response is not an object");
9089
9247
  }
@@ -9091,19 +9249,48 @@ function parseLooseEventsPollResponse(raw) {
9091
9249
  if (!Array.isArray(events)) {
9092
9250
  throw new Error("events poll response.events is not an array");
9093
9251
  }
9094
- if (typeof cursor !== "number" || !Number.isInteger(cursor)) {
9095
- throw new Error("events poll response.cursor is not an integer");
9252
+ if (!isSafeNonnegativeInteger(cursor)) {
9253
+ throw new Error("events poll response.cursor is not a safe nonnegative integer");
9254
+ }
9255
+ if (cursor < requestedCursor) {
9256
+ throw new Error("events poll response.cursor regressed below the requested cursor");
9096
9257
  }
9097
9258
  if (capabilities !== void 0 && (!Array.isArray(capabilities) || capabilities.some((flag) => typeof flag !== "string"))) {
9098
9259
  throw new Error("events poll response.capabilities is not an array of strings");
9099
9260
  }
9100
9261
  return { events, cursor, capabilities: capabilities ?? [] };
9101
9262
  }
9263
+ function isSafeNonnegativeInteger(value) {
9264
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
9265
+ }
9266
+ function validateTrustedEventsPage(events, requestedCursor, pageCursor) {
9267
+ let previousTaskSeq;
9268
+ for (const raw of events) {
9269
+ if (typeof raw !== "object" || raw === null) continue;
9270
+ const { type, seq } = raw;
9271
+ if (typeof type !== "string" || !type.startsWith("task.")) continue;
9272
+ if (!isSafeNonnegativeInteger(seq)) {
9273
+ throw new Error("events poll task seq is not a safe nonnegative integer");
9274
+ }
9275
+ if (seq <= requestedCursor || seq > pageCursor) {
9276
+ throw new Error("events poll task seq lies outside the trusted page cursor range");
9277
+ }
9278
+ if (previousTaskSeq !== void 0 && seq <= previousTaskSeq) {
9279
+ throw new Error("events poll task seq is not strictly page ordered");
9280
+ }
9281
+ const knownTaskType = MESSAGE_TYPES.includes(type);
9282
+ const predecessor = previousTaskSeq ?? requestedCursor;
9283
+ if (!knownTaskType && seq !== predecessor + 1) {
9284
+ throw new Error("events poll unknown task seq would cross an untrusted gap");
9285
+ }
9286
+ previousTaskSeq = seq;
9287
+ }
9288
+ }
9102
9289
  function extractSkippableSeq(raw) {
9103
9290
  if (typeof raw !== "object" || raw === null) return void 0;
9104
9291
  const { type, seq } = raw;
9105
9292
  if (typeof type !== "string" || !type.startsWith("task.")) return void 0;
9106
- return typeof seq === "number" && Number.isInteger(seq) ? seq : void 0;
9293
+ return isSafeNonnegativeInteger(seq) ? seq : void 0;
9107
9294
  }
9108
9295
  var LongPollClient = class {
9109
9296
  constructor(opts) {
@@ -9114,6 +9301,8 @@ var LongPollClient = class {
9114
9301
  }
9115
9302
  opts;
9116
9303
  running = false;
9304
+ /** Owns exactly one active loop generation, including its held GET and retry delays. */
9305
+ loopAbortController;
9117
9306
  /**
9118
9307
  * Finding R1: seqs this loop has already `console.warn`'d about for a
9119
9308
  * validation-failed (recognized-type, invalid-payload) entry — a poison
@@ -9173,16 +9362,22 @@ var LongPollClient = class {
9173
9362
  noteRevoked(err) {
9174
9363
  if (!(err instanceof DeviceRevokedError)) return false;
9175
9364
  this.running = false;
9365
+ this.loopAbortController?.abort();
9176
9366
  this.opts.onRevoked?.();
9177
9367
  return true;
9178
9368
  }
9179
9369
  start() {
9180
9370
  if (this.running) return;
9181
9371
  this.running = true;
9182
- void this.loop();
9372
+ const controller = new AbortController();
9373
+ this.loopAbortController = controller;
9374
+ void this.loop(controller.signal).finally(() => {
9375
+ if (this.loopAbortController === controller) this.loopAbortController = void 0;
9376
+ });
9183
9377
  }
9184
9378
  stop() {
9185
9379
  this.running = false;
9380
+ this.loopAbortController?.abort();
9186
9381
  }
9187
9382
  /**
9188
9383
  * POST one batch of envelopes to `/byok/messages` (finding F6/protocol
@@ -9191,14 +9386,14 @@ var LongPollClient = class {
9191
9386
  * (`ConnectionHub.handleInbound`), so a resend of the SAME batch (same
9192
9387
  * envelope `id`s — the caller must never rebuild them) is deduped
9193
9388
  * server-side into a safe no-op rather than reprocessed (§9). Returns
9194
- * `true` once the server has accepted the batch.
9389
+ * validated frozen-v1 counts only after a readable response body.
9195
9390
  */
9196
9391
  async postBatch(envelopes) {
9197
9392
  try {
9198
9393
  await this.opts.auth.getValidAccessToken();
9199
9394
  } catch (err) {
9200
9395
  this.noteRevoked(err);
9201
- return false;
9396
+ return void 0;
9202
9397
  }
9203
9398
  let res;
9204
9399
  try {
@@ -9214,23 +9409,26 @@ var LongPollClient = class {
9214
9409
  );
9215
9410
  } catch (err) {
9216
9411
  if (!this.noteRevoked(err)) this.warnRouteFailure(this.messagesEndpoint, void 0, err);
9217
- return false;
9412
+ return void 0;
9218
9413
  }
9219
9414
  if (!res.ok) {
9220
9415
  this.warnRouteFailure(this.messagesEndpoint, res.status, void 0);
9221
- return false;
9416
+ return void 0;
9222
9417
  }
9223
9418
  try {
9224
- MessagesSendResponseSchema.parse(await res.json());
9419
+ const response = MessagesSendResponseSchema.parse(await res.json());
9420
+ if (response.accepted + (response.rejected ?? 0) !== envelopes.length) {
9421
+ throw new Error("messages response counts do not match the posted batch length");
9422
+ }
9423
+ return response;
9225
9424
  } catch (err) {
9226
9425
  this.warnRouteFailure(this.messagesEndpoint, res.status, err);
9227
- return false;
9426
+ return void 0;
9228
9427
  }
9229
- return true;
9230
9428
  }
9231
- async loop() {
9429
+ async loop(signal) {
9232
9430
  let retryAttempt = 0;
9233
- while (this.running) {
9431
+ while (this.running && !signal.aborted) {
9234
9432
  try {
9235
9433
  await this.opts.auth.getValidAccessToken();
9236
9434
  const base = toHttpBase(this.opts.serverUrl);
@@ -9239,8 +9437,9 @@ var LongPollClient = class {
9239
9437
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
9240
9438
  let res;
9241
9439
  try {
9242
- res = await authedFetch(url, { method: "GET" }, this.opts.auth);
9440
+ res = await authedFetch(url, { method: "GET", signal }, this.opts.auth);
9243
9441
  } catch (err) {
9442
+ if (signal.aborted) return;
9244
9443
  if (!(err instanceof DeviceRevokedError)) {
9245
9444
  this.warnRouteFailure(this.eventsEndpoint, void 0, err);
9246
9445
  }
@@ -9254,21 +9453,24 @@ var LongPollClient = class {
9254
9453
  return;
9255
9454
  }
9256
9455
  this.warnRouteFailure(this.eventsEndpoint, res.status, void 0);
9257
- this.opts.onServerCapabilities?.([]);
9456
+ this.opts.onServerCapabilitiesInvalidated?.();
9457
+ this.opts.onPollFailure?.();
9258
9458
  this.opts.onOperationalOutcome?.("failure");
9259
9459
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9260
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
9460
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9261
9461
  continue;
9262
9462
  }
9263
9463
  let parsed;
9264
9464
  try {
9265
- parsed = parseLooseEventsPollResponse(await res.json());
9465
+ parsed = parseLooseEventsPollResponse(await res.json(), cursor ?? 0);
9466
+ validateTrustedEventsPage(parsed.events, cursor ?? 0, parsed.cursor);
9266
9467
  } catch (err) {
9267
9468
  this.warnRouteFailure(this.eventsEndpoint, res.status, err);
9268
9469
  throw err;
9269
9470
  }
9270
9471
  this.opts.onServerCapabilities?.(parsed.capabilities);
9271
9472
  let hadValidationFailureThisBatch = false;
9473
+ let acceptedAnyEntry = false;
9272
9474
  for (const raw of parsed.events) {
9273
9475
  let envelope;
9274
9476
  try {
@@ -9276,7 +9478,10 @@ var LongPollClient = class {
9276
9478
  } catch (err) {
9277
9479
  if (err instanceof UnknownMessageTypeError) {
9278
9480
  const skippableSeq = extractSkippableSeq(raw);
9279
- if (skippableSeq !== void 0) this.opts.onSkippedSeq?.(skippableSeq);
9481
+ if (skippableSeq !== void 0) {
9482
+ this.opts.onSkippedSeq?.(skippableSeq);
9483
+ acceptedAnyEntry = true;
9484
+ }
9280
9485
  } else {
9281
9486
  const failedSeq = extractSkippableSeq(raw);
9282
9487
  if (failedSeq !== void 0) {
@@ -9296,27 +9501,28 @@ var LongPollClient = class {
9296
9501
  }
9297
9502
  continue;
9298
9503
  }
9299
- this.opts.onEnvelope(envelope);
9504
+ if (this.opts.onEnvelope(envelope) !== false) acceptedAnyEntry = true;
9300
9505
  }
9301
9506
  if (parsed.events.length === 0) {
9302
9507
  retryAttempt = 0;
9303
9508
  this.opts.onOperationalOutcome?.("success");
9304
- await sleep(this.opts.idleDelayMs ?? 250);
9305
- } else if (this.opts.isStalled?.() || hadValidationFailureThisBatch) {
9509
+ await sleep(this.opts.idleDelayMs ?? 250, signal);
9510
+ } else if (this.opts.isStalled?.() || hadValidationFailureThisBatch || !acceptedAnyEntry && this.opts.getCursor() === cursor) {
9306
9511
  this.opts.onOperationalOutcome?.("failure");
9307
9512
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9308
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
9513
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9309
9514
  } else {
9310
9515
  retryAttempt = 0;
9311
9516
  this.opts.onOperationalOutcome?.("success");
9312
9517
  }
9313
9518
  } catch (err) {
9314
- this.opts.onServerCapabilities?.([]);
9519
+ this.opts.onServerCapabilitiesInvalidated?.();
9315
9520
  if (this.noteRevoked(err)) return;
9316
- if (!this.running) return;
9521
+ if (!this.running || signal.aborted) return;
9522
+ this.opts.onPollFailure?.();
9317
9523
  this.opts.onOperationalOutcome?.("failure");
9318
9524
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9319
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
9525
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9320
9526
  }
9321
9527
  }
9322
9528
  }
@@ -9336,20 +9542,24 @@ async function parseReplayCursorTooOld(res) {
9336
9542
  }
9337
9543
  return new ReplayCursorTooOldError(recoverableFrom);
9338
9544
  }
9339
- function sleep(ms) {
9340
- return new Promise((resolve) => setTimeout(resolve, ms));
9545
+ function sleep(ms, signal) {
9546
+ if (signal.aborted) return Promise.resolve();
9547
+ return new Promise((resolve) => {
9548
+ const finish = () => {
9549
+ clearTimeout(timer);
9550
+ signal.removeEventListener("abort", finish);
9551
+ resolve();
9552
+ };
9553
+ const timer = setTimeout(finish, ms);
9554
+ signal.addEventListener("abort", finish, { once: true });
9555
+ });
9341
9556
  }
9342
- var WsUnexpectedStatusError = class extends Error {
9343
- constructor(status, endpoint) {
9344
- super(`WS upgrade rejected with HTTP ${status} (ws ${endpoint.host}${endpoint.path})`);
9345
- this.status = status;
9346
- this.endpoint = endpoint;
9347
- this.name = "WsUnexpectedStatusError";
9348
- }
9349
- status;
9350
- endpoint;
9351
- };
9352
- function createConnectionHelloEnvelope(opts) {
9557
+
9558
+ // src/daemon/connection-manager.ts
9559
+ function isCursorEnvelopeType(type) {
9560
+ return type.startsWith("task.") || type === "agent.egress.ack" || type === "agent.content.read" || type === "agent.home.projection";
9561
+ }
9562
+ function createConnectionHelloEnvelope(opts, getCursor) {
9353
9563
  const configuredToolsets = opts.getConfiguredToolsets?.();
9354
9564
  return createEnvelope("conn.hello", {
9355
9565
  protocolVersions: [PROTOCOL_VERSION],
@@ -9359,222 +9569,32 @@ function createConnectionHelloEnvelope(opts) {
9359
9569
  clientVersion: opts.clientVersion,
9360
9570
  runtimes: opts.runtimes,
9361
9571
  configuredToolsets: configuredToolsets === void 0 ? void 0 : [...configuredToolsets],
9362
- cursor: opts.getCursor?.()
9572
+ cursor: getCursor()
9363
9573
  });
9364
9574
  }
9365
- var WsTransport = class {
9366
- constructor(opts) {
9367
- this.opts = opts;
9368
- }
9369
- opts;
9370
- socket;
9371
- closedByUser = false;
9372
- autoReconnect = true;
9373
- acked = false;
9374
- everAckedThisAttempt = false;
9375
- reconnectAttempt = 0;
9376
- reconnectTimer;
9377
- livenessTimer;
9378
- lastActivity = 0;
9379
- lastUnexpectedStatus;
9380
- ackWaiters = [];
9381
- connect(opts = {}) {
9382
- this.autoReconnect = opts.auto ?? true;
9383
- this.closedByUser = false;
9384
- void this.openSocket();
9385
- }
9386
- /** Stop scheduling further reconnect attempts (any pending one is cancelled too); the current socket, if any, is left alone. Used when handing retry ownership to a slower external cadence (e.g. the long-poll fallback's periodic WS probe). */
9387
- stopAutoReconnect() {
9388
- this.autoReconnect = false;
9389
- if (this.reconnectTimer) {
9390
- clearTimeout(this.reconnectTimer);
9391
- this.reconnectTimer = void 0;
9392
- }
9393
- }
9394
- /** Resume normal auto-reconnect-on-close behavior (does not itself trigger a connect — only affects future closes). */
9395
- resumeAutoReconnect() {
9396
- this.autoReconnect = true;
9397
- }
9398
- get isOpen() {
9399
- return this.acked;
9400
- }
9401
- /**
9402
- * Attempt to send ONE envelope right now; returns whether it actually went
9403
- * out. `false` means the socket isn't currently open+acked — the caller
9404
- * (`ConnectionManager.drainOutbox`, Design B/finding N4) owns re-queueing
9405
- * and retrying later (e.g. on the next `onAcked`), since this transport no
9406
- * longer buffers anything itself.
9407
- */
9408
- sendNow(envelope) {
9409
- if (this.socket && this.socket.readyState === WebSocket.OPEN && this.acked) {
9410
- this.socket.send(encodeEnvelope(envelope));
9411
- return true;
9412
- }
9413
- return false;
9414
- }
9415
- waitForAck(timeoutMs = 1e4) {
9416
- if (this.acked) return Promise.resolve();
9417
- return new Promise((resolve, reject) => {
9418
- const timer = setTimeout(() => reject(new Error("Timed out waiting for conn.ack")), timeoutMs);
9419
- this.ackWaiters.push({
9420
- resolve: () => {
9421
- clearTimeout(timer);
9422
- resolve();
9423
- },
9424
- reject: (err) => {
9425
- clearTimeout(timer);
9426
- reject(err);
9427
- }
9428
- });
9429
- });
9430
- }
9431
- close() {
9432
- this.closedByUser = true;
9433
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
9434
- this.socket?.close();
9435
- }
9436
- async openSocket() {
9437
- const url = toWsUrl(this.opts.serverUrl);
9438
- const endpoint = describeEndpoint("ws", url);
9439
- this.acked = false;
9440
- this.everAckedThisAttempt = false;
9441
- this.opts.onStateChange?.("connecting");
9442
- let token;
9443
- try {
9444
- token = await this.opts.getToken();
9445
- } catch (err) {
9446
- this.opts.onStateChange?.("closed");
9447
- this.opts.onConnectOutcome?.(false, err, endpoint);
9448
- if (!this.closedByUser && this.autoReconnect) this.scheduleReconnect();
9449
- return;
9450
- }
9451
- const socket = new WebSocket(url, {
9452
- headers: { Authorization: `Bearer ${token}` }
9453
- });
9454
- this.socket = socket;
9455
- this.lastActivity = Date.now();
9456
- this.startLivenessCheck(socket);
9457
- socket.on("open", () => {
9458
- const hello = createConnectionHelloEnvelope(this.opts);
9459
- socket.send(encodeEnvelope(hello));
9460
- });
9461
- socket.on("ping", () => {
9462
- this.lastActivity = Date.now();
9463
- });
9464
- socket.on("message", (data, isBinary) => {
9465
- this.lastActivity = Date.now();
9466
- const bytes = toBytes(data);
9467
- let envelope;
9468
- try {
9469
- envelope = decodeEnvelope(bytes);
9470
- } catch {
9471
- return;
9472
- }
9473
- if (envelope.type === "conn.ack") {
9474
- this.reconnectAttempt = 0;
9475
- this.acked = true;
9476
- this.everAckedThisAttempt = true;
9477
- this.opts.onStateChange?.("open");
9478
- for (const waiter of this.ackWaiters.splice(0)) waiter.resolve();
9479
- this.opts.onAcked?.(envelope.payload.capabilities);
9480
- }
9481
- this.opts.onEnvelope(envelope);
9482
- });
9483
- socket.on("close", (code, reason) => {
9484
- this.socket = void 0;
9485
- this.stopLivenessCheck();
9486
- this.opts.onStateChange?.("closed");
9487
- const acked = this.everAckedThisAttempt;
9488
- const status = this.lastUnexpectedStatus;
9489
- this.lastUnexpectedStatus = void 0;
9490
- const replayCursorTooOld = code === 1008 && reason.toString("utf8") === "cursor_too_old" ? new ReplayCursorTooOldError() : void 0;
9491
- this.opts.onConnectOutcome?.(
9492
- acked,
9493
- replayCursorTooOld ?? (status !== void 0 ? new WsUnexpectedStatusError(status, endpoint) : void 0),
9494
- endpoint
9495
- );
9496
- if (!this.closedByUser && this.autoReconnect) this.scheduleReconnect();
9497
- });
9498
- socket.on("error", () => {
9499
- });
9500
- socket.on("unexpected-response", (_req, res) => {
9501
- this.lastUnexpectedStatus = res.statusCode;
9502
- res.resume();
9503
- socket.terminate();
9504
- });
9505
- }
9506
- scheduleReconnect() {
9507
- const { baseMs = 1e3, maxMs = 3e4, factor = 2 } = this.opts.backoff ?? {};
9508
- const delay5 = Math.min(maxMs, baseMs * factor ** this.reconnectAttempt);
9509
- const jitter = this.opts.reconnectDelayMs?.(this.reconnectAttempt, delay5) ?? delay5;
9510
- this.reconnectAttempt += 1;
9511
- this.reconnectTimer = setTimeout(() => void this.openSocket(), jitter);
9512
- }
9513
- startLivenessCheck(socket) {
9514
- const { timeoutMs = 75e3, checkIntervalMs = Math.max(1e3, Math.floor(timeoutMs / 3)) } = this.opts.liveness ?? {};
9515
- this.livenessTimer = setInterval(() => {
9516
- if (Date.now() - this.lastActivity > timeoutMs) {
9517
- socket.terminate();
9518
- }
9519
- }, checkIntervalMs);
9520
- this.livenessTimer.unref?.();
9521
- }
9522
- stopLivenessCheck() {
9523
- if (this.livenessTimer) {
9524
- clearInterval(this.livenessTimer);
9525
- this.livenessTimer = void 0;
9526
- }
9527
- }
9528
- };
9529
- function toBytes(data, _isBinary) {
9530
- if (Buffer.isBuffer(data)) return data;
9531
- if (Array.isArray(data)) return Buffer.concat(data);
9532
- if (data instanceof ArrayBuffer) return new Uint8Array(data);
9533
- return Buffer.from(String(data), "utf8");
9534
- }
9535
-
9536
- // src/daemon/connection-manager.ts
9537
- function isCursorEnvelopeType(type) {
9538
- return type.startsWith("task.") || type === "agent.egress.ack" || type === "agent.content.read" || type === "agent.home.projection";
9539
- }
9575
+ var MAX_REJECTED_OUTBOX_ENTRIES = 1e3;
9540
9576
  var ConnectionManager = class {
9541
9577
  constructor(opts) {
9542
9578
  this.opts = opts;
9543
9579
  this.fleetJitter = opts.fleetJitter ?? createFleetJitter(opts.productId, opts.deviceId);
9544
- this.ws = new WsTransport({
9545
- serverUrl: opts.serverUrl,
9546
- getToken: () => opts.auth.getValidAccessToken(),
9547
- deviceId: opts.deviceId,
9548
- productId: opts.productId,
9549
- capabilities: opts.capabilities,
9550
- clientVersion: opts.clientVersion,
9551
- runtimes: opts.runtimes,
9552
- getConfiguredToolsets: opts.getConfiguredToolsets,
9553
- getCursor: () => this.cursor,
9554
- onEnvelope: (envelope) => this.deliver(envelope),
9555
- onStateChange: (state) => {
9556
- if (!this.stopped && !this.revoked) this.opts.onStateChange?.(state);
9557
- },
9558
- onAcked: (capabilities) => this.onAcked(capabilities),
9559
- onConnectOutcome: (acked, err) => this.onWsOutcome(acked, err),
9560
- backoff: opts.backoff,
9561
- liveness: opts.liveness,
9562
- reconnectDelayMs: (attempt, baseMs) => this.fleetJitter.delay("reconnect", attempt, baseMs)
9563
- });
9564
9580
  this.longPoll = new LongPollClient({
9565
9581
  serverUrl: opts.serverUrl,
9566
9582
  auth: opts.auth,
9567
- // Design A: the query cursor for the NEXT `GET /byok/events` is the
9568
- // same watermark `deliver()` dedupes against (see `dedupWatermark`) —
9569
- // normally the eager `deliveredSeq` (so an in-flight envelope isn't
9570
- // re-pulled), but the durable `cursor` while `stalledAtSeq` is set, so
9571
- // the failed envelope (and everything after it) IS re-pulled and
9572
- // re-attempted.
9573
- getCursor: () => this.dedupWatermark(),
9583
+ // The long-poll query cursor is also the kernel's irreversible ack.
9584
+ // Only report the successfully processed cursor here. `deliveredSeq`
9585
+ // remains a local dedup watermark; using it on the wire would ack an
9586
+ // in-flight envelope before its handler settles and make a later
9587
+ // failure impossible to redeliver.
9588
+ getCursor: () => this.cursor,
9574
9589
  onEnvelope: (envelope) => this.deliver(envelope),
9575
9590
  onServerCapabilities: (capabilities) => {
9576
- if (this.mode === "long-poll") this.serverCapabilities = capabilities;
9591
+ this.serverCapabilities = capabilities;
9592
+ this.noteConnected();
9577
9593
  },
9594
+ onServerCapabilitiesInvalidated: () => {
9595
+ this.serverCapabilities = [];
9596
+ },
9597
+ onPollFailure: () => this.noteDisconnected(),
9578
9598
  onRevoked: () => this.enterRevoked(),
9579
9599
  onReplayCursorTooOld: (error) => this.enterReplayCursorTooOld(error),
9580
9600
  // M4 Phase 4 (version-negotiation drill fix): a batch entry
@@ -9602,13 +9622,10 @@ var ConnectionManager = class {
9602
9622
  }
9603
9623
  opts;
9604
9624
  fleetJitter;
9605
- ws;
9606
9625
  longPoll;
9607
- mode = "ws";
9608
- consecutiveFailures = 0;
9609
- wsRetryTimer;
9610
- wsProbeSequence = 0;
9611
9626
  uploadRetryAttempt = 0;
9627
+ started = false;
9628
+ connected = false;
9612
9629
  cursor;
9613
9630
  /**
9614
9631
  * Finding F3 (at-most-once redelivery): the lowest `task.*` envelope `seq`
@@ -9630,10 +9647,9 @@ var ConnectionManager = class {
9630
9647
  * (see `advanceCursor`) — that semantics is unchanged. `deliveredSeq`
9631
9648
  * advances eagerly, the instant a `task.*` envelope is admitted past
9632
9649
  * dedup (see `deliver`/`noteDelivered`), independent of whether its
9633
- * handler has even started, let alone succeeded. It exists so a
9634
- * long-poll re-query (`LongPollClient`'s `getCursor`) doesn't re-pull an
9635
- * envelope that's already been delivered once and is still in flight
9636
- * `handleOffer` is NOT idempotent and must never be re-pulled while a
9650
+ * handler has even started, let alone succeeded. It exists so a repeated
9651
+ * read at the durable cursor does not re-dispatch an envelope already in
9652
+ * flight `handleOffer` must not start a second adapter session while a
9637
9653
  * first attempt is still running. On WS this same field is written the
9638
9654
  * same way, but since a live WS connection only ever pushes a given `seq`
9639
9655
  * once, it never has an observable effect there beyond mirroring
@@ -9644,18 +9660,18 @@ var ConnectionManager = class {
9644
9660
  /** Finding F3: serializes `onEnvelope` calls into a per-connection FIFO — one envelope's handler always fully settles before the next one starts. */
9645
9661
  processingChain = Promise.resolve();
9646
9662
  /**
9647
- * Design B (finding N4): the ONE outbound queue both transports drain
9648
- * from — holds `Envelope` OBJECTS, never re-encoded/rebuilt strings, so a
9663
+ * Design B (finding N4): the ONE outbound queue holds `Envelope` OBJECTS,
9664
+ * never re-encoded/rebuilt strings, so a
9649
9665
  * resend after a failed send attempt is byte-identical to the original
9650
9666
  * (same `id`), which is what lets the server's per-(deviceId,id) dedup
9651
9667
  * (Wave 1) recognize it as a safe no-op retry rather than a second
9652
- * application (protocol §9). A transport switch (long-poll <-> WS) never
9653
- * touches this queue — see `drainOutbox` — so nothing queued while one
9654
- * transport was active is ever stranded when the other takes over.
9668
+ * application (protocol §9).
9655
9669
  */
9656
9670
  outbox = [];
9671
+ /** Terminally rejected outbound envelopes, retained as a bounded observable quarantine. */
9672
+ rejectedOutboundEnvelopes = [];
9657
9673
  /**
9658
- * Finding F5(b): how many envelopes `drainOutbox`'s long-poll branch has
9674
+ * Finding F5(b): how many envelopes `drainOutbox` has
9659
9675
  * currently spliced OUT of `this.outbox` for an in-flight (not yet
9660
9676
  * confirmed delivered) `postBatch` call — 0 the rest of the time. See
9661
9677
  * `outboxLength`'s own doc comment for why this needs to be tracked
@@ -9708,39 +9724,22 @@ var ConnectionManager = class {
9708
9724
  */
9709
9725
  cancelPendingDrainRetry;
9710
9726
  /**
9711
- * The capabilities the CURRENT transport's server advertised — untyped
9712
- * `string[]` for forward compatibility. WS populates it from `conn.ack`;
9713
- * long-poll populates it from each successful events response. Empty until
9714
- * the active transport supplies an advertisement.
9715
- *
9716
- * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
9717
- * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
9718
- * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
9719
- * `acked` branch), `stop()`, or a transport switch to long-poll
9720
- * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
9721
- * the transport that is still current.
9722
- * The previous version of this doc comment claimed long-poll mode simply
9723
- * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
9724
- * a daemon that once learned e.g. `approval_resolved` from an earlier WS
9725
- * session kept believing it applied to whatever it's connected to NOW,
9726
- * even after a disconnect/degrade where nothing has actually confirmed
9727
- * that's still true (a reconnect could land on a DIFFERENT server behind a
9728
- * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
9729
- * `task.approval_resolved` on this list — sending it to a server that
9730
- * doesn't actually understand it over the long-poll path would get a
9731
- * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
9732
- * `drainOutbox`'s retry-the-same-batch-forever loop then head-of-line
9733
- * blocks EVERY envelope queued behind it on, permanently. Clearing this
9734
- * eagerly means that gate reliably fails closed (falls back to the
9735
- * pre-existing implicit-resume inference, unconditionally — see
9736
- * `sendApprovalResolved`'s own doc comment) the moment the connection that
9737
- * advertised the capability is no longer the one actually in use.
9727
+ * The capabilities the current server response advertised — untyped
9728
+ * `string[]` for forward compatibility. An advertisement is scoped to the
9729
+ * current long-poll response stream and is cleared after an HTTP failure,
9730
+ * terminal shutdown, or revocation. This keeps capability-gated outbound
9731
+ * messages fail-closed until the current server has explicitly advertised
9732
+ * support.
9738
9733
  */
9739
9734
  serverCapabilities = [];
9740
9735
  async start() {
9741
9736
  if (this.terminalError) throw this.terminalError;
9742
9737
  this.cursor = await this.opts.cursorStore.load(this.opts.serverUrl, this.opts.deviceId);
9743
- this.ws.connect({ auto: true });
9738
+ this.started = true;
9739
+ this.opts.onStateChange?.("connecting");
9740
+ this.longPoll.start();
9741
+ this.outbox.unshift(createConnectionHelloEnvelope(this.opts, () => this.cursor));
9742
+ void this.drainOutbox();
9744
9743
  }
9745
9744
  /**
9746
9745
  * Design B (finding N4): push onto the single shared outbox and try to
@@ -9751,17 +9750,13 @@ var ConnectionManager = class {
9751
9750
  this.outbox.push(envelope);
9752
9751
  void this.drainOutbox();
9753
9752
  }
9753
+ /** Publish a fresh local configuration snapshot while this daemon is running. */
9754
+ refreshHello() {
9755
+ if (!this.started || this.stopped || this.revoked || this.terminalError) return;
9756
+ this.send(createConnectionHelloEnvelope(this.opts, () => this.cursor));
9757
+ }
9754
9758
  /**
9755
- * Design B (finding N4): drain the shared outbox through whichever
9756
- * transport is currently active, re-checking `this.mode` fresh on every
9757
- * iteration so a transport switch mid-drain is picked up immediately
9758
- * rather than fighting a stale decision made before the switch.
9759
- *
9760
- * WS: a synchronous, one-at-a-time `sendNow` per envelope while open+
9761
- * acked; stops (without dropping anything — the remainder stays queued)
9762
- * the moment it isn't, and is re-invoked once `onAcked` fires.
9763
- *
9764
- * Long-poll: POSTs the outbox in chunks of at most
9759
+ * POSTs the outbox through long-poll in chunks of at most
9765
9760
  * `MAX_MESSAGES_PER_BATCH` (finding P1) — the server hard-caps a single
9766
9761
  * `/byok/messages` batch there (`MessagesSendRequestSchema`, protocol
9767
9762
  * §8.2) and 400s the WHOLE request if it's exceeded, which — before this
@@ -9773,43 +9768,48 @@ var ConnectionManager = class {
9773
9768
  * failure that SAME chunk is unshifted back (order-preserving, same
9774
9769
  * Envelope objects/ids — never rebuilt, so a retry is exactly the resend
9775
9770
  * Wave 1's server-side dedup expects) and retried after a short backoff,
9776
- * re-reading `this.mode` each time so a WS recovery that happens
9777
- * mid-retry is honored on the very next loop iteration instead of only
9778
- * after this attempt's backoff chain gives up.
9779
- *
9780
9771
  * Re-entrancy is guarded by `draining`: a call arriving while a drain is
9781
9772
  * already in progress just returns — the in-progress loop's own
9782
- * `while (this.outbox.length > 0)` check will pick up anything newly
9783
- * pushed (or left over after a mode switch) on its very next iteration.
9773
+ * `while (this.outbox.length > 0)` check picks up anything newly pushed.
9784
9774
  */
9785
9775
  async drainOutbox() {
9786
- if (this.draining) return;
9776
+ if (!this.started || this.draining) return;
9787
9777
  this.draining = true;
9778
+ const isolateRejectedBatch = async (batch) => {
9779
+ const pending = [[...batch]];
9780
+ while (pending.length > 0) {
9781
+ if (this.stopped || this.revoked) return pending.flat();
9782
+ const segment = pending.shift();
9783
+ const result = await this.longPoll.postBatch(segment);
9784
+ if (result === void 0) return [...segment, ...pending.flat()];
9785
+ if ((result.rejected ?? 0) === 0) continue;
9786
+ if (segment.length === 1) {
9787
+ this.quarantineRejectedOutbound(segment[0], "inbound_rejected");
9788
+ continue;
9789
+ }
9790
+ const midpoint = Math.floor(segment.length / 2);
9791
+ pending.unshift(segment.slice(0, midpoint), segment.slice(midpoint));
9792
+ }
9793
+ return void 0;
9794
+ };
9788
9795
  try {
9789
9796
  while (this.outbox.length > 0) {
9790
9797
  if (this.stopped || this.revoked) return;
9791
- if (this.mode === "ws") {
9792
- if (!this.ws.isOpen) return;
9793
- const envelope = this.outbox[0];
9794
- if (!this.ws.sendNow(envelope)) return;
9795
- this.outbox.shift();
9796
- continue;
9797
- }
9798
9798
  const batch = this.outbox.splice(0, MAX_MESSAGES_PER_BATCH);
9799
9799
  this.inFlightBatchSize = batch.length;
9800
- let ok;
9800
+ let retrySegments;
9801
9801
  try {
9802
- ok = await this.longPoll.postBatch(batch);
9802
+ retrySegments = await isolateRejectedBatch(batch);
9803
9803
  } finally {
9804
9804
  this.inFlightBatchSize = 0;
9805
9805
  }
9806
- if (ok) {
9806
+ if (retrySegments === void 0) {
9807
9807
  this.uploadRetryAttempt = 0;
9808
9808
  this.opts.onOperationalOutcome?.("success", "upload");
9809
9809
  continue;
9810
9810
  }
9811
9811
  this.opts.onOperationalOutcome?.("failure", "upload");
9812
- this.outbox.unshift(...batch);
9812
+ this.outbox.unshift(...retrySegments);
9813
9813
  if (this.stopped || this.revoked) return;
9814
9814
  const baseMs = this.opts.longPollRetryDelayMs ?? 2e3;
9815
9815
  await this.drainRetryDelay(this.fleetJitter.delay("upload", this.uploadRetryAttempt++, baseMs));
@@ -9825,8 +9825,7 @@ var ConnectionManager = class {
9825
9825
  * in-flight wait immediately instead of leaving `drainOutbox` parked here
9826
9826
  * for up to the rest of the delay before it next checks `this.revoked` —
9827
9827
  * and (b) unref'd, so the timer never keeps the Node process alive by
9828
- * itself while nothing else (a live long-poll GET, an open WS connection)
9829
- * legitimately is.
9828
+ * itself while nothing else (such as the live long-poll GET) legitimately is.
9830
9829
  */
9831
9830
  drainRetryDelay(ms) {
9832
9831
  return new Promise((resolve) => {
@@ -9842,14 +9841,9 @@ var ConnectionManager = class {
9842
9841
  };
9843
9842
  });
9844
9843
  }
9845
- isTransportDegraded() {
9846
- return this.mode === "long-poll";
9847
- }
9848
9844
  /**
9849
- * The capabilities the CURRENT transport's server advertised: from
9850
- * `conn.ack` on WS, or the latest successful `GET /byok/events` response
9851
- * on long-poll. Empty before either transport has supplied its current
9852
- * advertisement, and cleared across disconnect/switch boundaries.
9845
+ * The capabilities the latest successful `GET /byok/events` response
9846
+ * advertised. Empty before a successful response and after a failed one.
9853
9847
  */
9854
9848
  getServerCapabilities() {
9855
9849
  return this.serverCapabilities;
@@ -9857,29 +9851,23 @@ var ConnectionManager = class {
9857
9851
  getTerminalError() {
9858
9852
  return this.terminalError;
9859
9853
  }
9860
- getMode() {
9861
- return this.mode;
9862
- }
9863
9854
  isConnected() {
9864
- return this.mode === "ws" && this.ws.isOpen;
9855
+ return this.connected;
9865
9856
  }
9866
9857
  isRevoked() {
9867
9858
  return this.revoked;
9868
9859
  }
9869
9860
  /**
9870
- * Resolves once the connection has settled either a working, acked WS
9871
- * connection, or the long-poll fallback taking over (protocol §8). This
9872
- * lets `daemon.start()` return promptly even when WS is unavailable from
9873
- * the very first attempt, rather than hanging until a WS `conn.ack` that
9874
- * may never come.
9861
+ * Resolves after the first successful long-poll response establishes the
9862
+ * authenticated connection.
9875
9863
  *
9876
9864
  * Rejects with {@link DeviceRevokedError} — instead of hanging until
9877
9865
  * `timeoutMs` — if the device turns out to be revoked while settling (or
9878
9866
  * already was): a cold `daemon.start()` against an already-revoked device
9879
9867
  * must fail fast, not surface a generic timeout (protocol §6.3).
9880
9868
  */
9881
- waitForAck(timeoutMs = 1e4) {
9882
- if (this.ws.isOpen || this.mode === "long-poll") return Promise.resolve();
9869
+ waitForConnection(timeoutMs = 1e4) {
9870
+ if (this.connected) return Promise.resolve();
9883
9871
  if (this.terminalError) return Promise.reject(this.terminalError);
9884
9872
  if (this.revoked) return Promise.reject(new DeviceRevokedError());
9885
9873
  return new Promise((resolve, reject) => {
@@ -9887,7 +9875,7 @@ var ConnectionManager = class {
9887
9875
  };
9888
9876
  const timer = setTimeout(() => {
9889
9877
  this.settledWaiters = this.settledWaiters.filter((w) => w !== settle);
9890
- reject(new Error("Timed out waiting for the connection to settle (WS ack or long-poll fallback)"));
9878
+ reject(new Error("Timed out waiting for the long-poll connection to settle"));
9891
9879
  }, timeoutMs);
9892
9880
  settle = (err) => {
9893
9881
  clearTimeout(timer);
@@ -9898,7 +9886,7 @@ var ConnectionManager = class {
9898
9886
  });
9899
9887
  }
9900
9888
  /**
9901
- * Stops both transports and waits for every in-flight envelope handler
9889
+ * Stops the long-poll transport and waits for every in-flight envelope handler
9902
9890
  * (the F3 FIFO chain) and the most recent cursor write to actually land on
9903
9891
  * disk — otherwise a `stop()` racing a just-processed envelope's
9904
9892
  * persistence could lose that cursor advance, or leave a handler running
@@ -9907,14 +9895,14 @@ var ConnectionManager = class {
9907
9895
  * Finding F5(b) (cross-model adversarial review): `drainTimeoutMs`, when
9908
9896
  * passed, bounds how long this waits for the shared outbox (`this.outbox`
9909
9897
  * — Design B) to actually finish draining BEFORE flipping `this.stopped`
9910
- * and closing the transports. Before this fix, `stop()` set `stopped`
9898
+ * and stopping the transport. Before this fix, `stop()` set `stopped`
9911
9899
  * synchronously and never waited for `drainOutbox` at all: an envelope
9912
9900
  * `send()` had just pushed moments earlier (e.g. `TaskRunner.shutdownTask`'s
9913
9901
  * own `task.fail`, sent right before `create-daemon.ts`'s
9914
9902
  * `performControlShutdown` calls this) could still be sitting UNSENT in
9915
9903
  * `this.outbox` — mid long-poll retry backoff, or simply not yet picked up
9916
9904
  * by the fire-and-forget `drainOutbox()` `send()` kicked off — and this
9917
- * method would happily proceed to `stopped = true` / `ws.close()` regardless,
9905
+ * method would happily proceed to `stopped = true` regardless,
9918
9906
  * after which NOTHING ever drains it again: silently lost, even though
9919
9907
  * `TaskRunner` believed it had been sent. `drainTimeoutMs` omitted (the
9920
9908
  * default) preserves the EXACT prior behavior for every other existing
@@ -9931,9 +9919,9 @@ var ConnectionManager = class {
9931
9919
  }
9932
9920
  this.stopped = true;
9933
9921
  this.serverCapabilities = [];
9934
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
9935
9922
  this.longPoll.stop();
9936
- this.ws.close();
9923
+ this.connected = false;
9924
+ this.opts.onStateChange?.("closed");
9937
9925
  await this.processingChain;
9938
9926
  await this.pendingCursorSave;
9939
9927
  }
@@ -9958,6 +9946,10 @@ var ConnectionManager = class {
9958
9946
  outboxLength() {
9959
9947
  return this.outbox.length + this.inFlightBatchSize;
9960
9948
  }
9949
+ /** A bounded terminal quarantine for operator inspection; these entries are never retried. */
9950
+ rejectedOutbox() {
9951
+ return this.rejectedOutboundEnvelopes;
9952
+ }
9961
9953
  /**
9962
9954
  * Finding F5(b): polls {@link outboxLength} (not `this.outbox.length`
9963
9955
  * alone — see that method's own doc comment for why a spliced-out,
@@ -9965,15 +9957,14 @@ var ConnectionManager = class {
9965
9957
  * a single `drainOutbox()` promise directly — a drain in progress can
9966
9958
  * itself loop through multiple retry/backoff cycles (`drainRetryDelay`)
9967
9959
  * while the server is unreachable, and a fresh, INDEPENDENT
9968
- * `drainOutbox()` call can also be triggered concurrently (`send()`, a
9969
- * mode switch's own `void this.drainOutbox()`) — polling the one thing
9960
+ * `drainOutbox()` call can also be triggered concurrently (`send()`)
9961
+ * polling the one thing
9970
9962
  * that actually matters (is anything still undelivered) can never go
9971
9963
  * stale the way capturing one specific in-flight promise reference
9972
9964
  * could. Kicks off one more `drainOutbox()` attempt itself first
9973
9965
  * (harmless no-op if one is already running — see its own re-entrancy
9974
- * guard) in case nothing is currently actively retrying (e.g. WS just
9975
- * dropped and long-poll hasn't taken over yet), so this bounded wait
9976
- * isn't just passively hoping something else happens to be making
9966
+ * guard) in case nothing is currently actively retrying, so this bounded
9967
+ * wait isn't just passively hoping something else happens to be making
9977
9968
  * progress.
9978
9969
  */
9979
9970
  waitForOutboxDrained(timeoutMs) {
@@ -10016,19 +10007,21 @@ var ConnectionManager = class {
10016
10007
  deliver(envelope) {
10017
10008
  const tracked = isCursorEnvelopeType(envelope.type) && typeof envelope.seq === "number";
10018
10009
  const watermark = this.dedupWatermark();
10019
- if (tracked && watermark !== void 0 && envelope.seq <= watermark) return;
10010
+ if (tracked && watermark !== void 0 && envelope.seq <= watermark) return false;
10020
10011
  if (tracked) {
10021
10012
  const seq = envelope.seq;
10022
- if (this.inFlightSeqs.has(seq) || this.processedSeqs.has(seq)) return;
10013
+ if (this.inFlightSeqs.has(seq) || this.processedSeqs.has(seq)) return false;
10023
10014
  this.inFlightSeqs.add(seq);
10024
10015
  this.noteDelivered(seq);
10025
10016
  }
10026
10017
  this.processingChain = this.processingChain.then(() => this.process(envelope, tracked));
10018
+ return true;
10027
10019
  }
10028
10020
  /**
10029
- * Design A: the watermark `deliver()` dedupes inbound `task.*` envelopes
10030
- * against, and the same value `LongPollClient` queries the next
10031
- * `GET /byok/events` cursor with (see the constructor). Normally this is
10021
+ * The local watermark `deliver()` dedupes inbound `task.*` envelopes
10022
+ * against. It is deliberately NOT the long-poll query cursor: that query
10023
+ * is the kernel acknowledgement and uses only the successfully processed
10024
+ * `cursor` (see the constructor). Normally this local watermark is
10032
10025
  * `deliveredSeq` — which is always >= `cursor` (every envelope that
10033
10026
  * reaches `advanceCursor` already passed through `noteDelivered` first,
10034
10027
  * see `deliver`) — so this is the literal `max(cursor, deliveredSeq)` the
@@ -10044,11 +10037,9 @@ var ConnectionManager = class {
10044
10037
  * whose outcome wasn't known yet. No separate "reset deliveredSeq on
10045
10038
  * reconnect" step is needed for this to be correct — collapsing to
10046
10039
  * `cursor` exactly while stalled already produces the right answer on
10047
- * every redelivery path (long-poll re-query AND a WS reconnect's
10048
- * backlog replay alike), and NOT resetting it unconditionally on every
10049
- * reconnect is what lets `deliveredSeq` keep doing its job of not
10050
- * re-pulling/re-dispatching something already in flight across a
10051
- * reconnect that happens to land while a handler is still running.
10040
+ * every long-poll retry path. NOT resetting it unconditionally on every
10041
+ * retry lets `deliveredSeq` keep doing its job of not re-dispatching
10042
+ * something already in flight while a handler is still running.
10052
10043
  */
10053
10044
  dedupWatermark() {
10054
10045
  return this.stalledAtSeq !== void 0 ? this.cursor : this.deliveredSeq ?? this.cursor;
@@ -10066,11 +10057,13 @@ var ConnectionManager = class {
10066
10057
  }
10067
10058
  await this.opts.onEnvelope(envelope);
10068
10059
  if (!tracked) return;
10069
- this.processedSeqs.add(seq);
10070
- if (this.stalledAtSeq !== void 0 && envelope.seq !== this.stalledAtSeq) return;
10060
+ if (this.stalledAtSeq !== void 0 && envelope.seq !== this.stalledAtSeq) {
10061
+ this.processedSeqs.add(seq);
10062
+ return;
10063
+ }
10071
10064
  this.stalledAtSeq = void 0;
10065
+ await this.advanceCursor(envelope.seq);
10072
10066
  this.processedSeqs.clear();
10073
- this.advanceCursor(envelope.seq);
10074
10067
  } catch (err) {
10075
10068
  if (tracked && this.stalledAtSeq === void 0) this.stalledAtSeq = envelope.seq;
10076
10069
  console.error(
@@ -10084,9 +10077,8 @@ var ConnectionManager = class {
10084
10077
  /**
10085
10078
  * M4 Phase 4 (version-negotiation drill fix): `LongPollClient` calls this
10086
10079
  * for a batch entry it could not parse into a known `Envelope` at all (an
10087
- * unrecognized message type mirrors `ws-transport.ts`'s identical
10088
- * per-frame tolerance, see `long-poll-transport.ts`'s own doc comment on
10089
- * `parseLooseEventsPollResponse`) but which still carried a numeric,
10080
+ * unrecognized message type (see `long-poll-transport.ts`'s own doc
10081
+ * comment on `parseLooseEventsPollResponse`) but which still carried a numeric,
10090
10082
  * task-class envelope-level `seq` (the caller only invokes this for a
10091
10083
  * `task.`-prefixed type — see `long-poll-transport.ts`'s own
10092
10084
  * `extractSkippableSeq`; `conn.*`-shaped or type-less entries never reach
@@ -10126,7 +10118,7 @@ var ConnectionManager = class {
10126
10118
  * `noteDelivered` (the eager, in-memory watermark) stays UNCHAINED —
10127
10119
  * called immediately, unconditionally, regardless of `stalledAtSeq` —
10128
10120
  * matching `deliver()`'s own eager, unconditional call for a real
10129
- * envelope: its only job is "don't re-pull something already handed off,"
10121
+ * envelope: its only job is "don't re-dispatch something already handed off,"
10130
10122
  * independent of outcome, and that property does not depend on FIFO
10131
10123
  * ordering the way the DURABLE cursor does.
10132
10124
  *
@@ -10146,9 +10138,18 @@ var ConnectionManager = class {
10146
10138
  */
10147
10139
  noteSkippedSeq(seq) {
10148
10140
  this.noteDelivered(seq);
10149
- this.processingChain = this.processingChain.then(() => {
10141
+ this.processingChain = this.processingChain.then(async () => {
10150
10142
  if (this.stalledAtSeq === void 0 || seq === this.stalledAtSeq) {
10151
- this.advanceCursor(seq);
10143
+ try {
10144
+ await this.advanceCursor(seq);
10145
+ if (this.stalledAtSeq === seq) this.stalledAtSeq = void 0;
10146
+ } catch (err) {
10147
+ if (this.stalledAtSeq === void 0) this.stalledAtSeq = seq;
10148
+ console.error(
10149
+ `[byok/client] unknown task seq=${seq} could not durably persist its cursor; cursor left unadvanced for redelivery:`,
10150
+ err
10151
+ );
10152
+ }
10152
10153
  }
10153
10154
  });
10154
10155
  }
@@ -10194,113 +10195,52 @@ var ConnectionManager = class {
10194
10195
  if (this.stalledAtSeq === void 0) this.stalledAtSeq = seq;
10195
10196
  });
10196
10197
  }
10197
- advanceCursor(seq) {
10198
+ async advanceCursor(seq) {
10198
10199
  if (this.cursor !== void 0 && seq <= this.cursor) return;
10200
+ const save = this.pendingCursorSave.catch(() => void 0).then(() => this.opts.cursorStore.save(this.opts.serverUrl, this.opts.deviceId, seq));
10201
+ this.pendingCursorSave = save;
10202
+ await save;
10199
10203
  this.cursor = seq;
10200
- this.pendingCursorSave = this.pendingCursorSave.catch(() => {
10201
- }).then(() => this.opts.cursorStore.save(this.opts.serverUrl, this.opts.deviceId, seq)).catch(() => {
10202
- });
10203
- }
10204
- /**
10205
- * Fires the moment a connection attempt reaches `conn.ack` — independent
10206
- * of whether/when it later closes. This is the ONLY place that can
10207
- * reliably detect "WS is back up" while long-polling: a healthy
10208
- * connection stays open indefinitely, so it never reaches `onWsOutcome`
10209
- * (which is close-only) at all.
10210
- */
10211
- onAcked(capabilities) {
10212
- if (this.terminalError) return;
10213
- this.serverCapabilities = capabilities;
10214
- this.consecutiveFailures = 0;
10215
- this.notifySettled();
10216
- this.opts.onOperationalOutcome?.("success", "reconnect");
10217
- if (this.mode === "long-poll") this.exitLongPoll();
10218
- void this.drainOutbox();
10219
10204
  }
10220
- onWsOutcome(acked, err) {
10221
- if (err instanceof ReplayCursorTooOldError) {
10222
- this.enterReplayCursorTooOld(err);
10223
- return;
10224
- }
10225
- if (this.stopped || this.revoked) return;
10226
- if (acked) this.serverCapabilities = [];
10227
- if (err instanceof WsUnexpectedStatusError && err.status === 401) {
10228
- this.opts.auth.handleUnauthorized().catch((renewErr) => {
10229
- if (renewErr instanceof DeviceRevokedError) this.enterRevoked();
10230
- });
10231
- }
10232
- if (acked) return;
10233
- this.opts.onOperationalOutcome?.("failure", "reconnect");
10234
- this.consecutiveFailures += 1;
10235
- if (this.mode === "ws" && this.consecutiveFailures >= (this.opts.wsFailureThreshold ?? 3)) {
10236
- this.enterLongPoll();
10237
- }
10205
+ quarantineRejectedOutbound(envelope, reason) {
10206
+ if (this.rejectedOutboundEnvelopes.length === MAX_REJECTED_OUTBOX_ENTRIES) this.rejectedOutboundEnvelopes.shift();
10207
+ this.rejectedOutboundEnvelopes.push({ envelope, reason });
10208
+ console.error(
10209
+ `[byok/client] outbound envelope ${envelope.id} was permanently rejected (${reason}) and moved to the terminal quarantine.`
10210
+ );
10238
10211
  }
10239
10212
  notifySettled(err) {
10240
10213
  for (const waiter of this.settledWaiters.splice(0)) waiter(err);
10241
10214
  }
10242
- enterLongPoll() {
10243
- this.mode = "long-poll";
10244
- this.serverCapabilities = [];
10245
- this.ws.stopAutoReconnect();
10246
- this.opts.onStateChange?.("degraded");
10247
- this.longPoll.start();
10248
- this.outbox.unshift(createConnectionHelloEnvelope({
10249
- deviceId: this.opts.deviceId,
10250
- productId: this.opts.productId,
10251
- capabilities: this.opts.capabilities,
10252
- clientVersion: this.opts.clientVersion,
10253
- runtimes: this.opts.runtimes,
10254
- getConfiguredToolsets: this.opts.getConfiguredToolsets,
10255
- getCursor: () => this.cursor
10256
- }));
10215
+ noteConnected() {
10216
+ if (this.connected || this.stopped || this.revoked || this.terminalError) return;
10217
+ this.connected = true;
10218
+ this.opts.onStateChange?.("open");
10257
10219
  this.notifySettled();
10258
- void this.drainOutbox();
10259
- this.scheduleWsProbe();
10220
+ }
10221
+ noteDisconnected() {
10222
+ if (!this.connected || this.stopped || this.revoked || this.terminalError) return;
10223
+ this.connected = false;
10224
+ this.opts.onStateChange?.("connecting");
10260
10225
  }
10261
10226
  enterReplayCursorTooOld(error) {
10262
10227
  if (this.terminalError) return;
10263
10228
  this.terminalError = error;
10264
10229
  this.stopped = true;
10265
10230
  this.serverCapabilities = [];
10266
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
10267
10231
  this.longPoll.stop();
10268
- this.ws.stopAutoReconnect();
10269
- this.ws.close();
10232
+ this.connected = false;
10270
10233
  this.cancelPendingDrainRetry?.();
10271
10234
  this.notifySettled(error);
10272
10235
  this.opts.onStateChange?.("closed");
10273
10236
  this.opts.onTerminalError?.(error);
10274
10237
  }
10275
- exitLongPoll() {
10276
- if (this.wsRetryTimer) {
10277
- clearInterval(this.wsRetryTimer);
10278
- this.wsRetryTimer = void 0;
10279
- }
10280
- this.longPoll.stop();
10281
- this.mode = "ws";
10282
- this.consecutiveFailures = 0;
10283
- this.ws.resumeAutoReconnect();
10284
- void this.drainOutbox();
10285
- }
10286
- scheduleWsProbe() {
10287
- const baseMs = this.opts.wsRetryIntervalMs ?? 5 * 60 * 1e3;
10288
- const delayMs = this.fleetJitter.delay("reconnect", this.wsProbeSequence++, baseMs);
10289
- const timer = setTimeout(() => {
10290
- this.wsRetryTimer = void 0;
10291
- if (this.stopped || this.revoked || this.mode !== "long-poll") return;
10292
- this.ws.connect({ auto: false });
10293
- this.scheduleWsProbe();
10294
- }, delayMs);
10295
- timer.unref?.();
10296
- this.wsRetryTimer = timer;
10297
- }
10298
10238
  enterRevoked() {
10299
10239
  if (this.revoked) return;
10300
10240
  this.revoked = true;
10301
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
10241
+ this.serverCapabilities = [];
10302
10242
  this.longPoll.stop();
10303
- this.ws.close();
10243
+ this.connected = false;
10304
10244
  this.cancelPendingDrainRetry?.();
10305
10245
  this.opts.onStateChange?.("revoked");
10306
10246
  this.notifySettled(new DeviceRevokedError());
@@ -13668,6 +13608,7 @@ function resultDocumentRejectionDetail(check) {
13668
13608
  }
13669
13609
  }
13670
13610
  var DEFAULT_MAX_TASK_OUTPUT_BYTES = 64 * 1024 * 1024;
13611
+ var DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME = 1;
13671
13612
  function isKnownRuntimeId(id) {
13672
13613
  return RuntimeIdSchema.safeParse(id).success;
13673
13614
  }
@@ -13914,6 +13855,10 @@ var TaskRunner = class {
13914
13855
  get maxTaskOutputBytes() {
13915
13856
  return this.deps.maxTaskOutputBytes ?? DEFAULT_MAX_TASK_OUTPUT_BYTES;
13916
13857
  }
13858
+ /** WP0: effective per-canonical-Agent-home Attempt cap — see {@link DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME}. */
13859
+ get maxConcurrentMutableSessionsPerAgentHome() {
13860
+ return this.deps.maxConcurrentMutableSessionsPerAgentHome ?? DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME;
13861
+ }
13917
13862
  /**
13918
13863
  * M4 Phase 4 (part B.3, observability): per-active-task queue watermarks
13919
13864
  * for the control socket's `status` result — see
@@ -14303,6 +14248,24 @@ var TaskRunner = class {
14303
14248
  decline("strict Agent-only daemon refuses legacy task offers", false);
14304
14249
  return;
14305
14250
  }
14251
+ if (agentRef !== void 0) {
14252
+ const limit = this.maxConcurrentMutableSessionsPerAgentHome;
14253
+ let canonicalHome;
14254
+ try {
14255
+ canonicalHome = await this.deps.agentHome.layout.canonicalHomePath(agentRef);
14256
+ } catch (error) {
14257
+ decline(
14258
+ `Agent home admission failed: ${errorMessage5(error)}`,
14259
+ !(error instanceof AgentHomeResolutionError)
14260
+ );
14261
+ return;
14262
+ }
14263
+ const active2 = this.deps.agentHome.executionLeaseManager.activeAttemptCount(canonicalHome);
14264
+ if (active2 >= limit) {
14265
+ decline(`agent home busy: ${active2} active attempt(s)`, true);
14266
+ return;
14267
+ }
14268
+ }
14306
14269
  const guarded = this.deps.admissionGuard?.({ taskId, payload });
14307
14270
  if (guarded !== void 0 && !guarded.admit) {
14308
14271
  decline(guarded.reason, guarded.retryable);
@@ -18014,7 +17977,7 @@ async function detectRuntimes(adapters) {
18014
17977
  }
18015
17978
  return runtimes;
18016
17979
  }
18017
- function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentOnly = false, agentHomeProjectionConfigured = false, agentEgressConfigured = false, contentReadPolicies) {
17980
+ function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentOnly = false, agentHomeProjectionConfigured = false, agentEgressConfigured = false, contentReadPolicies, providerProfileBindingConfigured = false) {
18018
17981
  const flags = [];
18019
17982
  if (adapters.some((adapter) => adapter.descriptor.capabilities.steer)) flags.push("steer");
18020
17983
  flags.push("blob-upload");
@@ -18026,6 +17989,9 @@ function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentO
18026
17989
  if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.descriptor.supportsDispatchSelection === true)) {
18027
17990
  flags.push("dispatch-selection");
18028
17991
  }
17992
+ if (providerProfileBindingConfigured && adapters.some((adapter) => adapter.descriptor.id === "pi" && adapter.descriptor.supportsDispatchSelection)) {
17993
+ flags.push(PROVIDER_PROFILE_BINDING_CAPABILITY);
17994
+ }
18029
17995
  if (adapters.some((adapter) => adapter.descriptor.capabilities.mcpToolsets === true)) {
18030
17996
  flags.push("toolset-selection");
18031
17997
  }
@@ -18188,6 +18154,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18188
18154
  if (config.strictAgentOnly === true && config.agentHome === void 0) {
18189
18155
  throw new Error("DaemonConfig.strictAgentOnly requires DaemonConfig.agentHome");
18190
18156
  }
18157
+ if (config.maxConcurrentMutableSessionsPerAgentHome !== void 0 && !(Number.isSafeInteger(config.maxConcurrentMutableSessionsPerAgentHome) && config.maxConcurrentMutableSessionsPerAgentHome > 0)) {
18158
+ throw new Error(
18159
+ `DaemonConfig.maxConcurrentMutableSessionsPerAgentHome must be a positive safe integer (or omitted to use the default of ${DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME}) \u2014 got ${config.maxConcurrentMutableSessionsPerAgentHome}. Raising it above 1 lets that many Attempts co-write one canonical Agent home; 0, a negative number, NaN, or a non-integer is rejected rather than silently treated as "uncapped".`
18160
+ );
18161
+ }
18162
+ const agentHomeAttemptLimit = config.maxConcurrentMutableSessionsPerAgentHome ?? DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME;
18191
18163
  const egressPolicy = resolveAgentEgressPolicy(config.agentEgress?.policy);
18192
18164
  const egressBatcherOptions = egressPolicy.activity.mode === "contentful-trajectory" ? {
18193
18165
  ...config.progressBatch,
@@ -18508,7 +18480,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18508
18480
  config.strictAgentOnly === true,
18509
18481
  agentHomeManager?.supportsTaskFreeProjection() === true,
18510
18482
  config.agentEgress !== void 0,
18511
- agentContentReadPolicies
18483
+ agentContentReadPolicies,
18484
+ config.piByokLauncher !== void 0
18512
18485
  );
18513
18486
  const agentHomeProjectionCompletion = agentHomeManager?.supportsTaskFreeProjection() === true ? new AgentHomeProjectionCompletionClient({
18514
18487
  serverUrl: config.serverUrl,
@@ -18567,6 +18540,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18567
18540
  workspaceRoot: config.workspaceRoot,
18568
18541
  ...agentHomeManager === void 0 ? {} : { agentHome: agentHomeManager },
18569
18542
  ...config.strictAgentOnly === true ? { strictAgentOnly: true } : {},
18543
+ // WP0: already validated up front — see `DaemonConfig.maxConcurrentMutableSessionsPerAgentHome`.
18544
+ maxConcurrentMutableSessionsPerAgentHome: agentHomeAttemptLimit,
18570
18545
  ...agentSessionHandoffs === void 0 ? {} : { agentSessionHandoffs },
18571
18546
  deviceId: record.deviceId,
18572
18547
  // M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment above.
@@ -18851,16 +18826,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18851
18826
  return runner?.handleEnvelope(envelope) ?? Promise.resolve();
18852
18827
  },
18853
18828
  onStateChange: (state) => {
18854
- const wasSettled = connectionState === "open" || connectionState === "degraded";
18829
+ const wasSettled = connectionState === "open";
18855
18830
  connectionState = state;
18856
18831
  observer.noteConnectionState(state);
18857
- if (!wasSettled && (state === "open" || state === "degraded")) runner?.retryRecoveredAgentMessages();
18858
- if (!wasSettled && (state === "open" || state === "degraded")) runPresenceDiscovery();
18832
+ if (!wasSettled && state === "open") runner?.retryRecoveredAgentMessages();
18833
+ if (!wasSettled && state === "open") runPresenceDiscovery();
18859
18834
  },
18860
- backoff: overrides.backoff,
18861
- liveness: overrides.liveness,
18862
- wsFailureThreshold: overrides.longPoll?.wsFailureThreshold,
18863
- wsRetryIntervalMs: overrides.longPoll?.wsRetryIntervalMs,
18864
18835
  longPollRetryDelayMs: overrides.longPoll?.retryDelayMs,
18865
18836
  longPollIdleDelayMs: overrides.longPoll?.idleDelayMs,
18866
18837
  fleetJitter,
@@ -18872,7 +18843,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18872
18843
  }
18873
18844
  });
18874
18845
  await connection.start();
18875
- await connection.waitForAck();
18846
+ await connection.waitForConnection();
18876
18847
  runner.retryRecoveredAgentMessages();
18877
18848
  for (const record2 of agentEgress.retryableReliableRecords(connection.getServerCapabilities())) {
18878
18849
  dispatchReliableRecord(record2);
@@ -19131,7 +19102,9 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19131
19102
  // fine.
19132
19103
  ...storageStatus === void 0 ? {} : { storage: storageStatus },
19133
19104
  operationalHealth: operationalHealth.snapshot(),
19134
- toolsets: toolsetRegistry.status()
19105
+ toolsets: toolsetRegistry.status(),
19106
+ // WP0: same counts `Daemon.status()` reports, from the same reader.
19107
+ agentHomeExecution: agentHomeExecutionStatus()
19135
19108
  };
19136
19109
  }
19137
19110
  async function performControlShutdown(reason) {
@@ -19366,9 +19339,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19366
19339
  const minted = mintDeviceAssertion({
19367
19340
  record,
19368
19341
  // `toHttpBase` is the one place a configured serverUrl is normalized
19369
- // (ws:->http:, wss:->https:, path stripped), so an operator who
19370
- // configured the websocket spelling and one who configured the HTTP
19371
- // spelling of the same deployment produce the same issuer.
19342
+ // (path stripped), so the issuer ignores an operator-provided path
19343
+ // or query.
19372
19344
  issuer: new URL(toHttpBase(config.serverUrl)).origin,
19373
19345
  productId: config.productId,
19374
19346
  audience: parsed.audience,
@@ -19483,23 +19455,33 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19483
19455
  await binding.lease.release();
19484
19456
  }
19485
19457
  }
19458
+ function agentHomeExecutionStatus() {
19459
+ const summary = agentHomeManager?.executionLeaseManager.activeAttemptSummary();
19460
+ return {
19461
+ maxConcurrentMutableSessionsPerAgentHome: agentHomeAttemptLimit,
19462
+ activeHomes: summary?.homes ?? 0,
19463
+ activeAttempts: summary?.attempts ?? 0
19464
+ };
19465
+ }
19486
19466
  function status() {
19487
19467
  return {
19488
19468
  localAgentRelease,
19489
19469
  paired: auth.deviceId !== void 0,
19490
19470
  connected: connectionState === "open",
19491
- degraded: connection?.isTransportDegraded() ?? false,
19492
19471
  revoked: connection?.isRevoked() ?? auth.isRevoked(),
19493
19472
  deviceId: auth.deviceId,
19494
19473
  activeTaskCount: runner?.activeTaskCount ?? 0,
19495
19474
  branding: config.branding,
19496
19475
  operationalHealth: operationalHealth.snapshot(),
19497
19476
  toolsets: toolsetRegistry.status(),
19498
- egress: agentEgress.status()
19477
+ egress: agentEgress.status(),
19478
+ agentHomeExecution: agentHomeExecutionStatus()
19499
19479
  };
19500
19480
  }
19501
19481
  function reloadMcpToolsets(mcpToolsets, expectedRevision) {
19502
- return toolsetRegistry.reload(mcpToolsets, expectedRevision);
19482
+ const receipt = toolsetRegistry.reload(mcpToolsets, expectedRevision);
19483
+ if (daemonStarted) connection?.refreshHello();
19484
+ return receipt;
19503
19485
  }
19504
19486
  function reportMcpToolsetObservation(toolsetId, expectedDefinitionRevision, observation) {
19505
19487
  toolsetRegistry.report(toolsetId, expectedDefinitionRevision, observation);
@@ -19908,7 +19890,7 @@ function createServiceLifecycle(def, opts = {}) {
19908
19890
 
19909
19891
  // src/bin/official-release.ts
19910
19892
  var OFFICIAL_LOCAL_AGENT_RELEASE = resolveLocalAgentReleaseIdentity({
19911
- version: "0.12.0"
19893
+ version: "0.13.0"
19912
19894
  });
19913
19895
 
19914
19896
  // src/bin/config.ts
@@ -21988,7 +21970,7 @@ async function recentAuditFacts(storeDir) {
21988
21970
  }
21989
21971
  function projectControl(control) {
21990
21972
  if (control.status === "offline") return { status: "offline" };
21991
- const transport = ["connecting", "open", "closed", "degraded", "revoked"].includes(control.transport) ? control.transport : "unavailable";
21973
+ const transport = ["connecting", "open", "closed", "revoked"].includes(control.transport) ? control.transport : "unavailable";
21992
21974
  const operationalHealth = control.operationalHealth.availability === "available" ? {
21993
21975
  availability: "available",
21994
21976
  state: control.operationalHealth.state,