@byok-sdk/client 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +93 -0
  2. package/dist/adapters/claude/process-client.d.ts +24 -0
  3. package/dist/adapters/codex/process-runner.d.ts +46 -1
  4. package/dist/adapters/index.js +433 -33
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/adapters/pi/events.d.ts +1 -1
  7. package/dist/adapters/pi/pi-adapter.d.ts +3 -0
  8. package/dist/adapters/pi/rpc-client.d.ts +47 -1
  9. package/dist/adapters/pi/subagents-policy-extension.js +1 -1
  10. package/dist/adapters/pi/subagents-policy-extension.js.map +1 -1
  11. package/dist/adapters/pi/team-interaction-extension.d.ts +24 -0
  12. package/dist/adapters/pi/team-interaction-extension.js +81 -0
  13. package/dist/adapters/pi/team-interaction-extension.js.map +1 -0
  14. package/dist/adapters/process-tree.d.ts +74 -4
  15. package/dist/adapters/provider-credential-environment.d.ts +1 -1
  16. package/dist/adapters/win32-job-object.d.ts +101 -0
  17. package/dist/agent-home.d.ts +74 -0
  18. package/dist/agent-memory/index.d.ts +1 -1
  19. package/dist/agent-memory/index.js.map +1 -1
  20. package/dist/bin/byok-agent-memory-mcp.js.map +1 -1
  21. package/dist/bin/byok-agent-message-mcp.js.map +1 -1
  22. package/dist/bin/byok-agent-team-mcp.js.map +1 -1
  23. package/dist/bin/byok-agent.js +9571 -8272
  24. package/dist/bin/byok-agent.js.map +1 -1
  25. package/dist/bin/byok-approval-mcp.js.map +1 -1
  26. package/dist/bin/commands/team-pi-relay.d.ts +24 -0
  27. package/dist/bin/commands/team-relay.d.ts +11 -0
  28. package/dist/bin/team-codex-relay.d.ts +32 -0
  29. package/dist/bin/team-notification-relay.d.ts +40 -0
  30. package/dist/bin/team-pi-session.d.ts +59 -0
  31. package/dist/daemon/agent-egress-policy.d.ts +8 -0
  32. package/dist/daemon/connection-manager.d.ts +66 -224
  33. package/dist/daemon/control-protocol.d.ts +10 -1
  34. package/dist/daemon/create-daemon.d.ts +58 -13
  35. package/dist/daemon/event-spill.d.ts +90 -0
  36. package/dist/daemon/journal/journal.d.ts +15 -3
  37. package/dist/daemon/journal/sqlite-journal.d.ts +7 -2
  38. package/dist/daemon/long-poll-transport.d.ts +28 -70
  39. package/dist/daemon/observer.d.ts +1 -1
  40. package/dist/daemon/task-runner.d.ts +31 -0
  41. package/dist/daemon/team-workspace.d.ts +9 -0
  42. package/dist/daemon/url.d.ts +10 -12
  43. package/dist/diagnostics/support-bundle.d.ts +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +1334 -696
  46. package/dist/index.js.map +1 -1
  47. package/package.json +9 -8
  48. package/dist/daemon/ws-transport.d.ts +0 -139
package/dist/index.js CHANGED
@@ -2,20 +2,142 @@ 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, isTaskOfferType, 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, decodeEnvelope, createEnvelope, encodeEnvelope, PROTOCOL_VERSION, 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, isServerToDaemonType, 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, 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';
9
- import { execFile, spawn } from 'child_process';
9
+ import { execFile, spawn, spawnSync } from 'child_process';
10
10
  import { fileURLToPath } from 'url';
11
11
  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
- // src/agent-home.ts
17
+ var __defProp = Object.defineProperty;
18
+ var __getOwnPropNames = Object.getOwnPropertyNames;
19
+ var __esm = (fn, res) => function __init() {
20
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
21
+ };
22
+ var __export = (target, all) => {
23
+ for (var name in all)
24
+ __defProp(target, name, { get: all[name], enumerable: true });
25
+ };
26
+
27
+ // src/adapters/win32-job-object.ts
28
+ var win32_job_object_exports = {};
29
+ __export(win32_job_object_exports, {
30
+ Win32JobObjectFailure: () => Win32JobObjectFailure,
31
+ assignOwnedProcessToJob: () => assignOwnedProcessToJob,
32
+ isWin32JobObjectFailure: () => isWin32JobObjectFailure,
33
+ loadBindings: () => loadBindings
34
+ });
35
+ function isWin32JobObjectFailure(value) {
36
+ return value instanceof Error && value.name === "Win32JobObjectFailure";
37
+ }
38
+ function isNullHandle(value) {
39
+ return value === null || value === void 0 || value === 0n || value === 0;
40
+ }
41
+ async function loadBindings() {
42
+ if (cachedBindings !== void 0) return cachedBindings;
43
+ let koffi;
44
+ try {
45
+ const imported = await import('koffi');
46
+ koffi = imported.default ?? imported;
47
+ } catch (cause) {
48
+ throw new Win32JobObjectFailure({
49
+ step: "loadKoffi",
50
+ reason: "win32 runtime process ownership requires the koffi optional dependency, which could not be loaded"
51
+ }, { cause });
52
+ }
53
+ const kernel32 = koffi.load("kernel32.dll");
54
+ const PVOID = koffi.pointer("void");
55
+ const bind = (name, result, args) => kernel32.func("__stdcall", name, result, args);
56
+ cachedBindings = {
57
+ createJobObjectW: bind("CreateJobObjectW", PVOID, [PVOID, "str16"]),
58
+ setInformationJobObject: bind("SetInformationJobObject", "int", [PVOID, "int", PVOID, "uint32"]),
59
+ openProcess: bind("OpenProcess", PVOID, ["uint32", "int", "uint32"]),
60
+ assignProcessToJobObject: bind("AssignProcessToJobObject", "int", [PVOID, PVOID]),
61
+ closeHandle: bind("CloseHandle", "int", [PVOID]),
62
+ getLastError: bind("GetLastError", "uint32", [])
63
+ };
64
+ return cachedBindings;
65
+ }
66
+ function killOnCloseLimitInformation() {
67
+ const information = Buffer.alloc(JOBOBJECT_EXTENDED_LIMIT_SIZE);
68
+ information.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET);
69
+ return information;
70
+ }
71
+ function daemonJob(bindings) {
72
+ const existing = jobsByBindings.get(bindings);
73
+ if (existing !== void 0) return existing;
74
+ const job = bindings.createJobObjectW(null, null);
75
+ if (isNullHandle(job)) {
76
+ throw new Win32JobObjectFailure({
77
+ step: "CreateJobObjectW",
78
+ reason: "win32 runtime process ownership could not create the daemon kill-on-close job object",
79
+ win32Code: bindings.getLastError()
80
+ });
81
+ }
82
+ const information = killOnCloseLimitInformation();
83
+ if (bindings.setInformationJobObject(job, JobObjectExtendedLimitInformation, information, information.length) === 0) {
84
+ const win32Code = bindings.getLastError();
85
+ bindings.closeHandle(job);
86
+ throw new Win32JobObjectFailure({
87
+ step: "SetInformationJobObject",
88
+ reason: "win32 runtime process ownership could not make the daemon job object kill-on-close",
89
+ win32Code
90
+ });
91
+ }
92
+ jobsByBindings.set(bindings, job);
93
+ return job;
94
+ }
95
+ async function assignOwnedProcessToJob(pid, options) {
96
+ const bindings = options?.bindings ?? await loadBindings();
97
+ const job = daemonJob(bindings);
98
+ const processHandle = bindings.openProcess(OWNED_PROCESS_ACCESS, 0, pid);
99
+ if (isNullHandle(processHandle)) {
100
+ throw new Win32JobObjectFailure({
101
+ step: "OpenProcess",
102
+ reason: `win32 runtime process ownership could not open process ${pid} for job assignment`,
103
+ win32Code: bindings.getLastError()
104
+ });
105
+ }
106
+ try {
107
+ if (bindings.assignProcessToJobObject(job, processHandle) === 0) {
108
+ throw new Win32JobObjectFailure({
109
+ step: "AssignProcessToJobObject",
110
+ reason: `win32 runtime process ownership could not assign process ${pid} to the daemon kill-on-close job object`,
111
+ win32Code: bindings.getLastError()
112
+ });
113
+ }
114
+ } finally {
115
+ bindings.closeHandle(processHandle);
116
+ }
117
+ }
118
+ var JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_SIZE, JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET, PROCESS_SET_QUOTA, PROCESS_TERMINATE, OWNED_PROCESS_ACCESS, Win32JobObjectFailure, cachedBindings, jobsByBindings;
119
+ var init_win32_job_object = __esm({
120
+ "src/adapters/win32-job-object.ts"() {
121
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 8192;
122
+ JobObjectExtendedLimitInformation = 9;
123
+ JOBOBJECT_EXTENDED_LIMIT_SIZE = 144;
124
+ JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16;
125
+ PROCESS_SET_QUOTA = 256;
126
+ PROCESS_TERMINATE = 1;
127
+ OWNED_PROCESS_ACCESS = PROCESS_SET_QUOTA | PROCESS_TERMINATE;
128
+ Win32JobObjectFailure = class extends Error {
129
+ step;
130
+ win32Code;
131
+ constructor(input, options) {
132
+ super(input.reason, options);
133
+ this.name = "Win32JobObjectFailure";
134
+ this.step = input.step;
135
+ this.win32Code = input.win32Code;
136
+ }
137
+ };
138
+ jobsByBindings = /* @__PURE__ */ new WeakMap();
139
+ }
140
+ });
19
141
  var tmpSeq = 0;
20
142
  async function atomicWriteFile(filePath, data, options = {}) {
21
143
  const tmpPath = `${filePath}.${process.pid}-${tmpSeq++}.tmp`;
@@ -423,6 +545,23 @@ async function resolveExistingAncestor(inputPath) {
423
545
  }
424
546
  }
425
547
  }
548
+ async function canonicalPath(inputPath) {
549
+ const { canonical: canonical2, tail } = await resolveExistingAncestor(inputPath);
550
+ return path3__default.resolve(canonical2, ...tail);
551
+ }
552
+ async function assertRealDirectoryIfPresent(target) {
553
+ let stat;
554
+ try {
555
+ stat = await promises.lstat(target);
556
+ } catch (error) {
557
+ const code = error.code;
558
+ if (code === "ENOENT" || code === "ENOTDIR") return;
559
+ throw error;
560
+ }
561
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
562
+ throw new AgentHomeResolutionError(`Agent home path component is not a real directory: ${target}`);
563
+ }
564
+ }
426
565
  async function materializeDirectory(inputPath) {
427
566
  const { canonical: canonical2, tail } = await resolveExistingAncestor(inputPath);
428
567
  let cursor = canonical2;
@@ -517,6 +656,39 @@ var AgentHomeLayout = class {
517
656
  await gate.release();
518
657
  }
519
658
  }
659
+ /**
660
+ * Pure canonical-home derivation for read-only callers, such as the
661
+ * pre-admission single-writer count. It validates the AgentRef and joins
662
+ * exactly the same `<hostStorageRoot>/agents/<agentId>` segments
663
+ * {@link AgentHomeLayout.resolve} would, canonicalizing only the components
664
+ * that already exist.
665
+ *
666
+ * It deliberately creates no directory, takes no cross-process mutation
667
+ * gate and records no Agent binding, so an offer the host vetoes after the
668
+ * count leaves nothing behind on disk. `resolve()` stays the only path that
669
+ * may materialize a home or bind it to an Agent identity.
670
+ *
671
+ * An `agents` root or `agents/<agentId>` leaf that already exists but is a
672
+ * symlink (or any non-directory) is rejected here with the same error class
673
+ * and message `resolve()` raises for it, so an in-root `two -> one` link
674
+ * fails closed instead of silently keying the count of `one`. A leaf that
675
+ * does not exist yet is not an error: this derivation runs before the home
676
+ * is materialized. A home canonicalizing outside the `agents` root stays
677
+ * rejected as before.
678
+ */
679
+ async canonicalHomePath(agentRefInput) {
680
+ const agentRef = validateAgentRef(agentRefInput);
681
+ const hostStorageRoot = this.canonicalRoot ?? await canonicalPath(this.hostStorageRootInput);
682
+ const agentsRoot = path3__default.join(hostStorageRoot, AGENT_HOME_DIRECTORY);
683
+ await assertRealDirectoryIfPresent(agentsRoot);
684
+ const lexicalHome = path3__default.join(agentsRoot, agentRef.agentId);
685
+ await assertRealDirectoryIfPresent(lexicalHome);
686
+ const canonicalHome = await canonicalPath(lexicalHome);
687
+ if (canonicalHome === agentsRoot || !isWithin(agentsRoot, canonicalHome)) {
688
+ throw new AgentHomeResolutionError(`Agent home resolves outside the Agent home root: ${canonicalHome}`);
689
+ }
690
+ return canonicalHome;
691
+ }
520
692
  /**
521
693
  * Prove the canonical root is materializable and writable before the daemon
522
694
  * advertises Agent-home capability. No Agent identity or persistent Agent
@@ -833,6 +1005,44 @@ var AgentHomeExecutionLeaseManager = class _AgentHomeExecutionLeaseManager {
833
1005
  });
834
1006
  });
835
1007
  }
1008
+ /**
1009
+ * WP0: Attempts currently holding an execution lease on this exact
1010
+ * canonical home, across every lane and every session. This is the number
1011
+ * the daemon's admission gate reads before any side effect — see
1012
+ * `TaskRunner.handleOffer`'s per-home busy gate.
1013
+ *
1014
+ * Derived from the one lease registry above rather than a second tally, so
1015
+ * it inherits the lease lifecycle exactly: an entry appears at `acquire()`,
1016
+ * survives `bindSession()` (which rekeys in place), and disappears only at
1017
+ * `release()`, which the task runner calls after the attempt is terminal
1018
+ * AND `Session.close()` resolved. A failed disposal never reaches
1019
+ * `release()`, so the slot stays held — fail closed, the same posture as
1020
+ * `runtime-disposal-failed`. Crash residue needs nothing extra here: a
1021
+ * restarted daemon starts with an empty registry and reclaims the on-disk
1022
+ * marker only under the same stable owner identity (`openLeaseMarker`).
1023
+ *
1024
+ * Counted regardless of which lease manager owns the group: the invariant
1025
+ * being protected is the filesystem path (`MEMORY.md`, `notes/`, `.git`),
1026
+ * not the owner identity.
1027
+ */
1028
+ activeAttemptCount(canonicalHome) {
1029
+ return _AgentHomeExecutionLeaseManager.groups.get(canonicalHome)?.leasesByKey.size ?? 0;
1030
+ }
1031
+ /**
1032
+ * Counts-only readback for daemon/control status. Scoped to this manager's
1033
+ * own leases, so the number describes this daemon rather than every home
1034
+ * any manager in the process happens to hold. Never exposes a home path.
1035
+ */
1036
+ activeAttemptSummary() {
1037
+ let homes = 0;
1038
+ let attempts = 0;
1039
+ for (const group of _AgentHomeExecutionLeaseManager.groups.values()) {
1040
+ if (group.manager !== this.manager) continue;
1041
+ homes += 1;
1042
+ attempts += group.leasesByKey.size;
1043
+ }
1044
+ return { homes, attempts };
1045
+ }
836
1046
  async mutate(binding, operation) {
837
1047
  return this.exclusive(binding.resolution.canonicalHome, async () => {
838
1048
  const group = _AgentHomeExecutionLeaseManager.groups.get(binding.resolution.canonicalHome);
@@ -2036,13 +2246,22 @@ function freezeRuntimeAdapterDescriptor(descriptor) {
2036
2246
  });
2037
2247
  }
2038
2248
  function sealRuntimeOperationManifest(manifest) {
2249
+ const dispatchSelection = manifest.dispatchSelection === void 0 ? void 0 : manifest.dispatchSelection.lane === "byok-profile" ? Object.freeze({
2250
+ ...manifest.dispatchSelection,
2251
+ providerProfile: Object.freeze({
2252
+ ...manifest.dispatchSelection.providerProfile,
2253
+ requiredCapabilities: Object.freeze([
2254
+ ...manifest.dispatchSelection.providerProfile.requiredCapabilities
2255
+ ])
2256
+ })
2257
+ }) : Object.freeze({ ...manifest.dispatchSelection });
2039
2258
  return Object.freeze({
2040
2259
  taskId: manifest.taskId,
2041
2260
  runtimeId: manifest.runtimeId,
2042
2261
  descriptor: freezeRuntimeAdapterDescriptor(manifest.descriptor),
2043
2262
  policy: frozenPolicy(manifest.policy),
2044
2263
  requiredToolsetIds: Object.freeze([...manifest.requiredToolsetIds]),
2045
- ...manifest.dispatchSelection === void 0 ? {} : { dispatchSelection: Object.freeze({ ...manifest.dispatchSelection }) },
2264
+ ...dispatchSelection === void 0 ? {} : { dispatchSelection },
2046
2265
  ...manifest.sessionRef === void 0 ? {} : { sessionRef: manifest.sessionRef },
2047
2266
  ...manifest.agentRef === void 0 ? {} : { agentRef: Object.freeze({ agentId: manifest.agentRef.agentId, profileRevision: manifest.agentRef.profileRevision }) },
2048
2267
  cwd: manifest.cwd ?? manifest.workspace.workspaceDir,
@@ -2282,6 +2501,9 @@ function parseTeamMessageReadParams(value) {
2282
2501
  if (!isRecord2(value) || !exactKeys(value, ["context", "afterSeq"]) || !contextString(value.context) || value.afterSeq !== void 0 && !safeInteger(value.afterSeq)) return void 0;
2283
2502
  return { context: value.context, ...value.afterSeq === void 0 ? {} : { afterSeq: value.afterSeq } };
2284
2503
  }
2504
+ function parseTeamNotificationSnapshotParams(value) {
2505
+ return parseTeamMessageReadParams(value);
2506
+ }
2285
2507
  function parseTeamMessageAckParams(value) {
2286
2508
  if (!isRecord2(value) || !exactKeys(value, ["context", "throughSeq"]) || !contextString(value.context) || !safeInteger(value.throughSeq)) return void 0;
2287
2509
  return { context: value.context, throughSeq: value.throughSeq };
@@ -3942,6 +4164,55 @@ function withOwnedProcessTree(options) {
3942
4164
  ...process.platform === "win32" ? { windowsHide: true } : { detached: true }
3943
4165
  };
3944
4166
  }
4167
+ var hostExitTargets = /* @__PURE__ */ new Map();
4168
+ var hostExitHandlerInstalled = false;
4169
+ function terminateOwnedTreesForHostExit() {
4170
+ for (const target of hostExitTargets.values()) {
4171
+ try {
4172
+ if (target.platform === "win32") {
4173
+ target.spawnSyncFn("taskkill", ["/PID", String(target.pid), "/T", "/F"], {
4174
+ stdio: "ignore",
4175
+ windowsHide: true
4176
+ });
4177
+ } else {
4178
+ target.kill(-target.pid, "SIGKILL");
4179
+ }
4180
+ } catch {
4181
+ }
4182
+ }
4183
+ hostExitTargets.clear();
4184
+ }
4185
+ function ensureHostExitHandler() {
4186
+ if (hostExitHandlerInstalled) return;
4187
+ hostExitHandlerInstalled = true;
4188
+ process.prependListener("exit", terminateOwnedTreesForHostExit);
4189
+ }
4190
+ var defaultJobObject = {
4191
+ async assign(pid) {
4192
+ const { assignOwnedProcessToJob: assignOwnedProcessToJob2 } = await Promise.resolve().then(() => (init_win32_job_object(), win32_job_object_exports));
4193
+ await assignOwnedProcessToJob2(pid);
4194
+ }
4195
+ };
4196
+ async function adoptOwnedProcessTree(options) {
4197
+ const pid = positivePid(options.child, options.label);
4198
+ if (pid === void 0) return;
4199
+ const platform = options.platform ?? process.platform;
4200
+ ensureHostExitHandler();
4201
+ hostExitTargets.set(options.child, {
4202
+ pid,
4203
+ label: options.label,
4204
+ platform,
4205
+ kill: options.killFn ?? defaultKill,
4206
+ spawnSyncFn: options.spawnSyncFn ?? spawnSync
4207
+ });
4208
+ const forget = () => {
4209
+ hostExitTargets.delete(options.child);
4210
+ };
4211
+ options.child.once("close", forget);
4212
+ options.child.once("exit", forget);
4213
+ if (platform !== "win32") return;
4214
+ await (options.jobObject ?? defaultJobObject).assign(pid);
4215
+ }
3945
4216
  function positivePid(child, label) {
3946
4217
  const pid = child.pid;
3947
4218
  if (pid === void 0) return void 0;
@@ -4153,21 +4424,8 @@ async function disposeOwnedProcessTree(options) {
4153
4424
  var STDERR_RING_CAPACITY = 20;
4154
4425
  var DIALOG_UI_METHODS = /* @__PURE__ */ new Set(["select", "confirm", "input", "editor"]);
4155
4426
  var PiRpcClient = class {
4156
- child;
4157
- buffer = "";
4158
- nextId = 1;
4159
- pending = /* @__PURE__ */ new Map();
4160
- eventQueue = new AsyncQueue();
4161
- closed = false;
4162
- exitError;
4163
- closedPromise;
4164
- resolveClosed;
4165
- disposalAttempt;
4166
- /** Bounded tail of recent stderr lines — pi discarded this entirely before (nothing ever read `child.stderr`), which is exactly why finding #1 (`Error: Unknown option: --session-id`, exit 1) had to be root-caused by hand instead of reading it off a thrown error. See `buildExitError`. */
4167
- stderrRing = [];
4168
- /** Count of pi RPC message types `PiSession` (pi-adapter.ts) has told us have no `AgentEvent` mapping and aren't routine bookkeeping — see `recordUnmappedFrame`. */
4169
- unmappedFrameCounts = /* @__PURE__ */ new Map();
4170
4427
  constructor(options) {
4428
+ this.options = options;
4171
4429
  const spawnFn = options.spawnFn ?? spawn;
4172
4430
  this.child = spawnFn(options.command, options.args, withOwnedProcessTree({
4173
4431
  cwd: options.cwd,
@@ -4177,6 +4435,9 @@ var PiRpcClient = class {
4177
4435
  this.closedPromise = new Promise((resolve) => {
4178
4436
  this.resolveClosed = resolve;
4179
4437
  });
4438
+ this.adopted = this.adoptOwnedTree(options);
4439
+ this.adopted.catch(() => {
4440
+ });
4180
4441
  this.child.stdout.setEncoding("utf8");
4181
4442
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
4182
4443
  this.child.stderr.setEncoding("utf8");
@@ -4188,10 +4449,37 @@ var PiRpcClient = class {
4188
4449
  this.onClosed(err);
4189
4450
  });
4190
4451
  }
4191
- /** Send a command, resolved with its correlated `response` message. */
4192
- send(command) {
4452
+ options;
4453
+ child;
4454
+ buffer = "";
4455
+ nextId = 1;
4456
+ pending = /* @__PURE__ */ new Map();
4457
+ eventQueue = new AsyncQueue();
4458
+ closed = false;
4459
+ exitError;
4460
+ closedPromise;
4461
+ resolveClosed;
4462
+ disposalAttempt;
4463
+ /** Resolves once this tree is backstopped (see `adoptOwnedProcessTree`); rejects with the adoption failure, having already terminated the tree. */
4464
+ adopted;
4465
+ /** Set before the fail-closed termination starts, so it — not the exit code of the kill we ourselves requested — becomes this client's exit error. */
4466
+ adoptionFailure;
4467
+ /** Bounded tail of recent stderr lines — pi discarded this entirely before (nothing ever read `child.stderr`), which is exactly why finding #1 (`Error: Unknown option: --session-id`, exit 1) had to be root-caused by hand instead of reading it off a thrown error. See `buildExitError`. */
4468
+ stderrRing = [];
4469
+ /** Count of pi RPC message types `PiSession` (pi-adapter.ts) has told us have no `AgentEvent` mapping and aren't routine bookkeeping — see `recordUnmappedFrame`. */
4470
+ unmappedFrameCounts = /* @__PURE__ */ new Map();
4471
+ /**
4472
+ * Send a command, resolved with its correlated `response` message.
4473
+ *
4474
+ * Adoption is awaited FIRST — this is `PiAdapter.start()`'s first awaited
4475
+ * operation, so an unbackstopped tree never receives the initial prompt and
4476
+ * never yields a session. The wait costs one settled-promise turn once the
4477
+ * tree is adopted; every later command sees it already resolved.
4478
+ */
4479
+ async send(command) {
4480
+ await this.adopted;
4193
4481
  if (this.closed) {
4194
- return Promise.reject(this.exitError ?? new Error("pi process is closed"));
4482
+ throw this.exitError ?? new Error("pi process is closed");
4195
4483
  }
4196
4484
  const id = command.id ?? `req-${this.nextId++}`;
4197
4485
  const full = { ...command, id };
@@ -4264,6 +4552,32 @@ var PiRpcClient = class {
4264
4552
  label: "pi"
4265
4553
  };
4266
4554
  }
4555
+ /**
4556
+ * Backstop this tree, or tear it down. Adoption failure is a start-time
4557
+ * precondition, not a degraded mode: the child is terminated through the one
4558
+ * disposal authority and the failure is re-thrown, which is what makes the
4559
+ * first `send()` — and therefore `PiAdapter.start()` — fail before any
4560
+ * session is published. Both cleanup attempts are best-effort because the
4561
+ * adoption failure, not a terminator's own complaint, is the reason to report.
4562
+ */
4563
+ async adoptOwnedTree(options) {
4564
+ try {
4565
+ await adoptOwnedProcessTree({
4566
+ child: this.child,
4567
+ label: "pi",
4568
+ platform: options.platform,
4569
+ jobObject: options.jobObject
4570
+ });
4571
+ } catch (cause) {
4572
+ const failure = cause instanceof Error ? cause : new Error(String(cause));
4573
+ this.adoptionFailure = failure;
4574
+ await requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
4575
+ });
4576
+ await disposeOwnedProcessTree(this.processTreeOptions()).catch(() => {
4577
+ });
4578
+ throw failure;
4579
+ }
4580
+ }
4267
4581
  onData(chunk) {
4268
4582
  this.buffer += chunk;
4269
4583
  let newlineIndex = this.buffer.indexOf("\n");
@@ -4282,6 +4596,7 @@ var PiRpcClient = class {
4282
4596
  } catch {
4283
4597
  return;
4284
4598
  }
4599
+ this.options.onFrame?.(msg);
4285
4600
  if (msg.type === "response" && typeof msg.id === "string" && this.pending.has(msg.id)) {
4286
4601
  const waiter = this.pending.get(msg.id);
4287
4602
  this.pending.delete(msg.id);
@@ -4289,11 +4604,20 @@ var PiRpcClient = class {
4289
4604
  return;
4290
4605
  }
4291
4606
  if (msg.type === "extension_ui_request" && typeof msg.id === "string" && typeof msg.method === "string") {
4292
- this.respondToExtensionUiRequest(msg.id, msg.method);
4607
+ if (this.options.extensionUi?.mode === "hold") this.options.extensionUi.onRequest(msg);
4608
+ else this.respondToExtensionUiRequest(msg.id, msg.method);
4293
4609
  return;
4294
4610
  }
4295
4611
  this.eventQueue.push(msg);
4296
4612
  }
4613
+ /** Write an explicit host-owned response; completion is a transport receipt only. */
4614
+ async respondExtensionUi(response) {
4615
+ if (this.options.extensionUi?.mode !== "hold") throw new Error("Pi interactions are not host-owned");
4616
+ await this.adopted;
4617
+ if (this.closed) throw new Error("Pi process is closed");
4618
+ await new Promise((resolve, reject) => this.child.stdin.write(`${JSON.stringify({ type: "extension_ui_response", ...response })}
4619
+ `, (error) => error ? reject(error) : resolve()));
4620
+ }
4297
4621
  /**
4298
4622
  * Answer pi's extension-UI blocking protocol headlessly (rpc.md's
4299
4623
  * "Extension UI Protocol"). Fail-closed policy, stated explicitly because
@@ -4347,9 +4671,10 @@ var PiRpcClient = class {
4347
4671
  onClosed(err) {
4348
4672
  if (this.closed) return;
4349
4673
  this.closed = true;
4350
- this.exitError = err;
4674
+ const terminal = this.adoptionFailure ?? err;
4675
+ this.exitError = terminal;
4351
4676
  this.resolveClosed();
4352
- for (const [, waiter] of this.pending) waiter.reject(err);
4677
+ for (const [, waiter] of this.pending) waiter.reject(terminal);
4353
4678
  this.pending.clear();
4354
4679
  this.eventQueue.end();
4355
4680
  }
@@ -4383,6 +4708,8 @@ var PROVIDER_CREDENTIAL_ENV_DENY_NAMES = [
4383
4708
  "TOGETHER_API_KEY",
4384
4709
  "BASETEN_API_KEY",
4385
4710
  "KIMI_API_KEY",
4711
+ "HF_TOKEN",
4712
+ "MOONSHOT_API_KEY",
4386
4713
  "MINIMAX_API_KEY",
4387
4714
  "MINIMAX_CN_API_KEY",
4388
4715
  "QWEN_TOKEN_PLAN_API_KEY",
@@ -4464,7 +4791,11 @@ function validatePiByokLauncherConfig(launcher) {
4464
4791
  "--macos-keychain-path",
4465
4792
  "--secret-service-prefix",
4466
4793
  "--provider",
4467
- "--model"
4794
+ "--model",
4795
+ "--profile-revision",
4796
+ "--profile-hash",
4797
+ "--required-capabilities",
4798
+ "--validate-only"
4468
4799
  ]);
4469
4800
  const conflicting = launcher.args?.find((arg) => reserved.has(arg));
4470
4801
  if (conflicting !== void 0) {
@@ -4526,11 +4857,17 @@ var PiAdapter = class {
4526
4857
  const bin = this.resolveBin();
4527
4858
  const extensions = (this.options.resolveExtensions ?? resolvePiExtensions)();
4528
4859
  const selection = input.offer.dispatchSelection;
4529
- const pinnedSelection = selection === void 0 ? void 0 : Object.freeze({ ...selection });
4860
+ const pinnedSelection = selection === void 0 ? void 0 : selection.lane === "byok-profile" ? Object.freeze({
4861
+ ...selection,
4862
+ providerProfile: Object.freeze({
4863
+ ...selection.providerProfile,
4864
+ requiredCapabilities: Object.freeze([...selection.providerProfile.requiredCapabilities])
4865
+ })
4866
+ }) : Object.freeze({ ...selection });
4530
4867
  let command = bin.command;
4531
4868
  let launcherArgs;
4532
4869
  if (pinnedSelection !== void 0) {
4533
- if (pinnedSelection.lane !== "byok" || pinnedSelection.runtimeId !== "pi") {
4870
+ if (pinnedSelection.lane !== "byok" && pinnedSelection.lane !== "byok-profile" || pinnedSelection.runtimeId !== "pi") {
4534
4871
  return { kind: "reject", reason: `pi adapter cannot execute ${pinnedSelection.lane} selection for runtime ${pinnedSelection.runtimeId}`, retryable: false };
4535
4872
  }
4536
4873
  const launcher = this.options.byokLauncher;
@@ -4538,6 +4875,21 @@ var PiAdapter = class {
4538
4875
  return { kind: "reject", reason: "pi BYOK selection requires a configured credential-custody launcher", retryable: false };
4539
4876
  }
4540
4877
  command = launcher.command;
4878
+ const providerProfile = pinnedSelection.lane === "byok-profile" ? pinnedSelection.providerProfile : void 0;
4879
+ if (providerProfile !== void 0) {
4880
+ try {
4881
+ await (this.options.validateProviderProfileBinding ?? validateProviderProfileBindingWithLauncher)(
4882
+ providerProfile,
4883
+ launcher
4884
+ );
4885
+ } catch (error) {
4886
+ return {
4887
+ kind: "reject",
4888
+ reason: `provider profile admission failed: ${errorMessage3(error)}`,
4889
+ retryable: false
4890
+ };
4891
+ }
4892
+ }
4541
4893
  launcherArgs = [
4542
4894
  ...launcher.args ?? [],
4543
4895
  "--pi-bin",
@@ -4549,9 +4901,19 @@ var PiAdapter = class {
4549
4901
  ...launcher.macosKeychainPath !== void 0 ? ["--macos-keychain-path", launcher.macosKeychainPath] : [],
4550
4902
  ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : [],
4551
4903
  "--provider",
4552
- pinnedSelection.providerId,
4904
+ providerProfile?.profileRef ?? (pinnedSelection.lane === "byok" ? pinnedSelection.providerId : ""),
4553
4905
  "--model",
4554
- pinnedSelection.modelId
4906
+ providerProfile?.modelId ?? (pinnedSelection.lane === "byok" ? pinnedSelection.modelId : ""),
4907
+ ...providerProfile === void 0 ? [] : [
4908
+ "--profile-revision",
4909
+ providerProfile.profileRevision,
4910
+ "--profile-hash",
4911
+ providerProfile.profileHash,
4912
+ "--required-capabilities",
4913
+ JSON.stringify(providerProfile.requiredCapabilities),
4914
+ "--validate-only",
4915
+ "false"
4916
+ ]
4555
4917
  ];
4556
4918
  }
4557
4919
  return {
@@ -4694,8 +5056,42 @@ var PiAdapter = class {
4694
5056
  return (this.options.resolveBin ?? resolvePiBin)();
4695
5057
  }
4696
5058
  };
5059
+ async function validateProviderProfileBindingWithLauncher(binding, launcher) {
5060
+ await execFileAsync(launcher.command, [
5061
+ ...launcher.args ?? [],
5062
+ "--pi-bin",
5063
+ process.execPath,
5064
+ "--profile-db",
5065
+ launcher.profileDbPath,
5066
+ "--session-dir",
5067
+ launcher.sessionDir,
5068
+ "--provider",
5069
+ binding.profileRef,
5070
+ "--model",
5071
+ binding.modelId,
5072
+ "--profile-revision",
5073
+ binding.profileRevision,
5074
+ "--profile-hash",
5075
+ binding.profileHash,
5076
+ "--required-capabilities",
5077
+ JSON.stringify(binding.requiredCapabilities),
5078
+ "--validate-only",
5079
+ "true",
5080
+ ...launcher.macosKeychainPath !== void 0 ? ["--macos-keychain-path", launcher.macosKeychainPath] : [],
5081
+ ...launcher.secretServicePrefix ? ["--secret-service-prefix", launcher.secretServicePrefix] : []
5082
+ ], { timeout: DETECT_TIMEOUT_MS });
5083
+ }
4697
5084
  function sameDispatchSelection(left, right) {
4698
5085
  if (left === void 0 || right === void 0) return left === right;
5086
+ if (left.lane === "byok-profile" || right.lane === "byok-profile") {
5087
+ if (left.lane !== "byok-profile" || right.lane !== "byok-profile") return false;
5088
+ if (left.runtimeId !== right.runtimeId) return false;
5089
+ const leftProfile = left.providerProfile;
5090
+ const rightProfile = right.providerProfile;
5091
+ 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(
5092
+ (capability, index) => capability === rightProfile.requiredCapabilities[index]
5093
+ );
5094
+ }
4699
5095
  return left.lane === right.lane && left.runtimeId === right.runtimeId && left.providerId === right.providerId && left.modelId === right.modelId;
4700
5096
  }
4701
5097
  async function resolveAuthoritativeSessionId(rpc) {
@@ -5135,6 +5531,10 @@ var ClaudeProcessClient = class {
5135
5531
  closedPromise;
5136
5532
  resolveClosed;
5137
5533
  disposalAttempt;
5534
+ /** Resolves once this tree is backstopped (see `adoptOwnedProcessTree`); rejects with the adoption failure, having already terminated the tree. */
5535
+ adopted;
5536
+ /** Set before the fail-closed termination starts, so it — not the exit code of the kill we ourselves requested — becomes this client's exit error. */
5537
+ adoptionFailure;
5138
5538
  stderrRing = [];
5139
5539
  unmappedFrameCounts = /* @__PURE__ */ new Map();
5140
5540
  sessionId;
@@ -5149,6 +5549,9 @@ var ClaudeProcessClient = class {
5149
5549
  this.closedPromise = new Promise((resolve) => {
5150
5550
  this.resolveClosed = resolve;
5151
5551
  });
5552
+ this.adopted = this.adoptOwnedTree(options);
5553
+ this.adopted.catch(() => {
5554
+ });
5152
5555
  this.child.stdout.setEncoding("utf8");
5153
5556
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
5154
5557
  this.child.stderr.setEncoding("utf8");
@@ -5192,9 +5595,10 @@ var ClaudeProcessClient = class {
5192
5595
  * same id; if the process already closed before init ever arrived,
5193
5596
  * every call rejects with that same exit error.
5194
5597
  */
5195
- waitForInit() {
5196
- if (this.sessionId !== void 0) return Promise.resolve(this.sessionId);
5197
- if (this.closed) return Promise.reject(this.exitError ?? new Error("claude process is closed"));
5598
+ async waitForInit() {
5599
+ await this.adopted;
5600
+ if (this.sessionId !== void 0) return this.sessionId;
5601
+ if (this.closed) throw this.exitError ?? new Error("claude process is closed");
5198
5602
  return new Promise((resolve, reject) => {
5199
5603
  this.initWaiter = { resolve, reject };
5200
5604
  });
@@ -5258,6 +5662,32 @@ var ClaudeProcessClient = class {
5258
5662
  label: "claude"
5259
5663
  };
5260
5664
  }
5665
+ /**
5666
+ * Backstop this tree, or tear it down. Adoption failure is a start-time
5667
+ * precondition, not a degraded mode: the child is terminated through the one
5668
+ * disposal authority and the failure is re-thrown, which is what makes
5669
+ * `waitForInit()` — and therefore `ClaudeAdapter.start()` — fail before any
5670
+ * session is published. Both cleanup attempts are best-effort because the
5671
+ * adoption failure, not a terminator's own complaint, is the reason to report.
5672
+ */
5673
+ async adoptOwnedTree(options) {
5674
+ try {
5675
+ await adoptOwnedProcessTree({
5676
+ child: this.child,
5677
+ label: "claude",
5678
+ platform: options.platform,
5679
+ jobObject: options.jobObject
5680
+ });
5681
+ } catch (cause) {
5682
+ const failure = cause instanceof Error ? cause : new Error(String(cause));
5683
+ this.adoptionFailure = failure;
5684
+ await requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
5685
+ });
5686
+ await disposeOwnedProcessTree(this.processTreeOptions()).catch(() => {
5687
+ });
5688
+ throw failure;
5689
+ }
5690
+ }
5261
5691
  onData(chunk) {
5262
5692
  this.buffer += chunk;
5263
5693
  let newlineIndex = this.buffer.indexOf("\n");
@@ -5306,9 +5736,10 @@ var ClaudeProcessClient = class {
5306
5736
  onClosed(err) {
5307
5737
  if (this.closed) return;
5308
5738
  this.closed = true;
5309
- this.exitError = err;
5739
+ const terminal = this.adoptionFailure ?? err;
5740
+ this.exitError = terminal;
5310
5741
  this.resolveClosed();
5311
- this.initWaiter?.reject(err);
5742
+ this.initWaiter?.reject(terminal);
5312
5743
  this.initWaiter = void 0;
5313
5744
  this.eventQueue.end();
5314
5745
  }
@@ -6175,6 +6606,19 @@ var CodexProcessRunner = class {
6175
6606
  closedPromise;
6176
6607
  resolveClosed;
6177
6608
  disposalAttempt;
6609
+ /** Resolves once this tree is backstopped (see `adoptOwnedProcessTree`); rejects with the adoption failure, having already terminated the tree. */
6610
+ adopted;
6611
+ /** Set before the fail-closed termination starts; `buildExitError` reports it instead of the exit status of the kill we ourselves requested. */
6612
+ adoptionFailure;
6613
+ adoption = "pending";
6614
+ /**
6615
+ * Lines parsed before adoption settled. Unlike pi and claude, this runner has
6616
+ * no first awaited operation of its own to gate on — its caller reads the
6617
+ * FIRST event as the authoritative thread id. Holding events until the tree
6618
+ * is backstopped is what keeps that caller from publishing a session for a
6619
+ * tree the job object never took.
6620
+ */
6621
+ deferredEvents = [];
6178
6622
  constructor(options) {
6179
6623
  this.onEvent = options.onEvent;
6180
6624
  const spawnFn = options.spawnFn ?? spawn;
@@ -6186,6 +6630,9 @@ var CodexProcessRunner = class {
6186
6630
  this.closedPromise = new Promise((resolve) => {
6187
6631
  this.resolveClosed = resolve;
6188
6632
  });
6633
+ this.adopted = this.adoptOwnedTree(options);
6634
+ this.adopted.catch(() => {
6635
+ });
6189
6636
  this.child.stdout.setEncoding("utf8");
6190
6637
  this.child.stdout.on("data", (chunk) => this.onData(chunk));
6191
6638
  this.child.stderr.setEncoding("utf8");
@@ -6251,8 +6698,58 @@ var CodexProcessRunner = class {
6251
6698
  label: "codex"
6252
6699
  };
6253
6700
  }
6254
- /** Builds a descriptive error folding in the exit code/signal and the stderr tail — mirrors `PiRpcClient.buildExitError`'s reasoning: a post-mortem on a failed start/resume should never need separately re-running codex by hand with a raw JSONL logger to learn why. */
6701
+ /**
6702
+ * Backstop this tree, or tear it down. Adoption failure is a start-time
6703
+ * precondition, not a degraded mode: the child is terminated through the one
6704
+ * disposal authority, every parsed line is dropped instead of delivered, and
6705
+ * the resulting close makes the caller's own `waitClosed()` race reject with
6706
+ * the adoption failure (`buildExitError`) before a thread id is published.
6707
+ * Both cleanup attempts are best-effort because the adoption failure, not a
6708
+ * terminator's own complaint, is the reason to report.
6709
+ */
6710
+ async adoptOwnedTree(options) {
6711
+ try {
6712
+ await adoptOwnedProcessTree({
6713
+ child: this.child,
6714
+ label: "codex",
6715
+ platform: options.platform,
6716
+ jobObject: options.jobObject
6717
+ });
6718
+ } catch (cause) {
6719
+ const failure = cause instanceof Error ? cause : new Error(String(cause));
6720
+ this.adoptionFailure = failure;
6721
+ this.adoption = "failed";
6722
+ this.deferredEvents.length = 0;
6723
+ await requestOwnedProcessTreeTermination(this.processTreeOptions()).catch(() => {
6724
+ });
6725
+ await disposeOwnedProcessTree(this.processTreeOptions()).catch(() => {
6726
+ });
6727
+ throw failure;
6728
+ }
6729
+ this.adoption = "adopted";
6730
+ for (const evt of this.deferredEvents.splice(0)) this.onEvent(evt);
6731
+ }
6732
+ /** Arrival-order delivery, held back until the tree is backstopped (see `deferredEvents`). */
6733
+ deliver(evt) {
6734
+ if (this.adoption === "failed") return;
6735
+ if (this.adoption === "pending") {
6736
+ this.deferredEvents.push(evt);
6737
+ return;
6738
+ }
6739
+ this.onEvent(evt);
6740
+ }
6741
+ /**
6742
+ * Builds a descriptive error folding in the exit code/signal and the stderr
6743
+ * tail — mirrors `PiRpcClient.buildExitError`'s reasoning: a post-mortem on a
6744
+ * failed start/resume should never need separately re-running codex by hand
6745
+ * with a raw JSONL logger to learn why.
6746
+ *
6747
+ * A tree this runner could not backstop is the one exception: that process
6748
+ * exited because THIS runner killed it, so `exit code=null, signal=SIGKILL`
6749
+ * plus an empty stderr tail would bury the only reason anyone can act on.
6750
+ */
6255
6751
  buildExitError(context) {
6752
+ if (this.adoptionFailure !== void 0) return this.adoptionFailure;
6256
6753
  const parts = [`${context} (exit code=${this.exitCode}, signal=${this.exitSignal})`];
6257
6754
  if (this.stderrRing.length > 0) {
6258
6755
  parts.push(`stderr: ${this.stderrRing.join(" | ")}`);
@@ -6280,7 +6777,7 @@ var CodexProcessRunner = class {
6280
6777
  if (!parsed || typeof parsed !== "object" || typeof parsed.type !== "string") {
6281
6778
  return;
6282
6779
  }
6283
- this.onEvent(parsed);
6780
+ this.deliver(parsed);
6284
6781
  }
6285
6782
  onStderr(chunk) {
6286
6783
  for (const rawLine of chunk.split("\n")) {
@@ -7603,19 +8100,14 @@ function signNonce(privateKey, nonce) {
7603
8100
  const signature = sign(null, nonceSigningBytes(nonce), privateKey);
7604
8101
  return signature.toString("base64url");
7605
8102
  }
8103
+
8104
+ // src/daemon/url.ts
7606
8105
  function toHttpBase(serverUrl) {
7607
8106
  const url = new URL(serverUrl);
7608
- if (url.protocol === "ws:") url.protocol = "http:";
7609
- else if (url.protocol === "wss:") url.protocol = "https:";
7610
8107
  url.pathname = "/";
7611
8108
  url.search = "";
7612
8109
  return url.toString();
7613
8110
  }
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
8111
  function describeEndpoint(transport, url) {
7620
8112
  const parsed = typeof url === "string" ? new URL(url) : url;
7621
8113
  return { transport, host: parsed.host, path: parsed.pathname };
@@ -7648,17 +8140,15 @@ function assertServerUrlAllowed(rawUrl, opts = {}) {
7648
8140
  const endpoint = formatServerUrl(url);
7649
8141
  switch (url.protocol) {
7650
8142
  case "https:":
7651
- case "wss:":
7652
8143
  return;
7653
8144
  case "http:":
7654
- case "ws:":
7655
8145
  if (opts.dangerouslyAllowInsecureRemote || isLoopbackHostname(url.hostname)) return;
7656
8146
  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.`
8147
+ `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
8148
  );
7659
8149
  default:
7660
8150
  throw new InsecureServerUrlError(
7661
- `refusing to connect to "${endpoint}" \u2014 unsupported scheme "${url.protocol}" (expected http:, https:, ws:, or wss:).`
8151
+ `refusing to connect to "${endpoint}" \u2014 unsupported scheme "${url.protocol}" (expected http: or https:).`
7662
8152
  );
7663
8153
  }
7664
8154
  }
@@ -9320,6 +9810,28 @@ var LocalTeamWorkspace = class {
9320
9810
  return Object.freeze({ accepted: true, durable: true, workspaceId: state.definition.workspaceId, memberId: input.lease.memberId, seq, messageId: message.messageId, message: publicValue });
9321
9811
  });
9322
9812
  }
9813
+ /** Metadata-only operator notification view. Never advances delivery or acknowledgement. */
9814
+ async notificationSnapshot(input) {
9815
+ assertLeaseShape(input?.lease);
9816
+ if (input.afterSeq !== void 0) assertAfterSeq(input.afterSeq);
9817
+ return this.enqueue(async () => {
9818
+ const state = await this.resolveLease(await this.load(), input.lease);
9819
+ const acknowledgedThroughSeq = state.receipts[input.lease.memberId].acknowledgedThroughSeq;
9820
+ const after = Math.max(input.afterSeq ?? 0, acknowledgedThroughSeq);
9821
+ let latestPeerSeq = null;
9822
+ for (const message of state.messages) {
9823
+ if (message.seq > after && message.senderMemberId !== input.lease.memberId) latestPeerSeq = message.seq;
9824
+ }
9825
+ return Object.freeze({
9826
+ workspaceId: state.definition.workspaceId,
9827
+ memberId: input.lease.memberId,
9828
+ registryRevision: state.definition.revision,
9829
+ expiresAt: input.lease.expiresAt,
9830
+ acknowledgedThroughSeq,
9831
+ latestPeerSeq
9832
+ });
9833
+ });
9834
+ }
9323
9835
  async readMessages(input) {
9324
9836
  assertLeaseShape(input?.lease);
9325
9837
  if (input?.afterSeq !== void 0) assertAfterSeq(input.afterSeq);
@@ -9710,7 +10222,7 @@ var LongPollRouteError = class extends Error {
9710
10222
  };
9711
10223
  var MAX_TRACKED_VALIDATION_FAILURE_WARNINGS = 1e3;
9712
10224
  var MAX_TRACKED_ROUTE_FAILURE_WARNINGS = 1e3;
9713
- function parseLooseEventsPollResponse(raw) {
10225
+ function parseLooseEventsPollResponse(raw, requestedCursor) {
9714
10226
  if (typeof raw !== "object" || raw === null) {
9715
10227
  throw new Error("events poll response is not an object");
9716
10228
  }
@@ -9718,19 +10230,49 @@ function parseLooseEventsPollResponse(raw) {
9718
10230
  if (!Array.isArray(events)) {
9719
10231
  throw new Error("events poll response.events is not an array");
9720
10232
  }
9721
- if (typeof cursor !== "number" || !Number.isInteger(cursor)) {
9722
- throw new Error("events poll response.cursor is not an integer");
10233
+ if (!isSafeNonnegativeInteger(cursor)) {
10234
+ throw new Error("events poll response.cursor is not a safe nonnegative integer");
10235
+ }
10236
+ if (cursor < requestedCursor) {
10237
+ throw new Error("events poll response.cursor regressed below the requested cursor");
9723
10238
  }
9724
10239
  if (capabilities !== void 0 && (!Array.isArray(capabilities) || capabilities.some((flag) => typeof flag !== "string"))) {
9725
10240
  throw new Error("events poll response.capabilities is not an array of strings");
9726
10241
  }
9727
10242
  return { events, cursor, capabilities: capabilities ?? [] };
9728
10243
  }
9729
- function extractSkippableSeq(raw) {
10244
+ function isSafeNonnegativeInteger(value) {
10245
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
10246
+ }
10247
+ function validateTrustedEventsPage(events, requestedCursor, pageCursor) {
10248
+ let previousTaskSeq;
10249
+ for (const raw of events) {
10250
+ if (typeof raw !== "object" || raw === null) throw new Error("events page contains an unidentifiable message");
10251
+ const { type, seq } = raw;
10252
+ if (typeof type !== "string") throw new Error("events page message has no authoritative type");
10253
+ if (type.startsWith("conn.")) continue;
10254
+ if (!isSafeNonnegativeInteger(seq)) {
10255
+ throw new Error("events poll task seq is not a safe nonnegative integer");
10256
+ }
10257
+ if (seq <= requestedCursor || seq > pageCursor) {
10258
+ throw new Error("events poll task seq lies outside the trusted page cursor range");
10259
+ }
10260
+ if (previousTaskSeq !== void 0 && seq <= previousTaskSeq) {
10261
+ throw new Error("events poll task seq is not strictly page ordered");
10262
+ }
10263
+ const knownTaskType = MESSAGE_TYPES.includes(type);
10264
+ const predecessor = previousTaskSeq ?? requestedCursor;
10265
+ if (!knownTaskType && seq !== predecessor + 1) {
10266
+ throw new Error("events poll unknown task seq would cross an untrusted gap");
10267
+ }
10268
+ previousTaskSeq = seq;
10269
+ }
10270
+ }
10271
+ function extractExecutableSeq(raw) {
9730
10272
  if (typeof raw !== "object" || raw === null) return void 0;
9731
10273
  const { type, seq } = raw;
9732
- if (typeof type !== "string" || !type.startsWith("task.")) return void 0;
9733
- return typeof seq === "number" && Number.isInteger(seq) ? seq : void 0;
10274
+ if (typeof type !== "string" || type.startsWith("conn.")) return void 0;
10275
+ return isSafeNonnegativeInteger(seq) ? seq : void 0;
9734
10276
  }
9735
10277
  var LongPollClient = class {
9736
10278
  constructor(opts) {
@@ -9741,6 +10283,8 @@ var LongPollClient = class {
9741
10283
  }
9742
10284
  opts;
9743
10285
  running = false;
10286
+ /** Owns exactly one active loop generation, including its held GET and retry delays. */
10287
+ loopAbortController;
9744
10288
  /**
9745
10289
  * Finding R1: seqs this loop has already `console.warn`'d about for a
9746
10290
  * validation-failed (recognized-type, invalid-payload) entry — a poison
@@ -9800,16 +10344,22 @@ var LongPollClient = class {
9800
10344
  noteRevoked(err) {
9801
10345
  if (!(err instanceof DeviceRevokedError)) return false;
9802
10346
  this.running = false;
10347
+ this.loopAbortController?.abort();
9803
10348
  this.opts.onRevoked?.();
9804
10349
  return true;
9805
10350
  }
9806
10351
  start() {
9807
10352
  if (this.running) return;
9808
10353
  this.running = true;
9809
- void this.loop();
10354
+ const controller = new AbortController();
10355
+ this.loopAbortController = controller;
10356
+ void this.loop(controller.signal).finally(() => {
10357
+ if (this.loopAbortController === controller) this.loopAbortController = void 0;
10358
+ });
9810
10359
  }
9811
10360
  stop() {
9812
10361
  this.running = false;
10362
+ this.loopAbortController?.abort();
9813
10363
  }
9814
10364
  /**
9815
10365
  * POST one batch of envelopes to `/byok/messages` (finding F6/protocol
@@ -9818,14 +10368,14 @@ var LongPollClient = class {
9818
10368
  * (`ConnectionHub.handleInbound`), so a resend of the SAME batch (same
9819
10369
  * envelope `id`s — the caller must never rebuild them) is deduped
9820
10370
  * server-side into a safe no-op rather than reprocessed (§9). Returns
9821
- * `true` once the server has accepted the batch.
10371
+ * validated frozen-v1 counts only after a readable response body.
9822
10372
  */
9823
10373
  async postBatch(envelopes) {
9824
10374
  try {
9825
10375
  await this.opts.auth.getValidAccessToken();
9826
10376
  } catch (err) {
9827
10377
  this.noteRevoked(err);
9828
- return false;
10378
+ return void 0;
9829
10379
  }
9830
10380
  let res;
9831
10381
  try {
@@ -9841,23 +10391,26 @@ var LongPollClient = class {
9841
10391
  );
9842
10392
  } catch (err) {
9843
10393
  if (!this.noteRevoked(err)) this.warnRouteFailure(this.messagesEndpoint, void 0, err);
9844
- return false;
10394
+ return void 0;
9845
10395
  }
9846
10396
  if (!res.ok) {
9847
10397
  this.warnRouteFailure(this.messagesEndpoint, res.status, void 0);
9848
- return false;
10398
+ return void 0;
9849
10399
  }
9850
10400
  try {
9851
- MessagesSendResponseSchema.parse(await res.json());
10401
+ const response = MessagesSendResponseSchema.parse(await res.json());
10402
+ if (response.accepted + (response.rejected ?? 0) !== envelopes.length) {
10403
+ throw new Error("messages response counts do not match the posted batch length");
10404
+ }
10405
+ return response;
9852
10406
  } catch (err) {
9853
10407
  this.warnRouteFailure(this.messagesEndpoint, res.status, err);
9854
- return false;
10408
+ return void 0;
9855
10409
  }
9856
- return true;
9857
10410
  }
9858
- async loop() {
10411
+ async loop(signal) {
9859
10412
  let retryAttempt = 0;
9860
- while (this.running) {
10413
+ while (this.running && !signal.aborted) {
9861
10414
  try {
9862
10415
  await this.opts.auth.getValidAccessToken();
9863
10416
  const base = toHttpBase(this.opts.serverUrl);
@@ -9866,8 +10419,9 @@ var LongPollClient = class {
9866
10419
  if (cursor !== void 0) url.searchParams.set("cursor", String(cursor));
9867
10420
  let res;
9868
10421
  try {
9869
- res = await authedFetch(url, { method: "GET" }, this.opts.auth);
10422
+ res = await authedFetch(url, { method: "GET", signal }, this.opts.auth);
9870
10423
  } catch (err) {
10424
+ if (signal.aborted) return;
9871
10425
  if (!(err instanceof DeviceRevokedError)) {
9872
10426
  this.warnRouteFailure(this.eventsEndpoint, void 0, err);
9873
10427
  }
@@ -9881,69 +10435,63 @@ var LongPollClient = class {
9881
10435
  return;
9882
10436
  }
9883
10437
  this.warnRouteFailure(this.eventsEndpoint, res.status, void 0);
9884
- this.opts.onServerCapabilities?.([]);
10438
+ this.opts.onServerCapabilitiesInvalidated?.();
10439
+ this.opts.onPollFailure?.();
9885
10440
  this.opts.onOperationalOutcome?.("failure");
9886
10441
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9887
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
10442
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9888
10443
  continue;
9889
10444
  }
9890
10445
  let parsed;
9891
10446
  try {
9892
- parsed = parseLooseEventsPollResponse(await res.json());
10447
+ parsed = parseLooseEventsPollResponse(await res.json(), cursor ?? 0);
10448
+ validateTrustedEventsPage(parsed.events, cursor ?? 0, parsed.cursor);
9893
10449
  } catch (err) {
9894
10450
  this.warnRouteFailure(this.eventsEndpoint, res.status, err);
9895
10451
  throw err;
9896
10452
  }
9897
10453
  this.opts.onServerCapabilities?.(parsed.capabilities);
9898
10454
  let hadValidationFailureThisBatch = false;
10455
+ let acceptedAnyEntry = false;
9899
10456
  for (const raw of parsed.events) {
9900
10457
  let envelope;
9901
10458
  try {
9902
10459
  envelope = parseMessage(raw);
9903
10460
  } catch (err) {
9904
- if (err instanceof UnknownMessageTypeError) {
9905
- const skippableSeq = extractSkippableSeq(raw);
9906
- if (skippableSeq !== void 0) this.opts.onSkippedSeq?.(skippableSeq);
9907
- } else {
9908
- const failedSeq = extractSkippableSeq(raw);
9909
- if (failedSeq !== void 0) {
9910
- hadValidationFailureThisBatch = true;
9911
- this.opts.onValidationFailedSeq?.(failedSeq);
9912
- if (!this.warnedValidationFailureSeqs.has(failedSeq)) {
9913
- if (this.warnedValidationFailureSeqs.size > MAX_TRACKED_VALIDATION_FAILURE_WARNINGS) {
9914
- this.warnedValidationFailureSeqs.clear();
9915
- }
9916
- this.warnedValidationFailureSeqs.add(failedSeq);
9917
- console.warn(
9918
- `[byok/client] long-poll: a recognized message type at seq=${failedSeq} failed payload validation \u2014 skipped for this batch, cursor frozen so the server keeps redelivering it until a corrected version arrives:`,
9919
- err
9920
- );
9921
- }
10461
+ const failedSeq = extractExecutableSeq(raw);
10462
+ if (failedSeq !== void 0) {
10463
+ hadValidationFailureThisBatch = true;
10464
+ this.opts.onValidationFailedSeq?.(failedSeq);
10465
+ if (!this.warnedValidationFailureSeqs.has(failedSeq)) {
10466
+ if (this.warnedValidationFailureSeqs.size > MAX_TRACKED_VALIDATION_FAILURE_WARNINGS) this.warnedValidationFailureSeqs.clear();
10467
+ this.warnedValidationFailureSeqs.add(failedSeq);
10468
+ console.warn(`[byok/client] long-poll: unknown or invalid executable message at seq=${failedSeq}; cursor frozen without durable disposition`, err);
9922
10469
  }
9923
10470
  }
9924
10471
  continue;
9925
10472
  }
9926
- this.opts.onEnvelope(envelope);
10473
+ if (this.opts.onEnvelope(envelope) !== false) acceptedAnyEntry = true;
9927
10474
  }
9928
10475
  if (parsed.events.length === 0) {
9929
10476
  retryAttempt = 0;
9930
10477
  this.opts.onOperationalOutcome?.("success");
9931
- await sleep(this.opts.idleDelayMs ?? 250);
9932
- } else if (this.opts.isStalled?.() || hadValidationFailureThisBatch) {
10478
+ await sleep(this.opts.idleDelayMs ?? 250, signal);
10479
+ } else if (this.opts.isStalled?.() || hadValidationFailureThisBatch || !acceptedAnyEntry && this.opts.getCursor() === cursor) {
9933
10480
  this.opts.onOperationalOutcome?.("failure");
9934
10481
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9935
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
10482
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9936
10483
  } else {
9937
10484
  retryAttempt = 0;
9938
10485
  this.opts.onOperationalOutcome?.("success");
9939
10486
  }
9940
10487
  } catch (err) {
9941
- this.opts.onServerCapabilities?.([]);
10488
+ this.opts.onServerCapabilitiesInvalidated?.();
9942
10489
  if (this.noteRevoked(err)) return;
9943
- if (!this.running) return;
10490
+ if (!this.running || signal.aborted) return;
10491
+ this.opts.onPollFailure?.();
9944
10492
  this.opts.onOperationalOutcome?.("failure");
9945
10493
  const baseMs = this.opts.retryDelayMs ?? 2e3;
9946
- await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs);
10494
+ await sleep(this.opts.retryDelayForAttempt?.(retryAttempt++, baseMs) ?? baseMs, signal);
9947
10495
  }
9948
10496
  }
9949
10497
  }
@@ -9963,20 +10511,24 @@ async function parseReplayCursorTooOld(res) {
9963
10511
  }
9964
10512
  return new ReplayCursorTooOldError(recoverableFrom);
9965
10513
  }
9966
- function sleep(ms) {
9967
- return new Promise((resolve) => setTimeout(resolve, ms));
10514
+ function sleep(ms, signal) {
10515
+ if (signal.aborted) return Promise.resolve();
10516
+ return new Promise((resolve) => {
10517
+ const finish = () => {
10518
+ clearTimeout(timer);
10519
+ signal.removeEventListener("abort", finish);
10520
+ resolve();
10521
+ };
10522
+ const timer = setTimeout(finish, ms);
10523
+ signal.addEventListener("abort", finish, { once: true });
10524
+ });
9968
10525
  }
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) {
10526
+
10527
+ // src/daemon/connection-manager.ts
10528
+ function isCursorEnvelopeType(type) {
10529
+ return isServerToDaemonType(type) && !type.startsWith("conn.");
10530
+ }
10531
+ function createConnectionHelloEnvelope(opts, getCursor) {
9980
10532
  const configuredToolsets = opts.getConfiguredToolsets?.();
9981
10533
  return createEnvelope("conn.hello", {
9982
10534
  protocolVersions: [PROTOCOL_VERSION],
@@ -9986,234 +10538,35 @@ function createConnectionHelloEnvelope(opts) {
9986
10538
  clientVersion: opts.clientVersion,
9987
10539
  runtimes: opts.runtimes,
9988
10540
  configuredToolsets: configuredToolsets === void 0 ? void 0 : [...configuredToolsets],
9989
- cursor: opts.getCursor?.()
10541
+ cursor: getCursor()
9990
10542
  });
9991
10543
  }
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
- }
10544
+ var MAX_REJECTED_OUTBOX_ENTRIES = 1e3;
10167
10545
  var ConnectionManager = class {
10168
10546
  constructor(opts) {
10169
10547
  this.opts = opts;
10170
10548
  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
10549
  this.longPoll = new LongPollClient({
10192
10550
  serverUrl: opts.serverUrl,
10193
10551
  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(),
10552
+ // The long-poll query cursor is also the kernel's irreversible ack.
10553
+ // Only report the successfully processed cursor here. `deliveredSeq`
10554
+ // remains a local dedup watermark; using it on the wire would ack an
10555
+ // in-flight envelope before its handler settles and make a later
10556
+ // failure impossible to redeliver.
10557
+ getCursor: () => this.cursor,
10201
10558
  onEnvelope: (envelope) => this.deliver(envelope),
10202
10559
  onServerCapabilities: (capabilities) => {
10203
- if (this.mode === "long-poll") this.serverCapabilities = capabilities;
10560
+ this.serverCapabilities = capabilities;
10561
+ this.noteConnected();
10562
+ },
10563
+ onServerCapabilitiesInvalidated: () => {
10564
+ this.serverCapabilities = [];
10204
10565
  },
10566
+ onPollFailure: () => this.noteDisconnected(),
10205
10567
  onRevoked: () => this.enterRevoked(),
10206
10568
  onReplayCursorTooOld: (error) => this.enterReplayCursorTooOld(error),
10207
- // M4 Phase 4 (version-negotiation drill fix): a batch entry
10208
- // LongPollClient couldn't parse into a known Envelope at all (an
10209
- // unrecognized message type) still needs its cursor/watermark
10210
- // advanced past it, exactly like a successfully-processed envelope
10211
- // would — see `noteSkippedSeq`'s own doc comment.
10212
- onSkippedSeq: (seq) => this.noteSkippedSeq(seq),
10213
- // Finding R1: a batch entry LongPollClient recognized the TYPE of but
10214
- // whose payload failed validation — a genuine delivery failure at
10215
- // that seq, engaged the same way a thrown handler failure is (see
10216
- // `noteValidationFailure`'s own doc comment).
10569
+ // Unknown and malformed executable work use the same ordered stall.
10217
10570
  onValidationFailedSeq: (seq) => this.noteValidationFailure(seq),
10218
10571
  // Finding P2 (Fix 2a): lets the long-poll loop distinguish "this
10219
10572
  // non-empty batch was a stalled backlog re-pull, no cursor progress
@@ -10229,13 +10582,10 @@ var ConnectionManager = class {
10229
10582
  }
10230
10583
  opts;
10231
10584
  fleetJitter;
10232
- ws;
10233
10585
  longPoll;
10234
- mode = "ws";
10235
- consecutiveFailures = 0;
10236
- wsRetryTimer;
10237
- wsProbeSequence = 0;
10238
10586
  uploadRetryAttempt = 0;
10587
+ started = false;
10588
+ connected = false;
10239
10589
  cursor;
10240
10590
  /**
10241
10591
  * Finding F3 (at-most-once redelivery): the lowest `task.*` envelope `seq`
@@ -10257,10 +10607,9 @@ var ConnectionManager = class {
10257
10607
  * (see `advanceCursor`) — that semantics is unchanged. `deliveredSeq`
10258
10608
  * advances eagerly, the instant a `task.*` envelope is admitted past
10259
10609
  * 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
10610
+ * handler has even started, let alone succeeded. It exists so a repeated
10611
+ * read at the durable cursor does not re-dispatch an envelope already in
10612
+ * flight `handleOffer` must not start a second adapter session while a
10264
10613
  * first attempt is still running. On WS this same field is written the
10265
10614
  * same way, but since a live WS connection only ever pushes a given `seq`
10266
10615
  * once, it never has an observable effect there beyond mirroring
@@ -10271,18 +10620,18 @@ var ConnectionManager = class {
10271
10620
  /** Finding F3: serializes `onEnvelope` calls into a per-connection FIFO — one envelope's handler always fully settles before the next one starts. */
10272
10621
  processingChain = Promise.resolve();
10273
10622
  /**
10274
- * Design B (finding N4): the ONE outbound queue both transports drain
10275
- * from — holds `Envelope` OBJECTS, never re-encoded/rebuilt strings, so a
10623
+ * Design B (finding N4): the ONE outbound queue holds `Envelope` OBJECTS,
10624
+ * never re-encoded/rebuilt strings, so a
10276
10625
  * resend after a failed send attempt is byte-identical to the original
10277
10626
  * (same `id`), which is what lets the server's per-(deviceId,id) dedup
10278
10627
  * (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.
10628
+ * application (protocol §9).
10282
10629
  */
10283
10630
  outbox = [];
10631
+ /** Terminally rejected outbound envelopes, retained as a bounded observable quarantine. */
10632
+ rejectedOutboundEnvelopes = [];
10284
10633
  /**
10285
- * Finding F5(b): how many envelopes `drainOutbox`'s long-poll branch has
10634
+ * Finding F5(b): how many envelopes `drainOutbox` has
10286
10635
  * currently spliced OUT of `this.outbox` for an in-flight (not yet
10287
10636
  * confirmed delivered) `postBatch` call — 0 the rest of the time. See
10288
10637
  * `outboxLength`'s own doc comment for why this needs to be tracked
@@ -10335,39 +10684,22 @@ var ConnectionManager = class {
10335
10684
  */
10336
10685
  cancelPendingDrainRetry;
10337
10686
  /**
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.
10687
+ * The capabilities the current server response advertised — untyped
10688
+ * `string[]` for forward compatibility. An advertisement is scoped to the
10689
+ * current long-poll response stream and is cleared after an HTTP failure,
10690
+ * terminal shutdown, or revocation. This keeps capability-gated outbound
10691
+ * messages fail-closed until the current server has explicitly advertised
10692
+ * support.
10365
10693
  */
10366
10694
  serverCapabilities = [];
10367
10695
  async start() {
10368
10696
  if (this.terminalError) throw this.terminalError;
10369
10697
  this.cursor = await this.opts.cursorStore.load(this.opts.serverUrl, this.opts.deviceId);
10370
- this.ws.connect({ auto: true });
10698
+ this.started = true;
10699
+ this.opts.onStateChange?.("connecting");
10700
+ this.longPoll.start();
10701
+ this.outbox.unshift(createConnectionHelloEnvelope(this.opts, () => this.cursor));
10702
+ void this.drainOutbox();
10371
10703
  }
10372
10704
  /**
10373
10705
  * Design B (finding N4): push onto the single shared outbox and try to
@@ -10376,19 +10708,16 @@ var ConnectionManager = class {
10376
10708
  */
10377
10709
  send(envelope) {
10378
10710
  this.outbox.push(envelope);
10711
+ this.opts.onOutboundQueued?.(envelope);
10379
10712
  void this.drainOutbox();
10380
10713
  }
10714
+ /** Publish a fresh local configuration snapshot while this daemon is running. */
10715
+ refreshHello() {
10716
+ if (!this.started || this.stopped || this.revoked || this.terminalError) return;
10717
+ this.send(createConnectionHelloEnvelope(this.opts, () => this.cursor));
10718
+ }
10381
10719
  /**
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
10720
+ * POSTs the outbox through long-poll in chunks of at most
10392
10721
  * `MAX_MESSAGES_PER_BATCH` (finding P1) — the server hard-caps a single
10393
10722
  * `/byok/messages` batch there (`MessagesSendRequestSchema`, protocol
10394
10723
  * §8.2) and 400s the WHOLE request if it's exceeded, which — before this
@@ -10400,43 +10729,58 @@ var ConnectionManager = class {
10400
10729
  * failure that SAME chunk is unshifted back (order-preserving, same
10401
10730
  * Envelope objects/ids — never rebuilt, so a retry is exactly the resend
10402
10731
  * 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
10732
  * Re-entrancy is guarded by `draining`: a call arriving while a drain is
10408
10733
  * 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.
10734
+ * `while (this.outbox.length > 0)` check picks up anything newly pushed.
10411
10735
  */
10412
10736
  async drainOutbox() {
10413
- if (this.draining) return;
10737
+ if (!this.started || this.draining) return;
10414
10738
  this.draining = true;
10739
+ const isolateRejectedBatch = async (batch) => {
10740
+ const pending = [[...batch]];
10741
+ while (pending.length > 0) {
10742
+ if (this.stopped || this.revoked) return pending.flat();
10743
+ const segment = pending.shift();
10744
+ try {
10745
+ this.opts.beforeOutboundPost?.(segment);
10746
+ const result = await this.longPoll.postBatch(segment);
10747
+ if (result === void 0) return [...segment, ...pending.flat()];
10748
+ if ((result.rejected ?? 0) === 0) {
10749
+ await this.opts.onOutboundAccepted?.(segment);
10750
+ continue;
10751
+ }
10752
+ if (segment.length === 1) {
10753
+ await this.opts.onOutboundRejected?.(segment[0]);
10754
+ this.quarantineRejectedOutbound(segment[0], "inbound_rejected");
10755
+ continue;
10756
+ }
10757
+ } catch (error) {
10758
+ console.error("[byok/client] outbound durable disposition failed; retaining exact batch", error);
10759
+ return [...segment, ...pending.flat()];
10760
+ }
10761
+ const midpoint = Math.floor(segment.length / 2);
10762
+ pending.unshift(segment.slice(0, midpoint), segment.slice(midpoint));
10763
+ }
10764
+ return void 0;
10765
+ };
10415
10766
  try {
10416
10767
  while (this.outbox.length > 0) {
10417
10768
  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
10769
  const batch = this.outbox.splice(0, MAX_MESSAGES_PER_BATCH);
10426
10770
  this.inFlightBatchSize = batch.length;
10427
- let ok;
10771
+ let retrySegments;
10428
10772
  try {
10429
- ok = await this.longPoll.postBatch(batch);
10773
+ retrySegments = await isolateRejectedBatch(batch);
10430
10774
  } finally {
10431
10775
  this.inFlightBatchSize = 0;
10432
10776
  }
10433
- if (ok) {
10777
+ if (retrySegments === void 0) {
10434
10778
  this.uploadRetryAttempt = 0;
10435
10779
  this.opts.onOperationalOutcome?.("success", "upload");
10436
10780
  continue;
10437
10781
  }
10438
10782
  this.opts.onOperationalOutcome?.("failure", "upload");
10439
- this.outbox.unshift(...batch);
10783
+ this.outbox.unshift(...retrySegments);
10440
10784
  if (this.stopped || this.revoked) return;
10441
10785
  const baseMs = this.opts.longPollRetryDelayMs ?? 2e3;
10442
10786
  await this.drainRetryDelay(this.fleetJitter.delay("upload", this.uploadRetryAttempt++, baseMs));
@@ -10452,8 +10796,7 @@ var ConnectionManager = class {
10452
10796
  * in-flight wait immediately instead of leaving `drainOutbox` parked here
10453
10797
  * for up to the rest of the delay before it next checks `this.revoked` —
10454
10798
  * 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.
10799
+ * itself while nothing else (such as the live long-poll GET) legitimately is.
10457
10800
  */
10458
10801
  drainRetryDelay(ms) {
10459
10802
  return new Promise((resolve) => {
@@ -10469,14 +10812,9 @@ var ConnectionManager = class {
10469
10812
  };
10470
10813
  });
10471
10814
  }
10472
- isTransportDegraded() {
10473
- return this.mode === "long-poll";
10474
- }
10475
10815
  /**
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.
10816
+ * The capabilities the latest successful `GET /byok/events` response
10817
+ * advertised. Empty before a successful response and after a failed one.
10480
10818
  */
10481
10819
  getServerCapabilities() {
10482
10820
  return this.serverCapabilities;
@@ -10484,29 +10822,23 @@ var ConnectionManager = class {
10484
10822
  getTerminalError() {
10485
10823
  return this.terminalError;
10486
10824
  }
10487
- getMode() {
10488
- return this.mode;
10489
- }
10490
10825
  isConnected() {
10491
- return this.mode === "ws" && this.ws.isOpen;
10826
+ return this.connected;
10492
10827
  }
10493
10828
  isRevoked() {
10494
10829
  return this.revoked;
10495
10830
  }
10496
10831
  /**
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.
10832
+ * Resolves after the first successful long-poll response establishes the
10833
+ * authenticated connection.
10502
10834
  *
10503
10835
  * Rejects with {@link DeviceRevokedError} — instead of hanging until
10504
10836
  * `timeoutMs` — if the device turns out to be revoked while settling (or
10505
10837
  * already was): a cold `daemon.start()` against an already-revoked device
10506
10838
  * must fail fast, not surface a generic timeout (protocol §6.3).
10507
10839
  */
10508
- waitForAck(timeoutMs = 1e4) {
10509
- if (this.ws.isOpen || this.mode === "long-poll") return Promise.resolve();
10840
+ waitForConnection(timeoutMs = 1e4) {
10841
+ if (this.connected) return Promise.resolve();
10510
10842
  if (this.terminalError) return Promise.reject(this.terminalError);
10511
10843
  if (this.revoked) return Promise.reject(new DeviceRevokedError());
10512
10844
  return new Promise((resolve, reject) => {
@@ -10514,7 +10846,7 @@ var ConnectionManager = class {
10514
10846
  };
10515
10847
  const timer = setTimeout(() => {
10516
10848
  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)"));
10849
+ reject(new Error("Timed out waiting for the long-poll connection to settle"));
10518
10850
  }, timeoutMs);
10519
10851
  settle = (err) => {
10520
10852
  clearTimeout(timer);
@@ -10525,7 +10857,7 @@ var ConnectionManager = class {
10525
10857
  });
10526
10858
  }
10527
10859
  /**
10528
- * Stops both transports and waits for every in-flight envelope handler
10860
+ * Stops the long-poll transport and waits for every in-flight envelope handler
10529
10861
  * (the F3 FIFO chain) and the most recent cursor write to actually land on
10530
10862
  * disk — otherwise a `stop()` racing a just-processed envelope's
10531
10863
  * persistence could lose that cursor advance, or leave a handler running
@@ -10534,14 +10866,14 @@ var ConnectionManager = class {
10534
10866
  * Finding F5(b) (cross-model adversarial review): `drainTimeoutMs`, when
10535
10867
  * passed, bounds how long this waits for the shared outbox (`this.outbox`
10536
10868
  * — Design B) to actually finish draining BEFORE flipping `this.stopped`
10537
- * and closing the transports. Before this fix, `stop()` set `stopped`
10869
+ * and stopping the transport. Before this fix, `stop()` set `stopped`
10538
10870
  * synchronously and never waited for `drainOutbox` at all: an envelope
10539
10871
  * `send()` had just pushed moments earlier (e.g. `TaskRunner.shutdownTask`'s
10540
10872
  * own `task.fail`, sent right before `create-daemon.ts`'s
10541
10873
  * `performControlShutdown` calls this) could still be sitting UNSENT in
10542
10874
  * `this.outbox` — mid long-poll retry backoff, or simply not yet picked up
10543
10875
  * by the fire-and-forget `drainOutbox()` `send()` kicked off — and this
10544
- * method would happily proceed to `stopped = true` / `ws.close()` regardless,
10876
+ * method would happily proceed to `stopped = true` regardless,
10545
10877
  * after which NOTHING ever drains it again: silently lost, even though
10546
10878
  * `TaskRunner` believed it had been sent. `drainTimeoutMs` omitted (the
10547
10879
  * default) preserves the EXACT prior behavior for every other existing
@@ -10558,9 +10890,9 @@ var ConnectionManager = class {
10558
10890
  }
10559
10891
  this.stopped = true;
10560
10892
  this.serverCapabilities = [];
10561
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
10562
10893
  this.longPoll.stop();
10563
- this.ws.close();
10894
+ this.connected = false;
10895
+ this.opts.onStateChange?.("closed");
10564
10896
  await this.processingChain;
10565
10897
  await this.pendingCursorSave;
10566
10898
  }
@@ -10585,6 +10917,10 @@ var ConnectionManager = class {
10585
10917
  outboxLength() {
10586
10918
  return this.outbox.length + this.inFlightBatchSize;
10587
10919
  }
10920
+ /** A bounded terminal quarantine for operator inspection; these entries are never retried. */
10921
+ rejectedOutbox() {
10922
+ return this.rejectedOutboundEnvelopes;
10923
+ }
10588
10924
  /**
10589
10925
  * Finding F5(b): polls {@link outboxLength} (not `this.outbox.length`
10590
10926
  * alone — see that method's own doc comment for why a spliced-out,
@@ -10592,15 +10928,14 @@ var ConnectionManager = class {
10592
10928
  * a single `drainOutbox()` promise directly — a drain in progress can
10593
10929
  * itself loop through multiple retry/backoff cycles (`drainRetryDelay`)
10594
10930
  * 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
10931
+ * `drainOutbox()` call can also be triggered concurrently (`send()`)
10932
+ * polling the one thing
10597
10933
  * that actually matters (is anything still undelivered) can never go
10598
10934
  * stale the way capturing one specific in-flight promise reference
10599
10935
  * could. Kicks off one more `drainOutbox()` attempt itself first
10600
10936
  * (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
10937
+ * guard) in case nothing is currently actively retrying, so this bounded
10938
+ * wait isn't just passively hoping something else happens to be making
10604
10939
  * progress.
10605
10940
  */
10606
10941
  waitForOutboxDrained(timeoutMs) {
@@ -10641,21 +10976,27 @@ var ConnectionManager = class {
10641
10976
  * documented idempotent (protocol §9).
10642
10977
  */
10643
10978
  deliver(envelope) {
10979
+ if (!isServerToDaemonType(envelope.type)) {
10980
+ if (typeof envelope.seq === "number") this.noteValidationFailure(envelope.seq);
10981
+ return false;
10982
+ }
10644
10983
  const tracked = isCursorEnvelopeType(envelope.type) && typeof envelope.seq === "number";
10645
10984
  const watermark = this.dedupWatermark();
10646
- if (tracked && watermark !== void 0 && envelope.seq <= watermark) return;
10985
+ if (tracked && watermark !== void 0 && envelope.seq <= watermark) return false;
10647
10986
  if (tracked) {
10648
10987
  const seq = envelope.seq;
10649
- if (this.inFlightSeqs.has(seq) || this.processedSeqs.has(seq)) return;
10988
+ if (this.inFlightSeqs.has(seq) || this.processedSeqs.has(seq)) return false;
10650
10989
  this.inFlightSeqs.add(seq);
10651
10990
  this.noteDelivered(seq);
10652
10991
  }
10653
10992
  this.processingChain = this.processingChain.then(() => this.process(envelope, tracked));
10993
+ return true;
10654
10994
  }
10655
10995
  /**
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
10996
+ * The local watermark `deliver()` dedupes inbound `task.*` envelopes
10997
+ * against. It is deliberately NOT the long-poll query cursor: that query
10998
+ * is the kernel acknowledgement and uses only the successfully processed
10999
+ * `cursor` (see the constructor). Normally this local watermark is
10659
11000
  * `deliveredSeq` — which is always >= `cursor` (every envelope that
10660
11001
  * reaches `advanceCursor` already passed through `noteDelivered` first,
10661
11002
  * see `deliver`) — so this is the literal `max(cursor, deliveredSeq)` the
@@ -10671,11 +11012,9 @@ var ConnectionManager = class {
10671
11012
  * whose outcome wasn't known yet. No separate "reset deliveredSeq on
10672
11013
  * reconnect" step is needed for this to be correct — collapsing to
10673
11014
  * `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.
11015
+ * every long-poll retry path. NOT resetting it unconditionally on every
11016
+ * retry lets `deliveredSeq` keep doing its job of not re-dispatching
11017
+ * something already in flight while a handler is still running.
10679
11018
  */
10680
11019
  dedupWatermark() {
10681
11020
  return this.stalledAtSeq !== void 0 ? this.cursor : this.deliveredSeq ?? this.cursor;
@@ -10693,11 +11032,13 @@ var ConnectionManager = class {
10693
11032
  }
10694
11033
  await this.opts.onEnvelope(envelope);
10695
11034
  if (!tracked) return;
10696
- this.processedSeqs.add(seq);
10697
- if (this.stalledAtSeq !== void 0 && envelope.seq !== this.stalledAtSeq) return;
11035
+ if (this.stalledAtSeq !== void 0 && envelope.seq !== this.stalledAtSeq) {
11036
+ this.processedSeqs.add(seq);
11037
+ return;
11038
+ }
10698
11039
  this.stalledAtSeq = void 0;
11040
+ await this.advanceCursor(envelope.seq);
10699
11041
  this.processedSeqs.clear();
10700
- this.advanceCursor(envelope.seq);
10701
11042
  } catch (err) {
10702
11043
  if (tracked && this.stalledAtSeq === void 0) this.stalledAtSeq = envelope.seq;
10703
11044
  console.error(
@@ -10708,226 +11049,58 @@ var ConnectionManager = class {
10708
11049
  if (tracked) this.inFlightSeqs.delete(seq);
10709
11050
  }
10710
11051
  }
10711
- /**
10712
- * M4 Phase 4 (version-negotiation drill fix): `LongPollClient` calls this
10713
- * 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,
10717
- * task-class envelope-level `seq` (the caller only invokes this for a
10718
- * `task.`-prefixed type — see `long-poll-transport.ts`'s own
10719
- * `extractSkippableSeq`; `conn.*`-shaped or type-less entries never reach
10720
- * here at all, mirroring F2's "conn.* is never cursor-tracked" rule).
10721
- * There is no real `Envelope` to hand to a handler — a genuinely
10722
- * unrecognized type has nothing this build could ever act on.
10723
- *
10724
- * GATEKEEPER-CAUGHT REGRESSION (fixed here): this used to call
10725
- * `advanceCursor(seq)` DIRECTLY, synchronously, the instant a skip was
10726
- * detected in `LongPollClient.loop()`'s per-entry for-loop. That is NOT
10727
- * "instantaneous and race-free" the way the previous version of this
10728
- * comment claimed — the hazard was never the skip racing against itself,
10729
- * it was the skip racing AHEAD of an EARLIER real envelope in the SAME
10730
- * batch that is still in flight on `processingChain` (`deliver()`, above,
10731
- * only ever CHAINS `process()` onto that promise chain — it never awaits
10732
- * it before returning). Concretely, batch `[real seq1, unknown seq2]`:
10733
- * `deliver(seq1)` chains `process(seq1)` but returns immediately without
10734
- * running it; the for-loop then reaches `seq2` and (pre-fix) called
10735
- * `advanceCursor(2)` synchronously, BEFORE `process(seq1)` had even
10736
- * started, let alone failed. If `seq1`'s handler then failed,
10737
- * `stalledAtSeq` became 1 — but the durable cursor was already 2, so
10738
- * `dedupWatermark()` returned 2, and every future redelivery of seq1 was
10739
- * dedup-dropped as "already past the cursor" forever: permanent envelope
10740
- * loss, exactly the F3 bug class the whole `stalledAtSeq`/frozen-watermark
10741
- * mechanism exists to prevent.
10742
- *
10743
- * Fix: the cursor-advancing half is now CHAINED onto `processingChain`
10744
- * too, exactly like `process()`'s own post-handler bookkeeping — so it
10745
- * only ever runs once every earlier envelope already queued ahead of it
10746
- * has fully settled (success or failure), and can observe `stalledAtSeq`'s
10747
- * REAL, up-to-date value rather than whatever it happened to be at the
10748
- * instant the skip was first noticed. The guard mirrors `process()`'s own
10749
- * success-path guard exactly: never advance past a still-unresolved
10750
- * earlier failure, unless (degenerate, cannot really happen for a skip)
10751
- * this exact seq IS the stalled one.
10752
- *
10753
- * `noteDelivered` (the eager, in-memory watermark) stays UNCHAINED —
10754
- * called immediately, unconditionally, regardless of `stalledAtSeq` —
10755
- * matching `deliver()`'s own eager, unconditional call for a real
10756
- * envelope: its only job is "don't re-pull something already handed off,"
10757
- * independent of outcome, and that property does not depend on FIFO
10758
- * ordering the way the DURABLE cursor does.
10759
- *
10760
- * Deliberately NO top-level `dedupWatermark() <= seq` early-return before
10761
- * queuing the chained callback (an earlier draft of this fix had one, and
10762
- * it was itself subtly wrong): `deliveredSeq` can already reflect a seq
10763
- * from the FIRST time it was ever seen, while the DURABLE cursor is still
10764
- * behind it because a stall intervened before that seq's chained
10765
- * advancement ran — a pre-check keyed on `deliveredSeq` would then
10766
- * wrongly treat a LATER redelivery of the same seq (arriving once the
10767
- * stall has since cleared) as "already accounted for" and never queue
10768
- * another attempt, permanently stranding the cursor one seq short. Always
10769
- * queuing is safe and cheap: `advanceCursor`'s own `seq <= this.cursor`
10770
- * guard already makes a genuinely-redundant call a no-op, so there is no
10771
- * correctness reason to short-circuit earlier, only a (here, unnecessary)
10772
- * micro-optimization one.
10773
- */
10774
- noteSkippedSeq(seq) {
10775
- this.noteDelivered(seq);
10776
- this.processingChain = this.processingChain.then(() => {
10777
- if (this.stalledAtSeq === void 0 || seq === this.stalledAtSeq) {
10778
- this.advanceCursor(seq);
10779
- }
10780
- });
10781
- }
10782
- /**
10783
- * Finding R1 (cross-model re-review — was NOT-CLOSED against F1):
10784
- * `LongPollClient` calls this for a batch entry whose `type` it
10785
- * recognized but whose payload failed schema validation
10786
- * ({@link EnvelopeValidationError}) — a genuine delivery failure at that
10787
- * seq, unlike `noteSkippedSeq`'s forward-compat case. Deliberately mirrors
10788
- * `process()`'s own catch block (`if (tracked && this.stalledAtSeq ===
10789
- * undefined) this.stalledAtSeq = envelope.seq;`) as closely as possible:
10790
- * the SAME "only the lowest unresolved failure holds the stall" rule, the
10791
- * SAME resulting freeze of `dedupWatermark()` at the durable cursor
10792
- * (protocol §9 keeps this seq alive), and — because it's the SAME
10793
- * `stalledAtSeq` field `process()`'s own post-success guard already
10794
- * checks — anything ELSE delivered after this seq (same batch or a later
10795
- * one) is automatically held back from advancing the cursor too, with
10796
- * zero changes needed to `process()` itself.
10797
- *
10798
- * Chained onto `processingChain` for exactly the reason `noteSkippedSeq`
10799
- * documents for its own identical chaining (see that method's sibling
10800
- * doc comment on `LongPollClient`, "GATEKEEPER-CAUGHT REGRESSION"): an
10801
- * EARLIER real envelope in the SAME batch may still be in flight on that
10802
- * FIFO chain when this is called (`deliver()` only ever chains
10803
- * `process()` onto it, never awaits before returning) — mutating
10804
- * `stalledAtSeq` synchronously here could race ahead of that still-
10805
- * unresolved earlier envelope. Chaining instead guarantees this only
10806
- * takes effect once every earlier-queued envelope has already settled,
10807
- * and reads `stalledAtSeq`'s real, up-to-date value rather than whatever
10808
- * it happened to be the instant the failure was first noticed.
10809
- *
10810
- * No `noteDelivered` call here (contrast `noteSkippedSeq`, which does
10811
- * call it): a validation-failed entry never becomes a real `Envelope` and
10812
- * never reaches `deliver()`, so it was never "delivered" in the eager
10813
- * in-memory-watermark sense that field tracks — there is nothing for it
10814
- * to eagerly mark. Once a corrected redelivery of this exact seq DOES
10815
- * arrive as a real envelope, it flows through the ordinary `deliver()`
10816
- * path (which calls `noteDelivered` itself) and, on success, clears the
10817
- * stall via `process()`'s own existing logic — no special-casing needed.
10818
- */
11052
+ /** Serialized with handler completion, so invalid work cannot be acked by later success. */
10819
11053
  noteValidationFailure(seq) {
10820
11054
  this.processingChain = this.processingChain.then(() => {
10821
11055
  if (this.stalledAtSeq === void 0) this.stalledAtSeq = seq;
10822
11056
  });
10823
11057
  }
10824
- advanceCursor(seq) {
11058
+ async advanceCursor(seq) {
10825
11059
  if (this.cursor !== void 0 && seq <= this.cursor) return;
11060
+ const save = this.pendingCursorSave.catch(() => void 0).then(() => this.opts.cursorStore.save(this.opts.serverUrl, this.opts.deviceId, seq));
11061
+ this.pendingCursorSave = save;
11062
+ await save;
10826
11063
  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
11064
  }
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
- }
11065
+ quarantineRejectedOutbound(envelope, reason) {
11066
+ if (this.rejectedOutboundEnvelopes.length === MAX_REJECTED_OUTBOX_ENTRIES) this.rejectedOutboundEnvelopes.shift();
11067
+ this.rejectedOutboundEnvelopes.push({ envelope, reason });
11068
+ console.error(
11069
+ `[byok/client] outbound envelope ${envelope.id} was permanently rejected (${reason}) and moved to the terminal quarantine.`
11070
+ );
10865
11071
  }
10866
11072
  notifySettled(err) {
10867
11073
  for (const waiter of this.settledWaiters.splice(0)) waiter(err);
10868
11074
  }
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
- }));
11075
+ noteConnected() {
11076
+ if (this.connected || this.stopped || this.revoked || this.terminalError) return;
11077
+ this.connected = true;
11078
+ this.opts.onStateChange?.("open");
10884
11079
  this.notifySettled();
10885
- void this.drainOutbox();
10886
- this.scheduleWsProbe();
11080
+ }
11081
+ noteDisconnected() {
11082
+ if (!this.connected || this.stopped || this.revoked || this.terminalError) return;
11083
+ this.connected = false;
11084
+ this.opts.onStateChange?.("connecting");
10887
11085
  }
10888
11086
  enterReplayCursorTooOld(error) {
10889
11087
  if (this.terminalError) return;
10890
11088
  this.terminalError = error;
10891
11089
  this.stopped = true;
10892
11090
  this.serverCapabilities = [];
10893
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
10894
11091
  this.longPoll.stop();
10895
- this.ws.stopAutoReconnect();
10896
- this.ws.close();
11092
+ this.connected = false;
10897
11093
  this.cancelPendingDrainRetry?.();
10898
11094
  this.notifySettled(error);
10899
11095
  this.opts.onStateChange?.("closed");
10900
11096
  this.opts.onTerminalError?.(error);
10901
11097
  }
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
11098
  enterRevoked() {
10926
11099
  if (this.revoked) return;
10927
11100
  this.revoked = true;
10928
- if (this.wsRetryTimer) clearInterval(this.wsRetryTimer);
11101
+ this.serverCapabilities = [];
10929
11102
  this.longPoll.stop();
10930
- this.ws.close();
11103
+ this.connected = false;
10931
11104
  this.cancelPendingDrainRetry?.();
10932
11105
  this.opts.onStateChange?.("revoked");
10933
11106
  this.notifySettled(new DeviceRevokedError());
@@ -11263,11 +11436,12 @@ var DaemonObserver = class {
11263
11436
  * where those are actually reported from.
11264
11437
  */
11265
11438
  handleInboundEnvelope(envelope) {
11266
- if (envelope.type !== "task.offer" && envelope.type !== "task.offer_with_toolsets" && envelope.type !== "task.offer_for_agent") return;
11267
- const taskId = envelope.task_id;
11439
+ if (!isTaskOfferType(envelope.type)) return;
11440
+ const offer = envelope;
11441
+ const taskId = offer.task_id;
11268
11442
  if (this.taskInfo.has(taskId)) return;
11269
- this.upsertTask(taskId, { state: "Offered", runtime: envelope.payload.runtime });
11270
- this.emit({ kind: "offered", ts: nowIso(), taskId, runtime: envelope.payload.runtime });
11443
+ this.upsertTask(taskId, { state: "Offered", runtime: offer.payload.runtime });
11444
+ this.emit({ kind: "offered", ts: nowIso(), taskId, runtime: offer.payload.runtime });
11271
11445
  }
11272
11446
  /**
11273
11447
  * Feed a raw OUTBOUND (daemon -> server) envelope — this is where every
@@ -11796,6 +11970,7 @@ CREATE INDEX IF NOT EXISTS journal_transition_task ON journal_transition (task_i
11796
11970
  CREATE TABLE IF NOT EXISTS journal_terminal (
11797
11971
  task_id TEXT PRIMARY KEY REFERENCES journal_task (task_id),
11798
11972
  terminal_type TEXT NOT NULL,
11973
+ bytes TEXT NOT NULL,
11799
11974
  payload_hash TEXT NOT NULL,
11800
11975
  truth_state TEXT NOT NULL,
11801
11976
  attempt INTEGER NOT NULL,
@@ -11950,6 +12125,11 @@ var SqliteLocalTaskJournal = class {
11950
12125
  const quarantinePath = quarantineDatabase(options.storeDir, this.#dbPath, reason, this.#clock());
11951
12126
  throw new JournalCorruptError(this.#dbPath, quarantinePath, reason, { cause: err });
11952
12127
  }
12128
+ const terminalColumns = db.prepare("PRAGMA table_info(journal_terminal)").all();
12129
+ if (!terminalColumns.some((column) => column.name === "bytes")) {
12130
+ db.close();
12131
+ throw new JournalUnavailableError("hash-only journal format cannot replay terminals; preserve/export this journal and resolve it before starting the new execution-recovery format");
12132
+ }
11953
12133
  this.#db = db;
11954
12134
  try {
11955
12135
  secureJournalFilePermissions(this.#dbPath);
@@ -12039,10 +12219,13 @@ var SqliteLocalTaskJournal = class {
12039
12219
  }
12040
12220
  return this.#enqueue("appendEnvelope", () => {
12041
12221
  this.#fault("append:before-begin");
12042
- return this.#transaction(() => {
12222
+ const committed = this.#transaction(() => {
12043
12223
  const existing = this.#db.prepare("SELECT receipt FROM journal_idempotency WHERE scope = ? AND key = ?").get("envelope", record3.envelopeId);
12044
12224
  if (existing) {
12045
12225
  const prior = JSON.parse(existing.receipt);
12226
+ if (prior.bytesHash !== record3.bytesHash) throw new Error("journal envelope identity collision");
12227
+ const priorIdentity = this.#db.prepare("SELECT tenant_id, product_id, device_id FROM journal_envelope WHERE envelope_id = ?").get(record3.envelopeId);
12228
+ this.#assertIdentity(priorIdentity, record3.identity);
12046
12229
  return { ...prior, created: false };
12047
12230
  }
12048
12231
  const committedAt = this.#clock().toISOString();
@@ -12064,6 +12247,16 @@ var SqliteLocalTaskJournal = class {
12064
12247
  );
12065
12248
  this.#fault("append:after-envelope");
12066
12249
  if (record3.opensTask && record3.taskId !== void 0) {
12250
+ const task = this.#db.prepare("SELECT * FROM journal_task WHERE task_id = ?").get(record3.taskId);
12251
+ if (task) {
12252
+ this.#assertIdentity(task, record3.identity);
12253
+ const original = this.#db.prepare("SELECT bytes FROM journal_envelope WHERE envelope_id = ?").get(task.envelope_id);
12254
+ const oldOffer = JSON.parse(original.bytes);
12255
+ const newOffer = JSON.parse(record3.bytes);
12256
+ if (oldOffer.type !== newOffer.type || JSON.stringify(oldOffer.payload) !== JSON.stringify(newOffer.payload)) {
12257
+ throw new Error("journal task identity reused with a different execution contract");
12258
+ }
12259
+ }
12067
12260
  this.#db.prepare(
12068
12261
  `INSERT OR IGNORE INTO journal_task
12069
12262
  (task_id, envelope_id, tenant_id, product_id, device_id, seq, local_state, created_at, updated_at)
@@ -12092,6 +12285,8 @@ var SqliteLocalTaskJournal = class {
12092
12285
  this.#fault("append:before-commit");
12093
12286
  return { ...receipt, created: true };
12094
12287
  });
12288
+ this.#fault("append:after-commit");
12289
+ return committed;
12095
12290
  });
12096
12291
  }
12097
12292
  recordAdmission(record3) {
@@ -12162,17 +12357,22 @@ var SqliteLocalTaskJournal = class {
12162
12357
  */
12163
12358
  recordTerminal(record3) {
12164
12359
  return this.#enqueue("recordTerminal", () => {
12360
+ if (typeof record3.bytes !== "string" || journalHash(record3.bytes) !== record3.payloadHash) {
12361
+ throw new Error("terminal bytes do not match their canonical hash");
12362
+ }
12363
+ if (byteLength2(record3.bytes) > this.#maxRecordBytes) {
12364
+ throw new JournalRecordTooLargeError("terminal bytes", byteLength2(record3.bytes), this.#maxRecordBytes);
12365
+ }
12165
12366
  this.#transaction(() => {
12166
12367
  this.#requireTask(record3.taskId, "recordTerminal");
12167
- const existing = this.#db.prepare("SELECT payload_hash FROM journal_terminal WHERE task_id = ?").get(record3.taskId);
12168
- if (existing === void 0) {
12169
- this.#db.prepare(
12170
- `INSERT INTO journal_terminal
12171
- (task_id, terminal_type, payload_hash, truth_state, attempt, last_error, recorded_at, updated_at)
12172
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
12173
- ).run(
12368
+ const existing = this.#db.prepare("SELECT * FROM journal_terminal WHERE task_id = ?").get(record3.taskId);
12369
+ if (!existing) {
12370
+ this.#db.prepare(`INSERT INTO journal_terminal
12371
+ (task_id, terminal_type, bytes, payload_hash, truth_state, attempt, last_error, recorded_at, updated_at)
12372
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
12174
12373
  record3.taskId,
12175
12374
  record3.terminalType,
12375
+ record3.bytes,
12176
12376
  record3.payloadHash,
12177
12377
  record3.truthState,
12178
12378
  record3.attempt,
@@ -12181,11 +12381,98 @@ var SqliteLocalTaskJournal = class {
12181
12381
  record3.recordedAt
12182
12382
  );
12183
12383
  this.#db.prepare("UPDATE journal_task SET local_state = ?, updated_at = ? WHERE task_id = ?").run(`terminal:${record3.terminalType}`, record3.recordedAt, record3.taskId);
12184
- } else if (existing.payload_hash === record3.payloadHash) {
12384
+ } else if (existing.payload_hash === record3.payloadHash && existing.bytes === record3.bytes && existing.truth_state !== "confirmed") {
12185
12385
  this.#db.prepare("UPDATE journal_terminal SET truth_state = ?, attempt = ?, last_error = ?, updated_at = ? WHERE task_id = ?").run(record3.truthState, record3.attempt, record3.lastError ?? null, record3.recordedAt, record3.taskId);
12186
12386
  }
12387
+ if (record3.recovery !== void 0 && !existing) {
12388
+ this.#db.prepare("UPDATE journal_task SET recovery_marker = ?, updated_at = ? WHERE task_id = ?").run(JSON.stringify({ ...record3.recovery, occurredAt: record3.recovery.occurredAt ?? record3.recordedAt }), record3.recordedAt, record3.taskId);
12389
+ this.#fault("recovery:before-commit");
12390
+ }
12187
12391
  this.#fault("terminal:before-commit");
12188
12392
  });
12393
+ if (record3.recovery !== void 0) this.#fault("recovery:after-commit");
12394
+ this.#fault("terminal:after-commit");
12395
+ });
12396
+ }
12397
+ #assertIdentity(row, identity) {
12398
+ if (row.tenant_id !== identity.tenantId || row.product_id !== identity.productId || row.device_id !== identity.deviceId) {
12399
+ throw new Error("journal enrollment identity mismatch; refusing cross-enrollment execution or terminal replay");
12400
+ }
12401
+ }
12402
+ #taskRow(row) {
12403
+ if (typeof row.envelope_bytes !== "string" || journalHash(row.envelope_bytes) !== row.envelope_hash) {
12404
+ throw new Error("durable offer bytes/hash mismatch");
12405
+ }
12406
+ return {
12407
+ taskId: row.task_id,
12408
+ envelopeId: row.envelope_id,
12409
+ seq: Number(row.seq),
12410
+ identity: { tenantId: row.tenant_id, productId: row.product_id, deviceId: row.device_id },
12411
+ localState: row.recovery_marker !== null && !String(row.local_state).startsWith("terminal:") ? "interrupted" : row.local_state,
12412
+ ...row.claimed_runtime == null ? {} : { claimedRuntime: row.claimed_runtime },
12413
+ ...row.workspace_ref == null ? {} : { workspaceRef: row.workspace_ref },
12414
+ updatedAt: row.updated_at,
12415
+ envelopeBytes: row.envelope_bytes
12416
+ };
12417
+ }
12418
+ readTask(taskId, identity) {
12419
+ return this.#enqueue("readTask", () => {
12420
+ const row = this.#db.prepare(`SELECT t.*, e.bytes AS envelope_bytes, e.bytes_hash AS envelope_hash FROM journal_task t
12421
+ JOIN journal_envelope e ON e.envelope_id = t.envelope_id WHERE t.task_id = ?`).get(taskId);
12422
+ if (!row) return void 0;
12423
+ this.#assertIdentity(row, identity);
12424
+ return this.#taskRow(row);
12425
+ });
12426
+ }
12427
+ listRecoveryTasks(identity) {
12428
+ return this.#enqueue("listRecoveryTasks", () => {
12429
+ const rows = this.#db.prepare(`SELECT t.*, e.bytes AS envelope_bytes, e.bytes_hash AS envelope_hash FROM journal_task t
12430
+ JOIN journal_envelope e ON e.envelope_id = t.envelope_id
12431
+ LEFT JOIN journal_terminal term ON term.task_id = t.task_id
12432
+ WHERE term.task_id IS NULL ORDER BY t.seq, t.task_id`).all();
12433
+ return rows.map((row) => {
12434
+ this.#assertIdentity(row, identity);
12435
+ return this.#taskRow(row);
12436
+ });
12437
+ });
12438
+ }
12439
+ listPendingTerminals(identity) {
12440
+ return this.#enqueue("listPendingTerminals", () => {
12441
+ const rows = this.#db.prepare(`SELECT term.*, t.tenant_id, t.product_id, t.device_id FROM journal_terminal term
12442
+ JOIN journal_task t ON t.task_id = term.task_id WHERE term.truth_state <> 'confirmed'
12443
+ ORDER BY term.recorded_at, term.task_id`).all();
12444
+ return rows.map((row) => {
12445
+ this.#assertIdentity(row, identity);
12446
+ if (typeof row.bytes !== "string" || journalHash(row.bytes) !== row.payload_hash) throw new Error("durable terminal bytes/hash mismatch");
12447
+ return {
12448
+ taskId: row.task_id,
12449
+ terminalType: row.terminal_type,
12450
+ bytes: row.bytes,
12451
+ payloadHash: row.payload_hash,
12452
+ truthState: row.truth_state,
12453
+ attempt: Number(row.attempt),
12454
+ recordedAt: row.recorded_at,
12455
+ ...row.last_error === null ? {} : { lastError: row.last_error }
12456
+ };
12457
+ });
12458
+ });
12459
+ }
12460
+ confirmTerminal(taskId, payloadHash) {
12461
+ return this.#terminalDisposition(taskId, payloadHash, "confirmed");
12462
+ }
12463
+ rejectTerminal(taskId, payloadHash, reason) {
12464
+ return this.#terminalDisposition(taskId, payloadHash, "failed", reason);
12465
+ }
12466
+ #terminalDisposition(taskId, payloadHash, state, reason) {
12467
+ return this.#enqueue("terminalDisposition", () => {
12468
+ this.#transaction(() => {
12469
+ const row = this.#db.prepare("SELECT payload_hash FROM journal_terminal WHERE task_id = ?").get(taskId);
12470
+ if (!row || row.payload_hash !== payloadHash) throw new Error("terminal acknowledgement does not match durable terminal");
12471
+ this.#db.prepare(`UPDATE journal_terminal SET truth_state = ?, last_error = ?, updated_at = ?
12472
+ WHERE task_id = ? AND truth_state <> 'confirmed'`).run(state, reason ?? null, this.#clock().toISOString(), taskId);
12473
+ this.#fault("confirm:before-commit");
12474
+ });
12475
+ this.#fault("confirm:after-commit");
12189
12476
  });
12190
12477
  }
12191
12478
  /**
@@ -12202,8 +12489,9 @@ var SqliteLocalTaskJournal = class {
12202
12489
  return this.#enqueue("listRecoverable", () => {
12203
12490
  const rows = this.#db.prepare(
12204
12491
  `SELECT t.task_id, t.envelope_id, t.seq, t.tenant_id, t.product_id, t.device_id,
12205
- t.local_state, t.claimed_runtime, t.workspace_ref, t.updated_at
12492
+ t.local_state, t.claimed_runtime, t.workspace_ref, t.updated_at, e.bytes AS envelope_bytes, e.bytes_hash AS envelope_hash
12206
12493
  FROM journal_task t
12494
+ JOIN journal_envelope e ON e.envelope_id = t.envelope_id
12207
12495
  LEFT JOIN journal_terminal term ON term.task_id = t.task_id
12208
12496
  WHERE t.recovery_marker IS NULL
12209
12497
  AND term.task_id IS NULL
@@ -12225,7 +12513,8 @@ var SqliteLocalTaskJournal = class {
12225
12513
  localState: row.local_state,
12226
12514
  ...claimedRuntime === null ? {} : { claimedRuntime },
12227
12515
  ...workspaceRef === null ? {} : { workspaceRef },
12228
- updatedAt: row.updated_at
12516
+ updatedAt: row.updated_at,
12517
+ envelopeBytes: row.envelope_bytes
12229
12518
  };
12230
12519
  });
12231
12520
  });
@@ -12882,6 +13171,204 @@ function createStatfsFreeBytesProvider(dir) {
12882
13171
  return Number(stats.bavail) * Number(stats.bsize);
12883
13172
  };
12884
13173
  }
13174
+ var DEFAULT_MAX_INLINE_EVENT_BYTES = 64 * 1024;
13175
+ var MIN_MAX_INLINE_EVENT_BYTES = 4096;
13176
+ var MAX_UNSTORED_REASON_BYTES = 512;
13177
+ var MAX_SPILL_DESCRIPTOR_BYTES = 768;
13178
+ function byteLength3(value) {
13179
+ return Buffer.byteLength(value, "utf8");
13180
+ }
13181
+ function safeStringify(value) {
13182
+ try {
13183
+ return JSON.stringify(value);
13184
+ } catch {
13185
+ return void 0;
13186
+ }
13187
+ }
13188
+ function utf8Width(codePoint) {
13189
+ if (codePoint < 128) return 1;
13190
+ if (codePoint < 2048) return 2;
13191
+ if (codePoint < 65536) return 3;
13192
+ return 4;
13193
+ }
13194
+ function utf8Prefix(value, maxBytes) {
13195
+ if (maxBytes <= 0) return "";
13196
+ let bytes = 0;
13197
+ let index = 0;
13198
+ while (index < value.length) {
13199
+ const codePoint = value.codePointAt(index);
13200
+ const width = utf8Width(codePoint);
13201
+ if (bytes + width > maxBytes) break;
13202
+ bytes += width;
13203
+ index += codePoint > 65535 ? 2 : 1;
13204
+ }
13205
+ return value.slice(0, index);
13206
+ }
13207
+ function utf8Suffix(value, maxBytes) {
13208
+ if (maxBytes <= 0) return "";
13209
+ let bytes = 0;
13210
+ let index = value.length;
13211
+ while (index > 0) {
13212
+ let step = 1;
13213
+ const last = value.charCodeAt(index - 1);
13214
+ if (last >= 56320 && last <= 57343 && index >= 2) {
13215
+ const previous = value.charCodeAt(index - 2);
13216
+ if (previous >= 55296 && previous <= 56319) step = 2;
13217
+ }
13218
+ const codePoint = value.codePointAt(index - step);
13219
+ const width = utf8Width(codePoint);
13220
+ if (bytes + width > maxBytes) break;
13221
+ bytes += width;
13222
+ index -= step;
13223
+ }
13224
+ return value.slice(index);
13225
+ }
13226
+ function boundReasonText(raw) {
13227
+ const collapsed = raw.replace(/\s+/g, " ").trim();
13228
+ const fallback = "blob upload failed with a non-descriptive error";
13229
+ if (collapsed.length === 0) return fallback;
13230
+ let budget = MAX_UNSTORED_REASON_BYTES;
13231
+ for (; ; ) {
13232
+ const reason = utf8Prefix(collapsed, budget);
13233
+ if (reason.length === 0) return fallback;
13234
+ const encoded = byteLength3(JSON.stringify(reason)) - 2;
13235
+ if (encoded <= MAX_UNSTORED_REASON_BYTES) return reason;
13236
+ budget = Math.min(Math.floor(budget * MAX_UNSTORED_REASON_BYTES / encoded), budget - 1);
13237
+ }
13238
+ }
13239
+ function boundedUnstoredReason(error) {
13240
+ const raw = error instanceof Error && error.message.length > 0 ? `${error.name}: ${error.message}` : String(error);
13241
+ return boundReasonText(raw);
13242
+ }
13243
+ async function spillOversizedEvent(event, deps) {
13244
+ if (event.type !== "tool_use" && event.type !== "tool_result") return event;
13245
+ const serializedEvent = safeStringify(event);
13246
+ if (serializedEvent === void 0) {
13247
+ deps.log?.(
13248
+ `task ${deps.taskId}: ${event.type} event cannot be JSON-serialized (a BigInt, a cycle, or a throwing toJSON in its payload); forwarding it unchanged and unbounded`
13249
+ );
13250
+ return event;
13251
+ }
13252
+ if (byteLength3(serializedEvent) <= deps.maxInlineBytes) return event;
13253
+ const spillable = event;
13254
+ const field = spillable.type === "tool_use" ? "input" : "output";
13255
+ const value = spillable.type === "tool_use" ? spillable.input : spillable.output;
13256
+ if (value === void 0) {
13257
+ deps.log?.(
13258
+ `task ${deps.taskId}: ${spillable.type} event is ${byteLength3(serializedEvent)} bytes, over the ${deps.maxInlineBytes}-byte inline cap, but carries no ${field} to spill; forwarding unchanged`
13259
+ );
13260
+ return event;
13261
+ }
13262
+ const serialized = safeStringify(value);
13263
+ if (serialized === void 0) {
13264
+ deps.log?.(
13265
+ `task ${deps.taskId}: ${spillable.type}.${field} is not JSON-serializable; forwarding the event unchanged`
13266
+ );
13267
+ return event;
13268
+ }
13269
+ const totalBytes = byteLength3(serialized);
13270
+ const skeleton = safeStringify({ ...spillable, [field]: { preview: { head: "", tail: "" } } });
13271
+ if (skeleton === void 0) {
13272
+ deps.log?.(
13273
+ `task ${deps.taskId}: ${spillable.type} event cannot be JSON-serialized once its ${field} is replaced by a preview; forwarding it unchanged and unbounded`
13274
+ );
13275
+ return event;
13276
+ }
13277
+ if (byteLength3(skeleton) + MAX_SPILL_DESCRIPTOR_BYTES > deps.maxInlineBytes) {
13278
+ deps.log?.(
13279
+ `task ${deps.taskId}: ${spillable.type} event exceeds the ${deps.maxInlineBytes}-byte inline cap independently of its ${field} (${byteLength3(skeleton)} bytes without it, plus up to ${MAX_SPILL_DESCRIPTOR_BYTES} bytes of spill descriptor); forwarding unchanged`
13280
+ );
13281
+ return event;
13282
+ }
13283
+ const digest4 = createHash("sha256").update(serialized, "utf8").digest("hex");
13284
+ let stored;
13285
+ let storedBlobId;
13286
+ try {
13287
+ const blob = await deps.blobClient.uploadArtifact(serialized, "application/json", {
13288
+ idempotencyKey: `spill_${deps.taskId}_${digest4}`,
13289
+ ...deps.signal === void 0 ? {} : { signal: deps.signal }
13290
+ });
13291
+ stored = { blob };
13292
+ storedBlobId = blob.blobId;
13293
+ } catch (error) {
13294
+ const unstoredReason = boundedUnstoredReason(error);
13295
+ stored = { unstoredReason };
13296
+ deps.log?.(
13297
+ `task ${deps.taskId}: ${totalBytes}-byte ${spillable.type}.${field} could not be stored, only a preview will reach the wire \u2014 ${unstoredReason}`
13298
+ );
13299
+ }
13300
+ const descriptorFor = (omittedBytes, half) => ({
13301
+ field,
13302
+ totalBytes,
13303
+ omittedBytes,
13304
+ contentType: "application/json",
13305
+ ...half
13306
+ });
13307
+ const bound = (half) => {
13308
+ const overheadJson = safeStringify({
13309
+ ...spillable,
13310
+ [field]: { preview: { head: "", tail: "" } },
13311
+ spill: descriptorFor(totalBytes, half)
13312
+ });
13313
+ if (overheadJson === void 0) return void 0;
13314
+ const overhead = byteLength3(overheadJson);
13315
+ let headBudget = Math.max(0, Math.ceil((deps.maxInlineBytes - overhead) / 2));
13316
+ let tailBudget = Math.max(0, Math.floor((deps.maxInlineBytes - overhead) / 2));
13317
+ for (; ; ) {
13318
+ const head = utf8Prefix(serialized, headBudget);
13319
+ const tail = utf8Suffix(serialized.slice(head.length), tailBudget);
13320
+ const retained = byteLength3(head) + byteLength3(tail);
13321
+ const result = {
13322
+ ...spillable,
13323
+ [field]: { preview: { head, tail } },
13324
+ spill: descriptorFor(totalBytes - retained, half)
13325
+ };
13326
+ const resultJson = safeStringify(result);
13327
+ if (resultJson === void 0) return void 0;
13328
+ const resultBytes = byteLength3(resultJson);
13329
+ if (resultBytes <= deps.maxInlineBytes) return { result, resultBytes, overhead };
13330
+ const headBytes = byteLength3(head);
13331
+ const tailBytes = byteLength3(tail);
13332
+ if (headBytes === 0 && tailBytes === 0) return { result, resultBytes, overhead };
13333
+ const excess = resultBytes - deps.maxInlineBytes;
13334
+ const dropHead = headBytes === 0 ? 0 : Math.min(headBytes, Math.max(1, Math.ceil(excess / 2)));
13335
+ const dropTail = tailBytes === 0 ? 0 : Math.min(tailBytes, Math.max(dropHead === 0 ? 1 : 0, excess - dropHead));
13336
+ if (dropHead === 0 && dropTail === 0) return { result, resultBytes, overhead };
13337
+ headBudget = headBytes - dropHead;
13338
+ tailBudget = tailBytes - dropTail;
13339
+ }
13340
+ };
13341
+ let bounded2 = bound(stored);
13342
+ if (bounded2 === void 0) {
13343
+ deps.log?.(
13344
+ `task ${deps.taskId}: ${spillable.type} event cannot be JSON-serialized once its ${field} is replaced by a preview and a spill descriptor; forwarding it unchanged and unbounded`
13345
+ );
13346
+ return event;
13347
+ }
13348
+ if (bounded2.resultBytes > deps.maxInlineBytes && storedBlobId !== void 0) {
13349
+ const unstoredReason = boundReasonText(
13350
+ `spill locator too large for maxInlineBytes: blobId ${storedBlobId.length} chars`
13351
+ );
13352
+ deps.log?.(
13353
+ `task ${deps.taskId}: ${totalBytes}-byte ${spillable.type}.${field} was stored, but its spill locator does not fit the ${deps.maxInlineBytes}-byte inline cap (descriptor overhead ${bounded2.overhead} bytes); shipping the preview with unstoredReason instead \u2014 ${unstoredReason}`
13354
+ );
13355
+ stored = { unstoredReason };
13356
+ storedBlobId = void 0;
13357
+ bounded2 = bound(stored);
13358
+ if (bounded2 === void 0) {
13359
+ deps.log?.(
13360
+ `task ${deps.taskId}: ${spillable.type} event cannot be JSON-serialized once its ${field} is replaced by a preview and a spill descriptor; forwarding it unchanged and unbounded`
13361
+ );
13362
+ return event;
13363
+ }
13364
+ }
13365
+ if (bounded2.resultBytes > deps.maxInlineBytes) {
13366
+ throw new Error(
13367
+ `event spill failed to bound a ${spillable.type} event: replacement is ${bounded2.resultBytes} bytes against a ${deps.maxInlineBytes}-byte cap (${field} was ${totalBytes} bytes, descriptor overhead ${bounded2.overhead} bytes)`
13368
+ );
13369
+ }
13370
+ return bounded2.result;
13371
+ }
12885
13372
 
12886
13373
  // src/daemon/environment.ts
12887
13374
  var BASE_PLATFORM_ALLOWLIST = [
@@ -14261,6 +14748,7 @@ function resultDocumentRejectionDetail(check) {
14261
14748
  }
14262
14749
  }
14263
14750
  var DEFAULT_MAX_TASK_OUTPUT_BYTES = 64 * 1024 * 1024;
14751
+ var DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME = 1;
14264
14752
  function isKnownRuntimeId(id) {
14265
14753
  return RuntimeIdSchema.safeParse(id).success;
14266
14754
  }
@@ -14507,6 +14995,14 @@ var TaskRunner = class {
14507
14995
  get maxTaskOutputBytes() {
14508
14996
  return this.deps.maxTaskOutputBytes ?? DEFAULT_MAX_TASK_OUTPUT_BYTES;
14509
14997
  }
14998
+ /** Effective per-event inline ceiling for this daemon — see `DaemonConfig.maxInlineEventBytes`. */
14999
+ get maxInlineEventBytes() {
15000
+ return this.deps.maxInlineEventBytes ?? DEFAULT_MAX_INLINE_EVENT_BYTES;
15001
+ }
15002
+ /** WP0: effective per-canonical-Agent-home Attempt cap — see {@link DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME}. */
15003
+ get maxConcurrentMutableSessionsPerAgentHome() {
15004
+ return this.deps.maxConcurrentMutableSessionsPerAgentHome ?? DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME;
15005
+ }
14510
15006
  /**
14511
15007
  * M4 Phase 4 (part B.3, observability): per-active-task queue watermarks
14512
15008
  * for the control socket's `status` result — see
@@ -14896,6 +15392,24 @@ var TaskRunner = class {
14896
15392
  decline("strict Agent-only daemon refuses legacy task offers", false);
14897
15393
  return;
14898
15394
  }
15395
+ if (agentRef !== void 0) {
15396
+ const limit = this.maxConcurrentMutableSessionsPerAgentHome;
15397
+ let canonicalHome;
15398
+ try {
15399
+ canonicalHome = await this.deps.agentHome.layout.canonicalHomePath(agentRef);
15400
+ } catch (error) {
15401
+ decline(
15402
+ `Agent home admission failed: ${errorMessage6(error)}`,
15403
+ !(error instanceof AgentHomeResolutionError)
15404
+ );
15405
+ return;
15406
+ }
15407
+ const active2 = this.deps.agentHome.executionLeaseManager.activeAttemptCount(canonicalHome);
15408
+ if (active2 >= limit) {
15409
+ decline(`agent home busy: ${active2} active attempt(s)`, true);
15410
+ return;
15411
+ }
15412
+ }
14899
15413
  const guarded = this.deps.admissionGuard?.({ taskId, payload });
14900
15414
  if (guarded !== void 0 && !guarded.admit) {
14901
15415
  decline(guarded.reason, guarded.retryable);
@@ -15165,6 +15679,7 @@ var TaskRunner = class {
15165
15679
  },
15166
15680
  forwardedEnvironmentNames: Object.freeze(Object.keys(env).sort())
15167
15681
  });
15682
+ await this.deps.beforeClaim?.(taskId, manifest.descriptor.id);
15168
15683
  this.deps.send(
15169
15684
  createEnvelope(
15170
15685
  "task.claim",
@@ -15651,9 +16166,17 @@ var TaskRunner = class {
15651
16166
  }
15652
16167
  async pump(active) {
15653
16168
  try {
15654
- for await (const event of active.session.events) {
16169
+ for await (let event of active.session.events) {
15655
16170
  if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
15656
16171
  if (this.tasks.get(active.taskId) !== active) return;
16172
+ event = await spillOversizedEvent(event, {
16173
+ maxInlineBytes: this.maxInlineEventBytes,
16174
+ blobClient: this.deps.blobClient,
16175
+ taskId: active.taskId,
16176
+ signal: active.blobAbort.signal,
16177
+ log: (message) => console.error(`[byok/client] ${message}`)
16178
+ });
16179
+ if (this.tasks.get(active.taskId) !== active || active.beingTornDown) return;
15657
16180
  active.outputBytesSoFar += estimateEventBytes(event);
15658
16181
  if (active.outputBytesSoFar > this.maxTaskOutputBytes) {
15659
16182
  active.batcher.flush();
@@ -18573,6 +19096,7 @@ function delay2(ms) {
18573
19096
  });
18574
19097
  }
18575
19098
  function terminalKindOf(type) {
19099
+ if (type === "task.decline") return "declined";
18576
19100
  if (type === "task.complete") return "complete";
18577
19101
  if (type === "task.fail") return "failed";
18578
19102
  if (type === "task.cancelled") return "cancelled";
@@ -18588,7 +19112,7 @@ function toJournalEnvelopeRecord(envelope, identity) {
18588
19112
  bytes,
18589
19113
  bytesHash: journalHash(bytes),
18590
19114
  receivedAt: (/* @__PURE__ */ new Date()).toISOString(),
18591
- opensTask: envelope.type === "task.offer" || envelope.type === "task.offer_with_toolsets" || envelope.type === "task.offer_for_agent"
19115
+ opensTask: isTaskOfferType(envelope.type)
18592
19116
  };
18593
19117
  }
18594
19118
  function isRuntimeId(id) {
@@ -18607,7 +19131,7 @@ async function detectRuntimes(adapters) {
18607
19131
  }
18608
19132
  return runtimes;
18609
19133
  }
18610
- function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentOnly = false, agentHomeProjectionConfigured = false, agentEgressConfigured = false, contentReadPolicies) {
19134
+ function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentOnly = false, agentHomeProjectionConfigured = false, agentEgressConfigured = false, contentReadPolicies, providerProfileBindingConfigured = false) {
18611
19135
  const flags = [];
18612
19136
  if (adapters.some((adapter) => adapter.descriptor.capabilities.steer)) flags.push("steer");
18613
19137
  flags.push("blob-upload");
@@ -18619,6 +19143,9 @@ function computeCapabilities(adapters, agentHomeConfigured = false, strictAgentO
18619
19143
  if (selectionAdapters.length > 0 && selectionAdapters.every((adapter) => adapter.descriptor.supportsDispatchSelection === true)) {
18620
19144
  flags.push("dispatch-selection");
18621
19145
  }
19146
+ if (providerProfileBindingConfigured && adapters.some((adapter) => adapter.descriptor.id === "pi" && adapter.descriptor.supportsDispatchSelection)) {
19147
+ flags.push(PROVIDER_PROFILE_BINDING_CAPABILITY);
19148
+ }
18622
19149
  if (adapters.some((adapter) => adapter.descriptor.capabilities.mcpToolsets === true)) {
18623
19150
  flags.push("toolset-selection");
18624
19151
  }
@@ -18774,6 +19301,11 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18774
19301
  `DaemonConfig.maxTaskOutputBytes must be a positive number (or omitted to use the ${DEFAULT_MAX_TASK_OUTPUT_BYTES}-byte default) \u2014 got ${config.maxTaskOutputBytes}. Pass Number.POSITIVE_INFINITY to explicitly disable the cap; 0 or a negative number is rejected rather than silently treated as "disabled".`
18775
19302
  );
18776
19303
  }
19304
+ if (config.maxInlineEventBytes !== void 0 && !(Number.isSafeInteger(config.maxInlineEventBytes) && config.maxInlineEventBytes >= MIN_MAX_INLINE_EVENT_BYTES)) {
19305
+ throw new Error(
19306
+ `DaemonConfig.maxInlineEventBytes must be a safe integer of at least ${MIN_MAX_INLINE_EVENT_BYTES} (or omitted to use the ${DEFAULT_MAX_INLINE_EVENT_BYTES}-byte default) \u2014 got ${config.maxInlineEventBytes}. A smaller cap cannot hold the spill descriptor that documents the omission, and there is no "disabled" value: an unbounded tool_use.input/tool_result.output is what this cap exists to prevent.`
19307
+ );
19308
+ }
18777
19309
  validateProgressBatcherOptions(config.progressBatch);
18778
19310
  if (config.agentEgress !== void 0 && config.agentHome === void 0) {
18779
19311
  throw new Error("DaemonConfig.agentEgress requires DaemonConfig.agentHome for the per-Agent local spool");
@@ -18781,6 +19313,12 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
18781
19313
  if (config.strictAgentOnly === true && config.agentHome === void 0) {
18782
19314
  throw new Error("DaemonConfig.strictAgentOnly requires DaemonConfig.agentHome");
18783
19315
  }
19316
+ if (config.maxConcurrentMutableSessionsPerAgentHome !== void 0 && !(Number.isSafeInteger(config.maxConcurrentMutableSessionsPerAgentHome) && config.maxConcurrentMutableSessionsPerAgentHome > 0)) {
19317
+ throw new Error(
19318
+ `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".`
19319
+ );
19320
+ }
19321
+ const agentHomeAttemptLimit = config.maxConcurrentMutableSessionsPerAgentHome ?? DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME;
18784
19322
  const egressPolicy = resolveAgentEgressPolicy(config.agentEgress?.policy);
18785
19323
  const egressBatcherOptions = egressPolicy.activity.mode === "contentful-trajectory" ? {
18786
19324
  ...config.progressBatch,
@@ -19062,19 +19600,31 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19062
19600
  `[byok/client] WARNING: permissionDefaults.workspaceRoot ("${config.permissionDefaults.workspaceRoot}") is configured, but no bundled runtime adapter (pi/claude/codex) enforces PermissionPolicy.workspaceRoot \u2014 every adapter confines a task to ctx.workspaceDir instead. This ceiling value has no enforcement effect; see docs/security.md's "Workspace confinement is a convention, not a sandbox" section.`
19063
19601
  );
19064
19602
  }
19065
- if (activeJournal) {
19066
- const recoverable = await activeJournal.listRecoverable();
19067
- for (const task of recoverable) {
19068
- await activeJournal.markRecovered(task.taskId, {
19069
- disposition: "interrupted",
19070
- reason: `daemon restarted while this task was in local state "${task.localState}"; local runtime sessions do not survive the process`
19603
+ const journalIdentity = activeJournal ? { tenantId: record3.tenantId, productId: config.productId, deviceId: record3.deviceId } : void 0;
19604
+ if (activeJournal && journalIdentity) {
19605
+ const durableCursor = await cursorStore.load(config.serverUrl, record3.deviceId) ?? 0;
19606
+ for (const task of await activeJournal.listRecoveryTasks(journalIdentity)) {
19607
+ if (task.localState === "received" && task.seq > durableCursor) continue;
19608
+ const offer = decodeEnvelope(task.envelopeBytes);
19609
+ if (!isTaskOfferType(offer.type) || offer.task_id !== task.taskId) throw new Error("invalid durable recovery offer binding");
19610
+ const payload = offer.payload;
19611
+ const terminal = createEnvelope("task.fail", {
19612
+ reason: "daemon_interrupted",
19613
+ retryable: false,
19614
+ ...payload.agentRef === void 0 ? {} : { agentRef: payload.agentRef }
19615
+ }, { taskId: task.taskId });
19616
+ const bytes = encodeEnvelope(terminal);
19617
+ await activeJournal.recordTerminal({
19618
+ taskId: task.taskId,
19619
+ terminalType: "failed",
19620
+ bytes,
19621
+ payloadHash: journalHash(bytes),
19622
+ truthState: "pending",
19623
+ attempt: 1,
19624
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
19625
+ recovery: { disposition: "interrupted", reason: "daemon_interrupted" }
19071
19626
  });
19072
19627
  }
19073
- if (recoverable.length > 0) {
19074
- console.warn(
19075
- `[byok/client] local journal recovery: ${recoverable.length} task(s) were interrupted by the previous shutdown and have been marked (not resumed, not deleted): ${recoverable.map((task) => task.taskId).join(", ")}`
19076
- );
19077
- }
19078
19628
  }
19079
19629
  if (activeOwnedPressureEngine) {
19080
19630
  await activeOwnedPressureEngine.tick();
@@ -19101,7 +19651,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19101
19651
  config.strictAgentOnly === true,
19102
19652
  agentHomeManager?.supportsTaskFreeProjection() === true,
19103
19653
  config.agentEgress !== void 0,
19104
- agentContentReadPolicies
19654
+ agentContentReadPolicies,
19655
+ config.piByokLauncher !== void 0
19105
19656
  );
19106
19657
  const agentHomeProjectionCompletion = agentHomeManager?.supportsTaskFreeProjection() === true ? new AgentHomeProjectionCompletionClient({
19107
19658
  serverUrl: config.serverUrl,
@@ -19109,35 +19660,64 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19109
19660
  tenantId: record3.tenantId,
19110
19661
  deviceId: record3.deviceId
19111
19662
  }) : void 0;
19112
- const journalIdentity = config.hostedJournal ? { tenantId: record3.tenantId, productId: config.productId, deviceId: record3.deviceId } : void 0;
19113
- const sendSanitizedEnvelope = activeJournal && journalIdentity ? (envelope) => {
19114
- observer.handleOutboundEnvelope(envelope);
19663
+ const sendSanitizedEnvelope = (envelope) => {
19115
19664
  const terminalKind = terminalKindOf(envelope.type);
19116
- const taskId = envelope.task_id;
19117
- if (terminalKind === void 0 || taskId === void 0) {
19665
+ if (!activeJournal || !journalIdentity || terminalKind === void 0 || envelope.task_id === void 0) {
19666
+ observer.handleOutboundEnvelope(envelope);
19118
19667
  connection?.send(envelope);
19119
19668
  return;
19120
19669
  }
19121
- const payloadHash = journalHash(encodeEnvelope(envelope));
19122
- journalTerminalTail = journalTerminalTail.then(
19123
- () => activeJournal.recordTerminal({
19124
- taskId,
19125
- terminalType: terminalKind,
19126
- payloadHash,
19127
- truthState: "pending",
19128
- attempt: 1,
19129
- recordedAt: (/* @__PURE__ */ new Date()).toISOString()
19130
- })
19131
- ).catch((err) => {
19132
- console.warn(
19133
- `[byok/client] could not record task ${taskId}'s terminal in the local journal (sending it anyway): ${err instanceof Error ? err.message : String(err)}`
19134
- );
19135
- }).then(() => {
19136
- connection?.send(envelope);
19670
+ const taskId = envelope.task_id;
19671
+ const bytes = encodeEnvelope(envelope);
19672
+ journalTerminalTail = journalTerminalTail.catch(() => void 0).then(async () => {
19673
+ try {
19674
+ await activeJournal.recordTerminal({
19675
+ taskId,
19676
+ terminalType: terminalKind,
19677
+ bytes,
19678
+ payloadHash: journalHash(bytes),
19679
+ truthState: "pending",
19680
+ attempt: 1,
19681
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
19682
+ });
19683
+ const terminal = (await activeJournal.listPendingTerminals(journalIdentity)).find((row) => row.taskId === taskId);
19684
+ if (!terminal || terminal.truthState !== "pending") return;
19685
+ const durable = decodeEnvelope(terminal.bytes);
19686
+ overrides.executionRecoveryFault?.("terminal:before-send");
19687
+ observer.handleOutboundEnvelope(durable);
19688
+ connection?.send(durable);
19689
+ } catch (error) {
19690
+ if (!(error instanceof JournalRecordTooLargeError)) throw error;
19691
+ if (!activeJournal || !journalIdentity) throw error;
19692
+ const task = await activeJournal.readTask(taskId, journalIdentity);
19693
+ if (!task) throw new Error(`missing durable task ${taskId}`);
19694
+ const offer = decodeEnvelope(task.envelopeBytes);
19695
+ if (!isTaskOfferType(offer.type) || offer.task_id !== taskId) throw new Error(`invalid durable offer binding for ${taskId}`);
19696
+ const offerPayload = offer.payload;
19697
+ const failure = createEnvelope("task.fail", {
19698
+ reason: "terminal_result_too_large",
19699
+ retryable: false,
19700
+ ...offerPayload.agentRef === void 0 ? {} : { agentRef: offerPayload.agentRef }
19701
+ }, { taskId });
19702
+ const failureBytes = encodeEnvelope(failure);
19703
+ await activeJournal.recordTerminal({
19704
+ taskId,
19705
+ terminalType: "failed",
19706
+ bytes: failureBytes,
19707
+ payloadHash: journalHash(failureBytes),
19708
+ truthState: "pending",
19709
+ attempt: 1,
19710
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
19711
+ });
19712
+ const durableFailure = (await activeJournal.listPendingTerminals(journalIdentity)).find((row) => row.taskId === taskId);
19713
+ if (!durableFailure || durableFailure.truthState !== "pending") return;
19714
+ const decoded = decodeEnvelope(durableFailure.bytes);
19715
+ observer.handleOutboundEnvelope(decoded);
19716
+ connection?.send(decoded);
19717
+ }
19718
+ }).catch((error) => {
19719
+ console.error("[byok/client] terminal durability failed; execution remains unsettled", error);
19137
19720
  });
19138
- } : (envelope) => {
19139
- observer.handleOutboundEnvelope(envelope);
19140
- connection?.send(envelope);
19141
19721
  };
19142
19722
  const sendEnvelope = (candidate) => {
19143
19723
  if (candidate.task_id === void 0 || runner?.usesAgentEgress(candidate.task_id) !== true) {
@@ -19160,6 +19740,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19160
19740
  workspaceRoot: config.workspaceRoot,
19161
19741
  ...agentHomeManager === void 0 ? {} : { agentHome: agentHomeManager },
19162
19742
  ...config.strictAgentOnly === true ? { strictAgentOnly: true } : {},
19743
+ // WP0: already validated up front — see `DaemonConfig.maxConcurrentMutableSessionsPerAgentHome`.
19744
+ maxConcurrentMutableSessionsPerAgentHome: agentHomeAttemptLimit,
19163
19745
  ...agentSessionHandoffs === void 0 ? {} : { agentSessionHandoffs },
19164
19746
  deviceId: record3.deviceId,
19165
19747
  // M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment above.
@@ -19174,6 +19756,11 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19174
19756
  // before is the entire integration; `task-runner.ts` itself is
19175
19757
  // untouched. See `observer.ts`'s module doc comment.
19176
19758
  send: sendEnvelope,
19759
+ ...activeJournal === void 0 ? {} : {
19760
+ beforeClaim: async (taskId, runtime) => {
19761
+ await activeJournal.recordAdmission({ taskId, admitted: true, claimedRuntime: runtime, decidedAt: (/* @__PURE__ */ new Date()).toISOString() });
19762
+ }
19763
+ },
19177
19764
  blobClient,
19178
19765
  batcherOptions: egressBatcherOptions,
19179
19766
  agentEgress,
@@ -19212,6 +19799,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19212
19799
  shutdownInterruptTimeoutMs: overrides.shutdown?.taskInterruptTimeoutMs,
19213
19800
  // M5 batch-3 (workstream 2): see DaemonConfig.maxTaskOutputBytes's own doc comment — already validated above.
19214
19801
  maxTaskOutputBytes: config.maxTaskOutputBytes,
19802
+ // See DaemonConfig.maxInlineEventBytes's own doc comment — already validated above.
19803
+ maxInlineEventBytes: config.maxInlineEventBytes,
19215
19804
  // additive-minor (`task.complete.document`): passed through verbatim,
19216
19805
  // absent when unconfigured — see `DaemonConfig.resultDocument`'s own
19217
19806
  // doc comment. Spread rather than assigned so an unconfigured daemon
@@ -19431,7 +20020,13 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19431
20020
  if (await handleAgentContentReadEnvelope(envelope)) return;
19432
20021
  activePressureEngine?.assertAckCriticalAllowed();
19433
20022
  await activeJournal.appendEnvelope(toJournalEnvelopeRecord(envelope, journalIdentity));
19434
- return runner?.handleEnvelope(envelope) ?? Promise.resolve();
20023
+ if (isTaskOfferType(envelope.type) && envelope.task_id !== void 0) {
20024
+ const task = await activeJournal.readTask(envelope.task_id, journalIdentity);
20025
+ if (!task) throw new Error("durable offer has no execution record");
20026
+ if (task.localState !== "received") return;
20027
+ }
20028
+ await runner?.handleEnvelope(envelope);
20029
+ await journalTerminalTail;
19435
20030
  } : (envelope) => {
19436
20031
  if (tenantRebinding) {
19437
20032
  return Promise.reject(new Error("tenant enrollment is being re-paired; inbound work is blocked until restart"));
@@ -19443,17 +20038,32 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19443
20038
  if (envelope.type === "agent.content.read") return handleAgentContentReadEnvelope(envelope).then(() => void 0);
19444
20039
  return runner?.handleEnvelope(envelope) ?? Promise.resolve();
19445
20040
  },
20041
+ onOutboundQueued: (envelope) => {
20042
+ if (terminalKindOf(envelope.type) !== void 0) overrides.executionRecoveryFault?.("terminal:queued");
20043
+ },
20044
+ beforeOutboundPost: (envelopes) => {
20045
+ if (envelopes.some((envelope) => terminalKindOf(envelope.type) !== void 0)) overrides.executionRecoveryFault?.("outbound:before-post");
20046
+ },
20047
+ onOutboundAccepted: async (envelopes) => {
20048
+ if (!activeJournal) return;
20049
+ for (const envelope of envelopes) {
20050
+ if (terminalKindOf(envelope.type) === void 0 || envelope.task_id === void 0) continue;
20051
+ overrides.executionRecoveryFault?.("outbound:after-ack");
20052
+ await activeJournal.confirmTerminal(envelope.task_id, journalHash(encodeEnvelope(envelope)));
20053
+ }
20054
+ },
20055
+ onOutboundRejected: async (envelope) => {
20056
+ if (activeJournal && terminalKindOf(envelope.type) !== void 0 && envelope.task_id !== void 0) {
20057
+ await activeJournal.rejectTerminal(envelope.task_id, journalHash(encodeEnvelope(envelope)), "inbound_rejected");
20058
+ }
20059
+ },
19446
20060
  onStateChange: (state) => {
19447
- const wasSettled = connectionState === "open" || connectionState === "degraded";
20061
+ const wasSettled = connectionState === "open";
19448
20062
  connectionState = state;
19449
20063
  observer.noteConnectionState(state);
19450
- if (!wasSettled && (state === "open" || state === "degraded")) runner?.retryRecoveredAgentMessages();
19451
- if (!wasSettled && (state === "open" || state === "degraded")) runPresenceDiscovery();
20064
+ if (!wasSettled && state === "open") runner?.retryRecoveredAgentMessages();
20065
+ if (!wasSettled && state === "open") runPresenceDiscovery();
19452
20066
  },
19453
- backoff: overrides.backoff,
19454
- liveness: overrides.liveness,
19455
- wsFailureThreshold: overrides.longPoll?.wsFailureThreshold,
19456
- wsRetryIntervalMs: overrides.longPoll?.wsRetryIntervalMs,
19457
20067
  longPollRetryDelayMs: overrides.longPoll?.retryDelayMs,
19458
20068
  longPollIdleDelayMs: overrides.longPoll?.idleDelayMs,
19459
20069
  fleetJitter,
@@ -19464,8 +20074,20 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19464
20074
  });
19465
20075
  }
19466
20076
  });
20077
+ if (activeJournal && journalIdentity) {
20078
+ for (const terminal of await activeJournal.listPendingTerminals(journalIdentity)) {
20079
+ if (terminal.truthState !== "pending") continue;
20080
+ const envelope = decodeEnvelope(terminal.bytes);
20081
+ if (envelope.task_id !== terminal.taskId || terminalKindOf(envelope.type) !== terminal.terminalType) {
20082
+ throw new Error("durable terminal execution binding mismatch");
20083
+ }
20084
+ overrides.executionRecoveryFault?.("terminal:before-send");
20085
+ observer.handleOutboundEnvelope(envelope);
20086
+ connection.send(envelope);
20087
+ }
20088
+ }
19467
20089
  await connection.start();
19468
- await connection.waitForAck();
20090
+ await connection.waitForConnection();
19469
20091
  runner.retryRecoveredAgentMessages();
19470
20092
  for (const record4 of agentEgress.retryableReliableRecords(connection.getServerCapabilities())) {
19471
20093
  dispatchReliableRecord(record4);
@@ -19724,7 +20346,9 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19724
20346
  // fine.
19725
20347
  ...storageStatus === void 0 ? {} : { storage: storageStatus },
19726
20348
  operationalHealth: operationalHealth.snapshot(),
19727
- toolsets: toolsetRegistry.status()
20349
+ toolsets: toolsetRegistry.status(),
20350
+ // WP0: same counts `Daemon.status()` reports, from the same reader.
20351
+ agentHomeExecution: agentHomeExecutionStatus()
19728
20352
  };
19729
20353
  }
19730
20354
  async function performControlShutdown(reason) {
@@ -19849,6 +20473,11 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19849
20473
  if (!parsed) throw new ControlError("bad_request", "team_messages.ack requires exactly {context,throughSeq}");
19850
20474
  return teamWorkspaces.ackMessages({ lease: decodeTeamMemberContext(parsed.context), throughSeq: parsed.throughSeq });
19851
20475
  },
20476
+ "team_notifications.snapshot": (params) => {
20477
+ const parsed = parseTeamNotificationSnapshotParams(params);
20478
+ if (!parsed) throw new ControlError("bad_request", "team_notifications.snapshot requires exactly {context,afterSeq?}");
20479
+ return teamWorkspaces.notificationSnapshot({ lease: decodeTeamMemberContext(parsed.context), ...parsed.afterSeq === void 0 ? {} : { afterSeq: parsed.afterSeq } });
20480
+ },
19852
20481
  "team_messages.inspect": (params) => {
19853
20482
  const parsed = parseTeamMessageInspectParams(params);
19854
20483
  if (!parsed) throw new ControlError("bad_request", "team_messages.inspect requires exactly {workspaceId,afterSeq?}");
@@ -19959,9 +20588,8 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
19959
20588
  const minted = mintDeviceAssertion({
19960
20589
  record: record3,
19961
20590
  // `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.
20591
+ // (path stripped), so the issuer ignores an operator-provided path
20592
+ // or query.
19965
20593
  issuer: new URL(toHttpBase(config.serverUrl)).origin,
19966
20594
  productId: config.productId,
19967
20595
  audience: parsed.audience,
@@ -20076,23 +20704,33 @@ function buildDaemonWithAdapters(config, adapters, overrides = {}, assertionProb
20076
20704
  await binding.lease.release();
20077
20705
  }
20078
20706
  }
20707
+ function agentHomeExecutionStatus() {
20708
+ const summary = agentHomeManager?.executionLeaseManager.activeAttemptSummary();
20709
+ return {
20710
+ maxConcurrentMutableSessionsPerAgentHome: agentHomeAttemptLimit,
20711
+ activeHomes: summary?.homes ?? 0,
20712
+ activeAttempts: summary?.attempts ?? 0
20713
+ };
20714
+ }
20079
20715
  function status() {
20080
20716
  return {
20081
20717
  localAgentRelease,
20082
20718
  paired: auth.deviceId !== void 0,
20083
20719
  connected: connectionState === "open",
20084
- degraded: connection?.isTransportDegraded() ?? false,
20085
20720
  revoked: connection?.isRevoked() ?? auth.isRevoked(),
20086
20721
  deviceId: auth.deviceId,
20087
20722
  activeTaskCount: runner?.activeTaskCount ?? 0,
20088
20723
  branding: config.branding,
20089
20724
  operationalHealth: operationalHealth.snapshot(),
20090
20725
  toolsets: toolsetRegistry.status(),
20091
- egress: agentEgress.status()
20726
+ egress: agentEgress.status(),
20727
+ agentHomeExecution: agentHomeExecutionStatus()
20092
20728
  };
20093
20729
  }
20094
20730
  function reloadMcpToolsets(mcpToolsets, expectedRevision) {
20095
- return toolsetRegistry.reload(mcpToolsets, expectedRevision);
20731
+ const receipt = toolsetRegistry.reload(mcpToolsets, expectedRevision);
20732
+ if (daemonStarted) connection?.refreshHello();
20733
+ return receipt;
20096
20734
  }
20097
20735
  function reportMcpToolsetObservation(toolsetId, expectedDefinitionRevision, observation) {
20098
20736
  toolsetRegistry.report(toolsetId, expectedDefinitionRevision, observation);