@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.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID, createHash, randomBytes, createPrivateKey, generateKeyPairS
2
2
  import { promises, mkdirSync, constants, existsSync, linkSync, fstatSync, lstatSync, unlinkSync, renameSync, writeFileSync, chmodSync, statSync, readdirSync, readFileSync, realpathSync } from 'fs';
3
3
  import * as path3 from 'path';
4
4
  import path3__default, { join, isAbsolute } from 'path';
5
- import { AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, AgentRefSchema, AgentHomeProjectionPayloadSchema, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, partitionAgentEvents, BYOK_SKILL_PACKS_PATH, byokSkillPackFilePath, BYOK_RECORDS_PATH, byokRecordPath, TASK_TRANSITIONS, AgentEgressPolicySchema, BYOK_PAIR_PATH, PairResponseSchema, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, AgentContentReceiptPayloadSchema, encodeEnvelope, PROTOCOL_VERSION, createEnvelope, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, 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';
5
+ import { AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, AgentRefSchema, AgentHomeProjectionPayloadSchema, byokBlobUrlPath, BYOK_BLOBS_PATH, byokBlobFinalizePath, partitionAgentEvents, BYOK_SKILL_PACKS_PATH, byokSkillPackFilePath, BYOK_RECORDS_PATH, byokRecordPath, TASK_TRANSITIONS, AgentEgressPolicySchema, BYOK_PAIR_PATH, PairResponseSchema, BYOK_CHALLENGE_PATH, BYOK_TOKEN_PATH, CONFIGURED_TOOLSETS_MAX_ITEMS, ToolsetIdSchema, AgentContentReceiptPayloadSchema, encodeEnvelope, PROTOCOL_VERSION, createEnvelope, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, 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';
6
6
  import net2, { createServer, createConnection } from 'net';
7
7
  import * as os4 from 'os';
8
8
  import os4__default from 'os';
@@ -12,7 +12,6 @@ import { createInterface } from 'readline';
12
12
  import { parseDeviceAssertionEnvelope, SKILL_PACK_MAX_BYTES, hasCapability, parseSkillPackManifest, checkSkillPackManifest, skillPackContentHashInput, checkSkillPackFileContent, SKILL_PACK_ENTRY_PATH, checkSkillPackEntry, isSkillPackPathSafe, DEVICE_PROOF_HEADER, contentHash as contentHash$1, TRUTH_RECORD_KINDS, DEVICE_ASSERTION_AUDIENCE_MAX_BYTES, DEVICE_ASSERTION_DEFAULT_TTL_MS, DEVICE_ASSERTION_MAX_TTL_MS, CONTENT_HASH_PATTERN, isTenantId, nonceSigningBytes, CapabilityDeclarationSchema, DeviceAssertionClaimsSchema, deviceAssertionSigningInput, DEVICE_ASSERTION_SCHEMA_ID } from '@byok-sdk/core';
13
13
  import { promisify } from 'util';
14
14
  import * as fs14 from 'fs/promises';
15
- import { WebSocket } from 'ws';
16
15
  import { createRequire } from 'module';
17
16
 
18
17
  // src/agent-home.ts
@@ -423,6 +422,23 @@ async function resolveExistingAncestor(inputPath) {
423
422
  }
424
423
  }
425
424
  }
425
+ async function canonicalPath(inputPath) {
426
+ const { canonical: canonical2, tail } = await resolveExistingAncestor(inputPath);
427
+ return path3__default.resolve(canonical2, ...tail);
428
+ }
429
+ async function assertRealDirectoryIfPresent(target) {
430
+ let stat;
431
+ try {
432
+ stat = await promises.lstat(target);
433
+ } catch (error) {
434
+ const code = error.code;
435
+ if (code === "ENOENT" || code === "ENOTDIR") return;
436
+ throw error;
437
+ }
438
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
439
+ throw new AgentHomeResolutionError(`Agent home path component is not a real directory: ${target}`);
440
+ }
441
+ }
426
442
  async function materializeDirectory(inputPath) {
427
443
  const { canonical: canonical2, tail } = await resolveExistingAncestor(inputPath);
428
444
  let cursor = canonical2;
@@ -517,6 +533,39 @@ var AgentHomeLayout = class {
517
533
  await gate.release();
518
534
  }
519
535
  }
536
+ /**
537
+ * Pure canonical-home derivation for read-only callers, such as the
538
+ * pre-admission single-writer count. It validates the AgentRef and joins
539
+ * exactly the same `<hostStorageRoot>/agents/<agentId>` segments
540
+ * {@link AgentHomeLayout.resolve} would, canonicalizing only the components
541
+ * that already exist.
542
+ *
543
+ * It deliberately creates no directory, takes no cross-process mutation
544
+ * gate and records no Agent binding, so an offer the host vetoes after the
545
+ * count leaves nothing behind on disk. `resolve()` stays the only path that
546
+ * may materialize a home or bind it to an Agent identity.
547
+ *
548
+ * An `agents` root or `agents/<agentId>` leaf that already exists but is a
549
+ * symlink (or any non-directory) is rejected here with the same error class
550
+ * and message `resolve()` raises for it, so an in-root `two -> one` link
551
+ * fails closed instead of silently keying the count of `one`. A leaf that
552
+ * does not exist yet is not an error: this derivation runs before the home
553
+ * is materialized. A home canonicalizing outside the `agents` root stays
554
+ * rejected as before.
555
+ */
556
+ async canonicalHomePath(agentRefInput) {
557
+ const agentRef = validateAgentRef(agentRefInput);
558
+ const hostStorageRoot = this.canonicalRoot ?? await canonicalPath(this.hostStorageRootInput);
559
+ const agentsRoot = path3__default.join(hostStorageRoot, AGENT_HOME_DIRECTORY);
560
+ await assertRealDirectoryIfPresent(agentsRoot);
561
+ const lexicalHome = path3__default.join(agentsRoot, agentRef.agentId);
562
+ await assertRealDirectoryIfPresent(lexicalHome);
563
+ const canonicalHome = await canonicalPath(lexicalHome);
564
+ if (canonicalHome === agentsRoot || !isWithin(agentsRoot, canonicalHome)) {
565
+ throw new AgentHomeResolutionError(`Agent home resolves outside the Agent home root: ${canonicalHome}`);
566
+ }
567
+ return canonicalHome;
568
+ }
520
569
  /**
521
570
  * Prove the canonical root is materializable and writable before the daemon
522
571
  * advertises Agent-home capability. No Agent identity or persistent Agent
@@ -833,6 +882,44 @@ var AgentHomeExecutionLeaseManager = class _AgentHomeExecutionLeaseManager {
833
882
  });
834
883
  });
835
884
  }
885
+ /**
886
+ * WP0: Attempts currently holding an execution lease on this exact
887
+ * canonical home, across every lane and every session. This is the number
888
+ * the daemon's admission gate reads before any side effect — see
889
+ * `TaskRunner.handleOffer`'s per-home busy gate.
890
+ *
891
+ * Derived from the one lease registry above rather than a second tally, so
892
+ * it inherits the lease lifecycle exactly: an entry appears at `acquire()`,
893
+ * survives `bindSession()` (which rekeys in place), and disappears only at
894
+ * `release()`, which the task runner calls after the attempt is terminal
895
+ * AND `Session.close()` resolved. A failed disposal never reaches
896
+ * `release()`, so the slot stays held — fail closed, the same posture as
897
+ * `runtime-disposal-failed`. Crash residue needs nothing extra here: a
898
+ * restarted daemon starts with an empty registry and reclaims the on-disk
899
+ * marker only under the same stable owner identity (`openLeaseMarker`).
900
+ *
901
+ * Counted regardless of which lease manager owns the group: the invariant
902
+ * being protected is the filesystem path (`MEMORY.md`, `notes/`, `.git`),
903
+ * not the owner identity.
904
+ */
905
+ activeAttemptCount(canonicalHome) {
906
+ return _AgentHomeExecutionLeaseManager.groups.get(canonicalHome)?.leasesByKey.size ?? 0;
907
+ }
908
+ /**
909
+ * Counts-only readback for daemon/control status. Scoped to this manager's
910
+ * own leases, so the number describes this daemon rather than every home
911
+ * any manager in the process happens to hold. Never exposes a home path.
912
+ */
913
+ activeAttemptSummary() {
914
+ let homes = 0;
915
+ let attempts = 0;
916
+ for (const group of _AgentHomeExecutionLeaseManager.groups.values()) {
917
+ if (group.manager !== this.manager) continue;
918
+ homes += 1;
919
+ attempts += group.leasesByKey.size;
920
+ }
921
+ return { homes, attempts };
922
+ }
836
923
  async mutate(binding, operation) {
837
924
  return this.exclusive(binding.resolution.canonicalHome, async () => {
838
925
  const group = _AgentHomeExecutionLeaseManager.groups.get(binding.resolution.canonicalHome);
@@ -2036,13 +2123,22 @@ function freezeRuntimeAdapterDescriptor(descriptor) {
2036
2123
  });
2037
2124
  }
2038
2125
  function sealRuntimeOperationManifest(manifest) {
2126
+ const dispatchSelection = manifest.dispatchSelection === void 0 ? void 0 : manifest.dispatchSelection.lane === "byok-profile" ? Object.freeze({
2127
+ ...manifest.dispatchSelection,
2128
+ providerProfile: Object.freeze({
2129
+ ...manifest.dispatchSelection.providerProfile,
2130
+ requiredCapabilities: Object.freeze([
2131
+ ...manifest.dispatchSelection.providerProfile.requiredCapabilities
2132
+ ])
2133
+ })
2134
+ }) : Object.freeze({ ...manifest.dispatchSelection });
2039
2135
  return Object.freeze({
2040
2136
  taskId: manifest.taskId,
2041
2137
  runtimeId: manifest.runtimeId,
2042
2138
  descriptor: freezeRuntimeAdapterDescriptor(manifest.descriptor),
2043
2139
  policy: frozenPolicy(manifest.policy),
2044
2140
  requiredToolsetIds: Object.freeze([...manifest.requiredToolsetIds]),
2045
- ...manifest.dispatchSelection === void 0 ? {} : { dispatchSelection: Object.freeze({ ...manifest.dispatchSelection }) },
2141
+ ...dispatchSelection === void 0 ? {} : { dispatchSelection },
2046
2142
  ...manifest.sessionRef === void 0 ? {} : { sessionRef: manifest.sessionRef },
2047
2143
  ...manifest.agentRef === void 0 ? {} : { agentRef: Object.freeze({ agentId: manifest.agentRef.agentId, profileRevision: manifest.agentRef.profileRevision }) },
2048
2144
  cwd: manifest.cwd ?? manifest.workspace.workspaceDir,
@@ -4464,7 +4560,11 @@ function validatePiByokLauncherConfig(launcher) {
4464
4560
  "--macos-keychain-path",
4465
4561
  "--secret-service-prefix",
4466
4562
  "--provider",
4467
- "--model"
4563
+ "--model",
4564
+ "--profile-revision",
4565
+ "--profile-hash",
4566
+ "--required-capabilities",
4567
+ "--validate-only"
4468
4568
  ]);
4469
4569
  const conflicting = launcher.args?.find((arg) => reserved.has(arg));
4470
4570
  if (conflicting !== void 0) {
@@ -4526,11 +4626,17 @@ var PiAdapter = class {
4526
4626
  const bin = this.resolveBin();
4527
4627
  const extensions = (this.options.resolveExtensions ?? resolvePiExtensions)();
4528
4628
  const selection = input.offer.dispatchSelection;
4529
- const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
4629
+ const pinnedSelection = selection === void 0 ? void 0 : selection.lane === "byok-profile" ? Object.freeze({
4630
+ ...selection,
4631
+ providerProfile: Object.freeze({
4632
+ ...selection.providerProfile,
4633
+ requiredCapabilities: Object.freeze([...selection.providerProfile.requiredCapabilities])
4634
+ })
4635
+ }) : Object.freeze({ ...selection });
4530
4636
  let command = bin.command;
4531
4637
  let launcherArgs;
4532
4638
  if (pinnedSelection !== void 0) {
4533
- if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
4639
+ if (pinnedSelection.lane !== "byok" && pinnedSelection.lane !== "byok-profile" || pinnedSelection.runtimeId !== "pi") {
4534
4640
  return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
4535
4641
  }
4536
4642
  const launcher = this.options.byokLauncher;
@@ -4538,6 +4644,21 @@ var PiAdapter = class {
4538
4644
  return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
4539
4645
  }
4540
4646
  command = launcher.command;
4647
+ const providerProfile = pinnedSelection.lane === "byok-profile" ? pinnedSelection.providerProfile : void 0;
4648
+ if (providerProfile !== void 0) {
4649
+ try {
4650
+ await (this.options.validateProviderProfileBinding ?? validateProviderProfileBindingWithLauncher)(
4651
+ providerProfile,
4652
+ launcher
4653
+ );
4654
+ } catch (error) {
4655
+ return {
4656
+ kind: "reject",
4657
+ reason: `provider profile admission failed: ${errorMessage3(error)}`,
4658
+ retryable: false
4659
+ };
4660
+ }
4661
+ }
4541
4662
  launcherArgs = [
4542
4663
  ...launcher.args ?? [],
4543
4664
  "--pi-bin",
@@ -4549,9 +4670,19 @@ var PiAdapter = class {
4549
4670
  ...launcher.macosKeychainPath !== void 0 ? ["--macos-keychain-path", launcher.macosKeychainPath] : [],
4550
4671
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
4551
4672
  "--provider",
4552
- pinnedSelection.providerId,
4673
+ providerProfile?.profileRef ?? (pinnedSelection.lane === "byok" ? pinnedSelection.providerId : ""),
4553
4674
  "--model",
4554
- pinnedSelection.modelId
4675
+ providerProfile?.modelId ?? (pinnedSelection.lane === "byok" ? pinnedSelection.modelId : ""),
4676
+ ...providerProfile === void 0 ? [] : [
4677
+ "--profile-revision",
4678
+ providerProfile.profileRevision,
4679
+ "--profile-hash",
4680
+ providerProfile.profileHash,
4681
+ "--required-capabilities",
4682
+ JSON.stringify(providerProfile.requiredCapabilities),
4683
+ "--validate-only",
4684
+ "false"
4685
+ ]
4555
4686
  ];
4556
4687
  }
4557
4688
  return {
@@ -4694,8 +4825,42 @@ var PiAdapter = class {
4694
4825
  return (this.options.resolveBin ?? resolvePiBin)();
4695
4826
  }
4696
4827
  };
4828
+ async function validateProviderProfileBindingWithLauncher(binding, launcher) {
4829
+ await execFileAsync(launcher.command, [
4830
+ ...launcher.args ?? [],
4831
+ "--pi-bin",
4832
+ process.execPath,
4833
+ "--profile-db",
4834
+ launcher.profileDbPath,
4835
+ "--session-dir",
4836
+ launcher.sessionDir,
4837
+ "--provider",
4838
+ binding.profileRef,
4839
+ "--model",
4840
+ binding.modelId,
4841
+ "--profile-revision",
4842
+ binding.profileRevision,
4843
+ "--profile-hash",
4844
+ binding.profileHash,
4845
+ "--required-capabilities",
4846
+ JSON.stringify(binding.requiredCapabilities),
4847
+ "--validate-only",
4848
+ "true",
4849
+ ...launcher.macosKeychainPath !== void 0 ? ["--macos-keychain-path", launcher.macosKeychainPath] : [],
4850
+ ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : []
4851
+ ], { timeout: DETECT_TIMEOUT_MS });
4852
+ }
4697
4853
  function sameDispatchSelection(left, right) {
4698
4854
  if (left === void 0 || right === void 0) return left === right;
4855
+ if (left.lane === "byok-profile" || right.lane === "byok-profile") {
4856
+ if (left.lane !== "byok-profile" || right.lane !== "byok-profile") return false;
4857
+ if (left.runtimeId !== right.runtimeId) return false;
4858
+ const leftProfile = left.providerProfile;
4859
+ const rightProfile = right.providerProfile;
4860
+ 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(
4861
+ (capability, index) => capability === rightProfile.requiredCapabilities[index]
4862
+ );
4863
+ }
4699
4864
  return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
4700
4865
  }
4701
4866
  async function resolveAuthoritativeSessionId(rpc) {
@@ -7603,19 +7768,14 @@ function signNonce(privateKey, nonce) {
7603
7768
  const signature = sign(null, nonceSigningBytes(nonce), privateKey);
7604
7769
  return signature.toString("base64url");
7605
7770
  }
7771
+
7772
+ // src/daemon/url.ts
7606
7773
  function toHttpBase(serverUrl) {
7607
7774
  const url = new URL(serverUrl);
7608
- if (url.protocol === "ws:") url.protocol = "http:";
7609
- else if (url.protocol === "wss:") url.protocol = "https:";
7610
7775
  url.pathname = "/";
7611
7776
  url.search = "";
7612
7777
  return url.toString();
7613
7778
  }
7614
- function toWsUrl(serverUrl) {
7615
- const url = new URL(BYOK_WS_PATH, toHttpBase(serverUrl));
7616
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
7617
- return url.toString();
7618
- }
7619
7779
  function describeEndpoint(transport, url) {
7620
7780
  const parsed = typeof url === "string" ? new URL(url) : url;
7621
7781
  return { transport, host: parsed.host, path: parsed.pathname };
@@ -7648,17 +7808,15 @@ function assertServerUrlAllowed(rawUrl, opts = {}) {
7648
7808
  const endpoint = formatServerUrl(url);
7649
7809
  switch (url.protocol) {
7650
7810
  case "https:":
7651
- case "wss:":
7652
7811
  return;
7653
7812
  case "http:":
7654
- case "ws:":
7655
7813
  if (opts.dangerouslyAllowInsecureRemote || isLoopbackHostname(url.hostname)) return;
7656
7814
  throw new InsecureServerUrlError(
7657
- `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.`
7815
+ `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.`
7658
7816
  );
7659
7817
  default:
7660
7818
  throw new InsecureServerUrlError(
7661
- `refusing to connect to "${endpoint}" \u2014 unsupported scheme "${url.protocol}" (expected http:, https:, ws:, or wss:).`
7819
+ `refusing to connect to "${endpoint}" \u2014 unsupported scheme "${url.protocol}" (expected http: or https:).`
7662
7820
  );
7663
7821
  }
7664
7822
  }
@@ -9710,7 +9868,7 @@ var LongPollRouteError = class extends Error {
9710
9868
  };
9711
9869
  var MAX_TRACKED_VALIDATION_FAILURE_WARNINGS = 1e3;
9712
9870
  var MAX_TRACKED_ROUTE_FAILURE_WARNINGS = 1e3;
9713
- function parseLooseEventsPollResponse(raw) {
9871
+ function parseLooseEventsPollResponse(raw, requestedCursor) {
9714
9872
  if (typeof raw !== "object" || raw === null) {
9715
9873
  throw new Error("events poll response is not an object");
9716
9874
  }
@@ -9718,19 +9876,48 @@ function parseLooseEventsPollResponse(raw) {
9718
9876
  if (!Array.isArray(events)) {
9719
9877
  throw new Error("events poll response.events is not an array");
9720
9878
  }
9721
- if (typeof cursor !== "number" || !Number.isInteger(cursor)) {
9722
- throw new Error("events poll response.cursor is not an integer");
9879
+ if (!isSafeNonnegativeInteger(cursor)) {
9880
+ throw new Error("events poll response.cursor is not a safe nonnegative integer");
9881
+ }
9882
+ if (cursor < requestedCursor) {
9883
+ throw new Error("events poll response.cursor regressed below the requested cursor");
9723
9884
  }
9724
9885
  if (capabilities !== void 0 && (!Array.isArray(capabilities) || capabilities.some((flag) => typeof flag !== "string"))) {
9725
9886
  throw new Error("events poll response.capabilities is not an array of strings");
9726
9887
  }
9727
9888
  return { events, cursor, capabilities: capabilities ?? [] };
9728
9889
  }
9890
+ function isSafeNonnegativeInteger(value) {
9891
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
9892
+ }
9893
+ function validateTrustedEventsPage(events, requestedCursor, pageCursor) {
9894
+ let previousTaskSeq;
9895
+ for (const raw of events) {
9896
+ if (typeof raw !== "object" || raw === null) continue;
9897
+ const { type, seq } = raw;
9898
+ if (typeof type !== "string" || !type.startsWith("task.")) continue;
9899
+ if (!isSafeNonnegativeInteger(seq)) {
9900
+ throw new Error("events poll task seq is not a safe nonnegative integer");
9901
+ }
9902
+ if (seq <= requestedCursor || seq > pageCursor) {
9903
+ throw new Error("events poll task seq lies outside the trusted page cursor range");
9904
+ }
9905
+ if (previousTaskSeq !== void 0 && seq <= previousTaskSeq) {
9906
+ throw new Error("events poll task seq is not strictly page ordered");
9907
+ }
9908
+ const knownTaskType = MESSAGE_TYPES.includes(type);
9909
+ const predecessor = previousTaskSeq ?? requestedCursor;
9910
+ if (!knownTaskType && seq !== predecessor + 1) {
9911
+ throw new Error("events poll unknown task seq would cross an untrusted gap");
9912
+ }
9913
+ previousTaskSeq = seq;
9914
+ }
9915
+ }
9729
9916
  function extractSkippableSeq(raw) {
9730
9917
  if (typeof raw !== "object" || raw === null) return void 0;
9731
9918
  const { type, seq } = raw;
9732
9919
  if (typeof type !== "string" || !type.startsWith("task.")) return void 0;
9733
- return typeof seq === "number" && Number.isInteger(seq) ? seq : void 0;
9920
+ return isSafeNonnegativeInteger(seq) ? seq : void 0;
9734
9921
  }
9735
9922
  var LongPollClient = class {
9736
9923
  constructor(opts) {
@@ -9741,6 +9928,8 @@ var LongPollClient = class {
9741
9928
  }
9742
9929
  opts;
9743
9930
  running = false;
9931
+ /** Owns exactly one active loop generation, including its held GET and retry delays. */
9932
+ loopAbortController;
9744
9933
  /**
9745
9934
  * Finding R1: seqs this loop has already `console.warn`'d about for a
9746
9935
  * validation-failed (recognized-type, invalid-payload) entry — a poison
@@ -9800,16 +9989,22 @@ var LongPollClient = class {
9800
9989
  noteRevoked(err) {
9801
9990
  if (!(err instanceof DeviceRevokedError)) return false;
9802
9991
  this.running = false;
9992
+ this.loopAbortController?.abort();
9803
9993
  this.opts.onRevoked?.();
9804
9994
  return true;
9805
9995
  }
9806
9996
  start() {
9807
9997
  if (this.running) return;
9808
9998
  this.running = true;
9809
- void this.loop();
9999
+ const controller = new AbortController();
10000
+ this.loopAbortController = controller;
10001
+ void this.loop(controller.signal).finally(() => {
10002
+ if (this.loopAbortController === controller) this.loopAbortController = void 0;
10003
+ });
9810
10004
  }
9811
10005
  stop() {
9812
10006
  this.running = false;
10007
+ this.loopAbortController?.abort();
9813
10008
  }
9814
10009
  /**
9815
10010
  * POST one batch of envelopes to `/byok/messages` (finding F6/protocol
@@ -9818,14 +10013,14 @@ var LongPollClient = class {
9818
10013
  * (`ConnectionHub.handleInbound`), so a resend of the SAME batch (same
9819
10014
  * envelope `id`s — the caller must never rebuild them) is deduped
9820
10015
  * server-side into a safe no-op rather than reprocessed (§9). Returns
9821
- * `true` once the server has accepted the batch.
10016
+ * validated frozen-v1 counts only after a readable response body.
9822
10017
  */
9823
10018
  async postBatch(envelopes) {
9824
10019
  try {
9825
10020
  await this.opts.auth.getValidAccessToken();
9826
10021
  } catch (err) {
9827
10022
  this.noteRevoked(err);
9828
- return false;
10023
+ return void 0;
9829
10024
  }
9830
10025
  let res;
9831
10026
  try {
@@ -9841,23 +10036,26 @@ var LongPollClient = class {
9841
10036
  );
9842
10037
  } catch (err) {
9843
10038
  if (!this.noteRevoked(err)) this.warnRouteFailure(this.messagesEndpoint, void 0, err);
9844
- return false;
10039
+ return void 0;
9845
10040
  }
9846
10041
  if (!res.ok) {
9847
10042
  this.warnRouteFailure(this.messagesEndpoint, res.status, void 0);
9848
- return false;
10043
+ return void 0;
9849
10044
  }
9850
10045
  try {
9851
- MessagesSendResponseSchema.parse(await res.json());
10046
+ const response = MessagesSendResponseSchema.parse(await res.json());
10047
+ if (response.accepted + (response.rejected ?? 0) !== envelopes.length) {
10048
+ throw new Error("messages response counts do not match the posted batch length");
10049
+ }
10050
+ return response;
9852
10051
  } catch (err) {
9853
10052
  this.warnRouteFailure(this.messagesEndpoint, res.status, err);
9854
- return false;
10053
+ return void 0;
9855
10054
  }
9856
- return true;
9857
10055
  }
9858
- async loop() {
10056
+ async loop(signal) {
9859
10057
  let retryAttempt = 0;
9860
- while (this.running) {
10058
+ while (this.running && !signal.aborted) {
9861
10059
  try {
9862
10060
  await this.opts.auth.getValidAccessToken();
9863
10061
  const base = toHttpBase(this.opts.serverUrl);
@@ -9866,8 +10064,9 @@ var LongPollClient = class {
9866
10064
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
9867
10065
  let res;
9868
10066
  try {
9869
- res = await authedFetch(url, { method: "GET" }, this.opts.auth);
10067
+ res = await authedFetch(url, { method: "GET", signal }, this.opts.auth);
9870
10068
  } catch (err) {
10069
+ if (signal.aborted) return;
9871
10070
  if (!(err instanceof DeviceRevokedError)) {
9872
10071
  this.warnRouteFailure(this.eventsEndpoint, void 0, err);
9873
10072
  }
@@ -9881,21 +10080,24 @@ var LongPollClient = class {
9881
10080
  return;
9882
10081
  }
9883
10082
  this.warnRouteFailure(this.eventsEndpoint, res.status, void 0);
9884
- this.opts.onServerCapabilities?.([]);
10083
+ this.opts.onServerCapabilitiesInvalidated?.();
10084
+ this.opts.onPollFailure?.();
9885
10085
  this.opts.onOperationalOutcome?.("failure");
9886
10086
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9887
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
10087
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9888
10088
  continue;
9889
10089
  }
9890
10090
  let parsed;
9891
10091
  try {
9892
- parsed = parseLooseEventsPollResponse(await res.json());
10092
+ parsed = parseLooseEventsPollResponse(await res.json(), cursor ?? 0);
10093
+ validateTrustedEventsPage(parsed.events, cursor ?? 0, parsed.cursor);
9893
10094
  } catch (err) {
9894
10095
  this.warnRouteFailure(this.eventsEndpoint, res.status, err);
9895
10096
  throw err;
9896
10097
  }
9897
10098
  this.opts.onServerCapabilities?.(parsed.capabilities);
9898
10099
  let hadValidationFailureThisBatch = false;
10100
+ let acceptedAnyEntry = false;
9899
10101
  for (const raw of parsed.events) {
9900
10102
  let envelope;
9901
10103
  try {
@@ -9903,7 +10105,10 @@ var LongPollClient = class {
9903
10105
  } catch (err) {
9904
10106
  if (err instanceof UnknownMessageTypeError) {
9905
10107
  const skippableSeq = extractSkippableSeq(raw);
9906
- if (skippableSeq !== void 0) this.opts.onSkippedSeq?.(skippableSeq);
10108
+ if (skippableSeq !== void 0) {
10109
+ this.opts.onSkippedSeq?.(skippableSeq);
10110
+ acceptedAnyEntry = true;
10111
+ }
9907
10112
  } else {
9908
10113
  const failedSeq = extractSkippableSeq(raw);
9909
10114
  if (failedSeq !== void 0) {
@@ -9923,27 +10128,28 @@ var LongPollClient = class {
9923
10128
  }
9924
10129
  continue;
9925
10130
  }
9926
- this.opts.onEnvelope(envelope);
10131
+ if (this.opts.onEnvelope(envelope) !== false) acceptedAnyEntry = true;
9927
10132
  }
9928
10133
  if (parsed.events.length === 0) {
9929
10134
  retryAttempt = 0;
9930
10135
  this.opts.onOperationalOutcome?.("success");
9931
- await sleep(this.opts.idleDelayMs ?? 250);
9932
- } else if (this.opts.isStalled?.() || hadValidationFailureThisBatch) {
10136
+ await sleep(this.opts.idleDelayMs ?? 250, signal);
10137
+ } else if (this.opts.isStalled?.() || hadValidationFailureThisBatch || !acceptedAnyEntry && this.opts.getCursor() === cursor) {
9933
10138
  this.opts.onOperationalOutcome?.("failure");
9934
10139
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9935
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
10140
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9936
10141
  } else {
9937
10142
  retryAttempt = 0;
9938
10143
  this.opts.onOperationalOutcome?.("success");
9939
10144
  }
9940
10145
  } catch (err) {
9941
- this.opts.onServerCapabilities?.([]);
10146
+ this.opts.onServerCapabilitiesInvalidated?.();
9942
10147
  if (this.noteRevoked(err)) return;
9943
- if (!this.running) return;
10148
+ if (!this.running || signal.aborted) return;
10149
+ this.opts.onPollFailure?.();
9944
10150
  this.opts.onOperationalOutcome?.("failure");
9945
10151
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9946
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
10152
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9947
10153
  }
9948
10154
  }
9949
10155
  }
@@ -9963,20 +10169,24 @@ async function parseReplayCursorTooOld(res) {
9963
10169
  }
9964
10170
  return new ReplayCursorTooOldError(recoverableFrom);
9965
10171
  }
9966
- function sleep(ms) {
9967
- return new Promise((resolve) => setTimeout(resolve, ms));
10172
+ function sleep(ms, signal) {
10173
+ if (signal.aborted) return Promise.resolve();
10174
+ return new Promise((resolve) => {
10175
+ const finish = () => {
10176
+ clearTimeout(timer);
10177
+ signal.removeEventListener("abort", finish);
10178
+ resolve();
10179
+ };
10180
+ const timer = setTimeout(finish, ms);
10181
+ signal.addEventListener("abort", finish, { once: true });
10182
+ });
9968
10183
  }
9969
- var WsUnexpectedStatusError = class extends Error {
9970
- constructor(status, endpoint) {
9971
- super(`WS upgrade rejected with HTTP ${status} (ws ${endpoint.host}${endpoint.path})`);
9972
- this.status = status;
9973
- this.endpoint = endpoint;
9974
- this.name = "WsUnexpectedStatusError";
9975
- }
9976
- status;
9977
- endpoint;
9978
- };
9979
- function createConnectionHelloEnvelope(opts) {
10184
+
10185
+ // src/daemon/connection-manager.ts
10186
+ function isCursorEnvelopeType(type) {
10187
+ return type.startsWith("task.") || type === "agent.egress.ack" || type === "agent.content.read" || type === "agent.home.projection";
10188
+ }
10189
+ function createConnectionHelloEnvelope(opts, getCursor) {
9980
10190
  const configuredToolsets = opts.getConfiguredToolsets?.();
9981
10191
  return createEnvelope("conn.hello", {
9982
10192
  protocolVersions: [PROTOCOL_VERSION],
@@ -9986,222 +10196,32 @@ function createConnectionHelloEnvelope(opts) {
9986
10196
  clientVersion: opts.clientVersion,
9987
10197
  runtimes: opts.runtimes,
9988
10198
  configuredToolsets: configuredToolsets === void 0 ? void 0 : [...configuredToolsets],
9989
- cursor: opts.getCursor?.()
10199
+ cursor: getCursor()
9990
10200
  });
9991
10201
  }
9992
- var WsTransport = class {
9993
- constructor(opts) {
9994
- this.opts = opts;
9995
- }
9996
- opts;
9997
- socket;
9998
- closedByUser = false;
9999
- autoReconnect = true;
10000
- acked = false;
10001
- everAckedThisAttempt = false;
10002
- reconnectAttempt = 0;
10003
- reconnectTimer;
10004
- livenessTimer;
10005
- lastActivity = 0;
10006
- lastUnexpectedStatus;
10007
- ackWaiters = [];
10008
- connect(opts = {}) {
10009
- this.autoReconnect = opts.auto ?? true;
10010
- this.closedByUser = false;
10011
- void this.openSocket();
10012
- }
10013
- /** 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). */
10014
- stopAutoReconnect() {
10015
- this.autoReconnect = false;
10016
- if (this.reconnectTimer) {
10017
- clearTimeout(this.reconnectTimer);
10018
- this.reconnectTimer = void 0;
10019
- }
10020
- }
10021
- /** Resume normal auto-reconnect-on-close behavior (does not itself trigger a connect — only affects future closes). */
10022
- resumeAutoReconnect() {
10023
- this.autoReconnect = true;
10024
- }
10025
- get isOpen() {
10026
- return this.acked;
10027
- }
10028
- /**
10029
- * Attempt to send ONE envelope right now; returns whether it actually went
10030
- * out. `false` means the socket isn't currently open+acked — the caller
10031
- * (`ConnectionManager.drainOutbox`, Design B/finding N4) owns re-queueing
10032
- * and retrying later (e.g. on the next `onAcked`), since this transport no
10033
- * longer buffers anything itself.
10034
- */
10035
- sendNow(envelope) {
10036
- if (this.socket && this.socket.readyState === WebSocket.OPEN && this.acked) {
10037
- this.socket.send(encodeEnvelope(envelope));
10038
- return true;
10039
- }
10040
- return false;
10041
- }
10042
- waitForAck(timeoutMs = 1e4) {
10043
- if (this.acked) return Promise.resolve();
10044
- return new Promise((resolve, reject) => {
10045
- const timer = setTimeout(() => reject(new Error("Timed out waiting for conn.ack")), timeoutMs);
10046
- this.ackWaiters.push({
10047
- resolve: () => {
10048
- clearTimeout(timer);
10049
- resolve();
10050
- },
10051
- reject: (err) => {
10052
- clearTimeout(timer);
10053
- reject(err);
10054
- }
10055
- });
10056
- });
10057
- }
10058
- close() {
10059
- this.closedByUser = true;
10060
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
10061
- this.socket?.close();
10062
- }
10063
- async openSocket() {
10064
- const url = toWsUrl(this.opts.serverUrl);
10065
- const endpoint = describeEndpoint("ws", url);
10066
- this.acked = false;
10067
- this.everAckedThisAttempt = false;
10068
- this.opts.onStateChange?.("connecting");
10069
- let token;
10070
- try {
10071
- token = await this.opts.getToken();
10072
- } catch (err) {
10073
- this.opts.onStateChange?.("closed");
10074
- this.opts.onConnectOutcome?.(false, err, endpoint);
10075
- if (!this.closedByUser && this.autoReconnect) this.scheduleReconnect();
10076
- return;
10077
- }
10078
- const socket = new WebSocket(url, {
10079
- headers: { Authorization: `Bearer ${token}` }
10080
- });
10081
- this.socket = socket;
10082
- this.lastActivity = Date.now();
10083
- this.startLivenessCheck(socket);
10084
- socket.on("open", () => {
10085
- const hello = createConnectionHelloEnvelope(this.opts);
10086
- socket.send(encodeEnvelope(hello));
10087
- });
10088
- socket.on("ping", () => {
10089
- this.lastActivity = Date.now();
10090
- });
10091
- socket.on("message", (data, isBinary) => {
10092
- this.lastActivity = Date.now();
10093
- const bytes = toBytes(data);
10094
- let envelope;
10095
- try {
10096
- envelope = decodeEnvelope(bytes);
10097
- } catch {
10098
- return;
10099
- }
10100
- if (envelope.type === "conn.ack") {
10101
- this.reconnectAttempt = 0;
10102
- this.acked = true;
10103
- this.everAckedThisAttempt = true;
10104
- this.opts.onStateChange?.("open");
10105
- for (const waiter of this.ackWaiters.splice(0)) waiter.resolve();
10106
- this.opts.onAcked?.(envelope.payload.capabilities);
10107
- }
10108
- this.opts.onEnvelope(envelope);
10109
- });
10110
- socket.on("close", (code, reason) => {
10111
- this.socket = void 0;
10112
- this.stopLivenessCheck();
10113
- this.opts.onStateChange?.("closed");
10114
- const acked = this.everAckedThisAttempt;
10115
- const status = this.lastUnexpectedStatus;
10116
- this.lastUnexpectedStatus = void 0;
10117
- const replayCursorTooOld = code === 1008 && reason.toString("utf8") === "cursor_too_old" ? new ReplayCursorTooOldError() : void 0;
10118
- this.opts.onConnectOutcome?.(
10119
- acked,
10120
- replayCursorTooOld ?? (status !== void 0 ? new WsUnexpectedStatusError(status, endpoint) : void 0),
10121
- endpoint
10122
- );
10123
- if (!this.closedByUser && this.autoReconnect) this.scheduleReconnect();
10124
- });
10125
- socket.on("error", () => {
10126
- });
10127
- socket.on("unexpected-response", (_req, res) => {
10128
- this.lastUnexpectedStatus = res.statusCode;
10129
- res.resume();
10130
- socket.terminate();
10131
- });
10132
- }
10133
- scheduleReconnect() {
10134
- const { baseMs = 1e3, maxMs = 3e4, factor = 2 } = this.opts.backoff ?? {};
10135
- const delay3 = Math.min(maxMs, baseMs * factor ** this.reconnectAttempt);
10136
- const jitter = this.opts.reconnectDelayMs?.(this.reconnectAttempt, delay3) ?? delay3;
10137
- this.reconnectAttempt += 1;
10138
- this.reconnectTimer = setTimeout(() => void this.openSocket(), jitter);
10139
- }
10140
- startLivenessCheck(socket) {
10141
- const { timeoutMs = 75e3, checkIntervalMs = Math.max(1e3, Math.floor(timeoutMs / 3)) } = this.opts.liveness ?? {};
10142
- this.livenessTimer = setInterval(() => {
10143
- if (Date.now() - this.lastActivity > timeoutMs) {
10144
- socket.terminate();
10145
- }
10146
- }, checkIntervalMs);
10147
- this.livenessTimer.unref?.();
10148
- }
10149
- stopLivenessCheck() {
10150
- if (this.livenessTimer) {
10151
- clearInterval(this.livenessTimer);
10152
- this.livenessTimer = void 0;
10153
- }
10154
- }
10155
- };
10156
- function toBytes(data, _isBinary) {
10157
- if (Buffer.isBuffer(data)) return data;
10158
- if (Array.isArray(data)) return Buffer.concat(data);
10159
- if (data instanceof ArrayBuffer) return new Uint8Array(data);
10160
- return Buffer.from(String(data), "utf8");
10161
- }
10162
-
10163
- // src/daemon/connection-manager.ts
10164
- function isCursorEnvelopeType(type) {
10165
- return type.startsWith("task.") || type === "agent.egress.ack" || type === "agent.content.read" || type === "agent.home.projection";
10166
- }
10202
+ var MAX_REJECTED_OUTBOX_ENTRIES = 1e3;
10167
10203
  var ConnectionManager = class {
10168
10204
  constructor(opts) {
10169
10205
  this.opts = opts;
10170
10206
  this.fleetJitter = opts.fleetJitter ?? createFleetJitter(opts.productId, opts.deviceId);
10171
- this.ws = new WsTransport({
10172
- serverUrl: opts.serverUrl,
10173
- getToken: () => opts.auth.getValidAccessToken(),
10174
- deviceId: opts.deviceId,
10175
- productId: opts.productId,
10176
- capabilities: opts.capabilities,
10177
- clientVersion: opts.clientVersion,
10178
- runtimes: opts.runtimes,
10179
- getConfiguredToolsets: opts.getConfiguredToolsets,
10180
- getCursor: () => this.cursor,
10181
- onEnvelope: (envelope) => this.deliver(envelope),
10182
- onStateChange: (state) => {
10183
- if (!this.stopped && !this.revoked) this.opts.onStateChange?.(state);
10184
- },
10185
- onAcked: (capabilities) => this.onAcked(capabilities),
10186
- onConnectOutcome: (acked, err) => this.onWsOutcome(acked, err),
10187
- backoff: opts.backoff,
10188
- liveness: opts.liveness,
10189
- reconnectDelayMs: (attempt, baseMs) => this.fleetJitter.delay("reconnect", attempt, baseMs)
10190
- });
10191
10207
  this.longPoll = new LongPollClient({
10192
10208
  serverUrl: opts.serverUrl,
10193
10209
  auth: opts.auth,
10194
- // Design A: the query cursor for the NEXT `GET /byok/events` is the
10195
- // same watermark `deliver()` dedupes against (see `dedupWatermark`) —
10196
- // normally the eager `deliveredSeq` (so an in-flight envelope isn't
10197
- // re-pulled), but the durable `cursor` while `stalledAtSeq` is set, so
10198
- // the failed envelope (and everything after it) IS re-pulled and
10199
- // re-attempted.
10200
- getCursor: () => this.dedupWatermark(),
10210
+ // The long-poll query cursor is also the kernel's irreversible ack.
10211
+ // Only report the successfully processed cursor here. `deliveredSeq`
10212
+ // remains a local dedup watermark; using it on the wire would ack an
10213
+ // in-flight envelope before its handler settles and make a later
10214
+ // failure impossible to redeliver.
10215
+ getCursor: () => this.cursor,
10201
10216
  onEnvelope: (envelope) => this.deliver(envelope),
10202
10217
  onServerCapabilities: (capabilities) => {
10203
- if (this.mode === "long-poll") this.serverCapabilities = capabilities;
10218
+ this.serverCapabilities = capabilities;
10219
+ this.noteConnected();
10204
10220
  },
10221
+ onServerCapabilitiesInvalidated: () => {
10222
+ this.serverCapabilities = [];
10223
+ },
10224
+ onPollFailure: () => this.noteDisconnected(),
10205
10225
  onRevoked: () => this.enterRevoked(),
10206
10226
  onReplayCursorTooOld: (error) => this.enterReplayCursorTooOld(error),
10207
10227
  // M4 Phase 4 (version-negotiation drill fix): a batch entry
@@ -10229,13 +10249,10 @@ var ConnectionManager = class {
10229
10249
  }
10230
10250
  opts;
10231
10251
  fleetJitter;
10232
- ws;
10233
10252
  longPoll;
10234
- mode = "ws";
10235
- consecutiveFailures = 0;
10236
- wsRetryTimer;
10237
- wsProbeSequence = 0;
10238
10253
  uploadRetryAttempt = 0;
10254
+ started = false;
10255
+ connected = false;
10239
10256
  cursor;
10240
10257
  /**
10241
10258
  * Finding F3 (at-most-once redelivery): the lowest `task.*` envelope `seq`
@@ -10257,10 +10274,9 @@ var ConnectionManager = class {
10257
10274
  * (see `advanceCursor`) — that semantics is unchanged. `deliveredSeq`
10258
10275
  * advances eagerly, the instant a `task.*` envelope is admitted past
10259
10276
  * dedup (see `deliver`/`noteDelivered`), independent of whether its
10260
- * handler has even started, let alone succeeded. It exists so a
10261
- * long-poll re-query (`LongPollClient`'s `getCursor`) doesn't re-pull an
10262
- * envelope that's already been delivered once and is still in flight
10263
- * `handleOffer` is NOT idempotent and must never be re-pulled while a
10277
+ * handler has even started, let alone succeeded. It exists so a repeated
10278
+ * read at the durable cursor does not re-dispatch an envelope already in
10279
+ * flight `handleOffer` must not start a second adapter session while a
10264
10280
  * first attempt is still running. On WS this same field is written the
10265
10281
  * same way, but since a live WS connection only ever pushes a given `seq`
10266
10282
  * once, it never has an observable effect there beyond mirroring
@@ -10271,18 +10287,18 @@ var ConnectionManager = class {
10271
10287
  /** Finding F3: serializes `onEnvelope` calls into a per-connection FIFO — one envelope's handler always fully settles before the next one starts. */
10272
10288
  processingChain = Promise.resolve();
10273
10289
  /**
10274
- * Design B (finding N4): the ONE outbound queue both transports drain
10275
- * from — holds `Envelope` OBJECTS, never re-encoded/rebuilt strings, so a
10290
+ * Design B (finding N4): the ONE outbound queue holds `Envelope` OBJECTS,
10291
+ * never re-encoded/rebuilt strings, so a
10276
10292
  * resend after a failed send attempt is byte-identical to the original
10277
10293
  * (same `id`), which is what lets the server's per-(deviceId,id) dedup
10278
10294
  * (Wave 1) recognize it as a safe no-op retry rather than a second
10279
- * application (protocol §9). A transport switch (long-poll <-> WS) never
10280
- * touches this queue — see `drainOutbox` — so nothing queued while one
10281
- * transport was active is ever stranded when the other takes over.
10295
+ * application (protocol §9).
10282
10296
  */
10283
10297
  outbox = [];
10298
+ /** Terminally rejected outbound envelopes, retained as a bounded observable quarantine. */
10299
+ rejectedOutboundEnvelopes = [];
10284
10300
  /**
10285
- * Finding F5(b): how many envelopes `drainOutbox`'s long-poll branch has
10301
+ * Finding F5(b): how many envelopes `drainOutbox` has
10286
10302
  * currently spliced OUT of `this.outbox` for an in-flight (not yet
10287
10303
  * confirmed delivered) `postBatch` call — 0 the rest of the time. See
10288
10304
  * `outboxLength`'s own doc comment for why this needs to be tracked
@@ -10335,39 +10351,22 @@ var ConnectionManager = class {
10335
10351
  */
10336
10352
  cancelPendingDrainRetry;
10337
10353
  /**
10338
- * The capabilities the CURRENT transport's server advertised — untyped
10339
- * `string[]` for forward compatibility. WS populates it from `conn.ack`;
10340
- * long-poll populates it from each successful events response. Empty until
10341
- * the active transport supplies an advertisement.
10342
- *
10343
- * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
10344
- * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
10345
- * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
10346
- * `acked` branch), `stop()`, or a transport switch to long-poll
10347
- * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
10348
- * the transport that is still current.
10349
- * The previous version of this doc comment claimed long-poll mode simply
10350
- * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
10351
- * a daemon that once learned e.g. `approval_resolved` from an earlier WS
10352
- * session kept believing it applied to whatever it's connected to NOW,
10353
- * even after a disconnect/degrade where nothing has actually confirmed
10354
- * that's still true (a reconnect could land on a DIFFERENT server behind a
10355
- * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
10356
- * `task.approval_resolved` on this list — sending it to a server that
10357
- * doesn't actually understand it over the long-poll path would get a
10358
- * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
10359
- * `drainOutbox`'s retry-the-same-batch-forever loop then head-of-line
10360
- * blocks EVERY envelope queued behind it on, permanently. Clearing this
10361
- * eagerly means that gate reliably fails closed (falls back to the
10362
- * pre-existing implicit-resume inference, unconditionally — see
10363
- * `sendApprovalResolved`'s own doc comment) the moment the connection that
10364
- * advertised the capability is no longer the one actually in use.
10354
+ * The capabilities the current server response advertised — untyped
10355
+ * `string[]` for forward compatibility. An advertisement is scoped to the
10356
+ * current long-poll response stream and is cleared after an HTTP failure,
10357
+ * terminal shutdown, or revocation. This keeps capability-gated outbound
10358
+ * messages fail-closed until the current server has explicitly advertised
10359
+ * support.
10365
10360
  */
10366
10361
  serverCapabilities = [];
10367
10362
  async start() {
10368
10363
  if (this.terminalError) throw this.terminalError;
10369
10364
  this.cursor = await this.opts.cursorStore.load(this.opts.serverUrl, this.opts.deviceId);
10370
- this.ws.connect({ auto: true });
10365
+ this.started = true;
10366
+ this.opts.onStateChange?.("connecting");
10367
+ this.longPoll.start();
10368
+ this.outbox.unshift(createConnectionHelloEnvelope(this.opts, () => this.cursor));
10369
+ void this.drainOutbox();
10371
10370
  }
10372
10371
  /**
10373
10372
  * Design B (finding N4): push onto the single shared outbox and try to
@@ -10378,17 +10377,13 @@ var ConnectionManager = class {
10378
10377
  this.outbox.push(envelope);
10379
10378
  void this.drainOutbox();
10380
10379
  }
10380
+ /** Publish a fresh local configuration snapshot while this daemon is running. */
10381
+ refreshHello() {
10382
+ if (!this.started || this.stopped || this.revoked || this.terminalError) return;
10383
+ this.send(createConnectionHelloEnvelope(this.opts, () => this.cursor));
10384
+ }
10381
10385
  /**
10382
- * Design B (finding N4): drain the shared outbox through whichever
10383
- * transport is currently active, re-checking `this.mode` fresh on every
10384
- * iteration so a transport switch mid-drain is picked up immediately
10385
- * rather than fighting a stale decision made before the switch.
10386
- *
10387
- * WS: a synchronous, one-at-a-time `sendNow` per envelope while open+
10388
- * acked; stops (without dropping anything — the remainder stays queued)
10389
- * the moment it isn't, and is re-invoked once `onAcked` fires.
10390
- *
10391
- * Long-poll: POSTs the outbox in chunks of at most
10386
+ * POSTs the outbox through long-poll in chunks of at most
10392
10387
  * `MAX_MESSAGES_PER_BATCH` (finding P1) — the server hard-caps a single
10393
10388
  * `/byok/messages` batch there (`MessagesSendRequestSchema`, protocol
10394
10389
  * §8.2) and 400s the WHOLE request if it's exceeded, which — before this
@@ -10400,43 +10395,48 @@ var ConnectionManager = class {
10400
10395
  * failure that SAME chunk is unshifted back (order-preserving, same
10401
10396
  * Envelope objects/ids — never rebuilt, so a retry is exactly the resend
10402
10397
  * Wave 1's server-side dedup expects) and retried after a short backoff,
10403
- * re-reading `this.mode` each time so a WS recovery that happens
10404
- * mid-retry is honored on the very next loop iteration instead of only
10405
- * after this attempt's backoff chain gives up.
10406
- *
10407
10398
  * Re-entrancy is guarded by `draining`: a call arriving while a drain is
10408
10399
  * already in progress just returns — the in-progress loop's own
10409
- * `while (this.outbox.length > 0)` check will pick up anything newly
10410
- * pushed (or left over after a mode switch) on its very next iteration.
10400
+ * `while (this.outbox.length > 0)` check picks up anything newly pushed.
10411
10401
  */
10412
10402
  async drainOutbox() {
10413
- if (this.draining) return;
10403
+ if (!this.started || this.draining) return;
10414
10404
  this.draining = true;
10405
+ const isolateRejectedBatch = async (batch) => {
10406
+ const pending = [[...batch]];
10407
+ while (pending.length > 0) {
10408
+ if (this.stopped || this.revoked) return pending.flat();
10409
+ const segment = pending.shift();
10410
+ const result = await this.longPoll.postBatch(segment);
10411
+ if (result === void 0) return [...segment, ...pending.flat()];
10412
+ if ((result.rejected ?? 0) === 0) continue;
10413
+ if (segment.length === 1) {
10414
+ this.quarantineRejectedOutbound(segment[0], "inbound_rejected");
10415
+ continue;
10416
+ }
10417
+ const midpoint = Math.floor(segment.length / 2);
10418
+ pending.unshift(segment.slice(0, midpoint), segment.slice(midpoint));
10419
+ }
10420
+ return void 0;
10421
+ };
10415
10422
  try {
10416
10423
  while (this.outbox.length > 0) {
10417
10424
  if (this.stopped || this.revoked) return;
10418
- if (this.mode === "ws") {
10419
- if (!this.ws.isOpen) return;
10420
- const envelope = this.outbox[0];
10421
- if (!this.ws.sendNow(envelope)) return;
10422
- this.outbox.shift();
10423
- continue;
10424
- }
10425
10425
  const batch = this.outbox.splice(0, MAX_MESSAGES_PER_BATCH);
10426
10426
  this.inFlightBatchSize = batch.length;
10427
- let ok;
10427
+ let retrySegments;
10428
10428
  try {
10429
- ok = await this.longPoll.postBatch(batch);
10429
+ retrySegments = await isolateRejectedBatch(batch);
10430
10430
  } finally {
10431
10431
  this.inFlightBatchSize = 0;
10432
10432
  }
10433
- if (ok) {
10433
+ if (retrySegments === void 0) {
10434
10434
  this.uploadRetryAttempt = 0;
10435
10435
  this.opts.onOperationalOutcome?.("success", "upload");
10436
10436
  continue;
10437
10437
  }
10438
10438
  this.opts.onOperationalOutcome?.("failure", "upload");
10439
- this.outbox.unshift(...batch);
10439
+ this.outbox.unshift(...retrySegments);
10440
10440
  if (this.stopped || this.revoked) return;
10441
10441
  const baseMs = this.opts.longPollRetryDelayMs ?? 2e3;
10442
10442
  await this.drainRetryDelay(this.fleetJitter.delay("upload", this.uploadRetryAttempt++, baseMs));
@@ -10452,8 +10452,7 @@ var ConnectionManager = class {
10452
10452
  * in-flight wait immediately instead of leaving `drainOutbox` parked here
10453
10453
  * for up to the rest of the delay before it next checks `this.revoked` —
10454
10454
  * and (b) unref'd, so the timer never keeps the Node process alive by
10455
- * itself while nothing else (a live long-poll GET, an open WS connection)
10456
- * legitimately is.
10455
+ * itself while nothing else (such as the live long-poll GET) legitimately is.
10457
10456
  */
10458
10457
  drainRetryDelay(ms) {
10459
10458
  return new Promise((resolve) => {
@@ -10469,14 +10468,9 @@ var ConnectionManager = class {
10469
10468
  };
10470
10469
  });
10471
10470
  }
10472
- isTransportDegraded() {
10473
- return this.mode === "long-poll";
10474
- }
10475
10471
  /**
10476
- * The capabilities the CURRENT transport's server advertised: from
10477
- * `conn.ack` on WS, or the latest successful `GET /byok/events` response
10478
- * on long-poll. Empty before either transport has supplied its current
10479
- * advertisement, and cleared across disconnect/switch boundaries.
10472
+ * The capabilities the latest successful `GET /byok/events` response
10473
+ * advertised. Empty before a successful response and after a failed one.
10480
10474
  */
10481
10475
  getServerCapabilities() {
10482
10476
  return this.serverCapabilities;
@@ -10484,29 +10478,23 @@ var ConnectionManager = class {
10484
10478
  getTerminalError() {
10485
10479
  return this.terminalError;
10486
10480
  }
10487
- getMode() {
10488
- return this.mode;
10489
- }
10490
10481
  isConnected() {
10491
- return this.mode === "ws" && this.ws.isOpen;
10482
+ return this.connected;
10492
10483
  }
10493
10484
  isRevoked() {
10494
10485
  return this.revoked;
10495
10486
  }
10496
10487
  /**
10497
- * Resolves once the connection has settled either a working, acked WS
10498
- * connection, or the long-poll fallback taking over (protocol §8). This
10499
- * lets `daemon.start()` return promptly even when WS is unavailable from
10500
- * the very first attempt, rather than hanging until a WS `conn.ack` that
10501
- * may never come.
10488
+ * Resolves after the first successful long-poll response establishes the
10489
+ * authenticated connection.
10502
10490
  *
10503
10491
  * Rejects with {@link DeviceRevokedError} — instead of hanging until
10504
10492
  * `timeoutMs` — if the device turns out to be revoked while settling (or
10505
10493
  * already was): a cold `daemon.start()` against an already-revoked device
10506
10494
  * must fail fast, not surface a generic timeout (protocol §6.3).
10507
10495
  */
10508
- waitForAck(timeoutMs = 1e4) {
10509
- if (this.ws.isOpen || this.mode === "long-poll") return Promise.resolve();
10496
+ waitForConnection(timeoutMs = 1e4) {
10497
+ if (this.connected) return Promise.resolve();
10510
10498
  if (this.terminalError) return Promise.reject(this.terminalError);
10511
10499
  if (this.revoked) return Promise.reject(new DeviceRevokedError());
10512
10500
  return new Promise((resolve, reject) => {
@@ -10514,7 +10502,7 @@ var ConnectionManager = class {
10514
10502
  };
10515
10503
  const timer = setTimeout(() => {
10516
10504
  this.settledWaiters = this.settledWaiters.filter((w) => w !== settle);
10517
- reject(new Error("Timed out waiting for the connection to settle (WS ack or long-poll fallback)"));
10505
+ reject(new Error("Timed out waiting for the long-poll connection to settle"));
10518
10506
  }, timeoutMs);
10519
10507
  settle = (err) => {
10520
10508
  clearTimeout(timer);
@@ -10525,7 +10513,7 @@ var ConnectionManager = class {
10525
10513
  });
10526
10514
  }
10527
10515
  /**
10528
- * Stops both transports and waits for every in-flight envelope handler
10516
+ * Stops the long-poll transport and waits for every in-flight envelope handler
10529
10517
  * (the F3 FIFO chain) and the most recent cursor write to actually land on
10530
10518
  * disk — otherwise a `stop()` racing a just-processed envelope's
10531
10519
  * persistence could lose that cursor advance, or leave a handler running
@@ -10534,14 +10522,14 @@ var ConnectionManager = class {
10534
10522
  * Finding F5(b) (cross-model adversarial review): `drainTimeoutMs`, when
10535
10523
  * passed, bounds how long this waits for the shared outbox (`this.outbox`
10536
10524
  * — Design B) to actually finish draining BEFORE flipping `this.stopped`
10537
- * and closing the transports. Before this fix, `stop()` set `stopped`
10525
+ * and stopping the transport. Before this fix, `stop()` set `stopped`
10538
10526
  * synchronously and never waited for `drainOutbox` at all: an envelope
10539
10527
  * `send()` had just pushed moments earlier (e.g. `TaskRunner.shutdownTask`'s
10540
10528
  * own `task.fail`, sent right before `create-daemon.ts`'s
10541
10529
  * `performControlShutdown` calls this) could still be sitting UNSENT in
10542
10530
  * `this.outbox` — mid long-poll retry backoff, or simply not yet picked up
10543
10531
  * by the fire-and-forget `drainOutbox()` `send()` kicked off — and this
10544
- * method would happily proceed to `stopped = true` / `ws.close()` regardless,
10532
+ * method would happily proceed to `stopped = true` regardless,
10545
10533
  * after which NOTHING ever drains it again: silently lost, even though
10546
10534
  * `TaskRunner` believed it had been sent. `drainTimeoutMs` omitted (the
10547
10535
  * default) preserves the EXACT prior behavior for every other existing
@@ -10558,9 +10546,9 @@ var ConnectionManager = class {
10558
10546
  }
10559
10547
  this.stopped = true;
10560
10548
  this.serverCapabilities = [];
10561
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
10562
10549
  this.longPoll.stop();
10563
- this.ws.close();
10550
+ this.connected = false;
10551
+ this.opts.onStateChange?.("closed");
10564
10552
  await this.processingChain;
10565
10553
  await this.pendingCursorSave;
10566
10554
  }
@@ -10585,6 +10573,10 @@ var ConnectionManager = class {
10585
10573
  outboxLength() {
10586
10574
  return this.outbox.length + this.inFlightBatchSize;
10587
10575
  }
10576
+ /** A bounded terminal quarantine for operator inspection; these entries are never retried. */
10577
+ rejectedOutbox() {
10578
+ return this.rejectedOutboundEnvelopes;
10579
+ }
10588
10580
  /**
10589
10581
  * Finding F5(b): polls {@link outboxLength} (not `this.outbox.length`
10590
10582
  * alone — see that method's own doc comment for why a spliced-out,
@@ -10592,15 +10584,14 @@ var ConnectionManager = class {
10592
10584
  * a single `drainOutbox()` promise directly — a drain in progress can
10593
10585
  * itself loop through multiple retry/backoff cycles (`drainRetryDelay`)
10594
10586
  * while the server is unreachable, and a fresh, INDEPENDENT
10595
- * `drainOutbox()` call can also be triggered concurrently (`send()`, a
10596
- * mode switch's own `void this.drainOutbox()`) — polling the one thing
10587
+ * `drainOutbox()` call can also be triggered concurrently (`send()`)
10588
+ * polling the one thing
10597
10589
  * that actually matters (is anything still undelivered) can never go
10598
10590
  * stale the way capturing one specific in-flight promise reference
10599
10591
  * could. Kicks off one more `drainOutbox()` attempt itself first
10600
10592
  * (harmless no-op if one is already running — see its own re-entrancy
10601
- * guard) in case nothing is currently actively retrying (e.g. WS just
10602
- * dropped and long-poll hasn't taken over yet), so this bounded wait
10603
- * isn't just passively hoping something else happens to be making
10593
+ * guard) in case nothing is currently actively retrying, so this bounded
10594
+ * wait isn't just passively hoping something else happens to be making
10604
10595
  * progress.
10605
10596
  */
10606
10597
  waitForOutboxDrained(timeoutMs) {
@@ -10643,19 +10634,21 @@ var ConnectionManager = class {
10643
10634
  deliver(envelope) {
10644
10635
  const tracked = isCursorEnvelopeType(envelope.type) && typeof envelope.seq === "number";
10645
10636
  const watermark = this.dedupWatermark();
10646
- if (tracked && watermark !== void 0 && envelope.seq <= watermark) return;
10637
+ if (tracked && watermark !== void 0 && envelope.seq <= watermark) return false;
10647
10638
  if (tracked) {
10648
10639
  const seq = envelope.seq;
10649
- if (this.inFlightSeqs.has(seq) || this.processedSeqs.has(seq)) return;
10640
+ if (this.inFlightSeqs.has(seq) || this.processedSeqs.has(seq)) return false;
10650
10641
  this.inFlightSeqs.add(seq);
10651
10642
  this.noteDelivered(seq);
10652
10643
  }
10653
10644
  this.processingChain = this.processingChain.then(() => this.process(envelope, tracked));
10645
+ return true;
10654
10646
  }
10655
10647
  /**
10656
- * Design A: the watermark `deliver()` dedupes inbound `task.*` envelopes
10657
- * against, and the same value `LongPollClient` queries the next
10658
- * `GET /byok/events` cursor with (see the constructor). Normally this is
10648
+ * The local watermark `deliver()` dedupes inbound `task.*` envelopes
10649
+ * against. It is deliberately NOT the long-poll query cursor: that query
10650
+ * is the kernel acknowledgement and uses only the successfully processed
10651
+ * `cursor` (see the constructor). Normally this local watermark is
10659
10652
  * `deliveredSeq` — which is always >= `cursor` (every envelope that
10660
10653
  * reaches `advanceCursor` already passed through `noteDelivered` first,
10661
10654
  * see `deliver`) — so this is the literal `max(cursor, deliveredSeq)` the
@@ -10671,11 +10664,9 @@ var ConnectionManager = class {
10671
10664
  * whose outcome wasn't known yet. No separate "reset deliveredSeq on
10672
10665
  * reconnect" step is needed for this to be correct — collapsing to
10673
10666
  * `cursor` exactly while stalled already produces the right answer on
10674
- * every redelivery path (long-poll re-query AND a WS reconnect's
10675
- * backlog replay alike), and NOT resetting it unconditionally on every
10676
- * reconnect is what lets `deliveredSeq` keep doing its job of not
10677
- * re-pulling/re-dispatching something already in flight across a
10678
- * reconnect that happens to land while a handler is still running.
10667
+ * every long-poll retry path. NOT resetting it unconditionally on every
10668
+ * retry lets `deliveredSeq` keep doing its job of not re-dispatching
10669
+ * something already in flight while a handler is still running.
10679
10670
  */
10680
10671
  dedupWatermark() {
10681
10672
  return this.stalledAtSeq !== void 0 ? this.cursor : this.deliveredSeq ?? this.cursor;
@@ -10693,11 +10684,13 @@ var ConnectionManager = class {
10693
10684
  }
10694
10685
  await this.opts.onEnvelope(envelope);
10695
10686
  if (!tracked) return;
10696
- this.processedSeqs.add(seq);
10697
- if (this.stalledAtSeq !== void 0 && envelope.seq !== this.stalledAtSeq) return;
10687
+ if (this.stalledAtSeq !== void 0 && envelope.seq !== this.stalledAtSeq) {
10688
+ this.processedSeqs.add(seq);
10689
+ return;
10690
+ }
10698
10691
  this.stalledAtSeq = void 0;
10692
+ await this.advanceCursor(envelope.seq);
10699
10693
  this.processedSeqs.clear();
10700
- this.advanceCursor(envelope.seq);
10701
10694
  } catch (err) {
10702
10695
  if (tracked && this.stalledAtSeq === void 0) this.stalledAtSeq = envelope.seq;
10703
10696
  console.error(
@@ -10711,9 +10704,8 @@ var ConnectionManager = class {
10711
10704
  /**
10712
10705
  * M4 Phase 4 (version-negotiation drill fix): `LongPollClient` calls this
10713
10706
  * for a batch entry it could not parse into a known `Envelope` at all (an
10714
- * unrecognized message type mirrors `ws-transport.ts`'s identical
10715
- * per-frame tolerance, see `long-poll-transport.ts`'s own doc comment on
10716
- * `parseLooseEventsPollResponse`) but which still carried a numeric,
10707
+ * unrecognized message type (see `long-poll-transport.ts`'s own doc
10708
+ * comment on `parseLooseEventsPollResponse`) but which still carried a numeric,
10717
10709
  * task-class envelope-level `seq` (the caller only invokes this for a
10718
10710
  * `task.`-prefixed type — see `long-poll-transport.ts`'s own
10719
10711
  * `extractSkippableSeq`; `conn.*`-shaped or type-less entries never reach
@@ -10753,7 +10745,7 @@ var ConnectionManager = class {
10753
10745
  * `noteDelivered` (the eager, in-memory watermark) stays UNCHAINED —
10754
10746
  * called immediately, unconditionally, regardless of `stalledAtSeq` —
10755
10747
  * matching `deliver()`'s own eager, unconditional call for a real
10756
- * envelope: its only job is "don't re-pull something already handed off,"
10748
+ * envelope: its only job is "don't re-dispatch something already handed off,"
10757
10749
  * independent of outcome, and that property does not depend on FIFO
10758
10750
  * ordering the way the DURABLE cursor does.
10759
10751
  *
@@ -10773,9 +10765,18 @@ var ConnectionManager = class {
10773
10765
  */
10774
10766
  noteSkippedSeq(seq) {
10775
10767
  this.noteDelivered(seq);
10776
- this.processingChain = this.processingChain.then(() => {
10768
+ this.processingChain = this.processingChain.then(async () => {
10777
10769
  if (this.stalledAtSeq === void 0 || seq === this.stalledAtSeq) {
10778
- this.advanceCursor(seq);
10770
+ try {
10771
+ await this.advanceCursor(seq);
10772
+ if (this.stalledAtSeq === seq) this.stalledAtSeq = void 0;
10773
+ } catch (err) {
10774
+ if (this.stalledAtSeq === void 0) this.stalledAtSeq = seq;
10775
+ console.error(
10776
+ `[byok/client] unknown task seq=${seq} could not durably persist its cursor; cursor left unadvanced for redelivery:`,
10777
+ err
10778
+ );
10779
+ }
10779
10780
  }
10780
10781
  });
10781
10782
  }
@@ -10821,113 +10822,52 @@ var ConnectionManager = class {
10821
10822
  if (this.stalledAtSeq === void 0) this.stalledAtSeq = seq;
10822
10823
  });
10823
10824
  }
10824
- advanceCursor(seq) {
10825
+ async advanceCursor(seq) {
10825
10826
  if (this.cursor !== void 0 && seq <= this.cursor) return;
10827
+ const save = this.pendingCursorSave.catch(() => void 0).then(() => this.opts.cursorStore.save(this.opts.serverUrl, this.opts.deviceId, seq));
10828
+ this.pendingCursorSave = save;
10829
+ await save;
10826
10830
  this.cursor = seq;
10827
- this.pendingCursorSave = this.pendingCursorSave.catch(() => {
10828
- }).then(() => this.opts.cursorStore.save(this.opts.serverUrl, this.opts.deviceId, seq)).catch(() => {
10829
- });
10830
- }
10831
- /**
10832
- * Fires the moment a connection attempt reaches `conn.ack` — independent
10833
- * of whether/when it later closes. This is the ONLY place that can
10834
- * reliably detect "WS is back up" while long-polling: a healthy
10835
- * connection stays open indefinitely, so it never reaches `onWsOutcome`
10836
- * (which is close-only) at all.
10837
- */
10838
- onAcked(capabilities) {
10839
- if (this.terminalError) return;
10840
- this.serverCapabilities = capabilities;
10841
- this.consecutiveFailures = 0;
10842
- this.notifySettled();
10843
- this.opts.onOperationalOutcome?.("success", "reconnect");
10844
- if (this.mode === "long-poll") this.exitLongPoll();
10845
- void this.drainOutbox();
10846
10831
  }
10847
- onWsOutcome(acked, err) {
10848
- if (err instanceof ReplayCursorTooOldError) {
10849
- this.enterReplayCursorTooOld(err);
10850
- return;
10851
- }
10852
- if (this.stopped || this.revoked) return;
10853
- if (acked) this.serverCapabilities = [];
10854
- if (err instanceof WsUnexpectedStatusError && err.status === 401) {
10855
- this.opts.auth.handleUnauthorized().catch((renewErr) => {
10856
- if (renewErr instanceof DeviceRevokedError) this.enterRevoked();
10857
- });
10858
- }
10859
- if (acked) return;
10860
- this.opts.onOperationalOutcome?.("failure", "reconnect");
10861
- this.consecutiveFailures += 1;
10862
- if (this.mode === "ws" && this.consecutiveFailures >= (this.opts.wsFailureThreshold ?? 3)) {
10863
- this.enterLongPoll();
10864
- }
10832
+ quarantineRejectedOutbound(envelope, reason) {
10833
+ if (this.rejectedOutboundEnvelopes.length === MAX_REJECTED_OUTBOX_ENTRIES) this.rejectedOutboundEnvelopes.shift();
10834
+ this.rejectedOutboundEnvelopes.push({ envelope, reason });
10835
+ console.error(
10836
+ `[byok/client] outbound envelope ${envelope.id} was permanently rejected (${reason}) and moved to the terminal quarantine.`
10837
+ );
10865
10838
  }
10866
10839
  notifySettled(err) {
10867
10840
  for (const waiter of this.settledWaiters.splice(0)) waiter(err);
10868
10841
  }
10869
- enterLongPoll() {
10870
- this.mode = "long-poll";
10871
- this.serverCapabilities = [];
10872
- this.ws.stopAutoReconnect();
10873
- this.opts.onStateChange?.("degraded");
10874
- this.longPoll.start();
10875
- this.outbox.unshift(createConnectionHelloEnvelope({
10876
- deviceId: this.opts.deviceId,
10877
- productId: this.opts.productId,
10878
- capabilities: this.opts.capabilities,
10879
- clientVersion: this.opts.clientVersion,
10880
- runtimes: this.opts.runtimes,
10881
- getConfiguredToolsets: this.opts.getConfiguredToolsets,
10882
- getCursor: () => this.cursor
10883
- }));
10842
+ noteConnected() {
10843
+ if (this.connected || this.stopped || this.revoked || this.terminalError) return;
10844
+ this.connected = true;
10845
+ this.opts.onStateChange?.("open");
10884
10846
  this.notifySettled();
10885
- void this.drainOutbox();
10886
- this.scheduleWsProbe();
10847
+ }
10848
+ noteDisconnected() {
10849
+ if (!this.connected || this.stopped || this.revoked || this.terminalError) return;
10850
+ this.connected = false;
10851
+ this.opts.onStateChange?.("connecting");
10887
10852
  }
10888
10853
  enterReplayCursorTooOld(error) {
10889
10854
  if (this.terminalError) return;
10890
10855
  this.terminalError = error;
10891
10856
  this.stopped = true;
10892
10857
  this.serverCapabilities = [];
10893
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
10894
10858
  this.longPoll.stop();
10895
- this.ws.stopAutoReconnect();
10896
- this.ws.close();
10859
+ this.connected = false;
10897
10860
  this.cancelPendingDrainRetry?.();
10898
10861
  this.notifySettled(error);
10899
10862
  this.opts.onStateChange?.("closed");
10900
10863
  this.opts.onTerminalError?.(error);
10901
10864
  }
10902
- exitLongPoll() {
10903
- if (this.wsRetryTimer) {
10904
- clearInterval(this.wsRetryTimer);
10905
- this.wsRetryTimer = void 0;
10906
- }
10907
- this.longPoll.stop();
10908
- this.mode = "ws";
10909
- this.consecutiveFailures = 0;
10910
- this.ws.resumeAutoReconnect();
10911
- void this.drainOutbox();
10912
- }
10913
- scheduleWsProbe() {
10914
- const baseMs = this.opts.wsRetryIntervalMs ?? 5 * 60 * 1e3;
10915
- const delayMs = this.fleetJitter.delay("reconnect", this.wsProbeSequence++, baseMs);
10916
- const timer = setTimeout(() => {
10917
- this.wsRetryTimer = void 0;
10918
- if (this.stopped || this.revoked || this.mode !== "long-poll") return;
10919
- this.ws.connect({ auto: false });
10920
- this.scheduleWsProbe();
10921
- }, delayMs);
10922
- timer.unref?.();
10923
- this.wsRetryTimer = timer;
10924
- }
10925
10865
  enterRevoked() {
10926
10866
  if (this.revoked) return;
10927
10867
  this.revoked = true;
10928
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
10868
+ this.serverCapabilities = [];
10929
10869
  this.longPoll.stop();
10930
- this.ws.close();
10870
+ this.connected = false;
10931
10871
  this.cancelPendingDrainRetry?.();
10932
10872
  this.opts.onStateChange?.("revoked");
10933
10873
  this.notifySettled(new DeviceRevokedError());
@@ -14261,6 +14201,7 @@ function resultDocumentRejectionDetail(check) {
14261
14201
  }
14262
14202
  }
14263
14203
  var DEFAULT_MAX_TASK_OUTPUT_BYTES = 64 * 1024 * 1024;
14204
+ var DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME = 1;
14264
14205
  function isKnownRuntimeId(id) {
14265
14206
  return RuntimeIdSchema.safeParse(id).success;
14266
14207
  }
@@ -14507,6 +14448,10 @@ var TaskRunner = class {
14507
14448
  get maxTaskOutputBytes() {
14508
14449
  return this.deps.maxTaskOutputBytes ?? DEFAULT_MAX_TASK_OUTPUT_BYTES;
14509
14450
  }
14451
+ /** WP0: effective per-canonical-Agent-home Attempt cap — see {@link DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME}. */
14452
+ get maxConcurrentMutableSessionsPerAgentHome() {
14453
+ return this.deps.maxConcurrentMutableSessionsPerAgentHome ?? DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME;
14454
+ }
14510
14455
  /**
14511
14456
  * M4 Phase 4 (part B.3, observability): per-active-task queue watermarks
14512
14457
  * for the control socket's `status` result — see
@@ -14896,6 +14841,24 @@ var TaskRunner = class {
14896
14841
  decline("strict Agent-only daemon refuses legacy task offers", false);
14897
14842
  return;
14898
14843
  }
14844
+ if (agentRef !== void 0) {
14845
+ const limit = this.maxConcurrentMutableSessionsPerAgentHome;
14846
+ let canonicalHome;
14847
+ try {
14848
+ canonicalHome = await this.deps.agentHome.layout.canonicalHomePath(agentRef);
14849
+ } catch (error) {
14850
+ decline(
14851
+ `Agent home admission failed: ${errorMessage6(error)}`,
14852
+ !(error instanceof AgentHomeResolutionError)
14853
+ );
14854
+ return;
14855
+ }
14856
+ const active2 = this.deps.agentHome.executionLeaseManager.activeAttemptCount(canonicalHome);
14857
+ if (active2 >= limit) {
14858
+ decline(`agent home busy: ${active2} active attempt(s)`, true);
14859
+ return;
14860
+ }
14861
+ }
14899
14862
  const guarded = this.deps.admissionGuard?.({ taskId, payload });
14900
14863
  if (guarded !== void 0 && !guarded.admit) {
14901
14864
  decline(guarded.reason, guarded.retryable);
@@ -18607,7 +18570,7 @@ async function detectRuntimes(adapters) {
18607
18570
  }
18608
18571
  return runtimes;
18609
18572
  }
18610
- function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentOnly = false, agentHomeProjectionConfigured = false, agentEgressConfigured = false, contentReadPolicies) {
18573
+ function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentOnly = false, agentHomeProjectionConfigured = false, agentEgressConfigured = false, contentReadPolicies, providerProfileBindingConfigured = false) {
18611
18574
  const flags = [];
18612
18575
  if (adapters.some((adapter) => adapter.descriptor.capabilities.steer)) flags.push("steer");
18613
18576
  flags.push("blob-upload");
@@ -18619,6 +18582,9 @@ function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentO
18619
18582
  if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.descriptor.supportsDispatchSelection === true)) {
18620
18583
  flags.push("dispatch-selection");
18621
18584
  }
18585
+ if (providerProfileBindingConfigured && adapters.some((adapter) => adapter.descriptor.id === "pi" && adapter.descriptor.supportsDispatchSelection)) {
18586
+ flags.push(PROVIDER_PROFILE_BINDING_CAPABILITY);
18587
+ }
18622
18588
  if (adapters.some((adapter) => adapter.descriptor.capabilities.mcpToolsets === true)) {
18623
18589
  flags.push("toolset-selection");
18624
18590
  }
@@ -18781,6 +18747,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18781
18747
  if (config.strictAgentOnly === true && config.agentHome === void 0) {
18782
18748
  throw new Error("DaemonConfig.strictAgentOnly requires DaemonConfig.agentHome");
18783
18749
  }
18750
+ if (config.maxConcurrentMutableSessionsPerAgentHome !== void 0 && !(Number.isSafeInteger(config.maxConcurrentMutableSessionsPerAgentHome) && config.maxConcurrentMutableSessionsPerAgentHome > 0)) {
18751
+ throw new Error(
18752
+ `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".`
18753
+ );
18754
+ }
18755
+ const agentHomeAttemptLimit = config.maxConcurrentMutableSessionsPerAgentHome ?? DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME;
18784
18756
  const egressPolicy = resolveAgentEgressPolicy(config.agentEgress?.policy);
18785
18757
  const egressBatcherOptions = egressPolicy.activity.mode === "contentful-trajectory" ? {
18786
18758
  ...config.progressBatch,
@@ -19101,7 +19073,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19101
19073
  config.strictAgentOnly === true,
19102
19074
  agentHomeManager?.supportsTaskFreeProjection() === true,
19103
19075
  config.agentEgress !== void 0,
19104
- agentContentReadPolicies
19076
+ agentContentReadPolicies,
19077
+ config.piByokLauncher !== void 0
19105
19078
  );
19106
19079
  const agentHomeProjectionCompletion = agentHomeManager?.supportsTaskFreeProjection() === true ? new AgentHomeProjectionCompletionClient({
19107
19080
  serverUrl: config.serverUrl,
@@ -19160,6 +19133,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19160
19133
  workspaceRoot: config.workspaceRoot,
19161
19134
  ...agentHomeManager === void 0 ? {} : { agentHome: agentHomeManager },
19162
19135
  ...config.strictAgentOnly === true ? { strictAgentOnly: true } : {},
19136
+ // WP0: already validated up front — see `DaemonConfig.maxConcurrentMutableSessionsPerAgentHome`.
19137
+ maxConcurrentMutableSessionsPerAgentHome: agentHomeAttemptLimit,
19163
19138
  ...agentSessionHandoffs === void 0 ? {} : { agentSessionHandoffs },
19164
19139
  deviceId: record3.deviceId,
19165
19140
  // M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment above.
@@ -19444,16 +19419,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19444
19419
  return runner?.handleEnvelope(envelope) ?? Promise.resolve();
19445
19420
  },
19446
19421
  onStateChange: (state) => {
19447
- const wasSettled = connectionState === "open" || connectionState === "degraded";
19422
+ const wasSettled = connectionState === "open";
19448
19423
  connectionState = state;
19449
19424
  observer.noteConnectionState(state);
19450
- if (!wasSettled && (state === "open" || state === "degraded")) runner?.retryRecoveredAgentMessages();
19451
- if (!wasSettled && (state === "open" || state === "degraded")) runPresenceDiscovery();
19425
+ if (!wasSettled && state === "open") runner?.retryRecoveredAgentMessages();
19426
+ if (!wasSettled && state === "open") runPresenceDiscovery();
19452
19427
  },
19453
- backoff: overrides.backoff,
19454
- liveness: overrides.liveness,
19455
- wsFailureThreshold: overrides.longPoll?.wsFailureThreshold,
19456
- wsRetryIntervalMs: overrides.longPoll?.wsRetryIntervalMs,
19457
19428
  longPollRetryDelayMs: overrides.longPoll?.retryDelayMs,
19458
19429
  longPollIdleDelayMs: overrides.longPoll?.idleDelayMs,
19459
19430
  fleetJitter,
@@ -19465,7 +19436,7 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19465
19436
  }
19466
19437
  });
19467
19438
  await connection.start();
19468
- await connection.waitForAck();
19439
+ await connection.waitForConnection();
19469
19440
  runner.retryRecoveredAgentMessages();
19470
19441
  for (const record4 of agentEgress.retryableReliableRecords(connection.getServerCapabilities())) {
19471
19442
  dispatchReliableRecord(record4);
@@ -19724,7 +19695,9 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19724
19695
  // fine.
19725
19696
  ...storageStatus === void 0 ? {} : { storage: storageStatus },
19726
19697
  operationalHealth: operationalHealth.snapshot(),
19727
- toolsets: toolsetRegistry.status()
19698
+ toolsets: toolsetRegistry.status(),
19699
+ // WP0: same counts `Daemon.status()` reports, from the same reader.
19700
+ agentHomeExecution: agentHomeExecutionStatus()
19728
19701
  };
19729
19702
  }
19730
19703
  async function performControlShutdown(reason) {
@@ -19959,9 +19932,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19959
19932
  const minted = mintDeviceAssertion({
19960
19933
  record: record3,
19961
19934
  // `toHttpBase` is the one place a configured serverUrl is normalized
19962
- // (ws:->http:, wss:->https:, path stripped), so an operator who
19963
- // configured the websocket spelling and one who configured the HTTP
19964
- // spelling of the same deployment produce the same issuer.
19935
+ // (path stripped), so the issuer ignores an operator-provided path
19936
+ // or query.
19965
19937
  issuer: new URL(toHttpBase(config.serverUrl)).origin,
19966
19938
  productId: config.productId,
19967
19939
  audience: parsed.audience,
@@ -20076,23 +20048,33 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
20076
20048
  await binding.lease.release();
20077
20049
  }
20078
20050
  }
20051
+ function agentHomeExecutionStatus() {
20052
+ const summary = agentHomeManager?.executionLeaseManager.activeAttemptSummary();
20053
+ return {
20054
+ maxConcurrentMutableSessionsPerAgentHome: agentHomeAttemptLimit,
20055
+ activeHomes: summary?.homes ?? 0,
20056
+ activeAttempts: summary?.attempts ?? 0
20057
+ };
20058
+ }
20079
20059
  function status() {
20080
20060
  return {
20081
20061
  localAgentRelease,
20082
20062
  paired: auth.deviceId !== void 0,
20083
20063
  connected: connectionState === "open",
20084
- degraded: connection?.isTransportDegraded() ?? false,
20085
20064
  revoked: connection?.isRevoked() ?? auth.isRevoked(),
20086
20065
  deviceId: auth.deviceId,
20087
20066
  activeTaskCount: runner?.activeTaskCount ?? 0,
20088
20067
  branding: config.branding,
20089
20068
  operationalHealth: operationalHealth.snapshot(),
20090
20069
  toolsets: toolsetRegistry.status(),
20091
- egress: agentEgress.status()
20070
+ egress: agentEgress.status(),
20071
+ agentHomeExecution: agentHomeExecutionStatus()
20092
20072
  };
20093
20073
  }
20094
20074
  function reloadMcpToolsets(mcpToolsets, expectedRevision) {
20095
- return toolsetRegistry.reload(mcpToolsets, expectedRevision);
20075
+ const receipt = toolsetRegistry.reload(mcpToolsets, expectedRevision);
20076
+ if (daemonStarted) connection?.refreshHello();
20077
+ return receipt;
20096
20078
  }
20097
20079
  function reportMcpToolsetObservation(toolsetId, expectedDefinitionRevision, observation) {
20098
20080
  toolsetRegistry.report(toolsetId, expectedDefinitionRevision, observation);