@byok-sdk/client 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +28 -3
  2. package/dist/adapters/index.js +74 -5
  3. package/dist/adapters/index.js.map +1 -1
  4. package/dist/adapters/pi/pi-adapter.d.ts +3 -0
  5. package/dist/adapters/pi/subagents-policy-extension.js +1 -1
  6. package/dist/adapters/pi/subagents-policy-extension.js.map +1 -1
  7. package/dist/agent-home.d.ts +107 -0
  8. package/dist/agent-memory/index.d.ts +1 -1
  9. package/dist/agent-memory/index.js +8 -5
  10. package/dist/agent-memory/index.js.map +1 -1
  11. package/dist/bin/byok-agent-memory-mcp.js.map +1 -1
  12. package/dist/bin/byok-agent-message-mcp.js.map +1 -1
  13. package/dist/bin/byok-agent-team-mcp.js.map +1 -1
  14. package/dist/bin/byok-agent.js +1279 -642
  15. package/dist/bin/byok-agent.js.map +1 -1
  16. package/dist/bin/byok-approval-mcp.js.map +1 -1
  17. package/dist/daemon/agent-egress-controller.d.ts +4 -0
  18. package/dist/daemon/auth-manager.d.ts +20 -0
  19. package/dist/daemon/blob-client.d.ts +24 -6
  20. package/dist/daemon/connection-manager.d.ts +69 -126
  21. package/dist/daemon/control-protocol.d.ts +8 -1
  22. package/dist/daemon/create-daemon.d.ts +28 -13
  23. package/dist/daemon/device-credential-store.d.ts +16 -0
  24. package/dist/daemon/long-poll-transport.d.ts +32 -23
  25. package/dist/daemon/observer.d.ts +1 -1
  26. package/dist/daemon/replay-cursor.d.ts +9 -0
  27. package/dist/daemon/task-runner.d.ts +22 -0
  28. package/dist/daemon/url.d.ts +17 -13
  29. package/dist/diagnostics/support-bundle.d.ts +1 -1
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.js +1278 -641
  32. package/dist/index.js.map +1 -1
  33. package/package.json +5 -7
  34. package/dist/daemon/ws-transport.d.ts +0 -139
@@ -9,6 +9,15 @@ import type { DeviceCredentialStore, InMemoryDeviceCredentialStore } from './dev
9
9
  export declare class DeviceRevokedError extends Error {
10
10
  constructor(message?: string);
11
11
  }
12
+ /**
13
+ * Thrown when the local AuthManager deadline or shutdown cancels its own
14
+ * in-flight request. This is deliberately distinct from `DeviceRevokedError`:
15
+ * only an actual challenge/token HTTP 401 is server authority for revocation.
16
+ */
17
+ export declare class AuthRequestAbortedError extends Error {
18
+ readonly reason: 'deadline' | 'stopped';
19
+ constructor(reason: 'deadline' | 'stopped');
20
+ }
12
21
  export interface AuthManagerOptions {
13
22
  serverUrl: string;
14
23
  store: DeviceStore;
@@ -23,6 +32,8 @@ export interface AuthManagerOptions {
23
32
  * permission to supersede this machine's prior active device rows.
24
33
  */
25
34
  machineId?: () => Promise<string | undefined>;
35
+ /** Upper bound for one pair/challenge/token fetch plus its response-body read. */
36
+ authRequestDeadlineMs?: number;
26
37
  /** Called once revocation is detected, so a caller (ConnectionManager) can stop retrying and surface the state instead of looping. */
27
38
  onRevoked?: () => void;
28
39
  }
@@ -42,7 +53,10 @@ export declare class AuthManager {
42
53
  private stopped;
43
54
  private pairing;
44
55
  private credentialMutationTail;
56
+ /** The sole cancellation authority for the request currently inside the serialized credential mutation. */
57
+ private activeRequest;
45
58
  private readonly credentials;
59
+ private readonly requestDeadlineMs;
46
60
  constructor(opts: AuthManagerOptions);
47
61
  get deviceId(): string | undefined;
48
62
  isRevoked(): boolean;
@@ -62,6 +76,12 @@ export declare class AuthManager {
62
76
  /** Always throws — `never` return type lets call sites use `if (x === 401) this.markRevoked();` without an explicit `return`/`throw` of their own. */
63
77
  private markRevoked;
64
78
  private scheduleProactiveRenewal;
79
+ /**
80
+ * Bounds one complete auth exchange rather than fetch alone. Keeping the
81
+ * controller active through `json()`/`text()` makes a non-cooperative or
82
+ * partial response body cancellable by the same authority that owns fetch.
83
+ */
84
+ private runRequest;
65
85
  private runCredentialMutation;
66
86
  /** Read the current paired authority afresh; metadata without its OS secret is re-pair required. */
67
87
  private loadRecord;
@@ -1,9 +1,25 @@
1
1
  import type { BlobRef } from '@byok-sdk/protocol';
2
2
  import type { AuthManager } from './auth-manager';
3
+ export type BlobRequestAbortReason = 'deadline' | 'cancelled';
4
+ /** A blob request/body read did not complete before its deadline or its owner cancelled it. */
5
+ export declare class BlobRequestAbortedError extends Error {
6
+ readonly reason: BlobRequestAbortReason;
7
+ constructor(reason: BlobRequestAbortReason);
8
+ }
9
+ export interface BlobClientOptions {
10
+ /** Bound for each individual HTTP request and response-body read. Default: 15 seconds. */
11
+ requestDeadlineMs?: number;
12
+ /** Daemon lifecycle authority; aborting it stops all in-flight blob I/O. */
13
+ signal?: AbortSignal;
14
+ }
15
+ export interface BlobRequestOptions {
16
+ /** Task lifecycle authority; aborting it stops this transfer before finalization. */
17
+ signal?: AbortSignal;
18
+ }
3
19
  /** Seam `TaskRunner` depends on, so tests can substitute a fake without spinning up real HTTP endpoints. */
4
20
  export interface BlobResolver {
5
- resolveInstruction(blobRef: BlobRef): Promise<string>;
6
- uploadArtifact(content: string | Uint8Array, contentType: string, options?: {
21
+ resolveInstruction(blobRef: BlobRef, options?: BlobRequestOptions): Promise<string>;
22
+ uploadArtifact(content: string | Uint8Array, contentType: string, options?: BlobRequestOptions & {
7
23
  readonly idempotencyKey?: string;
8
24
  }): Promise<BlobRef>;
9
25
  }
@@ -16,11 +32,13 @@ export declare class BlobClient implements BlobResolver {
16
32
  #private;
17
33
  private readonly serverUrl;
18
34
  private readonly auth;
19
- constructor(serverUrl: string, auth: AuthManager);
35
+ private readonly options;
36
+ private readonly requestDeadlineMs;
37
+ constructor(serverUrl: string, auth: AuthManager, options?: BlobClientOptions);
20
38
  /** `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. */
21
- resolveInstruction(blobRef: BlobRef): Promise<string>;
22
- /** `POST /byok/blobs` (declares size/contentType/contentHash) -> PUT the bytes to the presigned upload URL -> a `BlobRef` for `task.artifact.blobRef`. */
23
- uploadArtifact(content: string | Uint8Array, contentType: string, options?: {
39
+ resolveInstruction(blobRef: BlobRef, options?: BlobRequestOptions): Promise<string>;
40
+ /** `POST /byok/blobs` -> PUT the bytes to the presigned URL -> finalize into a `BlobRef`. */
41
+ uploadArtifact(content: string | Uint8Array, contentType: string, options?: BlobRequestOptions & {
24
42
  readonly idempotencyKey?: string;
25
43
  }): Promise<BlobRef>;
26
44
  }
@@ -2,17 +2,19 @@ import { type CapabilityFlag, type Envelope, type RuntimeInfo, type ToolsetId }
2
2
  import { AuthManager } from './auth-manager';
3
3
  import type { CursorStore } from './cursor-store';
4
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';
5
+ import { ReplayCursorTooOldError } from './replay-cursor';
6
+ export { ReplayCursorTooOldError } from './replay-cursor';
7
+ /** The lifecycle of the daemon's one long-poll connection. */
8
+ export type ConnectionState = 'connecting' | 'open' | 'closed' | 'revoked';
7
9
  export interface ConnectionManagerOptions {
8
10
  serverUrl: string;
9
11
  deviceId: string;
10
12
  productId: string;
11
13
  capabilities: CapabilityFlag[];
12
- /** U4a Local Agent release version; passed unchanged to both transports. */
14
+ /** U4a Local Agent release version, sent unchanged in `conn.hello`. */
13
15
  clientVersion?: string;
14
16
  runtimes: RuntimeInfo[];
15
- /** Reads current sorted logical IDs from the validated local registry for every WS hello. */
17
+ /** Reads current sorted logical IDs from the validated local registry for every `conn.hello`. */
16
18
  getConfiguredToolsets?: () => readonly ToolsetId[];
17
19
  auth: AuthManager;
18
20
  cursorStore: CursorStore;
@@ -23,46 +25,35 @@ export interface ConnectionManagerOptions {
23
25
  */
24
26
  onEnvelope: (envelope: Envelope) => void | Promise<void>;
25
27
  onStateChange?: (state: ConnectionState) => void;
26
- backoff?: BackoffOptions;
27
- liveness?: LivenessOptions;
28
- /** Consecutive never-acked WS connect failures before falling back to long-poll (protocol §8). Default 3. */
29
- wsFailureThreshold?: number;
30
- /** While long-polling, how often to retry establishing WS (protocol §8, "e.g. every 5 min"). Default 5 minutes. */
31
- wsRetryIntervalMs?: number;
32
28
  /** Backoff between failed long-poll HTTP attempts. Default 2s. */
33
29
  longPollRetryDelayMs?: number;
34
30
  /** Minimum delay before the next long-poll request after an empty (no-events) response. Default 250ms. */
35
31
  longPollIdleDelayMs?: number;
36
32
  fleetJitter?: FleetJitter;
37
33
  onOperationalOutcome?: (outcome: 'success' | 'failure', source: 'reconnect' | 'upload') => void;
34
+ onTerminalError?: (error: ReplayCursorTooOldError) => void;
35
+ }
36
+ export interface RejectedOutboundEnvelope {
37
+ readonly envelope: Envelope;
38
+ readonly reason: 'inbound_rejected';
38
39
  }
39
40
  /**
40
- * Owns the daemon's one logical connection to the server, which may be
41
- * backed by either transport the wire protocol defines: WS (the normal
42
- * path) or long-poll (protocol §8's fallback for environments where an
43
- * outbound WSS connection isn't viable). Both funnel every received
44
- * envelope through the same cursor-dedupe/persistence logic (protocol §9),
45
- * so redelivery is safe regardless of which transport happens to deliver a
46
- * given envelope — including during the brief overlap window when handing
47
- * off between them.
41
+ * Owns the daemon's one authenticated long-poll connection to the server.
42
+ * Every received envelope passes through the same cursor-dedupe/persistence
43
+ * logic (protocol §9), so redelivery remains safe after an HTTP retry or a
44
+ * daemon restart.
48
45
  *
49
46
  * `send()` (Design B, finding N4) pushes onto a single shared outbox this
50
- * class owns and drains through whichever transport is currently active —
51
- * WS raw-sends while acked, `POST /byok/messages` while long-polling
52
- * (finding F6, long-poll is a full transport, not receive-only; see
53
- * docs/protocol.md §8) — so a transport switch mid-flight never strands a
54
- * queued envelope. See `drainOutbox`.
47
+ * class owns and drains through `POST /byok/messages`; long-poll is a full
48
+ * bidirectional transport, not a receive-only path. See `drainOutbox`.
55
49
  */
56
50
  export declare class ConnectionManager {
57
51
  private readonly opts;
58
52
  private readonly fleetJitter;
59
- private readonly ws;
60
53
  private readonly longPoll;
61
- private mode;
62
- private consecutiveFailures;
63
- private wsRetryTimer;
64
- private wsProbeSequence;
65
54
  private uploadRetryAttempt;
55
+ private started;
56
+ private connected;
66
57
  private cursor;
67
58
  /**
68
59
  * Finding F3 (at-most-once redelivery): the lowest `task.*` envelope `seq`
@@ -84,10 +75,9 @@ export declare class ConnectionManager {
84
75
  * (see `advanceCursor`) — that semantics is unchanged. `deliveredSeq`
85
76
  * advances eagerly, the instant a `task.*` envelope is admitted past
86
77
  * dedup (see `deliver`/`noteDelivered`), independent of whether its
87
- * handler has even started, let alone succeeded. It exists so a
88
- * long-poll re-query (`LongPollClient`'s `getCursor`) doesn't re-pull an
89
- * envelope that's already been delivered once and is still in flight
90
- * `handleOffer` is NOT idempotent and must never be re-pulled while a
78
+ * handler has even started, let alone succeeded. It exists so a repeated
79
+ * read at the durable cursor does not re-dispatch an envelope already in
80
+ * flight `handleOffer` must not start a second adapter session while a
91
81
  * first attempt is still running. On WS this same field is written the
92
82
  * same way, but since a live WS connection only ever pushes a given `seq`
93
83
  * once, it never has an observable effect there beyond mirroring
@@ -98,18 +88,18 @@ export declare class ConnectionManager {
98
88
  /** Finding F3: serializes `onEnvelope` calls into a per-connection FIFO — one envelope's handler always fully settles before the next one starts. */
99
89
  private processingChain;
100
90
  /**
101
- * Design B (finding N4): the ONE outbound queue both transports drain
102
- * from — holds `Envelope` OBJECTS, never re-encoded/rebuilt strings, so a
91
+ * Design B (finding N4): the ONE outbound queue holds `Envelope` OBJECTS,
92
+ * never re-encoded/rebuilt strings, so a
103
93
  * resend after a failed send attempt is byte-identical to the original
104
94
  * (same `id`), which is what lets the server's per-(deviceId,id) dedup
105
95
  * (Wave 1) recognize it as a safe no-op retry rather than a second
106
- * application (protocol §9). A transport switch (long-poll <-> WS) never
107
- * touches this queue — see `drainOutbox` — so nothing queued while one
108
- * transport was active is ever stranded when the other takes over.
96
+ * application (protocol §9).
109
97
  */
110
98
  private readonly outbox;
99
+ /** Terminally rejected outbound envelopes, retained as a bounded observable quarantine. */
100
+ private readonly rejectedOutboundEnvelopes;
111
101
  /**
112
- * Finding F5(b): how many envelopes `drainOutbox`'s long-poll branch has
102
+ * Finding F5(b): how many envelopes `drainOutbox` has
113
103
  * currently spliced OUT of `this.outbox` for an in-flight (not yet
114
104
  * confirmed delivered) `postBatch` call — 0 the rest of the time. See
115
105
  * `outboxLength`'s own doc comment for why this needs to be tracked
@@ -119,6 +109,7 @@ export declare class ConnectionManager {
119
109
  private draining;
120
110
  private stopped;
121
111
  private revoked;
112
+ private terminalError;
122
113
  private settledWaiters;
123
114
  private pendingCursorSave;
124
115
  /**
@@ -161,33 +152,12 @@ export declare class ConnectionManager {
161
152
  */
162
153
  private cancelPendingDrainRetry;
163
154
  /**
164
- * The capabilities the CURRENT transport's server advertised — untyped
165
- * `string[]` for forward compatibility. WS populates it from `conn.ack`;
166
- * long-poll populates it from each successful events response. Empty until
167
- * the active transport supplies an advertisement.
168
- *
169
- * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
170
- * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
171
- * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
172
- * `acked` branch), `stop()`, or a transport switch to long-poll
173
- * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
174
- * the transport that is still current.
175
- * The previous version of this doc comment claimed long-poll mode simply
176
- * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
177
- * a daemon that once learned e.g. `approval_resolved` from an earlier WS
178
- * session kept believing it applied to whatever it's connected to NOW,
179
- * even after a disconnect/degrade where nothing has actually confirmed
180
- * that's still true (a reconnect could land on a DIFFERENT server behind a
181
- * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
182
- * `task.approval_resolved` on this list — sending it to a server that
183
- * doesn't actually understand it over the long-poll path would get a
184
- * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
185
- * `drainOutbox`'s retry-the-same-batch-forever loop then head-of-line
186
- * blocks EVERY envelope queued behind it on, permanently. Clearing this
187
- * eagerly means that gate reliably fails closed (falls back to the
188
- * pre-existing implicit-resume inference, unconditionally — see
189
- * `sendApprovalResolved`'s own doc comment) the moment the connection that
190
- * advertised the capability is no longer the one actually in use.
155
+ * The capabilities the current server response advertised — untyped
156
+ * `string[]` for forward compatibility. An advertisement is scoped to the
157
+ * current long-poll response stream and is cleared after an HTTP failure,
158
+ * terminal shutdown, or revocation. This keeps capability-gated outbound
159
+ * messages fail-closed until the current server has explicitly advertised
160
+ * support.
191
161
  */
192
162
  private serverCapabilities;
193
163
  constructor(opts: ConnectionManagerOptions);
@@ -198,17 +168,10 @@ export declare class ConnectionManager {
198
168
  * `drainOutbox`.
199
169
  */
200
170
  send(envelope: Envelope): void;
171
+ /** Publish a fresh local configuration snapshot while this daemon is running. */
172
+ refreshHello(): void;
201
173
  /**
202
- * Design B (finding N4): drain the shared outbox through whichever
203
- * transport is currently active, re-checking `this.mode` fresh on every
204
- * iteration so a transport switch mid-drain is picked up immediately
205
- * rather than fighting a stale decision made before the switch.
206
- *
207
- * WS: a synchronous, one-at-a-time `sendNow` per envelope while open+
208
- * acked; stops (without dropping anything — the remainder stays queued)
209
- * the moment it isn't, and is re-invoked once `onAcked` fires.
210
- *
211
- * Long-poll: POSTs the outbox in chunks of at most
174
+ * POSTs the outbox through long-poll in chunks of at most
212
175
  * `MAX_MESSAGES_PER_BATCH` (finding P1) — the server hard-caps a single
213
176
  * `/byok/messages` batch there (`MessagesSendRequestSchema`, protocol
214
177
  * §8.2) and 400s the WHOLE request if it's exceeded, which — before this
@@ -220,14 +183,9 @@ export declare class ConnectionManager {
220
183
  * failure that SAME chunk is unshifted back (order-preserving, same
221
184
  * Envelope objects/ids — never rebuilt, so a retry is exactly the resend
222
185
  * Wave 1's server-side dedup expects) and retried after a short backoff,
223
- * re-reading `this.mode` each time so a WS recovery that happens
224
- * mid-retry is honored on the very next loop iteration instead of only
225
- * after this attempt's backoff chain gives up.
226
- *
227
186
  * Re-entrancy is guarded by `draining`: a call arriving while a drain is
228
187
  * already in progress just returns — the in-progress loop's own
229
- * `while (this.outbox.length > 0)` check will pick up anything newly
230
- * pushed (or left over after a mode switch) on its very next iteration.
188
+ * `while (this.outbox.length > 0)` check picks up anything newly pushed.
231
189
  */
232
190
  private drainOutbox;
233
191
  /**
@@ -237,35 +195,29 @@ export declare class ConnectionManager {
237
195
  * in-flight wait immediately instead of leaving `drainOutbox` parked here
238
196
  * for up to the rest of the delay before it next checks `this.revoked` —
239
197
  * and (b) unref'd, so the timer never keeps the Node process alive by
240
- * itself while nothing else (a live long-poll GET, an open WS connection)
241
- * legitimately is.
198
+ * itself while nothing else (such as the live long-poll GET) legitimately is.
242
199
  */
243
200
  private drainRetryDelay;
244
- isTransportDegraded(): boolean;
245
201
  /**
246
- * The capabilities the CURRENT transport's server advertised: from
247
- * `conn.ack` on WS, or the latest successful `GET /byok/events` response
248
- * on long-poll. Empty before either transport has supplied its current
249
- * advertisement, and cleared across disconnect/switch boundaries.
202
+ * The capabilities the latest successful `GET /byok/events` response
203
+ * advertised. Empty before a successful response and after a failed one.
250
204
  */
251
205
  getServerCapabilities(): readonly string[];
206
+ getTerminalError(): ReplayCursorTooOldError | undefined;
252
207
  isConnected(): boolean;
253
208
  isRevoked(): boolean;
254
209
  /**
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.
210
+ * Resolves after the first successful long-poll response establishes the
211
+ * authenticated connection.
260
212
  *
261
213
  * Rejects with {@link DeviceRevokedError} — instead of hanging until
262
214
  * `timeoutMs` — if the device turns out to be revoked while settling (or
263
215
  * already was): a cold `daemon.start()` against an already-revoked device
264
216
  * must fail fast, not surface a generic timeout (protocol §6.3).
265
217
  */
266
- waitForAck(timeoutMs?: number): Promise<void>;
218
+ waitForConnection(timeoutMs?: number): Promise<void>;
267
219
  /**
268
- * Stops both transports and waits for every in-flight envelope handler
220
+ * Stops the long-poll transport and waits for every in-flight envelope handler
269
221
  * (the F3 FIFO chain) and the most recent cursor write to actually land on
270
222
  * disk — otherwise a `stop()` racing a just-processed envelope's
271
223
  * persistence could lose that cursor advance, or leave a handler running
@@ -274,14 +226,14 @@ export declare class ConnectionManager {
274
226
  * Finding F5(b) (cross-model adversarial review): `drainTimeoutMs`, when
275
227
  * passed, bounds how long this waits for the shared outbox (`this.outbox`
276
228
  * — Design B) to actually finish draining BEFORE flipping `this.stopped`
277
- * and closing the transports. Before this fix, `stop()` set `stopped`
229
+ * and stopping the transport. Before this fix, `stop()` set `stopped`
278
230
  * synchronously and never waited for `drainOutbox` at all: an envelope
279
231
  * `send()` had just pushed moments earlier (e.g. `TaskRunner.shutdownTask`'s
280
232
  * own `task.fail`, sent right before `create-daemon.ts`'s
281
233
  * `performControlShutdown` calls this) could still be sitting UNSENT in
282
234
  * `this.outbox` — mid long-poll retry backoff, or simply not yet picked up
283
235
  * by the fire-and-forget `drainOutbox()` `send()` kicked off — and this
284
- * method would happily proceed to `stopped = true` / `ws.close()` regardless,
236
+ * method would happily proceed to `stopped = true` regardless,
285
237
  * after which NOTHING ever drains it again: silently lost, even though
286
238
  * `TaskRunner` believed it had been sent. `drainTimeoutMs` omitted (the
287
239
  * default) preserves the EXACT prior behavior for every other existing
@@ -312,6 +264,8 @@ export declare class ConnectionManager {
312
264
  * for the one case (a hung POST) this finding exists to catch honestly.
313
265
  */
314
266
  outboxLength(): number;
267
+ /** A bounded terminal quarantine for operator inspection; these entries are never retried. */
268
+ rejectedOutbox(): readonly RejectedOutboundEnvelope[];
315
269
  /**
316
270
  * Finding F5(b): polls {@link outboxLength} (not `this.outbox.length`
317
271
  * alone — see that method's own doc comment for why a spliced-out,
@@ -319,15 +273,14 @@ export declare class ConnectionManager {
319
273
  * a single `drainOutbox()` promise directly — a drain in progress can
320
274
  * itself loop through multiple retry/backoff cycles (`drainRetryDelay`)
321
275
  * 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
276
+ * `drainOutbox()` call can also be triggered concurrently (`send()`)
277
+ * polling the one thing
324
278
  * that actually matters (is anything still undelivered) can never go
325
279
  * stale the way capturing one specific in-flight promise reference
326
280
  * could. Kicks off one more `drainOutbox()` attempt itself first
327
281
  * (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
282
+ * guard) in case nothing is currently actively retrying, so this bounded
283
+ * wait isn't just passively hoping something else happens to be making
331
284
  * progress.
332
285
  */
333
286
  private waitForOutboxDrained;
@@ -354,9 +307,10 @@ export declare class ConnectionManager {
354
307
  */
355
308
  private deliver;
356
309
  /**
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
310
+ * The local watermark `deliver()` dedupes inbound `task.*` envelopes
311
+ * against. It is deliberately NOT the long-poll query cursor: that query
312
+ * is the kernel acknowledgement and uses only the successfully processed
313
+ * `cursor` (see the constructor). Normally this local watermark is
360
314
  * `deliveredSeq` — which is always >= `cursor` (every envelope that
361
315
  * reaches `advanceCursor` already passed through `noteDelivered` first,
362
316
  * see `deliver`) — so this is the literal `max(cursor, deliveredSeq)` the
@@ -372,11 +326,9 @@ export declare class ConnectionManager {
372
326
  * whose outcome wasn't known yet. No separate "reset deliveredSeq on
373
327
  * reconnect" step is needed for this to be correct — collapsing to
374
328
  * `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.
329
+ * every long-poll retry path. NOT resetting it unconditionally on every
330
+ * retry lets `deliveredSeq` keep doing its job of not re-dispatching
331
+ * something already in flight while a handler is still running.
380
332
  */
381
333
  private dedupWatermark;
382
334
  /** 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. */
@@ -385,9 +337,8 @@ export declare class ConnectionManager {
385
337
  /**
386
338
  * M4 Phase 4 (version-negotiation drill fix): `LongPollClient` calls this
387
339
  * 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,
340
+ * unrecognized message type (see `long-poll-transport.ts`'s own doc
341
+ * comment on `parseLooseEventsPollResponse`) but which still carried a numeric,
391
342
  * task-class envelope-level `seq` (the caller only invokes this for a
392
343
  * `task.`-prefixed type — see `long-poll-transport.ts`'s own
393
344
  * `extractSkippableSeq`; `conn.*`-shaped or type-less entries never reach
@@ -427,7 +378,7 @@ export declare class ConnectionManager {
427
378
  * `noteDelivered` (the eager, in-memory watermark) stays UNCHAINED —
428
379
  * called immediately, unconditionally, regardless of `stalledAtSeq` —
429
380
  * matching `deliver()`'s own eager, unconditional call for a real
430
- * envelope: its only job is "don't re-pull something already handed off,"
381
+ * envelope: its only job is "don't re-dispatch something already handed off,"
431
382
  * independent of outcome, and that property does not depend on FIFO
432
383
  * ordering the way the DURABLE cursor does.
433
384
  *
@@ -485,18 +436,10 @@ export declare class ConnectionManager {
485
436
  */
486
437
  private noteValidationFailure;
487
438
  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;
439
+ private quarantineRejectedOutbound;
497
440
  private notifySettled;
498
- private enterLongPoll;
499
- private exitLongPoll;
500
- private scheduleWsProbe;
441
+ private noteConnected;
442
+ private noteDisconnected;
443
+ private enterReplayCursorTooOld;
501
444
  private enterRevoked;
502
445
  }
@@ -5,6 +5,7 @@ import type { StoragePressureState } from './journal/storage-policy';
5
5
  import type { OperationalHealthSnapshot } from './operational-health';
6
6
  import type { LocalAgentReleaseIdentity } from '../release-identity';
7
7
  import type { McpToolsetConfig, McpToolsetRegistryStatus } from '../types';
8
+ import type { AgentHomeExecutionStatus } from '../agent-home';
8
9
  /**
9
10
  * M4 Phase 2: shared local-IPC contract between the daemon's control server
10
11
  * (`control-server.ts`) and the CLI's control client (`bin/control-client.ts`)
@@ -252,7 +253,7 @@ export interface ControlStatusResult {
252
253
  uptimeMs: number;
253
254
  paired: boolean;
254
255
  deviceId?: string;
255
- /** The connection state machine's own current value (`ws-transport.ts`'s `ConnectionState`) — e.g. `'open'`, `'degraded'` (long-poll fallback), `'revoked'`, `'closed'`, `'connecting'`. */
256
+ /** The connection state machine's current value: `'open'`, `'revoked'`, `'closed'`, or `'connecting'`. */
256
257
  transport: string;
257
258
  activeTasks: ControlActiveTask[];
258
259
  runtimeIds: string[];
@@ -276,6 +277,12 @@ export interface ControlStatusResult {
276
277
  operationalHealth: OperationalHealthSnapshot;
277
278
  /** Redacted content-addressed status from the daemon's single local registry. */
278
279
  toolsets: McpToolsetRegistryStatus;
280
+ /**
281
+ * WP0: per-canonical-Agent-home execution serialization, counts only —
282
+ * see {@link AgentHomeExecutionStatus}. Absent only for an older control
283
+ * peer that predates the cap.
284
+ */
285
+ agentHomeExecution?: AgentHomeExecutionStatus;
279
286
  }
280
287
  export interface ToolsetsReloadParams {
281
288
  expectedRevision: string;
@@ -1,10 +1,9 @@
1
1
  import type { AgentEgressPolicy, RuntimeId } from '@byok-sdk/protocol';
2
2
  import type { PermissionPolicy } from '@byok-sdk/protocol';
3
3
  import type { RuntimeAdapter, GitWorkspaceConfig, McpToolsetConfig, McpToolsetObservation, McpToolsetRegistryStatus, McpToolsetReloadReceipt } from '../types';
4
- import { type AgentHomeProjection } from '../agent-home';
4
+ import { type AgentHomeExecutionStatus, type AgentHomeProjection } from '../agent-home';
5
5
  import type { AgentRef } from '../agent-home';
6
6
  import { type LocalAgentReleaseIdentity } from '../release-identity';
7
- import type { BackoffOptions, LivenessOptions } from './ws-transport';
8
7
  import { type OperationalHealthSnapshot } from './operational-health';
9
8
  import { type DaemonEventListener, type DaemonTaskInfo, type Unsubscribe } from './observer';
10
9
  import { GitWorkspaceManager } from './git-workspace';
@@ -91,6 +90,8 @@ export interface DaemonConfig {
91
90
  productName: string;
92
91
  productId: string;
93
92
  serverUrl: string;
93
+ /** Bounds one AuthManager pair/challenge/token exchange, including response-body reads. */
94
+ authRequestDeadlineMs?: number;
94
95
  deviceName?: string;
95
96
  /**
96
97
  * Optional override for the client-hashed physical machine identity sent
@@ -130,6 +131,26 @@ export interface DaemonConfig {
130
131
  * after the SDK-owned Agent home has passed construction-time preflight.
131
132
  */
132
133
  strictAgentOnly?: boolean;
134
+ /**
135
+ * WP0: how many Attempts this daemon lets execute CONCURRENTLY in one
136
+ * canonical Agent home, across every lane and every session. Default
137
+ * {@link DEFAULT_MAX_CONCURRENT_MUTABLE_SESSIONS_PER_AGENT_HOME} (1).
138
+ *
139
+ * The canonical home is every Agent session's cwd, so each concurrent
140
+ * Attempt in it is another writer of the same `MEMORY.md`, `notes/` and
141
+ * `.git`. At the default, a second offer for a home that already has an
142
+ * active Attempt is declined retryably before adapter preparation, the
143
+ * claim, or any process side effect — the busy-home contract downstream
144
+ * hosts already depend on.
145
+ *
146
+ * Raising it above 1 is an explicit host choice that re-enables the
147
+ * 0.12.0 concurrent-session behaviour, including its co-writing exposure;
148
+ * the SDK never falls back to it on its own. Validated up front, the same
149
+ * way `maxTaskOutputBytes` is: a positive safe integer, so `0`, a negative
150
+ * number, `NaN` and a non-integer are construction errors rather than a
151
+ * silently reinterpreted "unlimited".
152
+ */
153
+ maxConcurrentMutableSessionsPerAgentHome?: number;
133
154
  /**
134
155
  * Explicit Agent-local/cloud egress selection. Omission still enforces the
135
156
  * SDK metadata/status projection, but does not advertise or admit the new
@@ -255,7 +276,7 @@ export interface DaemonConfig {
255
276
  /**
256
277
  * M5: explicit escape hatch for `url.ts`'s `assertServerUrlAllowed` — see
257
278
  * that function's own doc comment for the full allow/deny rule. Default
258
- * (unset/`false`): a `serverUrl` using plaintext `ws:`/`http:` is only
279
+ * (unset/`false`): a `serverUrl` using plaintext `http:` is only
259
280
  * accepted when its host is loopback (`localhost`/`*.localhost`,
260
281
  * `127.0.0.0/8`, `::1`); anything else over plaintext throws a typed
261
282
  * `InsecureServerUrlError` from `pair()`/`start()` below, BEFORE any
@@ -266,7 +287,7 @@ export interface DaemonConfig {
266
287
  * server) — doing so also logs a loud `console.warn` (see
267
288
  * `checkServerUrl`, this file) every time it actually changes the
268
289
  * outcome. Never overrides an unsupported scheme (anything other than
269
- * `http:`/`https:`/`ws:`/`wss:`), which is refused unconditionally.
290
+ * `http:`/`https:`), which is refused unconditionally.
270
291
  */
271
292
  dangerouslyAllowInsecureRemote?: boolean;
272
293
  /**
@@ -473,8 +494,6 @@ export interface DaemonStatus {
473
494
  localAgentRelease: Readonly<LocalAgentReleaseIdentity>;
474
495
  paired: boolean;
475
496
  connected: boolean;
476
- /** True once the connection has fallen back to long-poll (protocol §8) — transport info only (finding F6): long-poll is a full transport, so work still proceeds normally while this holds; outbound envelopes POST to /byok/messages instead of going out over WS. */
477
- degraded: boolean;
478
497
  /** True once the server has revoked this device (401 on challenge/token, protocol §6.3). The only recourse is calling `pair()` again — the daemon does not keep retrying on its own. */
479
498
  revoked: boolean;
480
499
  deviceId?: string;
@@ -487,6 +506,8 @@ export interface DaemonStatus {
487
506
  toolsets: McpToolsetRegistryStatus;
488
507
  /** Content-free egress lane watermarks and typed last-drop facts. */
489
508
  egress: AgentEgressStatus;
509
+ /** WP0: per-canonical-Agent-home execution serialization — see {@link AgentHomeExecutionStatus}. */
510
+ agentHomeExecution: AgentHomeExecutionStatus;
490
511
  }
491
512
  export interface Daemon {
492
513
  /** Pairing result is intentionally credential-blind. */
@@ -536,10 +557,8 @@ export interface Daemon {
536
557
  /** M3-2a: same as {@link approve} but rejects — see that method's doc comment. */
537
558
  reject(taskId: string, reason?: string): Promise<void>;
538
559
  }
539
- /** Internal seam so tests can substitute stub adapters / faster backoff+batch+liveness+long-poll timing. `createDaemonWithAdapters` (which takes this) is also the real entry point for products supplying a hand-built adapter set `createDaemon` can't construct on its own — e.g. custom adapter options, or an adapter that REPLACES a bundled runtime's implementation under the same id. Honest limit: an adapter id outside `pi`/`claude`/`codex` cannot pass wire validation today — `RuntimeIdSchema` (`@byok-sdk/protocol`) is a closed `z.enum(['pi', 'claude', 'codex'])`, and `isRuntimeId` filtering below (see `detectRuntimes`) drops any detected adapter outside that set before it ever reaches a wire-visible field. A genuinely fourth/namespaced runtime id is a future protocol change, not something this seam enables today. */
560
+ /** Internal seam so tests can substitute stub adapters / faster batch and long-poll timing. `createDaemonWithAdapters` (which takes this) is also the real entry point for products supplying a hand-built adapter set `createDaemon` can't construct on its own — e.g. custom adapter options, or an adapter that REPLACES a bundled runtime's implementation under the same id. Honest limit: an adapter id outside `pi`/`claude`/`codex` cannot pass wire validation today — `RuntimeIdSchema` (`@byok-sdk/protocol`) is a closed `z.enum(['pi', 'claude', 'codex'])`, and `isRuntimeId` filtering below (see `detectRuntimes`) drops any detected adapter outside that set before it ever reaches a wire-visible field. A genuinely fourth/namespaced runtime id is a future protocol change, not something this seam enables today. */
540
561
  export interface DaemonOverrides {
541
- backoff?: BackoffOptions;
542
- liveness?: LivenessOptions;
543
562
  /** M4 Phase 3: overrides `TaskRunner`'s default out-of-band approval wait (`DEFAULT_APPROVAL_TIMEOUT_MS`, 10 minutes) before an unanswered `requestApproval` force-resolves as a fail-closed rejection. */
544
563
  approvalTimeoutMs?: number;
545
564
  /** Finding F5: overrides for the control-socket shutdown path's own bounded waits — see `TaskRunner.shutdownTask`'s and `ConnectionManager.stop`'s own doc comments. Both default to 5s; neither affects an ordinary (non-shutdown-RPC) `daemon.stop()` call. */
@@ -550,10 +569,6 @@ export interface DaemonOverrides {
550
569
  outboxDrainTimeoutMs?: number;
551
570
  };
552
571
  longPoll?: {
553
- /** Consecutive never-acked WS connect failures before falling back to long-poll. Default 3. */
554
- wsFailureThreshold?: number;
555
- /** While long-polling, how often to retry establishing WS. Default 5 minutes. */
556
- wsRetryIntervalMs?: number;
557
572
  /** Backoff between failed long-poll HTTP attempts. Default 2s. */
558
573
  retryDelayMs?: number;
559
574
  /** Minimum delay before the next long-poll request after an empty (no-events) response — avoids busy-looping against a server that responds instantly. Default 250ms. */
@@ -16,6 +16,18 @@ export interface DeviceMetadata {
16
16
  * from one pairing response with metadata from another.
17
17
  */
18
18
  export type DeviceRecord = DeviceMetadata & DeviceCredentials;
19
+ /**
20
+ * The one durable authority allowed before a first pairing response is
21
+ * received. It keeps the generated key immutable across a lost response, so
22
+ * an exact server-side retry can prove the same public-key binding.
23
+ */
24
+ export interface FirstPairingAttempt {
25
+ readonly kind: 'first-pairing-attempt-v1';
26
+ readonly deviceName: string;
27
+ readonly devicePublicKey: string;
28
+ readonly devicePrivateKeyPem: string;
29
+ readonly machineId?: string;
30
+ }
19
31
  export interface DeviceCommandResult {
20
32
  readonly exitCode: number;
21
33
  readonly stdout: string;
@@ -44,6 +56,8 @@ export declare class DeviceCredentialStore {
44
56
  #private;
45
57
  constructor(options: DeviceCredentialStoreOptions);
46
58
  read(): Promise<DeviceRecord | undefined>;
59
+ readFirstPairingAttempt(): Promise<FirstPairingAttempt | undefined>;
60
+ saveFirstPairingAttempt(attempt: FirstPairingAttempt): Promise<void>;
47
61
  replace(record: DeviceRecord): Promise<void>;
48
62
  /** Returns true only after the sole secret authority is confirmed absent. */
49
63
  clear(): Promise<boolean>;
@@ -52,6 +66,8 @@ export declare class DeviceCredentialStore {
52
66
  export declare class InMemoryDeviceCredentialStore {
53
67
  #private;
54
68
  read(): Promise<DeviceRecord | undefined>;
69
+ readFirstPairingAttempt(): Promise<FirstPairingAttempt | undefined>;
70
+ saveFirstPairingAttempt(attempt: FirstPairingAttempt): Promise<void>;
55
71
  replace(record: DeviceRecord): Promise<void>;
56
72
  clear(): Promise<boolean>;
57
73
  }