@pouchy_ai/companion-sdk 0.33.0 → 0.35.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.
package/src/client.ts CHANGED
@@ -107,6 +107,31 @@ export interface CompanionClientOptions {
107
107
  * configuration errors and never trigger it. Alternative: call `setToken()`
108
108
  * proactively on your own refresh schedule. */
109
109
  onAuthError?: () => string | null | Promise<string | null>;
110
+ /** Opt-in offline send queue (0.34.0). A sendText that fails at the NETWORK
111
+ * level (fetch rejected before any HTTP response, or `navigator.onLine` is
112
+ * false) is queued instead of lost, and replayed FIFO once the server is
113
+ * reachable again — after a successful connect(), on every stream
114
+ * reconnect, or via `flushOutbox()`. Replays reuse the ORIGINAL turnId:
115
+ * the server dedupes completed turnIds (10 min TTL, last 8 per session),
116
+ * so a retry after an ambiguous failure can never double-run a turn. HTTP
117
+ * error responses (4xx/5xx incl. 429) are NEVER queued — the server saw
118
+ * those; they keep the normal throw semantics. Default false. */
119
+ queueOffline?: boolean;
120
+ /** Persistence for the offline queue (see `queueOffline`). Defaults to
121
+ * localStorage when available (privacy-mode / quota failures fall back
122
+ * silently), else an in-memory page-lifetime store. The key is
123
+ * `pouchy-outbox:${surface}`, so each surface keeps its own queue. */
124
+ outboxStore?: OutboxStore;
125
+ /** Deadline for a request to produce RESPONSE HEADERS, in ms (0.35.0).
126
+ * Bounds every HTTP call the client makes (connect, sendText, recall, …) so
127
+ * a server that accepts the TCP connection but never answers can't hang a
128
+ * helper forever — previously only `sendText({ awaitReply })` had any
129
+ * deadline. Reading a response BODY (the SSE event stream, a streaming
130
+ * sendText reply) is never bounded by this — the timer is cleared the
131
+ * moment headers arrive. Composes with any per-call AbortSignal (whichever
132
+ * fires first wins); the timeout rejects as a status-0 CompanionError with
133
+ * code 'request_timeout'. Default 30_000; 0 disables. */
134
+ requestTimeoutMs?: number;
110
135
  }
111
136
 
112
137
  export interface HelloAck {
@@ -268,6 +293,53 @@ export interface SendTextReply {
268
293
  envelope: CompanionEnvelope;
269
294
  }
270
295
 
296
+ /** One send queued while offline (see the `queueOffline` option) — replayed
297
+ * FIFO by flushOutbox with its ORIGINAL `turnId` (the server's turn-dedup is
298
+ * what makes the retry safe). `at` is Date.now() at enqueue. */
299
+ export interface QueuedSend {
300
+ turnId: string;
301
+ text: string;
302
+ images?: string[];
303
+ at: number;
304
+ }
305
+
306
+ /** Pluggable persistence for the offline send queue (the `outboxStore`
307
+ * option). Both methods receive the storage key
308
+ * (`pouchy-outbox:${surface}`); `load` returns the previously-saved value or
309
+ * null. */
310
+ export interface OutboxStore {
311
+ load(key: string): string | null;
312
+ save(key: string, value: string): void;
313
+ }
314
+
315
+ // Default outbox persistence: localStorage when the environment has one (and
316
+ // allows it — privacy mode / quota errors fall through), else a module-level
317
+ // Map so the queue at least survives for the page's lifetime.
318
+ const memoryOutbox = new Map<string, string>();
319
+ const defaultOutboxStore: OutboxStore = {
320
+ load(key) {
321
+ try {
322
+ if (typeof window !== 'undefined' && window.localStorage) {
323
+ return window.localStorage.getItem(key);
324
+ }
325
+ } catch {
326
+ /* storage denied — fall through to the in-memory copy */
327
+ }
328
+ return memoryOutbox.get(key) ?? null;
329
+ },
330
+ save(key, value) {
331
+ try {
332
+ if (typeof window !== 'undefined' && window.localStorage) {
333
+ window.localStorage.setItem(key, value);
334
+ return;
335
+ }
336
+ } catch {
337
+ /* quota / privacy mode — keep the in-memory copy below */
338
+ }
339
+ memoryOutbox.set(key, value);
340
+ }
341
+ };
342
+
271
343
  type Handler = (envelope: CompanionEnvelope) => void;
272
344
 
273
345
  /** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply
@@ -345,6 +417,18 @@ export class CompanionClient {
345
417
  // paths pay one falsy check.
346
418
  private readonly debugSink: ((event: CompanionDebugEvent) => void) | null;
347
419
 
420
+ // Offline send queue (0.34.0) — null until first use, then loaded lazily
421
+ // from the store so a reloaded tab resumes the queue its predecessor
422
+ // persisted.
423
+ private outbox: QueuedSend[] | null = null;
424
+ private readonly outboxHandlers = new Set<(items: readonly QueuedSend[]) => void>();
425
+ // In-flight flush, shared so concurrent triggers run ONE flush at a time.
426
+ private outboxFlush: Promise<{ sent: number; remaining: number }> | null = null;
427
+ /** Epoch-ms before which flush attempts are skipped — set from a 429's
428
+ * Retry-After during replay so the auto-flush on every reconnect doesn't
429
+ * re-hammer a server that just told us to back off (0.35.0). */
430
+ private outboxBlockedUntil = 0;
431
+
348
432
  constructor(opts: CompanionClientOptions) {
349
433
  if (!opts.baseUrl)
350
434
  throw new CompanionError('CompanionClient: baseUrl is required', 0, 'missing_option');
@@ -409,6 +493,9 @@ export class CompanionClient {
409
493
  console.error('[companion-sdk] stream-state handler threw', e);
410
494
  }
411
495
  }
496
+ // The stream coming back up is the strongest "server reachable again"
497
+ // signal there is — drain any sends queued while offline (0.34.0).
498
+ if (next === 'connected' && this.opts.queueOffline) void this.flushOutbox().catch(() => {});
412
499
  }
413
500
 
414
501
  /** Swap the bearer token used by every subsequent request and stream
@@ -457,6 +544,48 @@ export class CompanionClient {
457
544
  return major > 1 || (major === 1 && minor >= 1);
458
545
  }
459
546
 
547
+ /** doFetch raced against the `requestTimeoutMs` HEADERS deadline (0.35.0).
548
+ * The deadline only bounds time-to-response-headers: it does NOT swap the
549
+ * request's AbortSignal, so a streaming response body stays tied to the
550
+ * caller's own signal, and the timer is cleared the moment the race
551
+ * settles. On timeout the helper rejects `request_timeout` (status 0) —
552
+ * code-bearing, so the offline queue's network-failure predicate correctly
553
+ * ignores it (the server may have received the request). The abandoned
554
+ * fetch keeps its rejection silenced so a late network error can't surface
555
+ * as an unhandled rejection. */
556
+ private async doFetchBounded(
557
+ url: string,
558
+ init: RequestInit & { headers: Record<string, string> }
559
+ ): Promise<Response> {
560
+ const budget = this.opts.requestTimeoutMs ?? 30_000;
561
+ if (!budget) return this.doFetch(url, init);
562
+ const req = this.doFetch(url, init);
563
+ let timer: ReturnType<typeof setTimeout> | undefined;
564
+ try {
565
+ return await Promise.race([
566
+ req,
567
+ new Promise<never>((_, reject) => {
568
+ timer = setTimeout(
569
+ () =>
570
+ reject(
571
+ new CompanionError(
572
+ `request timed out after ${budget}ms waiting for response headers`,
573
+ 0,
574
+ 'request_timeout'
575
+ )
576
+ ),
577
+ budget
578
+ );
579
+ })
580
+ ]);
581
+ } catch (e) {
582
+ req.catch(() => {});
583
+ throw e;
584
+ } finally {
585
+ if (timer !== undefined) clearTimeout(timer);
586
+ }
587
+ }
588
+
460
589
  private async fetchAuthed(
461
590
  url: string,
462
591
  init: RequestInit & { headers: Record<string, string> }
@@ -467,7 +596,7 @@ export class CompanionClient {
467
596
  this.dbg({ kind: 'request', at: startedAt, method, path });
468
597
  let res: Response;
469
598
  try {
470
- res = await this.doFetch(url, init);
599
+ res = await this.doFetchBounded(url, init);
471
600
  } catch (e) {
472
601
  // A caller-supplied AbortSignal makes fetch throw a native AbortError —
473
602
  // convert it at this single choke point so cancellation honors the
@@ -497,7 +626,7 @@ export class CompanionClient {
497
626
  const retryAt = Date.now();
498
627
  this.dbg({ kind: 'request', at: retryAt, method, path });
499
628
  try {
500
- const retryRes = await this.doFetch(url, init);
629
+ const retryRes = await this.doFetchBounded(url, init);
501
630
  this.dbg({
502
631
  kind: 'response',
503
632
  at: Date.now(),
@@ -616,6 +745,9 @@ export class CompanionClient {
616
745
  }, opts?.signal);
617
746
  this._session = json.session;
618
747
  this.cursor = json.resumeCursor ?? 0;
748
+ // A successful handshake proves reachability — replay any sends queued
749
+ // while offline (0.34.0; no-op when the queue is empty).
750
+ if (this.opts.queueOffline) void this.flushOutbox().catch(() => {});
619
751
  return {
620
752
  ...json,
621
753
  representative: json.representative ?? false,
@@ -668,7 +800,15 @@ export class CompanionClient {
668
800
  * can't ride the request (`stream: false`, or an old server) does it fall
669
801
  * back to the event stream — which then DOES need start() — resolving with
670
802
  * the next `companion.message`, or rejecting with code `reply_timeout`
671
- * after `replyTimeoutMs` (default 60s). */
803
+ * after `replyTimeoutMs` (default 60s).
804
+ *
805
+ * OFFLINE QUEUE (0.34.0, opt-in `queueOffline: true`): a send that never
806
+ * REACHED the server — fetch rejected at the network level, or
807
+ * `navigator.onLine` is already false — is queued for replay instead of
808
+ * lost, and resolves `{ seq: null, queued: true, turnId }`. With
809
+ * `awaitReply: true` there is no reply to await this session, so it
810
+ * rejects with code `queued_offline` (the item IS queued first). HTTP
811
+ * error responses keep the throw semantics above — they are never queued. */
672
812
  async sendText(
673
813
  text: string,
674
814
  opts: { awaitReply: true; images?: string[]; stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
@@ -676,11 +816,11 @@ export class CompanionClient {
676
816
  async sendText(
677
817
  text: string,
678
818
  opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
679
- ): Promise<{ seq: number | null }>;
819
+ ): Promise<{ seq: number | null; queued?: boolean; turnId?: string }>;
680
820
  async sendText(
681
821
  text: string,
682
822
  opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
683
- ): Promise<{ seq: number | null } | SendTextReply> {
823
+ ): Promise<{ seq: number | null; queued?: boolean; turnId?: string } | SendTextReply> {
684
824
  const id = this.requireSession();
685
825
  // Client-minted correlation id (0.28.0): the server echoes it back as
686
826
  // `replyTo` on this turn's companion.message — even across an app-tool
@@ -688,6 +828,29 @@ export class CompanionClient {
688
828
  // message that happens by (world-state reactions, confirm outcomes).
689
829
  const turnId = `turn_${this.randomId()}`;
690
830
  const body = { text, turnId, ...(opts?.images?.length ? { images: opts.images } : {}) };
831
+ if (!this.opts.queueOffline) return this.dispatchText(id, body, opts, turnId);
832
+ // Offline queue: two triggers only — the browser already knows it's
833
+ // offline, or fetch itself rejected without an HTTP response. An HTTP
834
+ // error (4xx/5xx incl. 429) means the server SAW the turn: those keep
835
+ // the normal throw semantics and are never queued.
836
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
837
+ return this.queueSend(turnId, text, opts);
838
+ }
839
+ try {
840
+ return await this.dispatchText(id, body, opts, turnId);
841
+ } catch (e) {
842
+ if (this.isNetworkSendFailure(e)) return this.queueSend(turnId, text, opts);
843
+ throw e;
844
+ }
845
+ }
846
+
847
+ /** The pre-queue dispatch of sendText: awaitReply / streaming / buffered. */
848
+ private async dispatchText(
849
+ id: string,
850
+ body: Record<string, unknown>,
851
+ opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal },
852
+ turnId?: string
853
+ ): Promise<{ seq: number | null } | SendTextReply> {
691
854
  if (opts?.awaitReply) return this.sendTextAwaitingReply(id, body, opts, turnId);
692
855
  const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
693
856
  if (!wantStream) {
@@ -701,6 +864,42 @@ export class CompanionClient {
701
864
  return this.sendTextStreaming(id, body, opts?.signal);
702
865
  }
703
866
 
867
+ /** True only for a send that never reached the server: fetch rejected at
868
+ * the network level, which asCompanionError wraps as status 0 with NO
869
+ * code. Every code-carrying status-0 error is deliberate — `aborted` is
870
+ * the caller cancelling, `reply_timeout`/`needs_event_stream` mean the
871
+ * POST was accepted — and any nonzero status is a response the server
872
+ * produced. None of those may queue. */
873
+ private isNetworkSendFailure(e: unknown): boolean {
874
+ return e instanceof CompanionError && e.status === 0 && e.code === undefined;
875
+ }
876
+
877
+ /** Enqueue an offline send. Plain sends resolve `{ seq: null, queued: true,
878
+ * turnId }`; awaitReply can't await a reply that won't arrive this
879
+ * session, so it rejects with `queued_offline` — the item is queued
880
+ * either way. */
881
+ private queueSend(
882
+ turnId: string,
883
+ text: string,
884
+ opts?: { images?: string[]; awaitReply?: boolean }
885
+ ): { seq: null; queued: true; turnId: string } {
886
+ this.loadOutbox().push({
887
+ turnId,
888
+ text,
889
+ ...(opts?.images?.length ? { images: opts.images } : {}),
890
+ at: Date.now()
891
+ });
892
+ this.commitOutbox();
893
+ if (opts?.awaitReply) {
894
+ throw new CompanionError(
895
+ 'offline — the text was queued and will send when the connection returns; no reply to await',
896
+ 0,
897
+ 'queued_offline'
898
+ );
899
+ }
900
+ return { seq: null, queued: true, turnId };
901
+ }
902
+
704
903
  /** The awaitReply leg of sendText. Prefers the in-request answer (the
705
904
  * streaming `done` frame's envelope); falls back to the next
706
905
  * `companion.message` off the event stream, bounded by a timeout. The
@@ -713,6 +912,12 @@ export class CompanionClient {
713
912
  turnId?: string
714
913
  ): Promise<SendTextReply> {
715
914
  const timeoutMs = opts?.replyTimeoutMs ?? 60_000;
915
+ // Already-aborted fast-fail comes FIRST (0.35.0): it used to sit after the
916
+ // companion.message subscription below, whose cleanup lives in the finally
917
+ // of the try we never entered — every call with a spent signal leaked one
918
+ // permanent handler (a host reusing one AbortController across turns
919
+ // accumulated them without bound).
920
+ if (opts?.signal?.aborted) throw new CompanionError('request aborted', 0, 'aborted');
716
921
  let resolveFallback!: (env: CompanionEnvelope) => void;
717
922
  const fallback = new Promise<CompanionEnvelope>((res) => (resolveFallback = res));
718
923
  // Correlate: resolve only on THIS turn's reply (`replyTo === turnId`).
@@ -731,10 +936,9 @@ export class CompanionClient {
731
936
  const ac = new AbortController();
732
937
  // Link the caller's signal: aborting it tears down the in-flight stream
733
938
  // (via ac) AND rejects the whole operation with `aborted`, exactly like
734
- // the deadline does. If it is already aborted, fail fast.
939
+ // the deadline does.
735
940
  let onCallerAbort: (() => void) | undefined;
736
941
  if (opts?.signal) {
737
- if (opts.signal.aborted) throw new CompanionError('request aborted', 0, 'aborted');
738
942
  onCallerAbort = () => ac.abort();
739
943
  opts.signal.addEventListener('abort', onCallerAbort, { once: true });
740
944
  }
@@ -923,10 +1127,139 @@ export class CompanionClient {
923
1127
  } catch {
924
1128
  /* stream already finished */
925
1129
  }
926
- throw e;
1130
+ // Normalize (0.35.0): a mid-stream network drop rejects reader.read()
1131
+ // with a NATIVE TypeError. Rethrowing it raw broke the "every helper
1132
+ // rejects with CompanionError" contract AND slipped past the offline
1133
+ // queue's isNetworkSendFailure (which requires a code-less status-0
1134
+ // CompanionError) — the exact loss queueOffline exists to prevent.
1135
+ // Queueing the replay is safe: it reuses the ORIGINAL turnId and the
1136
+ // server dedupes completed turns, so a turn that did run isn't re-run.
1137
+ throw this.asCompanionError(e);
927
1138
  }
928
1139
  }
929
1140
 
1141
+ /** The live offline queue, loaded from the store on first use so a reloaded
1142
+ * tab resumes what its predecessor persisted. Unreadable or foreign store
1143
+ * content is discarded — a corrupt store must never break sending. */
1144
+ private loadOutbox(): QueuedSend[] {
1145
+ if (this.outbox) return this.outbox;
1146
+ let items: QueuedSend[] = [];
1147
+ try {
1148
+ const raw = (this.opts.outboxStore ?? defaultOutboxStore).load(this.outboxKey());
1149
+ const parsed = raw ? (JSON.parse(raw) as unknown) : null;
1150
+ if (Array.isArray(parsed)) {
1151
+ items = parsed.filter(
1152
+ (q): q is QueuedSend =>
1153
+ !!q &&
1154
+ typeof (q as QueuedSend).turnId === 'string' &&
1155
+ typeof (q as QueuedSend).text === 'string'
1156
+ );
1157
+ }
1158
+ } catch {
1159
+ /* unreadable store — start empty */
1160
+ }
1161
+ this.outbox = items;
1162
+ return items;
1163
+ }
1164
+
1165
+ private outboxKey(): string {
1166
+ return `pouchy-outbox:${this.opts.surface ?? 'default'}`;
1167
+ }
1168
+
1169
+ /** Persist + notify after EVERY queue mutation (enqueue / flushed / drop),
1170
+ * so the stored copy never lags what a reload would need. Handler
1171
+ * isolation matches emit(): a throwing consumer must not skip the rest. */
1172
+ private commitOutbox(): void {
1173
+ const items = this.loadOutbox();
1174
+ try {
1175
+ (this.opts.outboxStore ?? defaultOutboxStore).save(this.outboxKey(), JSON.stringify(items));
1176
+ } catch {
1177
+ /* store save failed — the in-memory queue still flushes this page */
1178
+ }
1179
+ const copy = Object.freeze([...items]) as readonly QueuedSend[];
1180
+ for (const h of this.outboxHandlers) {
1181
+ try {
1182
+ h(copy);
1183
+ } catch (e) {
1184
+ console.error('[companion-sdk] outbox handler threw', e);
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ /** The sends queued while offline (oldest first), as a defensive copy —
1190
+ * see the `queueOffline` option. */
1191
+ pendingOutbox(): readonly QueuedSend[] {
1192
+ return [...this.loadOutbox()];
1193
+ }
1194
+
1195
+ /** Subscribe to offline-queue changes (enqueue / flushed / dropped) — drive
1196
+ * a "N pending" badge from `items.length`. Returns an unsubscribe fn. */
1197
+ onOutboxChange(handler: (items: readonly QueuedSend[]) => void): () => void {
1198
+ this.outboxHandlers.add(handler);
1199
+ return () => this.outboxHandlers.delete(handler);
1200
+ }
1201
+
1202
+ /** Replay the offline queue: FIFO, sequential, each item a normal buffered
1203
+ * input POST reusing its ORIGINAL `turnId` (the server dedupes completed
1204
+ * turnIds — see `queueOffline` — so a retry can never double-run a turn).
1205
+ * Runs automatically after connect() and on every stream reconnect; call
1206
+ * it yourself for a manual retry affordance. One flush at a time —
1207
+ * concurrent calls share the in-flight run. Per item: success (or a
1208
+ * server-side duplicate) → removed; network failure / 5xx / 429 → stop,
1209
+ * keep this item and the rest for the next flush; any other 4xx → the
1210
+ * item itself is invalid (the server deliberately rejected it) — drop it,
1211
+ * console.warn, and keep flushing. */
1212
+ async flushOutbox(): Promise<{ sent: number; remaining: number }> {
1213
+ this.requireSession();
1214
+ this.outboxFlush ??= this.runOutboxFlush().finally(() => {
1215
+ this.outboxFlush = null;
1216
+ });
1217
+ return this.outboxFlush;
1218
+ }
1219
+
1220
+ private async runOutboxFlush(): Promise<{ sent: number; remaining: number }> {
1221
+ const id = this.requireSession();
1222
+ const items = this.loadOutbox();
1223
+ // Respect the last replay 429's Retry-After (0.35.0): the flush re-arms
1224
+ // on EVERY stream reconnect, so without this gate a rate-limited server
1225
+ // got re-hammered on each reconnect despite naming its own window.
1226
+ if (Date.now() < this.outboxBlockedUntil) return { sent: 0, remaining: items.length };
1227
+ let sent = 0;
1228
+ while (items.length) {
1229
+ const item = items[0];
1230
+ try {
1231
+ await this.postJson<{ seq: number | null; duplicate?: boolean }>(
1232
+ `/api/companion/session/${id}/input`,
1233
+ {
1234
+ text: item.text,
1235
+ turnId: item.turnId,
1236
+ ...(item.images?.length ? { images: item.images } : {})
1237
+ }
1238
+ );
1239
+ } catch (e) {
1240
+ const status = e instanceof CompanionError ? e.status : 0;
1241
+ if (status >= 400 && status < 500 && status !== 429) {
1242
+ // The server saw it and said no — retrying can't fix the item.
1243
+ console.warn('[companion-sdk] dropping queued send the server rejected', status, e);
1244
+ items.shift();
1245
+ this.commitOutbox();
1246
+ continue;
1247
+ }
1248
+ // A throttle names its own retry window — honor it across flushes.
1249
+ if (status === 429 && e instanceof CompanionError && typeof e.retryAfter === 'number') {
1250
+ this.outboxBlockedUntil = Date.now() + e.retryAfter * 1000;
1251
+ }
1252
+ // Unreachable again (or throttled / a server-side failure): keep
1253
+ // this item and everything behind it for the next flush.
1254
+ break;
1255
+ }
1256
+ items.shift();
1257
+ sent += 1;
1258
+ this.commitOutbox();
1259
+ }
1260
+ return { sent, remaining: items.length };
1261
+ }
1262
+
930
1263
  /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
931
1264
  * subscriber makes sendText stream automatically. Returns an unsubscribe
932
1265
  * function. Render chunks as they arrive; on `meta.reset` clear the partial;
@@ -1256,7 +1589,10 @@ export class CompanionClient {
1256
1589
  * arrive as `control.error`. Since 0.30.0 `code` is typed as
1257
1590
  * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the
1258
1591
  * SDK-synthesized stream codes, open for newer servers) so a `switch` on it
1259
- * autocompletes. Returns an unsubscribe fn. */
1592
+ * autocompletes. A malformed frame with no string `code` is delivered with
1593
+ * the literal fallback `'error'` (not part of the exported vocabulary) —
1594
+ * give your `switch` a `default:` arm rather than enumerating every code.
1595
+ * Returns an unsubscribe fn. */
1260
1596
  onError(
1261
1597
  handler: (
1262
1598
  err: { code: ControlErrorCodeValue; message: string },
@@ -1713,13 +2049,19 @@ export class CompanionClient {
1713
2049
  let backoff = 500;
1714
2050
  while (this.streaming && gen === this.streamGen) {
1715
2051
  let errored = false;
1716
- const connectedAt = Date.now();
2052
+ // Stamped in onopen (0.35.0) — measuring from before the constructor
2053
+ // counted DNS/TCP/handshake time as "connection lifetime", so a gateway
2054
+ // that took ~2s to accept and then instantly closed read as long-lived
2055
+ // and RESET the backoff (a near-hot reconnect loop). 0 = never opened,
2056
+ // which always backs off.
2057
+ let openedAt = 0;
1717
2058
  try {
1718
2059
  await new Promise<void>((resolve, reject) => {
1719
2060
  const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
1720
2061
  let poll: ReturnType<typeof setInterval> | undefined;
1721
2062
  let settled = false;
1722
2063
  ws.onopen = () => {
2064
+ openedAt = Date.now();
1723
2065
  if (this.streaming && gen === this.streamGen) this.setStreamState('connected');
1724
2066
  };
1725
2067
  const settle = (err?: unknown) => {
@@ -1772,7 +2114,7 @@ export class CompanionClient {
1772
2114
  // back off like the SSE loop, or this reconnects in a zero-delay hot
1773
2115
  // loop. A connection that lived a while resets the backoff.
1774
2116
  if (this.streaming && gen === this.streamGen) this.setStreamState('reconnecting');
1775
- if (Date.now() - connectedAt < 2000) {
2117
+ if (openedAt === 0 || Date.now() - openedAt < 2000) {
1776
2118
  await this.backoffSleep(backoff);
1777
2119
  backoff = Math.min(backoff * 2, 8000);
1778
2120
  } else {
@@ -1937,13 +2279,33 @@ export class CompanionClient {
1937
2279
  const reader = body.getReader();
1938
2280
  const decoder = new TextDecoder();
1939
2281
  let buf = '';
1940
- while (this.streaming && gen === this.streamGen) {
1941
- const { done, value } = await reader.read();
1942
- if (done) break;
1943
- buf += decoder.decode(value, { stream: true });
1944
- const { events, rest } = parseSse(buf);
1945
- buf = rest;
1946
- for (const ev of events) this.handleFrame(ev.event, ev.data);
2282
+ // Liveness watchdog (0.35.0). The server writes a `: ping` comment every
2283
+ // ~1.5s and closes each stream window at 45s — a healthy connection is
2284
+ // NEVER byte-silent for long. On a silent half-open TCP drop (NAT/LB
2285
+ // idle-kill, laptop sleep) no FIN ever arrives: reader.read() blocks for
2286
+ // minutes while streamState still reads 'connected' and every reply is
2287
+ // silently lost. 10× the ping interval without a single byte can only be
2288
+ // a dead connection cancel the reader so read() returns and the loop
2289
+ // reconnects through its normal backoff + cursor resume (replays dedupe
2290
+ // by envelope id). Only the SSE plane gets this: the WS plane has no
2291
+ // heartbeat contract, so an idle-but-healthy socket can't be told apart.
2292
+ const SSE_STALL_MS = 15_000;
2293
+ let lastByteAt = Date.now();
2294
+ const watchdog = setInterval(() => {
2295
+ if (Date.now() - lastByteAt > SSE_STALL_MS) void reader.cancel().catch(() => {});
2296
+ }, 5_000);
2297
+ try {
2298
+ while (this.streaming && gen === this.streamGen) {
2299
+ const { done, value } = await reader.read();
2300
+ if (done) break;
2301
+ lastByteAt = Date.now();
2302
+ buf += decoder.decode(value, { stream: true });
2303
+ const { events, rest } = parseSse(buf);
2304
+ buf = rest;
2305
+ for (const ev of events) this.handleFrame(ev.event, ev.data);
2306
+ }
2307
+ } finally {
2308
+ clearInterval(watchdog);
1947
2309
  }
1948
2310
  }
1949
2311
 
package/src/errors.ts CHANGED
@@ -20,7 +20,9 @@ export const SDK_SYNTHESIZED_ERROR_CODES = [
20
20
  'call_unsupported', // startCall on a server/provider without voice
21
21
  'call_connect_failed', // WebRTC/provider handshake failed
22
22
  'call_dependency_missing', // optional voice dependency not installed
23
- 'aborted' // a caller-supplied AbortSignal cancelled the request (0.29.0)
23
+ 'aborted', // a caller-supplied AbortSignal cancelled the request (0.29.0)
24
+ 'queued_offline', // queueOffline: the text was queued while offline and will send on reconnect (0.34.0)
25
+ 'request_timeout' // requestTimeoutMs elapsed before response headers arrived (0.35.0)
24
26
  ] as const;
25
27
  export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number];
26
28
 
package/src/index.ts CHANGED
@@ -30,7 +30,9 @@ export type {
30
30
  WalletBalance,
31
31
  BrandIconSize,
32
32
  EndSessionResult,
33
- SendTextReply
33
+ SendTextReply,
34
+ QueuedSend,
35
+ OutboxStore
34
36
  } from './client';
35
37
  export {
36
38
  openCompanionCall,