@securecoms/agent-bridge 0.2.0 → 0.3.1

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.
@@ -2,6 +2,7 @@ import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
2
2
  import type { BridgeConfig } from "./config.js";
3
3
  import { LatencyRecorder } from "./latency.js";
4
4
  import type { AttachmentMeta } from "./attachments.js";
5
+ import type { AgentActionNodeType, AgentActionNodeStatus, AgentActionNodeDetail } from "@agentvault/connect-core";
5
6
  export interface BridgeMessage {
6
7
  channel_id: string;
7
8
  message_id: string;
@@ -60,6 +61,19 @@ export interface BridgeAgentDeps {
60
61
  * recent (a real-time messaging consumer cares about the newest). Default 1000.
61
62
  */
62
63
  maxBuffer?: number;
64
+ /**
65
+ * Review I3/M3: clock seam for the agent.action.* partial-throttle token
66
+ * buckets AND the unsettled-run stale-TTL sweep. Defaults to Date.now.
67
+ * Injected so tests can simulate a full minute (or a stale-run TTL window)
68
+ * of elapsed time without a real wait.
69
+ */
70
+ actionThrottleNow?: () => number;
71
+ /**
72
+ * C3: randomness seam for self-heal-sweep jitter (fleet decorrelation).
73
+ * Defaults to Math.random. Injected so tests can assert deterministic jittered
74
+ * timer delays.
75
+ */
76
+ rng?: () => number;
63
77
  }
64
78
  /** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
65
79
  export declare const DEFAULT_MAX_BUFFER = 1000;
@@ -91,6 +105,71 @@ export declare class InvalidPathError extends Error {
91
105
  readonly filePath: string;
92
106
  constructor(filePath: string, cause?: unknown);
93
107
  }
108
+ /**
109
+ * Per-room agent pause toggle (daemon side): the backend rejected a publish
110
+ * with `403 {detail:{error:"agent_paused"}}`, which the SDK's `RestTransport`
111
+ * classifies into {@link AgentPausedError} (Task 8). This is a TERMINAL
112
+ * outcome — an owner/admin has deliberately muted this agent in this room, it
113
+ * is not a transient/network failure — so the daemon rethrows its OWN typed
114
+ * error (mirroring {@link ChannelPendingError} / {@link RateLimitedError}) for
115
+ * `send()`/`sendA2A()`/`sendAttachment()` callers to `instanceof`-check,
116
+ * instead of letting a heal path retry or re-Welcome into a paused room.
117
+ */
118
+ export declare class AgentPausedSendError extends Error {
119
+ readonly channelId: string;
120
+ constructor(channelId: string);
121
+ }
122
+ /** Thrown by `agent.action.*` when a runId (or nodeId within a run) is unknown
123
+ * or the run has already settled — settle() is terminal, mirroring the client
124
+ * ingest side's "later partials for a settled run are dropped" rule. */
125
+ export declare class UnknownActionRunError extends Error {
126
+ readonly runId: string;
127
+ constructor(runId: string);
128
+ }
129
+ export interface AgentActionStartOpts {
130
+ runId: string;
131
+ type: AgentActionNodeType;
132
+ title: string;
133
+ parentId?: string;
134
+ }
135
+ export interface AgentActionUpdatePatch {
136
+ status?: AgentActionNodeStatus;
137
+ detail?: AgentActionNodeDetail;
138
+ }
139
+ export interface AgentActionFinishPatch {
140
+ status: "done" | "error";
141
+ detail?: AgentActionNodeDetail;
142
+ }
143
+ export interface AgentActionSettleOpts {
144
+ resultMessageId?: string;
145
+ }
146
+ export interface AgentActionWrapOpts {
147
+ title: string;
148
+ type: AgentActionNodeType;
149
+ parentId?: string;
150
+ }
151
+ /**
152
+ * The agent action tree emit surface. A "run" groups the nodes (steps /
153
+ * tool_calls / sub_tasks / results) of one agent turn under `runId`.
154
+ * `start`/`update`/`finish` mutate the run's local node tree and attempt a
155
+ * throttled `partial` send (~1/sec per run — see `flushActionPartial`);
156
+ * `settle` always sends exactly once, carrying the FULL node set (folding in
157
+ * anything the throttle held back) plus an optional `resultMessageId`.
158
+ */
159
+ export interface AgentActionAPI {
160
+ /** Start a new run scoped to `channelId`. Local-only (no send). */
161
+ begin(channelId: string): string;
162
+ /** Add a node (default status "running") to the run's tree. May emit a throttled partial. */
163
+ start(opts: AgentActionStartOpts): Promise<string>;
164
+ /** Patch an existing node's status/detail. May emit a throttled partial. */
165
+ update(runId: string, nodeId: string, patch: AgentActionUpdatePatch): Promise<void>;
166
+ /** Terminal patch for a node (status "done" | "error"). May emit a throttled partial. */
167
+ finish(runId: string, nodeId: string, patch: AgentActionFinishPatch): Promise<void>;
168
+ /** Flush + freeze the run: sends exactly one `settled` frame with all nodes. */
169
+ settle(runId: string, opts?: AgentActionSettleOpts): Promise<void>;
170
+ /** Auto-capture helper: start a node, run `fn`, finish it done/error from the outcome. */
171
+ wrap<T>(runId: string, fn: () => T | Promise<T>, opts: AgentActionWrapOpts): Promise<T>;
172
+ }
94
173
  export declare const BRIDGE_VERSION = "0.0.0";
95
174
  export declare class BridgeAgent {
96
175
  private readonly cfg;
@@ -121,6 +200,15 @@ export declare class BridgeAgent {
121
200
  * per channel on a miss.
122
201
  */
123
202
  private readonly channelRoster;
203
+ /**
204
+ * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
205
+ * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
206
+ * the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
207
+ * means the DM roster shape was not exactly one non-self, non-agent
208
+ * counterpart, OR the freshly-fetched key no longer matches an existing pin —
209
+ * either way the hook must fail closed (return null) for that channel.
210
+ */
211
+ private readonly channelOwner;
124
212
  private readonly buffer;
125
213
  private readonly maxBuffer;
126
214
  /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
@@ -135,6 +223,13 @@ export declare class BridgeAgent {
135
223
  private rateLimitedSends;
136
224
  /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
137
225
  private welcomeRequestsSent;
226
+ /** 2026-07-03 latency triage: cumulative delivery pings received on the WS
227
+ * stream. 0 while messages still flow = the stream is dead and inbound is
228
+ * riding the 25s safety floor (surfaced on health()/metrics). */
229
+ private pingsReceived;
230
+ /** True once close() begins — gates the ping-stream-death stderr warning so
231
+ * a normal shutdown doesn't cry wolf. */
232
+ private closingDown;
138
233
  /**
139
234
  * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
140
235
  * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
@@ -145,7 +240,87 @@ export declare class BridgeAgent {
145
240
  private readonly lastWelcomeRequest;
146
241
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
147
242
  private static readonly REQUEST_WELCOME_COOLDOWN_MS;
243
+ private static readonly GRACEFUL_DRAIN_MS;
244
+ /**
245
+ * P0b (daemon-stability): collapses the onDecryptError console.warn from
246
+ * "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
247
+ * most one line immediately + one summary line per 60s per channel. LOG
248
+ * OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
249
+ * live entirely in the SDK provider and are unaffected by this class.
250
+ */
251
+ private readonly decryptErrorLogLimiter;
252
+ /**
253
+ * Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
254
+ * only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
255
+ * rows — an idle-but-wiped channel therefore loses its NEXT message (the
256
+ * very message that would have proven it diverged). selfHealSweep() closes
257
+ * that gap by proactively walking every non-DM member channel at boot and
258
+ * on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
259
+ * back to the delivery-row-driven Task 11 behaviour only.
260
+ */
261
+ private readonly selfHealSweepEnabled;
262
+ /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
263
+ private static readonly SELF_HEAL_SWEEP_INTERVAL_MS;
264
+ private static readonly SELF_HEAL_SWEEP_JITTER_MS;
265
+ private selfHealTimer?;
266
+ private bootSweepTimer?;
267
+ private readonly rng;
148
268
  private readonly rateFloor;
269
+ /**
270
+ * agent.action.* run state, keyed by runId.
271
+ *
272
+ * Review M3 (bug fix): the ORIGINAL comment here said "deliberately
273
+ * unbounded" — but nothing ever bounded it either. Only actionSettle()
274
+ * deleted an entry; a begun-but-never-settled run (agent crash, bug,
275
+ * dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
276
+ * bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
277
+ * lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
278
+ * mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
279
+ */
280
+ private readonly actionRuns;
281
+ /** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
282
+ * evicts the oldest (LRU by startedAt) to make room — never grows past
283
+ * this regardless of how many runs are begun and abandoned. */
284
+ private static readonly MAX_UNSETTLED_ACTION_RUNS;
285
+ /** Review M3: an unsettled run older than this (by startedAt, relative to
286
+ * actionClock()) is swept away on the next actionBegin(), independent of
287
+ * the count-based cap — bounds memory even when begin() calls are rare. */
288
+ private static readonly ACTION_RUN_STALE_MS;
289
+ /** Review I3/M3: injected clock (defaults Date.now) for the action-partial
290
+ * token buckets AND the stale-run sweep — a single seam so tests can
291
+ * simulate elapsed time deterministically. */
292
+ private readonly actionClock;
293
+ /**
294
+ * Per-run token bucket for `agent_action` PARTIAL frames (Global
295
+ * Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
296
+ * `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
297
+ * capacity" — partials instead COALESCE: a throttled call just leaves its
298
+ * change in `dirty` for the next allowed send to pick up).
299
+ *
300
+ * Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
301
+ * backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
302
+ * the settled frame + the agent's real reply + reportStatus all compete
303
+ * for that one shared per-sender budget, so a long run's settled frame or
304
+ * reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
305
+ * partials alone use at most ~half the identity's send budget, leaving
306
+ * headroom for the settled frame / reply / status traffic riding the SAME
307
+ * cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
308
+ * `settled` frames bypass this bucket entirely — they always send, exactly
309
+ * once, per run.
310
+ */
311
+ private readonly actionRateFloor;
312
+ /**
313
+ * Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
314
+ * the per-run bucket above. Without this, N concurrent runs each get their
315
+ * OWN ~30/min budget, so the partial send rate multiplies with agent
316
+ * concurrency and can still blow past the shared 60/min identity edge cap
317
+ * (each run individually "has headroom" but the SUM doesn't). Same
318
+ * capacity/refill as the per-run bucket — it caps the aggregate, not any
319
+ * single run.
320
+ */
321
+ private readonly globalActionRateFloor;
322
+ /** Public `agent.action.*` surface — assigned once at the end of the constructor. */
323
+ readonly action: AgentActionAPI;
149
324
  private readonly waiters;
150
325
  private pingHandle?;
151
326
  private safetyTimer?;
@@ -173,13 +348,120 @@ export declare class BridgeAgent {
173
348
  */
174
349
  private capBuffer;
175
350
  start(): Promise<void>;
351
+ /** C3: a random 0..SELF_HEAL_SWEEP_JITTER_MS decorrelation offset. */
352
+ private sweepJitter;
176
353
  private reconcileJoin;
354
+ /**
355
+ * The held-KP re-Welcome signal (Task 11), shared by EVERY heal path: the
356
+ * reactive no-local-state branch in ingestOnce(), the proactive
357
+ * selfHealSweep() (Task 12), the onWedged forked-state recovery
358
+ * (healWedgedChannel), and the reset supersede (healSupersedeUnopenable).
359
+ *
360
+ * Before asking the committer to (re-)Welcome us, it RECONCILES the server's
361
+ * published KeyPackage to our held bundle. This is the fix for the observed
362
+ * "fresh DM never replies" AND "reset doesn't work": the committer seals every
363
+ * Welcome to our SERVER-PUBLISHED KP, but that KP had diverged from our
364
+ * locally-HELD bundle, so joinFromWelcome throws "No matching secret found" /
365
+ * "private KeyPackage key no longer held" and the Welcome is discarded. A bare
366
+ * re-Welcome re-seals to the SAME diverged published KP — un-openable again —
367
+ * looping until the channel quarantines. So:
368
+ * - published == held → publishKeyPackage() (no-op while held; mints only if
369
+ * somehow none is held), then re-Welcome re-seals to the current KP we hold.
370
+ * - published != held → forceRepublishKeyPackage() re-syncs the server to a
371
+ * bundle we hold, so the owner's re-Welcome is openable. Orphan-safe: a
372
+ * diverged published KP means every other pending Welcome sealed via the
373
+ * server is un-openable too, so replacing the shared per-device bundle
374
+ * strands nothing that was openable (see heldBundleMatchesPublishedKp).
375
+ * getLastPublishedKpId then names the reconciled KP so the owner re-seals to it.
376
+ * Callers own their cooldown/rate-limit gate and try/catch — this does neither.
377
+ */
378
+ private signalHeldKpWelcome;
379
+ /**
380
+ * P0b (daemon-stability): the onWedged→heal path. onWedged only hands us a
381
+ * `connect_channel_id` — resolve it to an MLS groupId the SAME way
382
+ * selfHealSweep does, via the read-only getConnectChannelGroup probe
383
+ * (never initConnectChannelGroup, which side-effects a member_device_ids
384
+ * union + commit), then fire the existing held-KP re-Welcome signal.
385
+ * Respects the SAME per-channel cooldown (lastWelcomeRequest /
386
+ * REQUEST_WELCOME_COOLDOWN_MS) used by ingestOnce's Task-11 branch and
387
+ * selfHealSweep — this must never hammer requestWelcome on its own.
388
+ *
389
+ * Best-effort in every sense: if the channel has no resolvable group (no
390
+ * local state to probe from), this logs nothing further and just returns —
391
+ * the channel is left to the next selfHealSweep pass or reactive Task-11
392
+ * branch, mirroring how those two paths already treat an unresolvable
393
+ * group (`continue`, no signal). ANY failure along the way (probe throws,
394
+ * signal throws) is swallowed: a wedge heal must never throw back into the
395
+ * SDK's onWedged caller. Single-shot per wedge episode — full rejoin
396
+ * reliability is P1, not this hook's job.
397
+ */
398
+ private healWedgedChannel;
399
+ /**
400
+ * P1a self-heal (onSupersedeUnopenable): a fully-verified reset supersede whose
401
+ * Welcome we cannot open — it was sealed to a stale published KeyPackage our
402
+ * held bundle no longer matches ("No matching secret found"). trySupersede has
403
+ * verified owner + attestation + signature, so this is a real reset we WANT; we
404
+ * just can't open THIS Welcome. Ask the committer to re-Welcome us to the reset
405
+ * `groupId` with our CURRENT KeyPackage (signalHeldKpWelcome republishes/ensures
406
+ * a held KP, then requestWelcome), so the owner re-seals a Welcome we can open
407
+ * and the reset converges. The groupId is known (handed in), so no probe needed.
408
+ *
409
+ * Returns true ONLY when a re-Welcome was actually requested — trySupersede then
410
+ * discards the un-openable row. Under the shared per-channel cooldown (or on any
411
+ * failure) returns false, so trySupersede LEAVES the row for a bounded retry
412
+ * rather than dropping it with no replacement in flight. Never throws.
413
+ */
414
+ private healSupersedeUnopenable;
415
+ /**
416
+ * Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
417
+ * branch only fires reactively — off a delivery ROW that already arrived
418
+ * for a diverged channel. A wiped-but-IDLE channel (no traffic since the
419
+ * wipe) never produces that row, so its next real message would be the
420
+ * first thing lost. This sweep walks every member channel — rooms AND DMs
421
+ * (H2: a wiped, idle DM with no traffic since the wipe otherwise never
422
+ * proactively re-Welcomes, so it stays dark until the owner happens to send)
423
+ * — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
424
+ * persisted state even after a reconcile attempt, resolves group_id via
425
+ * the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
426
+ * which would side-effect a member_device_ids union + commit) and applies
427
+ * the same held-KP signal — respecting the existing per-channel cooldown.
428
+ *
429
+ * Each channel is wrapped in its own try/catch: `listChannels` filters at
430
+ * the identity level while `requestWelcome` (via request_welcome) gates at
431
+ * the device level, so a channel where this identity is a member but this
432
+ * DEVICE never held a leaf can 403 on the signal call. That's harmless and
433
+ * must not abort the sweep for every other channel.
434
+ */
435
+ private selfHealSweep;
177
436
  /**
178
437
  * Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
179
438
  * trip (incrementing the observable counter). `kind` namespaces the bucket so
180
439
  * room and a2a traffic on the same channel are tracked separately.
181
440
  */
182
441
  private enforceRateFloor;
442
+ /**
443
+ * The ONE seam every application-send path (send/sendA2A/sendAttachment)
444
+ * calls to publish, wrapping the SDK's {@link sendWithSelfHeal} with the
445
+ * daemon's per-room-pause terminal classification (Task 9).
446
+ *
447
+ * `AgentPausedError` (Task 8's SDK classification of the backend's 403
448
+ * `agent_paused`) means an owner/admin has deliberately muted this agent in
449
+ * this room — NOT a transient/network failure. That distinction MUST be
450
+ * made here, before control returns to any caller, so:
451
+ * - it never reaches a heal/self-heal trigger (healWedgedChannel /
452
+ * onWedged re-Welcome are wired off the RECEIVE path via SDK provider
453
+ * callbacks, not off a send failure — this seam guarantees a send
454
+ * failure never grows one either, now or as the code evolves), and
455
+ * - it never gets treated as a generic/retryable failure by a caller —
456
+ * rethrown as the daemon's OWN typed `AgentPausedSendError` (mirroring
457
+ * ChannelPendingError/RateLimitedError, both also bridge-agent.ts
458
+ * locals) so http-server.ts/ws-server.ts can `instanceof`-check it the
459
+ * same way they already do for those two, instead of it falling into
460
+ * their generic "internal error" bucket.
461
+ * Any other error (including StaleEpochError retries, which sendWithSelfHeal
462
+ * already resolves internally) propagates unchanged.
463
+ */
464
+ private sendWithSelfHealTerminal;
183
465
  send(channelId: string, text: string, messageId?: string): Promise<{
184
466
  message_id: string;
185
467
  status: "sent";
@@ -223,7 +505,65 @@ export declare class BridgeAgent {
223
505
  * attribution must NEVER block or drop a message.
224
506
  */
225
507
  private refreshChannelRoster;
508
+ /**
509
+ * Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
510
+ * NEVER read the server's `role` field to pick the owner. In a DM the owner
511
+ * is the SINGLE non-self, non-agent counterpart; any other roster shape
512
+ * (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
513
+ * supersede eligibility). On the first clean sighting of an unambiguous
514
+ * owner with no existing pin, TOFU-pin its public key. On a later sighting
515
+ * where a pin already exists and the freshly-fetched key differs, this is an
516
+ * anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
517
+ * this must never throw into the roster refresh path.
518
+ */
519
+ private resolveAndPinOwner;
520
+ /**
521
+ * Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
522
+ * SDK's step-0 guard calls this before honoring an owner-authored supersede;
523
+ * returning null makes it fail closed. Returns a value ONLY when (a) the
524
+ * structural owner cache has an unambiguous entry for this channel AND (b) a
525
+ * persisted pin still exists and matches it — a bare in-memory cache hit is
526
+ * never trusted on its own, it is always reconfirmed against the store.
527
+ */
528
+ private resolvePinnedOwnerHook;
226
529
  reportStatus(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
530
+ /** `runId` must exist and not have settled yet — settle() is terminal. */
531
+ private requireActionRun;
532
+ /**
533
+ * Build + send one `agent_action` E2E body over `system_event` (mirrors
534
+ * sendA2A/reportStatus's envelope construction — same self-heal seam). Used
535
+ * for BOTH partial and settled frames; the caller decides whether a send is
536
+ * throttle-gated (partials, via flushActionPartial) or unconditional
537
+ * (settled, always exactly once).
538
+ */
539
+ private sendActionFrame;
540
+ /**
541
+ * The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
542
+ * If the run has no dirty nodes, no-op. If the per-run bucket has no token,
543
+ * no-op too — the change stays in `dirty` and rides along with whatever the
544
+ * NEXT allowed send captures (a later start/update/finish call, or settle(),
545
+ * which always flushes regardless of the bucket). This is the coalescing:
546
+ * a burst of rapid calls collapses into however many sends the ~1/sec floor
547
+ * actually allows, each carrying every node changed since the last send.
548
+ */
549
+ private flushActionPartial;
550
+ /** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
551
+ * (relative to `now`). Lazy — invoked on every actionBegin() rather than
552
+ * a background timer, mirroring the client store's orphan-TTL pattern. */
553
+ private sweepStaleActionRuns;
554
+ private actionBegin;
555
+ private actionStart;
556
+ private actionUpdate;
557
+ private actionFinish;
558
+ private actionSettle;
559
+ /**
560
+ * Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
561
+ * invocation so a bare agent gets a start→finish node pair without hand-
562
+ * rolling the begin/start/update/finish calls itself. Success → status
563
+ * "done"; a thrown error → status "error" with the message captured in
564
+ * `detail.error`, then the original error is rethrown (wrap never swallows).
565
+ */
566
+ private actionWrap;
227
567
  protected startReceivePump(): void;
228
568
  private ingestOnce;
229
569
  drain(channelId: string | undefined, waitSeconds: number): Promise<BridgeMessage[]>;
@@ -263,6 +603,9 @@ export declare class BridgeAgent {
263
603
  permanently_skipped: number;
264
604
  /** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
265
605
  failed_attachments: number;
606
+ /** 2026-07-03 latency triage: delivery pings received on the WS stream.
607
+ * 0 while messages flow = push is dead; inbound rides the 25s floor. */
608
+ delivery_pings: number;
266
609
  uptime_s: number;
267
610
  version: string;
268
611
  };
@@ -0,0 +1,52 @@
1
+ export interface DecryptErrorInfo {
2
+ /** MLS epoch the failing row was at, if known. */
3
+ epoch?: number | string;
4
+ /** Delivery queue id the failing row came from, if known. */
5
+ queueId?: string;
6
+ /** The decrypt error's message (already stringified by the caller). */
7
+ message: string;
8
+ }
9
+ export interface DecryptErrorLogLimiterOpts {
10
+ /** Suppression window in ms. Default 60_000 (60s). */
11
+ windowMs?: number;
12
+ /** Clock seam for tests. Defaults to Date.now. */
13
+ now?: () => number;
14
+ /** Log sink. Defaults to console.warn. Injectable so tests can assert on
15
+ * emitted lines without capturing the real console. */
16
+ log?: (line: string) => void;
17
+ }
18
+ /** Default suppression window: 60 seconds. */
19
+ export declare const DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 60000;
20
+ /** Hard cap on distinct error reasons surfaced per channel per window. A wedged
21
+ * channel has a small handful of genuine reasons; beyond this the extra volume
22
+ * is noise (or an adversarial/buggy server cycling messages) and is suppressed. */
23
+ export declare const MAX_DISTINCT_PER_WINDOW = 4;
24
+ export declare class DecryptErrorLogLimiter {
25
+ private readonly windowMs;
26
+ private readonly now;
27
+ private readonly log;
28
+ private readonly windows;
29
+ constructor(opts?: DecryptErrorLogLimiterOpts);
30
+ /**
31
+ * Record one decrypt error for `channelId`. Logs immediately if this is
32
+ * the channel's first error since its window opened (or its prior window
33
+ * had already elapsed, in which case a summary for that just-closed
34
+ * window is flushed first); otherwise the error is counted silently
35
+ * toward the window's eventual summary line.
36
+ */
37
+ record(channelId: string, info: DecryptErrorInfo): void;
38
+ /**
39
+ * Clear a channel's window (e.g. once the caller learns the channel
40
+ * cleared/healed) so its NEXT error logs immediately as a fresh "first"
41
+ * rather than being folded into a stale window's summary. There is
42
+ * currently no onDecryptSuccess-style hook from the SDK to wire this to
43
+ * automatically (see bridge-agent.ts's onDecryptError wiring comment) —
44
+ * exposed here so a future success signal (or a caller with its own
45
+ * channel-cleared signal) can reset explicitly. In its absence, the
46
+ * window simply rolls over on interval elapse, which is an accepted
47
+ * simplification (see report).
48
+ */
49
+ reset(channelId: string): void;
50
+ private logFirst;
51
+ private flushSummary;
52
+ }
package/dist/errors.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
7
7
  * same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
8
8
  */
9
- export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "internal";
9
+ export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "unknown_action_run" | "internal";
10
10
  export interface BridgeErrorSpec {
11
11
  /** HTTP status this code maps to (the WS binding ignores it). */
12
12
  http: number;