@byok-sdk/client 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,18 +3,18 @@ import { AuthManager } from './auth-manager';
3
3
  import type { CursorStore } from './cursor-store';
4
4
  import { type FleetJitter } from './deterministic-jitter';
5
5
  import { ReplayCursorTooOldError } from './replay-cursor';
6
- import { type BackoffOptions, type ConnectionState, type LivenessOptions } from './ws-transport';
7
- export type { ConnectionState } from './ws-transport';
8
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';
9
9
  export interface ConnectionManagerOptions {
10
10
  serverUrl: string;
11
11
  deviceId: string;
12
12
  productId: string;
13
13
  capabilities: CapabilityFlag[];
14
- /** U4a Local Agent release version; passed unchanged to both transports. */
14
+ /** U4a Local Agent release version, sent unchanged in `conn.hello`. */
15
15
  clientVersion?: string;
16
16
  runtimes: RuntimeInfo[];
17
- /** 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`. */
18
18
  getConfiguredToolsets?: () => readonly ToolsetId[];
19
19
  auth: AuthManager;
20
20
  cursorStore: CursorStore;
@@ -25,12 +25,6 @@ export interface ConnectionManagerOptions {
25
25
  */
26
26
  onEnvelope: (envelope: Envelope) => void | Promise<void>;
27
27
  onStateChange?: (state: ConnectionState) => void;
28
- backoff?: BackoffOptions;
29
- liveness?: LivenessOptions;
30
- /** Consecutive never-acked WS connect failures before falling back to long-poll (protocol §8). Default 3. */
31
- wsFailureThreshold?: number;
32
- /** While long-polling, how often to retry establishing WS (protocol §8, "e.g. every 5 min"). Default 5 minutes. */
33
- wsRetryIntervalMs?: number;
34
28
  /** Backoff between failed long-poll HTTP attempts. Default 2s. */
35
29
  longPollRetryDelayMs?: number;
36
30
  /** Minimum delay before the next long-poll request after an empty (no-events) response. Default 250ms. */
@@ -39,33 +33,27 @@ export interface ConnectionManagerOptions {
39
33
  onOperationalOutcome?: (outcome: 'success' | 'failure', source: 'reconnect' | 'upload') => void;
40
34
  onTerminalError?: (error: ReplayCursorTooOldError) => void;
41
35
  }
36
+ export interface RejectedOutboundEnvelope {
37
+ readonly envelope: Envelope;
38
+ readonly reason: 'inbound_rejected';
39
+ }
42
40
  /**
43
- * Owns the daemon's one logical connection to the server, which may be
44
- * backed by either transport the wire protocol defines: WS (the normal
45
- * path) or long-poll (protocol §8's fallback for environments where an
46
- * outbound WSS connection isn't viable). Both funnel every received
47
- * envelope through the same cursor-dedupe/persistence logic (protocol §9),
48
- * so redelivery is safe regardless of which transport happens to deliver a
49
- * given envelope — including during the brief overlap window when handing
50
- * 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.
51
45
  *
52
46
  * `send()` (Design B, finding N4) pushes onto a single shared outbox this
53
- * class owns and drains through whichever transport is currently active —
54
- * WS raw-sends while acked, `POST /byok/messages` while long-polling
55
- * (finding F6, long-poll is a full transport, not receive-only; see
56
- * docs/protocol.md §8) — so a transport switch mid-flight never strands a
57
- * 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`.
58
49
  */
59
50
  export declare class ConnectionManager {
60
51
  private readonly opts;
61
52
  private readonly fleetJitter;
62
- private readonly ws;
63
53
  private readonly longPoll;
64
- private mode;
65
- private consecutiveFailures;
66
- private wsRetryTimer;
67
- private wsProbeSequence;
68
54
  private uploadRetryAttempt;
55
+ private started;
56
+ private connected;
69
57
  private cursor;
70
58
  /**
71
59
  * Finding F3 (at-most-once redelivery): the lowest `task.*` envelope `seq`
@@ -87,10 +75,9 @@ export declare class ConnectionManager {
87
75
  * (see `advanceCursor`) — that semantics is unchanged. `deliveredSeq`
88
76
  * advances eagerly, the instant a `task.*` envelope is admitted past
89
77
  * dedup (see `deliver`/`noteDelivered`), independent of whether its
90
- * handler has even started, let alone succeeded. It exists so a
91
- * long-poll re-query (`LongPollClient`'s `getCursor`) doesn't re-pull an
92
- * envelope that's already been delivered once and is still in flight
93
- * `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
94
81
  * first attempt is still running. On WS this same field is written the
95
82
  * same way, but since a live WS connection only ever pushes a given `seq`
96
83
  * once, it never has an observable effect there beyond mirroring
@@ -101,18 +88,18 @@ export declare class ConnectionManager {
101
88
  /** Finding F3: serializes `onEnvelope` calls into a per-connection FIFO — one envelope's handler always fully settles before the next one starts. */
102
89
  private processingChain;
103
90
  /**
104
- * Design B (finding N4): the ONE outbound queue both transports drain
105
- * 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
106
93
  * resend after a failed send attempt is byte-identical to the original
107
94
  * (same `id`), which is what lets the server's per-(deviceId,id) dedup
108
95
  * (Wave 1) recognize it as a safe no-op retry rather than a second
109
- * application (protocol §9). A transport switch (long-poll <-> WS) never
110
- * touches this queue — see `drainOutbox` — so nothing queued while one
111
- * transport was active is ever stranded when the other takes over.
96
+ * application (protocol §9).
112
97
  */
113
98
  private readonly outbox;
99
+ /** Terminally rejected outbound envelopes, retained as a bounded observable quarantine. */
100
+ private readonly rejectedOutboundEnvelopes;
114
101
  /**
115
- * Finding F5(b): how many envelopes `drainOutbox`'s long-poll branch has
102
+ * Finding F5(b): how many envelopes `drainOutbox` has
116
103
  * currently spliced OUT of `this.outbox` for an in-flight (not yet
117
104
  * confirmed delivered) `postBatch` call — 0 the rest of the time. See
118
105
  * `outboxLength`'s own doc comment for why this needs to be tracked
@@ -165,33 +152,12 @@ export declare class ConnectionManager {
165
152
  */
166
153
  private cancelPendingDrainRetry;
167
154
  /**
168
- * The capabilities the CURRENT transport's server advertised — untyped
169
- * `string[]` for forward compatibility. WS populates it from `conn.ack`;
170
- * long-poll populates it from each successful events response. Empty until
171
- * the active transport supplies an advertisement.
172
- *
173
- * Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
174
- * not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
175
- * connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
176
- * `acked` branch), `stop()`, or a transport switch to long-poll
177
- * (`enterLongPoll`) — and only repopulated by a fresh advertisement from
178
- * the transport that is still current.
179
- * The previous version of this doc comment claimed long-poll mode simply
180
- * "stays at whatever the last real WS `conn.ack` said" — that was the bug:
181
- * a daemon that once learned e.g. `approval_resolved` from an earlier WS
182
- * session kept believing it applied to whatever it's connected to NOW,
183
- * even after a disconnect/degrade where nothing has actually confirmed
184
- * that's still true (a reconnect could land on a DIFFERENT server behind a
185
- * load balancer). Concretely, `TaskRunner.sendApprovalResolved` gates
186
- * `task.approval_resolved` on this list — sending it to a server that
187
- * doesn't actually understand it over the long-poll path would get a
188
- * batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
189
- * `drainOutbox`'s retry-the-same-batch-forever loop then head-of-line
190
- * blocks EVERY envelope queued behind it on, permanently. Clearing this
191
- * eagerly means that gate reliably fails closed (falls back to the
192
- * pre-existing implicit-resume inference, unconditionally — see
193
- * `sendApprovalResolved`'s own doc comment) the moment the connection that
194
- * 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.
195
161
  */
196
162
  private serverCapabilities;
197
163
  constructor(opts: ConnectionManagerOptions);
@@ -202,17 +168,10 @@ export declare class ConnectionManager {
202
168
  * `drainOutbox`.
203
169
  */
204
170
  send(envelope: Envelope): void;
171
+ /** Publish a fresh local configuration snapshot while this daemon is running. */
172
+ refreshHello(): void;
205
173
  /**
206
- * Design B (finding N4): drain the shared outbox through whichever
207
- * transport is currently active, re-checking `this.mode` fresh on every
208
- * iteration so a transport switch mid-drain is picked up immediately
209
- * rather than fighting a stale decision made before the switch.
210
- *
211
- * WS: a synchronous, one-at-a-time `sendNow` per envelope while open+
212
- * acked; stops (without dropping anything — the remainder stays queued)
213
- * the moment it isn't, and is re-invoked once `onAcked` fires.
214
- *
215
- * Long-poll: POSTs the outbox in chunks of at most
174
+ * POSTs the outbox through long-poll in chunks of at most
216
175
  * `MAX_MESSAGES_PER_BATCH` (finding P1) — the server hard-caps a single
217
176
  * `/byok/messages` batch there (`MessagesSendRequestSchema`, protocol
218
177
  * §8.2) and 400s the WHOLE request if it's exceeded, which — before this
@@ -224,14 +183,9 @@ export declare class ConnectionManager {
224
183
  * failure that SAME chunk is unshifted back (order-preserving, same
225
184
  * Envelope objects/ids — never rebuilt, so a retry is exactly the resend
226
185
  * Wave 1's server-side dedup expects) and retried after a short backoff,
227
- * re-reading `this.mode` each time so a WS recovery that happens
228
- * mid-retry is honored on the very next loop iteration instead of only
229
- * after this attempt's backoff chain gives up.
230
- *
231
186
  * Re-entrancy is guarded by `draining`: a call arriving while a drain is
232
187
  * already in progress just returns — the in-progress loop's own
233
- * `while (this.outbox.length > 0)` check will pick up anything newly
234
- * 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.
235
189
  */
236
190
  private drainOutbox;
237
191
  /**
@@ -241,37 +195,29 @@ export declare class ConnectionManager {
241
195
  * in-flight wait immediately instead of leaving `drainOutbox` parked here
242
196
  * for up to the rest of the delay before it next checks `this.revoked` —
243
197
  * and (b) unref'd, so the timer never keeps the Node process alive by
244
- * itself while nothing else (a live long-poll GET, an open WS connection)
245
- * legitimately is.
198
+ * itself while nothing else (such as the live long-poll GET) legitimately is.
246
199
  */
247
200
  private drainRetryDelay;
248
- isTransportDegraded(): boolean;
249
201
  /**
250
- * The capabilities the CURRENT transport's server advertised: from
251
- * `conn.ack` on WS, or the latest successful `GET /byok/events` response
252
- * on long-poll. Empty before either transport has supplied its current
253
- * 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.
254
204
  */
255
205
  getServerCapabilities(): readonly string[];
256
206
  getTerminalError(): ReplayCursorTooOldError | undefined;
257
- getMode(): 'ws' | 'long-poll';
258
207
  isConnected(): boolean;
259
208
  isRevoked(): boolean;
260
209
  /**
261
- * Resolves once the connection has settled either a working, acked WS
262
- * connection, or the long-poll fallback taking over (protocol §8). This
263
- * lets `daemon.start()` return promptly even when WS is unavailable from
264
- * the very first attempt, rather than hanging until a WS `conn.ack` that
265
- * may never come.
210
+ * Resolves after the first successful long-poll response establishes the
211
+ * authenticated connection.
266
212
  *
267
213
  * Rejects with {@link DeviceRevokedError} — instead of hanging until
268
214
  * `timeoutMs` — if the device turns out to be revoked while settling (or
269
215
  * already was): a cold `daemon.start()` against an already-revoked device
270
216
  * must fail fast, not surface a generic timeout (protocol §6.3).
271
217
  */
272
- waitForAck(timeoutMs?: number): Promise<void>;
218
+ waitForConnection(timeoutMs?: number): Promise<void>;
273
219
  /**
274
- * 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
275
221
  * (the F3 FIFO chain) and the most recent cursor write to actually land on
276
222
  * disk — otherwise a `stop()` racing a just-processed envelope's
277
223
  * persistence could lose that cursor advance, or leave a handler running
@@ -280,14 +226,14 @@ export declare class ConnectionManager {
280
226
  * Finding F5(b) (cross-model adversarial review): `drainTimeoutMs`, when
281
227
  * passed, bounds how long this waits for the shared outbox (`this.outbox`
282
228
  * — Design B) to actually finish draining BEFORE flipping `this.stopped`
283
- * and closing the transports. Before this fix, `stop()` set `stopped`
229
+ * and stopping the transport. Before this fix, `stop()` set `stopped`
284
230
  * synchronously and never waited for `drainOutbox` at all: an envelope
285
231
  * `send()` had just pushed moments earlier (e.g. `TaskRunner.shutdownTask`'s
286
232
  * own `task.fail`, sent right before `create-daemon.ts`'s
287
233
  * `performControlShutdown` calls this) could still be sitting UNSENT in
288
234
  * `this.outbox` — mid long-poll retry backoff, or simply not yet picked up
289
235
  * by the fire-and-forget `drainOutbox()` `send()` kicked off — and this
290
- * method would happily proceed to `stopped = true` / `ws.close()` regardless,
236
+ * method would happily proceed to `stopped = true` regardless,
291
237
  * after which NOTHING ever drains it again: silently lost, even though
292
238
  * `TaskRunner` believed it had been sent. `drainTimeoutMs` omitted (the
293
239
  * default) preserves the EXACT prior behavior for every other existing
@@ -318,6 +264,8 @@ export declare class ConnectionManager {
318
264
  * for the one case (a hung POST) this finding exists to catch honestly.
319
265
  */
320
266
  outboxLength(): number;
267
+ /** A bounded terminal quarantine for operator inspection; these entries are never retried. */
268
+ rejectedOutbox(): readonly RejectedOutboundEnvelope[];
321
269
  /**
322
270
  * Finding F5(b): polls {@link outboxLength} (not `this.outbox.length`
323
271
  * alone — see that method's own doc comment for why a spliced-out,
@@ -325,15 +273,14 @@ export declare class ConnectionManager {
325
273
  * a single `drainOutbox()` promise directly — a drain in progress can
326
274
  * itself loop through multiple retry/backoff cycles (`drainRetryDelay`)
327
275
  * while the server is unreachable, and a fresh, INDEPENDENT
328
- * `drainOutbox()` call can also be triggered concurrently (`send()`, a
329
- * 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
330
278
  * that actually matters (is anything still undelivered) can never go
331
279
  * stale the way capturing one specific in-flight promise reference
332
280
  * could. Kicks off one more `drainOutbox()` attempt itself first
333
281
  * (harmless no-op if one is already running — see its own re-entrancy
334
- * guard) in case nothing is currently actively retrying (e.g. WS just
335
- * dropped and long-poll hasn't taken over yet), so this bounded wait
336
- * 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
337
284
  * progress.
338
285
  */
339
286
  private waitForOutboxDrained;
@@ -360,9 +307,10 @@ export declare class ConnectionManager {
360
307
  */
361
308
  private deliver;
362
309
  /**
363
- * Design A: the watermark `deliver()` dedupes inbound `task.*` envelopes
364
- * against, and the same value `LongPollClient` queries the next
365
- * `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
366
314
  * `deliveredSeq` — which is always >= `cursor` (every envelope that
367
315
  * reaches `advanceCursor` already passed through `noteDelivered` first,
368
316
  * see `deliver`) — so this is the literal `max(cursor, deliveredSeq)` the
@@ -378,11 +326,9 @@ export declare class ConnectionManager {
378
326
  * whose outcome wasn't known yet. No separate "reset deliveredSeq on
379
327
  * reconnect" step is needed for this to be correct — collapsing to
380
328
  * `cursor` exactly while stalled already produces the right answer on
381
- * every redelivery path (long-poll re-query AND a WS reconnect's
382
- * backlog replay alike), and NOT resetting it unconditionally on every
383
- * reconnect is what lets `deliveredSeq` keep doing its job of not
384
- * re-pulling/re-dispatching something already in flight across a
385
- * 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.
386
332
  */
387
333
  private dedupWatermark;
388
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. */
@@ -391,9 +337,8 @@ export declare class ConnectionManager {
391
337
  /**
392
338
  * M4 Phase 4 (version-negotiation drill fix): `LongPollClient` calls this
393
339
  * for a batch entry it could not parse into a known `Envelope` at all (an
394
- * unrecognized message type mirrors `ws-transport.ts`'s identical
395
- * per-frame tolerance, see `long-poll-transport.ts`'s own doc comment on
396
- * `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,
397
342
  * task-class envelope-level `seq` (the caller only invokes this for a
398
343
  * `task.`-prefixed type — see `long-poll-transport.ts`'s own
399
344
  * `extractSkippableSeq`; `conn.*`-shaped or type-less entries never reach
@@ -433,7 +378,7 @@ export declare class ConnectionManager {
433
378
  * `noteDelivered` (the eager, in-memory watermark) stays UNCHAINED —
434
379
  * called immediately, unconditionally, regardless of `stalledAtSeq` —
435
380
  * matching `deliver()`'s own eager, unconditional call for a real
436
- * 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,"
437
382
  * independent of outcome, and that property does not depend on FIFO
438
383
  * ordering the way the DURABLE cursor does.
439
384
  *
@@ -491,19 +436,10 @@ export declare class ConnectionManager {
491
436
  */
492
437
  private noteValidationFailure;
493
438
  private advanceCursor;
494
- /**
495
- * Fires the moment a connection attempt reaches `conn.ack` — independent
496
- * of whether/when it later closes. This is the ONLY place that can
497
- * reliably detect "WS is back up" while long-polling: a healthy
498
- * connection stays open indefinitely, so it never reaches `onWsOutcome`
499
- * (which is close-only) at all.
500
- */
501
- private onAcked;
502
- private onWsOutcome;
439
+ private quarantineRejectedOutbound;
503
440
  private notifySettled;
504
- private enterLongPoll;
441
+ private noteConnected;
442
+ private noteDisconnected;
505
443
  private enterReplayCursorTooOld;
506
- private exitLongPoll;
507
- private scheduleWsProbe;
508
444
  private enterRevoked;
509
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';
@@ -132,6 +131,26 @@ export interface DaemonConfig {
132
131
  * after the SDK-owned Agent home has passed construction-time preflight.
133
132
  */
134
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;
135
154
  /**
136
155
  * Explicit Agent-local/cloud egress selection. Omission still enforces the
137
156
  * SDK metadata/status projection, but does not advertise or admit the new
@@ -257,7 +276,7 @@ export interface DaemonConfig {
257
276
  /**
258
277
  * M5: explicit escape hatch for `url.ts`'s `assertServerUrlAllowed` — see
259
278
  * that function's own doc comment for the full allow/deny rule. Default
260
- * (unset/`false`): a `serverUrl` using plaintext `ws:`/`http:` is only
279
+ * (unset/`false`): a `serverUrl` using plaintext `http:` is only
261
280
  * accepted when its host is loopback (`localhost`/`*.localhost`,
262
281
  * `127.0.0.0/8`, `::1`); anything else over plaintext throws a typed
263
282
  * `InsecureServerUrlError` from `pair()`/`start()` below, BEFORE any
@@ -268,7 +287,7 @@ export interface DaemonConfig {
268
287
  * server) — doing so also logs a loud `console.warn` (see
269
288
  * `checkServerUrl`, this file) every time it actually changes the
270
289
  * outcome. Never overrides an unsupported scheme (anything other than
271
- * `http:`/`https:`/`ws:`/`wss:`), which is refused unconditionally.
290
+ * `http:`/`https:`), which is refused unconditionally.
272
291
  */
273
292
  dangerouslyAllowInsecureRemote?: boolean;
274
293
  /**
@@ -475,8 +494,6 @@ export interface DaemonStatus {
475
494
  localAgentRelease: Readonly<LocalAgentReleaseIdentity>;
476
495
  paired: boolean;
477
496
  connected: boolean;
478
- /** 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. */
479
- degraded: boolean;
480
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. */
481
498
  revoked: boolean;
482
499
  deviceId?: string;
@@ -489,6 +506,8 @@ export interface DaemonStatus {
489
506
  toolsets: McpToolsetRegistryStatus;
490
507
  /** Content-free egress lane watermarks and typed last-drop facts. */
491
508
  egress: AgentEgressStatus;
509
+ /** WP0: per-canonical-Agent-home execution serialization — see {@link AgentHomeExecutionStatus}. */
510
+ agentHomeExecution: AgentHomeExecutionStatus;
492
511
  }
493
512
  export interface Daemon {
494
513
  /** Pairing result is intentionally credential-blind. */
@@ -538,10 +557,8 @@ export interface Daemon {
538
557
  /** M3-2a: same as {@link approve} but rejects — see that method's doc comment. */
539
558
  reject(taskId: string, reason?: string): Promise<void>;
540
559
  }
541
- /** 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. */
542
561
  export interface DaemonOverrides {
543
- backoff?: BackoffOptions;
544
- liveness?: LivenessOptions;
545
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. */
546
563
  approvalTimeoutMs?: number;
547
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. */
@@ -552,10 +569,6 @@ export interface DaemonOverrides {
552
569
  outboxDrainTimeoutMs?: number;
553
570
  };
554
571
  longPoll?: {
555
- /** Consecutive never-acked WS connect failures before falling back to long-poll. Default 3. */
556
- wsFailureThreshold?: number;
557
- /** While long-polling, how often to retry establishing WS. Default 5 minutes. */
558
- wsRetryIntervalMs?: number;
559
572
  /** Backoff between failed long-poll HTTP attempts. Default 2s. */
560
573
  retryDelayMs?: number;
561
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. */
@@ -33,13 +33,18 @@ export interface LongPollClientOptions {
33
33
  serverUrl: string;
34
34
  auth: AuthManager;
35
35
  getCursor: () => number | undefined;
36
- onEnvelope: (envelope: Envelope) => void;
36
+ /** Returns false when the envelope was a local duplicate and no handler was queued. */
37
+ onEnvelope: (envelope: Envelope) => boolean | void;
37
38
  /**
38
39
  * Capabilities advertised by the server that produced the current poll
39
40
  * response. Called before any envelopes from that response are delivered.
40
41
  * An older responder omitting the additive field is reported as `[]`.
41
42
  */
42
43
  onServerCapabilities?: (capabilities: string[]) => void;
44
+ /** Called when a failed poll invalidates the preceding response's capability snapshot. */
45
+ onServerCapabilitiesInvalidated?: () => void;
46
+ /** Called after a poll fails and before the retry delay begins. */
47
+ onPollFailure?: () => void;
43
48
  /** Called once the device is found to be revoked (401 surfaced through {@link AuthManager}) — the loop stops itself rather than retrying. */
44
49
  onRevoked?: () => void;
45
50
  /** Called when the server cannot replay the durable cursor supplied to this poll. */
@@ -48,8 +53,7 @@ export interface LongPollClientOptions {
48
53
  * M4 Phase 4 (version-negotiation drill fix), scope narrowed by finding F1:
49
54
  * called ONLY for a batch entry that failed to parse because its `type`
50
55
  * is entirely unrecognized (`parseMessage` throwing
51
- * {@link UnknownMessageTypeError} mirrors `ws-transport.ts`'s identical
52
- * per-frame tolerance for that SPECIFIC failure) and which still carries a
56
+ * {@link UnknownMessageTypeError}) and which still carries a
53
57
  * numeric envelope-level `seq` AND a recognizably task-class `type` (a
54
58
  * `task.` prefix — see `extractSkippableSeq`'s own doc comment for why a
55
59
  * `conn.*`-shaped or type-less entry is deliberately excluded, mirroring
@@ -67,11 +71,8 @@ export interface LongPollClientOptions {
67
71
  * not forward-compat tolerance — forwarding its `seq` here would
68
72
  * permanently ack a message the daemon never actually understood (the
69
73
  * server would stop redelivering it, silently stranding whatever it was
70
- * offering). The WS path never had this hazard (an unparseable WS frame
71
- * has no skip-side cursor bookkeeping at all — see
72
- * `ws-transport.ts` — so it simply gets redelivered later); this callback
73
- * being scoped to `UnknownMessageTypeError` only is what makes long-poll
74
- * match that same "no silent permanent ack" property for real. Optional
74
+ * offering). This callback being scoped to `UnknownMessageTypeError` only
75
+ * preserves the no-silent-permanent-ack property. Optional
75
76
  * only for constructor/test convenience — `ConnectionManager` always
76
77
  * supplies it.
77
78
  */
@@ -114,7 +115,7 @@ export interface LongPollClientOptions {
114
115
  * `ConnectionManager` always supplies it.
115
116
  */
116
117
  isStalled?: () => boolean;
117
- /** Backoff between failed poll attempts (network/HTTP errors), AND between cycles that made no cursor progress while stalled (finding P2/Fix 2a — see {@link isStalled}). The reference server holds each successful, non-stalled request open ~50s itself (protocol §8), so this only matters when a request errors outright or is stalled. Default 2s. */
118
+ /** Backoff between failed poll attempts (network/HTTP errors), stalled cycles, and duplicate-only cycles that made no cursor progress. The reference server holds a genuinely idle request open ~50s itself (protocol §8). Default 2s. */
118
119
  retryDelayMs?: number;
119
120
  /** Deterministic delay authority for automatic failed/stalled cycles. */
120
121
  retryDelayForAttempt?: (attempt: number, baseDelayMs: number) => number;
@@ -128,24 +129,29 @@ export interface LongPollClientOptions {
128
129
  */
129
130
  idleDelayMs?: number;
130
131
  }
132
+ /** A fully-read frozen-v1 count acknowledgement for the batch that was posted. */
133
+ export interface MessageBatchPostResult {
134
+ readonly accepted: number;
135
+ readonly rejected?: number;
136
+ }
131
137
  /**
132
- * Protocol §8 long-poll fallback: `GET /byok/events?cursor=N` in a loop,
133
- * used while WS connectivity is unavailable (see `ConnectionManager`), plus
134
- * `POST /byok/messages` for the daemon's own outbound envelopes while in
135
- * this mode (finding F6 — long-poll is a full transport, not receive-only:
136
- * see docs/protocol.md §8).
138
+ * Protocol §8 long-poll transport: `GET /byok/events?cursor=N` in a loop,
139
+ * plus `POST /byok/messages` for the daemon's own outbound envelopes
140
+ * (finding F6 long-poll is a full transport, not receive-only: see
141
+ * docs/protocol.md §8).
137
142
  *
138
- * Design B (finding N4): this is a stateless drainer, symmetric with
139
- * `WsTransport.sendNow` it holds no outbound queue of its own.
140
- * `ConnectionManager` owns the single shared outbox both transports drain
141
- * from (so a transport switch never strands a queued envelope);
142
- * `postBatch` is a single POST attempt, reporting back whether the server
143
- * accepted it. All retry/backoff policy (and re-checking which transport is
144
- * currently active) lives in the caller (`ConnectionManager.drainOutbox`).
143
+ * Design B (finding N4): this is a stateless drainer; it holds no outbound
144
+ * queue of its own. `ConnectionManager` owns the single shared outbox;
145
+ * `postBatch` is a single POST attempt, reporting only frozen-v1 accepted and
146
+ * rejected counts after its response body has been read and validated. All
147
+ * retry/backoff and rejection isolation policy lives in the caller
148
+ * (`ConnectionManager.drainOutbox`).
145
149
  */
146
150
  export declare class LongPollClient {
147
151
  private readonly opts;
148
152
  private running;
153
+ /** Owns exactly one active loop generation, including its held GET and retry delays. */
154
+ private loopAbortController;
149
155
  /**
150
156
  * Finding R1: seqs this loop has already `console.warn`'d about for a
151
157
  * validation-failed (recognized-type, invalid-payload) entry — a poison
@@ -204,8 +210,8 @@ export declare class LongPollClient {
204
210
  * (`ConnectionHub.handleInbound`), so a resend of the SAME batch (same
205
211
  * envelope `id`s — the caller must never rebuild them) is deduped
206
212
  * server-side into a safe no-op rather than reprocessed (§9). Returns
207
- * `true` once the server has accepted the batch.
213
+ * validated frozen-v1 counts only after a readable response body.
208
214
  */
209
- postBatch(envelopes: Envelope[]): Promise<boolean>;
215
+ postBatch(envelopes: Envelope[]): Promise<MessageBatchPostResult | undefined>;
210
216
  private loop;
211
217
  }
@@ -1,5 +1,5 @@
1
1
  import { type AgentEvent, type BlobRef, type Envelope, type RuntimeInfo, type TaskState } from '@byok-sdk/protocol';
2
- import type { ConnectionState } from './ws-transport';
2
+ import type { ConnectionState } from './connection-manager';
3
3
  /**
4
4
  * M3-2a: local observability for the daemon — the seam a CLI (M3-2b) drives a
5
5
  * live task feed, a task list, and approve/reject/unpair from, all LOCALLY