@byok-sdk/client 0.1.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/adapters/claude/claude-adapter.d.ts +155 -0
  4. package/dist/adapters/claude/events.d.ts +91 -0
  5. package/dist/adapters/claude/permission-mapping.d.ts +142 -0
  6. package/dist/adapters/claude/process-client.d.ts +103 -0
  7. package/dist/adapters/claude/resolve-approval-mcp-bin.d.ts +45 -0
  8. package/dist/adapters/claude/resolve-bin.d.ts +27 -0
  9. package/dist/adapters/codex/codex-adapter.d.ts +97 -0
  10. package/dist/adapters/codex/events.d.ts +41 -0
  11. package/dist/adapters/codex/permission-mapping.d.ts +107 -0
  12. package/dist/adapters/codex/process-runner.d.ts +86 -0
  13. package/dist/adapters/codex/resolve-bin.d.ts +25 -0
  14. package/dist/adapters/pi/events.d.ts +58 -0
  15. package/dist/adapters/pi/permission-mapping.d.ts +46 -0
  16. package/dist/adapters/pi/pi-adapter.d.ts +29 -0
  17. package/dist/adapters/pi/resolve-bin.d.ts +44 -0
  18. package/dist/adapters/pi/rpc-client.d.ts +101 -0
  19. package/dist/bin/approval-mcp-server.d.ts +69 -0
  20. package/dist/bin/audit-log.d.ts +212 -0
  21. package/dist/bin/byok-agent.d.ts +2 -0
  22. package/dist/bin/byok-agent.js +12347 -0
  23. package/dist/bin/byok-agent.js.map +1 -0
  24. package/dist/bin/byok-approval-mcp.d.ts +2 -0
  25. package/dist/bin/byok-approval-mcp.js +498 -0
  26. package/dist/bin/byok-approval-mcp.js.map +1 -0
  27. package/dist/bin/commands/approvals.d.ts +31 -0
  28. package/dist/bin/commands/approve-reject.d.ts +29 -0
  29. package/dist/bin/commands/doctor.d.ts +15 -0
  30. package/dist/bin/commands/pair.d.ts +7 -0
  31. package/dist/bin/commands/runtimes.d.ts +7 -0
  32. package/dist/bin/commands/service.d.ts +54 -0
  33. package/dist/bin/commands/start.d.ts +21 -0
  34. package/dist/bin/commands/status.d.ts +25 -0
  35. package/dist/bin/commands/support-bundle.d.ts +7 -0
  36. package/dist/bin/commands/tasks.d.ts +27 -0
  37. package/dist/bin/commands/unpair.d.ts +124 -0
  38. package/dist/bin/commands/workspaces.d.ts +16 -0
  39. package/dist/bin/config.d.ts +41 -0
  40. package/dist/bin/control-client.d.ts +50 -0
  41. package/dist/bin/format.d.ts +67 -0
  42. package/dist/bin/runtime-probe.d.ts +34 -0
  43. package/dist/bin/tasks-view.d.ts +49 -0
  44. package/dist/daemon/approvals.d.ts +87 -0
  45. package/dist/daemon/auth-manager.d.ts +52 -0
  46. package/dist/daemon/blob-client.d.ts +22 -0
  47. package/dist/daemon/connection-manager.d.ts +502 -0
  48. package/dist/daemon/control-protocol.d.ts +299 -0
  49. package/dist/daemon/control-server.d.ts +61 -0
  50. package/dist/daemon/create-daemon.d.ts +364 -0
  51. package/dist/daemon/cursor-store.d.ts +30 -0
  52. package/dist/daemon/daemon-owner.d.ts +24 -0
  53. package/dist/daemon/deterministic-jitter.d.ts +20 -0
  54. package/dist/daemon/device-keys.d.ts +45 -0
  55. package/dist/daemon/device-proof-signer.d.ts +41 -0
  56. package/dist/daemon/environment.d.ts +96 -0
  57. package/dist/daemon/git-workspace-store.d.ts +49 -0
  58. package/dist/daemon/git-workspace.d.ts +80 -0
  59. package/dist/daemon/http-client.d.ts +8 -0
  60. package/dist/daemon/journal/journal.d.ts +342 -0
  61. package/dist/daemon/journal/sqlite-journal.d.ts +173 -0
  62. package/dist/daemon/journal/sqlite-support.d.ts +96 -0
  63. package/dist/daemon/journal/storage-policy.d.ts +368 -0
  64. package/dist/daemon/long-poll-transport.d.ts +142 -0
  65. package/dist/daemon/observer.d.ts +345 -0
  66. package/dist/daemon/operational-health.d.ts +87 -0
  67. package/dist/daemon/policy.d.ts +16 -0
  68. package/dist/daemon/progress-batcher.d.ts +31 -0
  69. package/dist/daemon/runtime-capabilities.d.ts +36 -0
  70. package/dist/daemon/session-workspace-store.d.ts +75 -0
  71. package/dist/daemon/store.d.ts +54 -0
  72. package/dist/daemon/task-runner.d.ts +862 -0
  73. package/dist/daemon/truth-memory-client.d.ts +94 -0
  74. package/dist/daemon/url.d.ts +60 -0
  75. package/dist/daemon/ws-transport.d.ts +110 -0
  76. package/dist/diagnostics/diagnostics.d.ts +114 -0
  77. package/dist/diagnostics/support-bundle.d.ts +81 -0
  78. package/dist/index.d.ts +42 -0
  79. package/dist/index.js +10056 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/lifecycle/create-service-lifecycle.d.ts +59 -0
  82. package/dist/lifecycle/exec-runner.d.ts +141 -0
  83. package/dist/lifecycle/launchd.d.ts +55 -0
  84. package/dist/lifecycle/service-types.d.ts +177 -0
  85. package/dist/lifecycle/systemd.d.ts +46 -0
  86. package/dist/lifecycle/winsw.d.ts +64 -0
  87. package/dist/types.d.ts +178 -0
  88. package/dist/util/async-queue.d.ts +45 -0
  89. package/dist/util/atomic-write.d.ts +86 -0
  90. package/dist/util/secure-dir.d.ts +124 -0
  91. package/package.json +66 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * M4 Phase 2: minimal pending-approval registry backing the control
3
+ * socket's `approvals.list`/`approvals.resolve` methods.
4
+ *
5
+ * Nothing PRODUCES an approval yet in Phase 2 — every one of the three
6
+ * bundled runtime adapters (pi/claude/codex) still has no interactive
7
+ * `needs_approval` path (see `create-daemon.ts`'s `toRuntimeInfoCapabilities`
8
+ * doc comment) — so `list()` always returns `[]` and `resolve()` always
9
+ * throws {@link ApprovalNotFoundError} against a real daemon today. This
10
+ * class exists now so Phase 3 (the claude permission-prompt path) only has
11
+ * to call `register()` from wherever it detects a prompt; the control-socket
12
+ * plumbing (`control-server.ts`'s method registry, the CLI's `approve`/
13
+ * `reject` commands) is already wired end-to-end against this same registry.
14
+ */
15
+ export type ApprovalDecision = 'approve' | 'reject';
16
+ /**
17
+ * M4 (additive-minor, `task.approval_resolved`): distinguishes a resolution
18
+ * that arrived over the wire (a server-sent `task.approve`/`task.reject`,
19
+ * relayed here via `TaskContext.approvalChannel.resolve` — `task-runner.ts`'s
20
+ * `handleOffer`) from one this device decided on its own (the local
21
+ * `approvals.resolve` control-socket RPC, a fail-closed `requestApproval`
22
+ * timeout, or a fail-closed finish/eviction rejection). `TaskRunner` uses
23
+ * this to decide whether to report `task.approval_resolved` back to the
24
+ * server: a wire-triggered resolution is something the server already knows
25
+ * about (it sent the decision itself) and must never be echoed back;
26
+ * everything else is new information only the device has, and — capability
27
+ * permitting — gets reported. `'local'` is the default (see `resolve()`
28
+ * below) precisely because it's the common case: every call site in this
29
+ * module and `task-runner.ts` except the one wire-relay closure is local by
30
+ * construction.
31
+ */
32
+ export type ApprovalOrigin = 'wire' | 'local';
33
+ /** What `approvals.list` returns per pending approval — deliberately small; a runtime-specific payload (e.g. the exact tool call awaiting approval) is Phase 3's concern, not this registry's. */
34
+ export interface PendingApproval {
35
+ approvalId: string;
36
+ taskId: string;
37
+ summary?: string;
38
+ createdAt: string;
39
+ }
40
+ export declare class ApprovalNotFoundError extends Error {
41
+ constructor(approvalId: string);
42
+ }
43
+ /** Cap on simultaneously pending approvals — generous for any plausible concurrent-approval workload, and existing purely as a defensive bound (mirrors `task-runner.ts`'s `MAX_TRACKED_TASK_IDS`/`observer.ts`'s `MAX_TRACKED_TASKS`), not a real-world limit this is expected to ever approach. */
44
+ export declare const MAX_PENDING_APPROVALS = 200;
45
+ type ResolveCallback = (decision: ApprovalDecision, reason: string | undefined, origin: ApprovalOrigin) => void;
46
+ /**
47
+ * `register()`/`resolve()` are the producer/consumer halves of one pending
48
+ * approval: a future runtime adapter integration calls `register()` when it
49
+ * pauses a task awaiting a decision and gets called back via `onResolve`
50
+ * once `resolve()` is invoked (locally, or — Phase 2's actual wiring — via
51
+ * the control socket's `approvals.resolve` RPC). `list()` is a pure read
52
+ * for `approvals.list`.
53
+ */
54
+ export declare class ApprovalRegistry {
55
+ private readonly pending;
56
+ /**
57
+ * Registers a new pending approval, evicting the OLDEST entry first if
58
+ * already at {@link MAX_PENDING_APPROVALS} — bounded, not unbounded
59
+ * growth, for a long-lived daemon. The evicted entry's own `onResolve` is
60
+ * called (as a reject, with a reason naming the eviction) rather than
61
+ * simply dropped: whatever registered it (a future Phase 3 producer,
62
+ * e.g. a paused runtime session awaiting a decision) is very likely
63
+ * still waiting on that callback firing at all — silently stranding it
64
+ * would leave that producer hanging forever instead of unblocking it
65
+ * with a clear, if unwelcome, outcome.
66
+ */
67
+ register(approval: PendingApproval, onResolve: ResolveCallback): void;
68
+ list(): PendingApproval[];
69
+ /**
70
+ * Throws {@link ApprovalNotFoundError} for an unknown/already-resolved id —
71
+ * never silently no-ops, since a caller (the control socket's
72
+ * `approvals.resolve`) needs to distinguish "resolved" from "nothing to
73
+ * resolve".
74
+ *
75
+ * `origin` defaults to `'local'` (see {@link ApprovalOrigin}'s own doc
76
+ * comment for why that's the correct default, not just a convenient one):
77
+ * every existing caller of this method — the control socket's
78
+ * `approvals.resolve` RPC (`create-daemon.ts`), `TaskRunner`'s
79
+ * `requestApproval` timeout and `finish()` fail-closed cleanup
80
+ * (`task-runner.ts`) — resolves a decision this device made on its own.
81
+ * The one exception, a server-sent wire `task.approve`/`task.reject`
82
+ * relayed through `TaskContext.approvalChannel.resolve`
83
+ * (`task-runner.ts`'s `handleOffer`), passes `'wire'` explicitly.
84
+ */
85
+ resolve(approvalId: string, decision: ApprovalDecision, reason?: string, origin?: ApprovalOrigin): void;
86
+ }
87
+ export {};
@@ -0,0 +1,52 @@
1
+ import { DeviceStore, type DeviceRecord } from './store';
2
+ /**
3
+ * Thrown when the server has revoked this device: a 401 on `/byok/challenge`
4
+ * or `/byok/token` (protocol §6.3). The only recourse is a fresh
5
+ * `/byok/pair` — callers must surface a clear "re-pair needed" state and
6
+ * must NOT retry the renewal in a loop.
7
+ */
8
+ export declare class DeviceRevokedError extends Error {
9
+ constructor(message?: string);
10
+ }
11
+ export interface AuthManagerOptions {
12
+ serverUrl: string;
13
+ store: DeviceStore;
14
+ deviceName?: string;
15
+ /** Called once revocation is detected, so a caller (ConnectionManager) can stop retrying and surface the state instead of looping. */
16
+ onRevoked?: () => void;
17
+ }
18
+ /**
19
+ * Owns device pairing and the access token lifecycle (protocol §6):
20
+ * generates/reuses the device Ed25519 keypair, pairs, and renews the access
21
+ * token both proactively (before expiry) and reactively (on a 401 from any
22
+ * caller). This is the single source of truth for "the current valid JWT"
23
+ * that WS connects, blob HTTP calls, and the long-poll fallback all use.
24
+ */
25
+ export declare class AuthManager {
26
+ private readonly opts;
27
+ private record;
28
+ private renewing;
29
+ private proactiveTimer;
30
+ private revoked;
31
+ private stopped;
32
+ private pairing;
33
+ private credentialMutationTail;
34
+ constructor(opts: AuthManagerOptions);
35
+ get deviceId(): string | undefined;
36
+ isRevoked(): boolean;
37
+ /** Load a previously-paired device record from disk, if any (idempotent — a second call is a no-op once loaded). */
38
+ loadExisting(): Promise<DeviceRecord | undefined>;
39
+ /** `POST /byok/pair` (v2): generates a device keypair on first pair, reuses it on any subsequent (e.g. post-revocation) re-pair. */
40
+ pair(pairingCode: string): Promise<DeviceRecord>;
41
+ /** The current, non-expired access token — renews first if it's expired or close to it. Throws {@link DeviceRevokedError} if the device has been revoked. */
42
+ getValidAccessToken(): Promise<string>;
43
+ /** Force a renewal regardless of the cached token's remaining lifetime — the reactive path, used after a caller sees a 401. */
44
+ handleUnauthorized(): Promise<string>;
45
+ stop(): Promise<void>;
46
+ private renew;
47
+ private doRenew;
48
+ /** Always throws — `never` return type lets call sites use `if (x === 401) this.markRevoked();` without an explicit `return`/`throw` of their own. */
49
+ private markRevoked;
50
+ private scheduleProactiveRenewal;
51
+ private runCredentialMutation;
52
+ }
@@ -0,0 +1,22 @@
1
+ import type { BlobRef } from '@byok-sdk/protocol';
2
+ import type { AuthManager } from './auth-manager';
3
+ /** Seam `TaskRunner` depends on, so tests can substitute a fake without spinning up real HTTP endpoints. */
4
+ export interface BlobResolver {
5
+ resolveInstruction(blobRef: BlobRef): Promise<string>;
6
+ uploadArtifact(content: string | Uint8Array, contentType: string): Promise<BlobRef>;
7
+ }
8
+ /**
9
+ * HTTP-side blob transfer (protocol §7): resolving an instruction `blobRef`
10
+ * into its actual content, and uploading an artifact too large to inline.
11
+ * Both require a valid bearer token, handled via `authedFetch`.
12
+ */
13
+ export declare class BlobClient implements BlobResolver {
14
+ #private;
15
+ private readonly serverUrl;
16
+ private readonly auth;
17
+ constructor(serverUrl: string, auth: AuthManager);
18
+ /** `blobRef` -> `GET /byok/blobs/:id/url` -> fetch the presigned download URL -> text content. Always resolves fresh rather than trusting any inlined `BlobRef.url`, per docs/protocol.md §7. */
19
+ resolveInstruction(blobRef: BlobRef): Promise<string>;
20
+ /** `POST /byok/blobs` (declares size/contentType/contentHash) -> PUT the bytes to the presigned upload URL -> a `BlobRef` for `task.artifact.blobRef`. */
21
+ uploadArtifact(content: string | Uint8Array, contentType: string): Promise<BlobRef>;
22
+ }
@@ -0,0 +1,502 @@
1
+ import { type CapabilityFlag, type Envelope, type RuntimeInfo } from '@byok-sdk/protocol';
2
+ import { AuthManager } from './auth-manager';
3
+ import type { CursorStore } from './cursor-store';
4
+ import { type FleetJitter } from './deterministic-jitter';
5
+ import { type BackoffOptions, type ConnectionState, type LivenessOptions } from './ws-transport';
6
+ export type { ConnectionState } from './ws-transport';
7
+ export interface ConnectionManagerOptions {
8
+ serverUrl: string;
9
+ deviceId: string;
10
+ productId: string;
11
+ capabilities: CapabilityFlag[];
12
+ runtimes: RuntimeInfo[];
13
+ auth: AuthManager;
14
+ cursorStore: CursorStore;
15
+ /**
16
+ * May return a promise; `ConnectionManager` awaits it before considering
17
+ * this envelope "processed" (findings F2/F3 — see `deliver`/`process`
18
+ * below). A handler that throws/rejects is caught here, not propagated.
19
+ */
20
+ onEnvelope: (envelope: Envelope) => void | Promise<void>;
21
+ onStateChange?: (state: ConnectionState) => void;
22
+ backoff?: BackoffOptions;
23
+ liveness?: LivenessOptions;
24
+ /** Consecutive never-acked WS connect failures before falling back to long-poll (protocol §8). Default 3. */
25
+ wsFailureThreshold?: number;
26
+ /** While long-polling, how often to retry establishing WS (protocol §8, "e.g. every 5 min"). Default 5 minutes. */
27
+ wsRetryIntervalMs?: number;
28
+ /** Backoff between failed long-poll HTTP attempts. Default 2s. */
29
+ longPollRetryDelayMs?: number;
30
+ /** Minimum delay before the next long-poll request after an empty (no-events) response. Default 250ms. */
31
+ longPollIdleDelayMs?: number;
32
+ fleetJitter?: FleetJitter;
33
+ onOperationalOutcome?: (outcome: 'success' | 'failure', source: 'reconnect' | 'upload') => void;
34
+ }
35
+ /**
36
+ * Owns the daemon's one logical connection to the server, which may be
37
+ * backed by either transport the wire protocol defines: WS (the normal
38
+ * path) or long-poll (protocol §8's fallback for environments where an
39
+ * outbound WSS connection isn't viable). Both funnel every received
40
+ * envelope through the same cursor-dedupe/persistence logic (protocol §9),
41
+ * so redelivery is safe regardless of which transport happens to deliver a
42
+ * given envelope — including during the brief overlap window when handing
43
+ * off between them.
44
+ *
45
+ * `send()` (Design B, finding N4) pushes onto a single shared outbox this
46
+ * class owns and drains through whichever transport is currently active —
47
+ * WS raw-sends while acked, `POST /byok/messages` while long-polling
48
+ * (finding F6, long-poll is a full transport, not receive-only; see
49
+ * docs/protocol.md §8) — so a transport switch mid-flight never strands a
50
+ * queued envelope. See `drainOutbox`.
51
+ */
52
+ export declare class ConnectionManager {
53
+ private readonly opts;
54
+ private readonly fleetJitter;
55
+ private readonly ws;
56
+ private readonly longPoll;
57
+ private mode;
58
+ private consecutiveFailures;
59
+ private wsRetryTimer;
60
+ private wsProbeSequence;
61
+ private uploadRetryAttempt;
62
+ private cursor;
63
+ /**
64
+ * Finding F3 (at-most-once redelivery): the lowest `task.*` envelope `seq`
65
+ * whose handler failed and hasn't yet been successfully reprocessed. While
66
+ * set, the cursor is frozen at its pre-failure value even if later
67
+ * envelopes succeed — advancing past a still-unresolved failure would
68
+ * mean a future reconnect's redelivery skips it forever (it's <= the
69
+ * persisted cursor), which is exactly the bug this fixes. Cleared once an
70
+ * envelope carrying this exact seq is reprocessed (via redelivery after a
71
+ * reconnect) and succeeds; everything from there back up to the new
72
+ * cursor gets safely re-attempted too, relying on the idempotency
73
+ * guarantees in docs/protocol.md §9.
74
+ */
75
+ private stalledAtSeq;
76
+ /**
77
+ * Design A (Wave 2, F3-on-long-poll): the second, in-memory watermark
78
+ * alongside the durable `cursor`. `cursor` only ever advances AFTER a
79
+ * `task.*` handler's side effects resolve successfully, and is persisted
80
+ * (see `advanceCursor`) — that semantics is unchanged. `deliveredSeq`
81
+ * advances eagerly, the instant a `task.*` envelope is admitted past
82
+ * dedup (see `deliver`/`noteDelivered`), independent of whether its
83
+ * handler has even started, let alone succeeded. It exists so a
84
+ * long-poll re-query (`LongPollClient`'s `getCursor`) doesn't re-pull an
85
+ * envelope that's already been delivered once and is still in flight —
86
+ * `handleOffer` is NOT idempotent and must never be re-pulled while a
87
+ * first attempt is still running. On WS this same field is written the
88
+ * same way, but since a live WS connection only ever pushes a given `seq`
89
+ * once, it never has an observable effect there beyond mirroring
90
+ * `cursor` (see `dedupWatermark`'s doc comment for why redelivery
91
+ * correctness doesn't depend on resetting it anywhere).
92
+ */
93
+ private deliveredSeq;
94
+ /** Finding F3: serializes `onEnvelope` calls into a per-connection FIFO — one envelope's handler always fully settles before the next one starts. */
95
+ private processingChain;
96
+ /**
97
+ * Design B (finding N4): the ONE outbound queue both transports drain
98
+ * from — holds `Envelope` OBJECTS, never re-encoded/rebuilt strings, so a
99
+ * resend after a failed send attempt is byte-identical to the original
100
+ * (same `id`), which is what lets the server's per-(deviceId,id) dedup
101
+ * (Wave 1) recognize it as a safe no-op retry rather than a second
102
+ * application (protocol §9). A transport switch (long-poll <-> WS) never
103
+ * touches this queue — see `drainOutbox` — so nothing queued while one
104
+ * transport was active is ever stranded when the other takes over.
105
+ */
106
+ private readonly outbox;
107
+ /**
108
+ * Finding F5(b): how many envelopes `drainOutbox`'s long-poll branch has
109
+ * currently spliced OUT of `this.outbox` for an in-flight (not yet
110
+ * confirmed delivered) `postBatch` call — 0 the rest of the time. See
111
+ * `outboxLength`'s own doc comment for why this needs to be tracked
112
+ * separately from `this.outbox.length` at all.
113
+ */
114
+ private inFlightBatchSize;
115
+ private draining;
116
+ private stopped;
117
+ private revoked;
118
+ private settledWaiters;
119
+ private pendingCursorSave;
120
+ /**
121
+ * Finding P2 (Fix 2b): seqs currently admitted into `processingChain` but
122
+ * not yet settled — added in `deliver()` the moment a `task.*` envelope is
123
+ * accepted past the ordinary watermark check, removed in `process()`'s
124
+ * `finally` once that specific attempt resolves (success OR failure).
125
+ * While stalled, `dedupWatermark()` deliberately stays frozen below
126
+ * already-delivered seqs (see its own doc comment) so the failed seq's own
127
+ * redelivery can get through — but that same frozen watermark also means
128
+ * every OTHER seq above it rides along on every re-poll too. Without this,
129
+ * a seq already mid-flight (e.g. a `task.offer` whose `adapter.start()`
130
+ * hasn't resolved yet) would be re-enqueued into `processingChain` on
131
+ * every such re-poll, piling up duplicate copies that — once the first
132
+ * finally resolves and the chain unwinds through them — run its handler
133
+ * again; for `task.offer` specifically, a second adapter session
134
+ * orphaning the first (`TaskRunner`'s own `this.tasks.has` guard, finding
135
+ * P2c, is the second, independent layer against exactly that).
136
+ */
137
+ private readonly inFlightSeqs;
138
+ /**
139
+ * Finding P2 (Fix 2b): seqs whose handler has already resolved
140
+ * successfully at least once this session, tracked only while a stall is
141
+ * in effect — cleared the moment `stalledAtSeq` itself clears (see
142
+ * `process()`), since once unstalled the ordinary watermark check via
143
+ * `deliveredSeq` already covers everything delivered so far, making this
144
+ * redundant. Needed because the stall-gap-prevention rule in `process()`
145
+ * deliberately does NOT advance `cursor` past a seq above the
146
+ * still-unresolved `stalledAtSeq`, even once that seq's own handler
147
+ * succeeds — so `dedupWatermark()` alone can't distinguish "already
148
+ * succeeded, don't re-run" from "never yet attempted" for anything in
149
+ * that gap.
150
+ */
151
+ private readonly processedSeqs;
152
+ /**
153
+ * Finding P3: the pending `drainOutbox` long-poll retry backoff, if any —
154
+ * cancellable so `enterRevoked()` can unblock it immediately instead of
155
+ * waiting out the rest of the delay before the loop notices `revoked` and
156
+ * exits. See `drainRetryDelay`.
157
+ */
158
+ private cancelPendingDrainRetry;
159
+ /**
160
+ * The capabilities the CURRENTLY connected server advertised in its
161
+ * `conn.ack` — untyped `string[]` (forward-compat: a server may advertise
162
+ * a flag this build doesn't recognize yet), populated by {@link onAcked}
163
+ * and read by {@link getServerCapabilities}. Empty until the very first
164
+ * successful handshake.
165
+ *
166
+ * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
167
+ * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
168
+ * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
169
+ * `acked` branch), `stop()`, or a transport switch to long-poll
170
+ * (`enterLongPoll`) — and only ever repopulated by a FRESH `conn.ack`.
171
+ * The previous version of this doc comment claimed long-poll mode simply
172
+ * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
173
+ * a daemon that once learned e.g. `approval_resolved` from an earlier WS
174
+ * session kept believing it applied to whatever it's connected to NOW,
175
+ * even after a disconnect/degrade where nothing has actually confirmed
176
+ * that's still true (a reconnect could land on a DIFFERENT server behind a
177
+ * load balancer; long-poll fallback itself never performs an equivalent
178
+ * handshake at all). Concretely, `TaskRunner.sendApprovalResolved` gates
179
+ * `task.approval_resolved` on this list — sending it to a server that
180
+ * doesn't actually understand it over the long-poll path would get a
181
+ * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
182
+ * `drainOutbox`'s retry-the-same-batch-forever loop then head-of-line
183
+ * blocks EVERY envelope queued behind it on, permanently. Clearing this
184
+ * eagerly means that gate reliably fails closed (falls back to the
185
+ * pre-existing implicit-resume inference, unconditionally — see
186
+ * `sendApprovalResolved`'s own doc comment) the moment the connection that
187
+ * advertised the capability is no longer the one actually in use.
188
+ */
189
+ private serverCapabilities;
190
+ constructor(opts: ConnectionManagerOptions);
191
+ start(): Promise<void>;
192
+ /**
193
+ * Design B (finding N4): push onto the single shared outbox and try to
194
+ * drain it now. Never routes directly to either transport itself — see
195
+ * `drainOutbox`.
196
+ */
197
+ send(envelope: Envelope): void;
198
+ /**
199
+ * Design B (finding N4): drain the shared outbox through whichever
200
+ * transport is currently active, re-checking `this.mode` fresh on every
201
+ * iteration so a transport switch mid-drain is picked up immediately
202
+ * rather than fighting a stale decision made before the switch.
203
+ *
204
+ * WS: a synchronous, one-at-a-time `sendNow` per envelope while open+
205
+ * acked; stops (without dropping anything — the remainder stays queued)
206
+ * the moment it isn't, and is re-invoked once `onAcked` fires.
207
+ *
208
+ * Long-poll: POSTs the outbox in chunks of at most
209
+ * `MAX_MESSAGES_PER_BATCH` (finding P1) — the server hard-caps a single
210
+ * `/byok/messages` batch there (`MessagesSendRequestSchema`, protocol
211
+ * §8.2) and 400s the WHOLE request if it's exceeded, which — before this
212
+ * fix — meant more than that queued during an outage produced an oversize
213
+ * batch that the server would reject forever, since the client re-queued
214
+ * and retried the identical (still oversize) batch unchanged. Each chunk
215
+ * is one `LongPollClient.postBatch` call; on success the loop continues
216
+ * (more may still be queued, or the next chunk still needs sending), on
217
+ * failure that SAME chunk is unshifted back (order-preserving, same
218
+ * Envelope objects/ids — never rebuilt, so a retry is exactly the resend
219
+ * Wave 1's server-side dedup expects) and retried after a short backoff,
220
+ * re-reading `this.mode` each time so a WS recovery that happens
221
+ * mid-retry is honored on the very next loop iteration instead of only
222
+ * after this attempt's backoff chain gives up.
223
+ *
224
+ * Re-entrancy is guarded by `draining`: a call arriving while a drain is
225
+ * already in progress just returns — the in-progress loop's own
226
+ * `while (this.outbox.length > 0)` check will pick up anything newly
227
+ * pushed (or left over after a mode switch) on its very next iteration.
228
+ */
229
+ private drainOutbox;
230
+ /**
231
+ * Finding P3: backoff delay for `drainOutbox`'s long-poll retry loop.
232
+ * Unlike a plain `setTimeout`-based wait, this is (a) cancellable —
233
+ * `enterRevoked()` calls `cancelPendingDrainRetry()` to unblock an
234
+ * in-flight wait immediately instead of leaving `drainOutbox` parked here
235
+ * for up to the rest of the delay before it next checks `this.revoked` —
236
+ * and (b) unref'd, so the timer never keeps the Node process alive by
237
+ * itself while nothing else (a live long-poll GET, an open WS connection)
238
+ * legitimately is.
239
+ */
240
+ private drainRetryDelay;
241
+ isTransportDegraded(): boolean;
242
+ /**
243
+ * The capabilities the CURRENTLY connected server advertised in its
244
+ * `conn.ack` — e.g. lets a caller gate a daemon->server message on whether
245
+ * THIS server understands it before sending (see `task-runner.ts`'s
246
+ * `sendApprovalResolved`, gated on `approval_resolved`). Empty before the
247
+ * first handshake completes, AND (finding R2) once again empty after any
248
+ * disconnect/degrade — see `serverCapabilities`'s own doc comment for why
249
+ * this is strictly per-connection rather than "sticky" across one.
250
+ */
251
+ getServerCapabilities(): readonly string[];
252
+ isConnected(): boolean;
253
+ isRevoked(): boolean;
254
+ /**
255
+ * Resolves once the connection has settled — either a working, acked WS
256
+ * connection, or the long-poll fallback taking over (protocol §8). This
257
+ * lets `daemon.start()` return promptly even when WS is unavailable from
258
+ * the very first attempt, rather than hanging until a WS `conn.ack` that
259
+ * may never come.
260
+ *
261
+ * Rejects with {@link DeviceRevokedError} — instead of hanging until
262
+ * `timeoutMs` — if the device turns out to be revoked while settling (or
263
+ * already was): a cold `daemon.start()` against an already-revoked device
264
+ * must fail fast, not surface a generic timeout (protocol §6.3).
265
+ */
266
+ waitForAck(timeoutMs?: number): Promise<void>;
267
+ /**
268
+ * Stops both transports and waits for every in-flight envelope handler
269
+ * (the F3 FIFO chain) and the most recent cursor write to actually land on
270
+ * disk — otherwise a `stop()` racing a just-processed envelope's
271
+ * persistence could lose that cursor advance, or leave a handler running
272
+ * unobserved after the daemon reports itself stopped.
273
+ *
274
+ * Finding F5(b) (cross-model adversarial review): `drainTimeoutMs`, when
275
+ * passed, bounds how long this waits for the shared outbox (`this.outbox`
276
+ * — Design B) to actually finish draining BEFORE flipping `this.stopped`
277
+ * and closing the transports. Before this fix, `stop()` set `stopped`
278
+ * synchronously and never waited for `drainOutbox` at all: an envelope
279
+ * `send()` had just pushed moments earlier (e.g. `TaskRunner.shutdownTask`'s
280
+ * own `task.fail`, sent right before `create-daemon.ts`'s
281
+ * `performControlShutdown` calls this) could still be sitting UNSENT in
282
+ * `this.outbox` — mid long-poll retry backoff, or simply not yet picked up
283
+ * by the fire-and-forget `drainOutbox()` `send()` kicked off — and this
284
+ * method would happily proceed to `stopped = true` / `ws.close()` regardless,
285
+ * after which NOTHING ever drains it again: silently lost, even though
286
+ * `TaskRunner` believed it had been sent. `drainTimeoutMs` omitted (the
287
+ * default) preserves the EXACT prior behavior for every other existing
288
+ * caller (an ordinary `daemon.stop()`/`unpair()`) — only the control-socket
289
+ * shutdown path opts into the bounded wait (see `create-daemon.ts`'s
290
+ * `performControlShutdown`). This can never claim delivery that didn't
291
+ * happen: on a timeout, whatever's still queued stays exactly where it is
292
+ * (readable via {@link outboxLength} immediately after this resolves) —
293
+ * it does NOT force-flush or pretend success.
294
+ */
295
+ stop(drainTimeoutMs?: number): Promise<void>;
296
+ /**
297
+ * Finding F5(b): how many envelopes are neither confirmed delivered NOR
298
+ * safely re-queued — meant to be read right after a bounded {@link stop}
299
+ * call returns, to know honestly whether the drain actually finished (0)
300
+ * or timed out with something still stuck (>0). See `create-daemon.ts`'s
301
+ * `performControlShutdown`, which surfaces this on the `shutdown-complete`
302
+ * audit event rather than silently claiming everything was delivered.
303
+ *
304
+ * Deliberately `this.outbox.length + this.inFlightBatchSize`, NOT just
305
+ * `this.outbox.length` alone: `drainOutbox`'s long-poll branch SPLICES a
306
+ * batch out of `this.outbox` before awaiting `postBatch` (so a concurrent
307
+ * `send()` sees an accurate, non-double-counted queue) — while that POST
308
+ * is in flight (or, this finding's whole point, genuinely STALLED and
309
+ * never resolving), those envelopes have already left `this.outbox` but
310
+ * are not delivered either. Reading `this.outbox.length` alone at exactly
311
+ * that moment would undercount to 0 — silently implying full delivery
312
+ * for the one case (a hung POST) this finding exists to catch honestly.
313
+ */
314
+ outboxLength(): number;
315
+ /**
316
+ * Finding F5(b): polls {@link outboxLength} (not `this.outbox.length`
317
+ * alone — see that method's own doc comment for why a spliced-out,
318
+ * in-flight batch would otherwise be invisible here) rather than hooking
319
+ * a single `drainOutbox()` promise directly — a drain in progress can
320
+ * itself loop through multiple retry/backoff cycles (`drainRetryDelay`)
321
+ * while the server is unreachable, and a fresh, INDEPENDENT
322
+ * `drainOutbox()` call can also be triggered concurrently (`send()`, a
323
+ * mode switch's own `void this.drainOutbox()`) — polling the one thing
324
+ * that actually matters (is anything still undelivered) can never go
325
+ * stale the way capturing one specific in-flight promise reference
326
+ * could. Kicks off one more `drainOutbox()` attempt itself first
327
+ * (harmless no-op if one is already running — see its own re-entrancy
328
+ * guard) in case nothing is currently actively retrying (e.g. WS just
329
+ * dropped and long-poll hasn't taken over yet), so this bounded wait
330
+ * isn't just passively hoping something else happens to be making
331
+ * progress.
332
+ */
333
+ private waitForOutboxDrained;
334
+ /**
335
+ * Findings F2 + F3. Two rules, both pinned in docs/protocol.md §1.2/§9:
336
+ *
337
+ * - F2 (redelivery dead on reconnect): cursor accounting covers ONLY
338
+ * `task.*` envelopes. `conn.ack` carries a `seq` too (required by the
339
+ * schema for schema uniformity across every server->daemon type), but a
340
+ * reconnecting server sends it BEFORE replaying the backlog and always
341
+ * assigns it the next (i.e. highest-so-far) per-device seq — advancing
342
+ * the cursor for it would make every backlog envelope's (necessarily
343
+ * lower) seq look already-delivered and drop it. `conn.*` envelopes
344
+ * never advance the cursor.
345
+ * - F3 (at-most-once): the old code persisted the cursor advance BEFORE
346
+ * `onEnvelope` even ran (fire-and-forget) — a handler that then failed
347
+ * left a redelivery-proof envelope permanently marked processed. Inbound
348
+ * envelopes are now serialized through `processingChain` (one handler
349
+ * fully settles before the next starts) and the cursor only advances
350
+ * AFTER the handler resolves successfully; a rejection leaves the
351
+ * cursor where it was (see `stalledAtSeq`), so a future reconnect's
352
+ * redelivery re-attempts it — safe because every server->daemon type is
353
+ * documented idempotent (protocol §9).
354
+ */
355
+ private deliver;
356
+ /**
357
+ * Design A: the watermark `deliver()` dedupes inbound `task.*` envelopes
358
+ * against, and the same value `LongPollClient` queries the next
359
+ * `GET /byok/events` cursor with (see the constructor). Normally this is
360
+ * `deliveredSeq` — which is always >= `cursor` (every envelope that
361
+ * reaches `advanceCursor` already passed through `noteDelivered` first,
362
+ * see `deliver`) — so this is the literal `max(cursor, deliveredSeq)` the
363
+ * design calls for, just expressed via that invariant rather than an
364
+ * explicit `Math.max`.
365
+ *
366
+ * While `stalledAtSeq` is set, this collapses to the durable `cursor`
367
+ * alone, deliberately ignoring however far `deliveredSeq` had already run
368
+ * ahead before the failure was known: that's what lets the stalled
369
+ * envelope's own redelivery (and everything after it, right up to a
370
+ * fresh success) get past this same dedup check instead of being
371
+ * self-deduped by the client's own earlier eager tracking of envelopes
372
+ * whose outcome wasn't known yet. No separate "reset deliveredSeq on
373
+ * reconnect" step is needed for this to be correct — collapsing to
374
+ * `cursor` exactly while stalled already produces the right answer on
375
+ * every redelivery path (long-poll re-query AND a WS reconnect's
376
+ * backlog replay alike), and NOT resetting it unconditionally on every
377
+ * reconnect is what lets `deliveredSeq` keep doing its job of not
378
+ * re-pulling/re-dispatching something already in flight across a
379
+ * reconnect that happens to land while a handler is still running.
380
+ */
381
+ private dedupWatermark;
382
+ /** Design A: eagerly advance the in-memory delivery watermark — called for every `task.*` envelope `deliver()` admits past dedup, regardless of transport or of whether its handler has even started yet. */
383
+ private noteDelivered;
384
+ private process;
385
+ /**
386
+ * M4 Phase 4 (version-negotiation drill fix): `LongPollClient` calls this
387
+ * for a batch entry it could not parse into a known `Envelope` at all (an
388
+ * unrecognized message type — mirrors `ws-transport.ts`'s identical
389
+ * per-frame tolerance, see `long-poll-transport.ts`'s own doc comment on
390
+ * `parseLooseEventsPollResponse`) but which still carried a numeric,
391
+ * task-class envelope-level `seq` (the caller only invokes this for a
392
+ * `task.`-prefixed type — see `long-poll-transport.ts`'s own
393
+ * `extractSkippableSeq`; `conn.*`-shaped or type-less entries never reach
394
+ * here at all, mirroring F2's "conn.* is never cursor-tracked" rule).
395
+ * There is no real `Envelope` to hand to a handler — a genuinely
396
+ * unrecognized type has nothing this build could ever act on.
397
+ *
398
+ * GATEKEEPER-CAUGHT REGRESSION (fixed here): this used to call
399
+ * `advanceCursor(seq)` DIRECTLY, synchronously, the instant a skip was
400
+ * detected in `LongPollClient.loop()`'s per-entry for-loop. That is NOT
401
+ * "instantaneous and race-free" the way the previous version of this
402
+ * comment claimed — the hazard was never the skip racing against itself,
403
+ * it was the skip racing AHEAD of an EARLIER real envelope in the SAME
404
+ * batch that is still in flight on `processingChain` (`deliver()`, above,
405
+ * only ever CHAINS `process()` onto that promise chain — it never awaits
406
+ * it before returning). Concretely, batch `[real seq1, unknown seq2]`:
407
+ * `deliver(seq1)` chains `process(seq1)` but returns immediately without
408
+ * running it; the for-loop then reaches `seq2` and (pre-fix) called
409
+ * `advanceCursor(2)` synchronously, BEFORE `process(seq1)` had even
410
+ * started, let alone failed. If `seq1`'s handler then failed,
411
+ * `stalledAtSeq` became 1 — but the durable cursor was already 2, so
412
+ * `dedupWatermark()` returned 2, and every future redelivery of seq1 was
413
+ * dedup-dropped as "already past the cursor" forever: permanent envelope
414
+ * loss, exactly the F3 bug class the whole `stalledAtSeq`/frozen-watermark
415
+ * mechanism exists to prevent.
416
+ *
417
+ * Fix: the cursor-advancing half is now CHAINED onto `processingChain`
418
+ * too, exactly like `process()`'s own post-handler bookkeeping — so it
419
+ * only ever runs once every earlier envelope already queued ahead of it
420
+ * has fully settled (success or failure), and can observe `stalledAtSeq`'s
421
+ * REAL, up-to-date value rather than whatever it happened to be at the
422
+ * instant the skip was first noticed. The guard mirrors `process()`'s own
423
+ * success-path guard exactly: never advance past a still-unresolved
424
+ * earlier failure, unless (degenerate, cannot really happen for a skip)
425
+ * this exact seq IS the stalled one.
426
+ *
427
+ * `noteDelivered` (the eager, in-memory watermark) stays UNCHAINED —
428
+ * called immediately, unconditionally, regardless of `stalledAtSeq` —
429
+ * matching `deliver()`'s own eager, unconditional call for a real
430
+ * envelope: its only job is "don't re-pull something already handed off,"
431
+ * independent of outcome, and that property does not depend on FIFO
432
+ * ordering the way the DURABLE cursor does.
433
+ *
434
+ * Deliberately NO top-level `dedupWatermark() <= seq` early-return before
435
+ * queuing the chained callback (an earlier draft of this fix had one, and
436
+ * it was itself subtly wrong): `deliveredSeq` can already reflect a seq
437
+ * from the FIRST time it was ever seen, while the DURABLE cursor is still
438
+ * behind it because a stall intervened before that seq's chained
439
+ * advancement ran — a pre-check keyed on `deliveredSeq` would then
440
+ * wrongly treat a LATER redelivery of the same seq (arriving once the
441
+ * stall has since cleared) as "already accounted for" and never queue
442
+ * another attempt, permanently stranding the cursor one seq short. Always
443
+ * queuing is safe and cheap: `advanceCursor`'s own `seq <= this.cursor`
444
+ * guard already makes a genuinely-redundant call a no-op, so there is no
445
+ * correctness reason to short-circuit earlier, only a (here, unnecessary)
446
+ * micro-optimization one.
447
+ */
448
+ private noteSkippedSeq;
449
+ /**
450
+ * Finding R1 (cross-model re-review — was NOT-CLOSED against F1):
451
+ * `LongPollClient` calls this for a batch entry whose `type` it
452
+ * recognized but whose payload failed schema validation
453
+ * ({@link EnvelopeValidationError}) — a genuine delivery failure at that
454
+ * seq, unlike `noteSkippedSeq`'s forward-compat case. Deliberately mirrors
455
+ * `process()`'s own catch block (`if (tracked && this.stalledAtSeq ===
456
+ * undefined) this.stalledAtSeq = envelope.seq;`) as closely as possible:
457
+ * the SAME "only the lowest unresolved failure holds the stall" rule, the
458
+ * SAME resulting freeze of `dedupWatermark()` at the durable cursor
459
+ * (protocol §9 keeps this seq alive), and — because it's the SAME
460
+ * `stalledAtSeq` field `process()`'s own post-success guard already
461
+ * checks — anything ELSE delivered after this seq (same batch or a later
462
+ * one) is automatically held back from advancing the cursor too, with
463
+ * zero changes needed to `process()` itself.
464
+ *
465
+ * Chained onto `processingChain` for exactly the reason `noteSkippedSeq`
466
+ * documents for its own identical chaining (see that method's sibling
467
+ * doc comment on `LongPollClient`, "GATEKEEPER-CAUGHT REGRESSION"): an
468
+ * EARLIER real envelope in the SAME batch may still be in flight on that
469
+ * FIFO chain when this is called (`deliver()` only ever chains
470
+ * `process()` onto it, never awaits before returning) — mutating
471
+ * `stalledAtSeq` synchronously here could race ahead of that still-
472
+ * unresolved earlier envelope. Chaining instead guarantees this only
473
+ * takes effect once every earlier-queued envelope has already settled,
474
+ * and reads `stalledAtSeq`'s real, up-to-date value rather than whatever
475
+ * it happened to be the instant the failure was first noticed.
476
+ *
477
+ * No `noteDelivered` call here (contrast `noteSkippedSeq`, which does
478
+ * call it): a validation-failed entry never becomes a real `Envelope` and
479
+ * never reaches `deliver()`, so it was never "delivered" in the eager
480
+ * in-memory-watermark sense that field tracks — there is nothing for it
481
+ * to eagerly mark. Once a corrected redelivery of this exact seq DOES
482
+ * arrive as a real envelope, it flows through the ordinary `deliver()`
483
+ * path (which calls `noteDelivered` itself) and, on success, clears the
484
+ * stall via `process()`'s own existing logic — no special-casing needed.
485
+ */
486
+ private noteValidationFailure;
487
+ private advanceCursor;
488
+ /**
489
+ * Fires the moment a connection attempt reaches `conn.ack` — independent
490
+ * of whether/when it later closes. This is the ONLY place that can
491
+ * reliably detect "WS is back up" while long-polling: a healthy
492
+ * connection stays open indefinitely, so it never reaches `onWsOutcome`
493
+ * (which is close-only) at all.
494
+ */
495
+ private onAcked;
496
+ private onWsOutcome;
497
+ private notifySettled;
498
+ private enterLongPoll;
499
+ private exitLongPoll;
500
+ private scheduleWsProbe;
501
+ private enterRevoked;
502
+ }