@pouchy_ai/companion-sdk 0.29.1 → 0.30.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/CHANGELOG.md CHANGED
@@ -12,6 +12,48 @@ a protocol bump is always called out explicitly here.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.30.0] - 2026-07-12
16
+
17
+ No breaking changes despite the minor bump — everything here is additive
18
+ surface (which this repo's house policy ships as `0.x.0`).
19
+
20
+ ### Added
21
+
22
+ - **Typed `control.error` code vocabulary.** New runtime export
23
+ `CONTROL_ERROR_CODES` — the server-emitted stream-plane codes
24
+ (`agent_error`, `call_mint_failed`) — plus the types `ControlErrorCode`,
25
+ `ControlErrorPayload`, and the open union `ControlErrorCodeValue` (server
26
+ codes + the SDK-synthesized `stream_unauthorized` + a forward-compat string
27
+ arm, same doctrine as `CompanionErrorCodeValue`). `onError`'s handler now
28
+ sees `code: ControlErrorCodeValue` instead of a bare `string`, so a `switch`
29
+ on it autocompletes. Drift-tested two ways: the SDK list must equal the
30
+ server list, AND every `control.error` emit site's code literal (server and
31
+ SDK) must be declared — a new site inventing a code fails CI.
32
+ - **`on()` narrows the envelope payload per event name.** New
33
+ `OutboundPayloadMap` + a generic overload:
34
+ `on('companion.typing', (env) => env.payload.active)` type-checks without a
35
+ cast; `on('*')` keeps the untyped envelope. Type-only — zero runtime change.
36
+ New wire-payload types exported along the way: `HelloAckPayload`,
37
+ `CallReadyPayload`, `ControlErrorPayload`.
38
+ - **Stream-state observability.** `client.streamState` +
39
+ `onStreamStateChange((state, prev) => …)` with
40
+ `'idle' | 'connecting' | 'connected' | 'reconnecting' | 'degraded_sse' |
41
+ 'stopped'` (`CompanionStreamState`) — including the previously invisible
42
+ WebSocket→SSE degrade (`degraded_sse`) and the permanent-401/403 stop. Hosts
43
+ no longer hand-roll a connection enum around `onMessage` traffic.
44
+
45
+ ### Fixed
46
+
47
+ - **Reconnect backoff now carries jitter (additive, 0–25%)** in both the SSE
48
+ and WebSocket receive loops, so a fleet of embeds doesn't reconnect in
49
+ lockstep after a server deploy/restart (thundering herd).
50
+ - **`Retry-After` HTTP-date form is parsed.** RFC 9110 allows a date form
51
+ (proxies/CDNs emit it); it used to read as `undefined`. Delta-seconds still
52
+ win; a date converts to a non-negative seconds delta.
53
+ - **The streaming `sendText` leg keeps the 429 body `hint`.** The non-SSE
54
+ error fallback dropped the actionable hint (e.g. "provision a project …")
55
+ that the buffered path has surfaced since 0.27.0.
56
+
15
57
  ## [0.29.1] - 2026-07-12
16
58
 
17
59
  ### Fixed
@@ -647,7 +689,8 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
647
689
  - **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
648
690
  server by `protocol.drift.test.ts` (fails CI on divergence).
649
691
 
650
- [Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.29.1...HEAD
692
+ [Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.30.0...HEAD
693
+ [0.30.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.29.1...companion-sdk-v0.30.0
651
694
  [0.29.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.29.0...companion-sdk-v0.29.1
652
695
  [0.29.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.1...companion-sdk-v0.29.0
653
696
  [0.28.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.0...companion-sdk-v0.28.1
package/README.md CHANGED
@@ -63,9 +63,25 @@ Token as `token` instead — same API. See the docs' authentication section.)
63
63
  - **Capabilities map:** [`docs/companion-capabilities.md`](../../docs/companion-capabilities.md)
64
64
  - **Instant UI renderer contract:** [`docs/companion-instant-ui.md`](../../docs/companion-instant-ui.md)
65
65
 
66
+ ### Constructor options (beyond `baseUrl` + `token`)
67
+
68
+ `createCompanion({ … })` also takes: `surface` (one resumable session per
69
+ surface), `modalities` / `handles` / `contextKinds` / `tools` /
70
+ `appContext` (the capability handshake), `visitor` (representative mode,
71
+ below), `onAuthError` (401 → return a fresh token and the client retries
72
+ transparently), `stream: 'sse' | 'websocket'` (receive transport —
73
+ `'websocket'` opts into the lower-latency WS plane when the deployment serves
74
+ one and **falls back to SSE automatically**, observable as
75
+ `streamState === 'degraded_sse'`), and two injection points for Node/tests:
76
+ `fetch` (custom fetch implementation) and `webSocketImpl` (WebSocket
77
+ constructor when `globalThis.WebSocket` is absent).
78
+
66
79
  ## Events
67
80
 
68
- Subscribe with `on(type, fn)` (or `'*'`), or these typed convenience helpers:
81
+ Subscribe with `on(type, fn)` (or `'*'`), or these typed convenience helpers.
82
+ Since 0.30.0 `on()` narrows the envelope payload per event name
83
+ (`OutboundPayloadMap`) — e.g. `on('companion.typing', (env) => env.payload.active)`
84
+ type-checks without a cast; `'*'` keeps the untyped envelope:
69
85
 
70
86
  | Helper | Event | Use |
71
87
  | --- | --- | --- |
@@ -82,7 +98,7 @@ Subscribe with `on(type, fn)` (or `'*'`), or these typed convenience helpers:
82
98
  | `onTyping(fn)` | `companion.typing` | activity indicator — `fn({active})` fires `true` when a turn starts working and `false` when it finishes / pauses, spanning the tool-loop / thinking phase before the first text delta. Drive a "typing…" state |
83
99
  | `on('control.call_ready', fn)` | `control.call_ready` | a voice call is ready — the stream echo of `startCall`'s accept. Deliberately **secret-free** (`{ provider, agentId/model, voice, … }` — the actual WebRTC credentials only ride the `startCall` HTTP response); useful for UI state on surfaces that didn't initiate the call |
84
100
  | `onUsage(fn)` | `control.usage` | per-token metering echo. _(reserved — not emitted yet)_ |
85
- | `onError(fn)` | `control.error` | agent / stream errors — `agent_error` (server-side turn failed after accept; safe to re-send), `call_mint_failed` (voice-credential mint failed — retry later or fall back to text) or the SDK-synthesized `stream_unauthorized` (stream 401 exhausted reconnects — refresh the token, `start()` again) |
101
+ | `onError(fn)` | `control.error` | agent / stream errors — `agent_error` (server-side turn failed after accept; safe to re-send), `call_mint_failed` (voice-credential mint failed — retry later or fall back to text) or the SDK-synthesized `stream_unauthorized` (stream 401 exhausted reconnects — refresh the token, `start()` again). Since 0.30.0 the vocabulary is EXPORTED TYPED: `CONTROL_ERROR_CODES` (runtime list, drift-tested against the server) + the `ControlErrorCodeValue` union `fn`'s `err.code` now carries — `switch` on it with autocomplete |
86
102
 
87
103
  ### Instant UI
88
104
 
@@ -112,7 +128,8 @@ local/device skills).
112
128
  | `endSession()` | Distill the session into durable memory now; returns `{ skipped?: 'no_session' \| 'no_content' \| 'throttled' }`. |
113
129
  | `close()` | One-call teardown: `stop()` + `endSession()` — the text-session mirror of the call handle's `close()`. |
114
130
  | `startCall(opts)` / `connectCall(opts)` | Voice plane: raw credentials / fully-wired call handle (`call.close()` ends + folds the transcript into memory). |
115
- | `getAvatar()` / `brandIconUrl(size?)` | The user's avatar (VRM/portrait) and the Pouchy brand icon for your UI. |
131
+ | `streamState` / `onStreamStateChange(fn)` | Receive-stream lifecycle (0.30.0): `'idle' \| 'connecting' \| 'connected' \| 'reconnecting' \| 'degraded_sse' \| 'stopped'` (`CompanionStreamState`). `fn(state, prev)` fires on change only — drive a connection indicator; `degraded_sse` = the WS transport errored and delivery continues on the SSE fallback; `stopped` = `stop()`/`close()` or a permanent `stream_unauthorized` failure. |
132
+ | `getAvatar()` / `brandIconUrl(size?)` | The user's avatar (VRM/portrait) and the Pouchy brand icon for your UI. Standalone (no client/token): `pouchyBrandIconUrl(baseUrl, size?)` derives the same URL before/without connecting. |
116
133
  | `getWallet()` | Read the instance's own wallet — `{ balances: [{ currency, amount }], totalUsd, currency }`. Read-only + receive-only; needs the `wallet.read` scope. |
117
134
 
118
135
  Tool calling beyond your own declared tools: `HOST_CONTROL_TOOLS` (universal
package/dist/client.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, ExpressionPayload, InterfaceUpdatePayload, OutboundType, PendingConfirm, RenderInterfacePayload, SocialMessagePayload, ToolCallPayload, TypingPayload, UsagePayload, VoiceInjectPayload, WorldStateEvent } from './protocol.js';
1
+ import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, ExpressionPayload, InterfaceUpdatePayload, OutboundPayloadMap, OutboundType, PendingConfirm, RenderInterfacePayload, SocialMessagePayload, ToolCallPayload, TypingPayload, UsagePayload, VoiceInjectPayload, WorldStateEvent } from './protocol.js';
2
2
  /** Ergonomic world-state input: a WorldStateEvent with the CloudEvents plumbing
3
3
  * (specversion / id / source) optional — sendWorldState fills them. */
4
4
  export type WorldStateInput<D = unknown> = Omit<WorldStateEvent<D>, 'specversion' | 'id' | 'source'> & Partial<Pick<WorldStateEvent<D>, 'specversion' | 'id' | 'source'>>;
5
+ import { type ControlErrorCodeValue } from './errors.js';
5
6
  import { type CompanionCall, type CompanionCallOptions } from './call.js';
6
7
  export interface CompanionClientOptions {
7
8
  /** Origin of the Pouchy deployment, e.g. "https://pouchy.ai". */
@@ -198,6 +199,18 @@ type Handler = (envelope: CompanionEnvelope) => void;
198
199
  type DeltaHandler = (chunk: string, meta: {
199
200
  reset?: boolean;
200
201
  }) => void;
202
+ /** Lifecycle of the receive event stream (0.30.0) — observable via
203
+ * `client.streamState` + `onStreamStateChange`, so a host can render a
204
+ * connection indicator without hand-rolling its own enum.
205
+ * - `idle` — before start()
206
+ * - `connecting` — start() called; first connection being established
207
+ * - `connected` — the event stream is live (WS or SSE as configured)
208
+ * - `reconnecting`— connection lost / closed; backing off before the next try
209
+ * - `degraded_sse`— the WebSocket transport errored and delivery continues on
210
+ * the SSE fallback (still live — replies keep arriving)
211
+ * - `stopped` — stop()/close() was called, or a permanent stream failure
212
+ * (the `stream_unauthorized` control.error) ended the loop */
213
+ export type CompanionStreamState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'degraded_sse' | 'stopped';
201
214
  export declare class CompanionClient {
202
215
  private readonly opts;
203
216
  private readonly doFetch;
@@ -210,9 +223,22 @@ export declare class CompanionClient {
210
223
  private readonly seen;
211
224
  private readonly deltaHandlers;
212
225
  private readonly pendingVoiceTools;
226
+ private _streamState;
227
+ private readonly streamStateHandlers;
213
228
  constructor(opts: CompanionClientOptions);
214
229
  /** The active session id, or null before connect(). */
215
230
  get sessionId(): string | null;
231
+ /** Current lifecycle of the receive event stream (0.30.0) — `idle` before
232
+ * start(), then `connecting` / `connected` / `reconnecting` /
233
+ * `degraded_sse` (WS errored, SSE fallback carrying delivery) / `stopped`.
234
+ * Subscribe to transitions with `onStreamStateChange`. */
235
+ get streamState(): CompanionStreamState;
236
+ /** Subscribe to receive-stream lifecycle transitions (0.30.0) — drive a
237
+ * connection indicator: `client.onStreamStateChange((s) => setStatus(s))`.
238
+ * Fires only on change, with the previous state as the second argument.
239
+ * Returns an unsubscribe function. */
240
+ onStreamStateChange(handler: (state: CompanionStreamState, prev: CompanionStreamState) => void): () => void;
241
+ private setStreamState;
216
242
  /** Swap the bearer token used by every subsequent request and stream
217
243
  * reconnect. Session tokens expire (default 1h) — call this when your
218
244
  * backend re-mints one, or wire `onAuthError` to do it on demand. */
@@ -407,9 +433,12 @@ export declare class CompanionClient {
407
433
  onToolCall(handler: (call: ToolCallEvent, envelope: CompanionEnvelope) => void): () => void;
408
434
  /** Convenience: subscribe to errors the companion surfaces — server-side agent
409
435
  * errors and stream failures (e.g. a permanent `stream_unauthorized`) both
410
- * arrive as `control.error`. Returns an unsubscribe fn. */
436
+ * arrive as `control.error`. Since 0.30.0 `code` is typed as
437
+ * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the
438
+ * SDK-synthesized stream codes, open for newer servers) so a `switch` on it
439
+ * autocompletes. Returns an unsubscribe fn. */
411
440
  onError(handler: (err: {
412
- code: string;
441
+ code: ControlErrorCodeValue;
413
442
  message: string;
414
443
  }, envelope: CompanionEnvelope) => void): () => void;
415
444
  /** Recall the memory this token is authorized to see. Without `query` the
@@ -533,8 +562,12 @@ export declare class CompanionClient {
533
562
  * the companion's app tile, etc. Sizes: 256 | 512 | 1024 (default 512). */
534
563
  brandIconUrl(size?: BrandIconSize): string;
535
564
  /** Subscribe to outbound events of a given type, or "*" for all. Returns an
536
- * unsubscribe function. */
537
- on(type: OutboundType | '*', handler: Handler): () => void;
565
+ * unsubscribe function. Since 0.30.0 the envelope payload is NARROWED per
566
+ * event name (OutboundPayloadMap) e.g. `on('companion.typing', (env) =>
567
+ * env.payload.active)` type-checks without a cast; `'*'` keeps the untyped
568
+ * envelope. Type-only: runtime behavior is unchanged. */
569
+ on<T extends OutboundType>(type: T, handler: (envelope: CompanionEnvelope<OutboundPayloadMap[T]>) => void): () => void;
570
+ on(type: '*', handler: Handler): () => void;
538
571
  /** Convenience: subscribe to assistant text replies. */
539
572
  onMessage(handler: (text: string, envelope: CompanionEnvelope) => void): () => void;
540
573
  /** Convenience: subscribe to Instant UI render surfaces (companion.ui_action).
@@ -627,7 +660,14 @@ export declare class CompanionClient {
627
660
  stop(): void;
628
661
  private emit;
629
662
  private handleFrame;
663
+ /** SSE receive loop. `degraded` marks that this loop is the WS transport's
664
+ * fallback, so its healthy state reads `degraded_sse` (still live) instead
665
+ * of `connected` — the host can tell "fine" from "fine, but on plan B". */
630
666
  private loop;
631
667
  private consume;
632
668
  private sleep;
669
+ /** Backoff sleep with additive jitter (0–25%, 0.30.0) so a fleet of embeds
670
+ * doesn't reconnect in lockstep after a server deploy/restart (thundering
671
+ * herd) — every reconnect path sleeps through here, never bare sleep(). */
672
+ private backoffSleep;
633
673
  }
package/dist/client.js CHANGED
@@ -62,6 +62,9 @@ export class CompanionClient {
62
62
  deltaHandlers = new Set();
63
63
  // Voice tool-calls awaiting the app's sendToolResult, keyed by a synthetic id.
64
64
  pendingVoiceTools = new Map();
65
+ // Receive-stream lifecycle (0.30.0) — see CompanionStreamState.
66
+ _streamState = 'idle';
67
+ streamStateHandlers = new Set();
65
68
  constructor(opts) {
66
69
  if (!opts.baseUrl)
67
70
  throw new CompanionError('CompanionClient: baseUrl is required', 0, 'missing_option');
@@ -74,6 +77,37 @@ export class CompanionClient {
74
77
  get sessionId() {
75
78
  return this._session;
76
79
  }
80
+ /** Current lifecycle of the receive event stream (0.30.0) — `idle` before
81
+ * start(), then `connecting` / `connected` / `reconnecting` /
82
+ * `degraded_sse` (WS errored, SSE fallback carrying delivery) / `stopped`.
83
+ * Subscribe to transitions with `onStreamStateChange`. */
84
+ get streamState() {
85
+ return this._streamState;
86
+ }
87
+ /** Subscribe to receive-stream lifecycle transitions (0.30.0) — drive a
88
+ * connection indicator: `client.onStreamStateChange((s) => setStatus(s))`.
89
+ * Fires only on change, with the previous state as the second argument.
90
+ * Returns an unsubscribe function. */
91
+ onStreamStateChange(handler) {
92
+ this.streamStateHandlers.add(handler);
93
+ return () => this.streamStateHandlers.delete(handler);
94
+ }
95
+ setStreamState(next) {
96
+ if (next === this._streamState)
97
+ return;
98
+ const prev = this._streamState;
99
+ this._streamState = next;
100
+ // Same isolation doctrine as emit(): a throwing consumer handler must not
101
+ // skip the remaining handlers or bubble into the stream loop.
102
+ for (const h of this.streamStateHandlers) {
103
+ try {
104
+ h(next, prev);
105
+ }
106
+ catch (e) {
107
+ console.error('[companion-sdk] stream-state handler threw', e);
108
+ }
109
+ }
110
+ }
77
111
  /** Swap the bearer token used by every subsequent request and stream
78
112
  * reconnect. Session tokens expire (default 1h) — call this when your
79
113
  * backend re-mints one, or wire `onAuthError` to do it on demand. */
@@ -198,7 +232,14 @@ export class CompanionClient {
198
232
  if (raw === null || raw.trim() === '')
199
233
  return undefined;
200
234
  const header = Number(raw);
201
- return Number.isFinite(header) && header >= 0 ? header : undefined;
235
+ if (Number.isFinite(header) && header >= 0)
236
+ return header;
237
+ // RFC 9110 also allows an HTTP-date (proxies/CDNs emit it) — convert to a
238
+ // non-negative seconds delta (0.30.0). Unparseable stays undefined.
239
+ const at = Date.parse(raw);
240
+ if (!Number.isNaN(at))
241
+ return Math.max(0, Math.ceil((at - Date.now()) / 1000));
242
+ return undefined;
202
243
  }
203
244
  async postJson(path, body, signal) {
204
245
  const res = await this.fetchAuthed(this.url(path), {
@@ -388,7 +429,11 @@ export class CompanionClient {
388
429
  // Old server / error before the stream opened — behave like postJson.
389
430
  const json = (await res.json().catch(() => null));
390
431
  if (!res.ok || !json || json.ok === false) {
391
- throw new CompanionError(json?.error || `request failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json));
432
+ // Same as postJson: the 429 body's actionable `hint` (e.g. "provision
433
+ // a project …") must survive the streaming leg too (0.30.0).
434
+ const hint = json?.hint;
435
+ const detail = typeof hint === 'string' && hint ? ` — ${hint}` : '';
436
+ throw new CompanionError((json?.error || `request failed (${res.status})`) + detail, res.status, json?.code, this.retryAfterFrom(res, json));
392
437
  }
393
438
  return { seq: json.seq ?? null };
394
439
  }
@@ -770,7 +815,10 @@ export class CompanionClient {
770
815
  }
771
816
  /** Convenience: subscribe to errors the companion surfaces — server-side agent
772
817
  * errors and stream failures (e.g. a permanent `stream_unauthorized`) both
773
- * arrive as `control.error`. Returns an unsubscribe fn. */
818
+ * arrive as `control.error`. Since 0.30.0 `code` is typed as
819
+ * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the
820
+ * SDK-synthesized stream codes, open for newer servers) so a `switch` on it
821
+ * autocompletes. Returns an unsubscribe fn. */
774
822
  onError(handler) {
775
823
  return this.on('control.error', (env) => {
776
824
  const p = env.payload;
@@ -940,8 +988,6 @@ export class CompanionClient {
940
988
  brandIconUrl(size = 512) {
941
989
  return pouchyBrandIconUrl(this.opts.baseUrl, size);
942
990
  }
943
- /** Subscribe to outbound events of a given type, or "*" for all. Returns an
944
- * unsubscribe function. */
945
991
  on(type, handler) {
946
992
  let set = this.handlers.get(type);
947
993
  if (!set) {
@@ -1096,6 +1142,7 @@ export class CompanionClient {
1096
1142
  return;
1097
1143
  this.requireSession();
1098
1144
  this.streaming = true;
1145
+ this.setStreamState('connecting');
1099
1146
  const gen = ++this.streamGen;
1100
1147
  const wsImpl = this.opts.webSocketImpl ?? globalThis.WebSocket;
1101
1148
  if (this.opts.stream === 'websocket' && wsImpl) {
@@ -1126,6 +1173,10 @@ export class CompanionClient {
1126
1173
  const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
1127
1174
  let poll;
1128
1175
  let settled = false;
1176
+ ws.onopen = () => {
1177
+ if (this.streaming && gen === this.streamGen)
1178
+ this.setStreamState('connected');
1179
+ };
1129
1180
  const settle = (err) => {
1130
1181
  if (poll) {
1131
1182
  clearInterval(poll);
@@ -1169,8 +1220,12 @@ export class CompanionClient {
1169
1220
  }
1170
1221
  if (errored) {
1171
1222
  // Socket unavailable / rejected — degrade to the robust SSE loop (once).
1172
- if (this.streaming && gen === this.streamGen)
1173
- await this.loop(gen);
1223
+ // The SSE loop's healthy state then reads `degraded_sse`, so the host
1224
+ // can observe the (previously invisible) WS→SSE degrade.
1225
+ if (this.streaming && gen === this.streamGen) {
1226
+ this.setStreamState('reconnecting');
1227
+ await this.loop(gen, true);
1228
+ }
1174
1229
  return;
1175
1230
  }
1176
1231
  // Clean close: the while-guard reconnects if still streaming, else exits.
@@ -1178,8 +1233,10 @@ export class CompanionClient {
1178
1233
  // closes — auth-as-close, LB idle policy, misconfigured proxy) must
1179
1234
  // back off like the SSE loop, or this reconnects in a zero-delay hot
1180
1235
  // loop. A connection that lived a while resets the backoff.
1236
+ if (this.streaming && gen === this.streamGen)
1237
+ this.setStreamState('reconnecting');
1181
1238
  if (Date.now() - connectedAt < 2000) {
1182
- await this.sleep(backoff);
1239
+ await this.backoffSleep(backoff);
1183
1240
  backoff = Math.min(backoff * 2, 8000);
1184
1241
  }
1185
1242
  else {
@@ -1193,6 +1250,7 @@ export class CompanionClient {
1193
1250
  this.streamGen++; // invalidate any loop still unwinding
1194
1251
  this.abort?.abort();
1195
1252
  this.abort = null;
1253
+ this.setStreamState('stopped');
1196
1254
  }
1197
1255
  emit(envelope) {
1198
1256
  // Dedup by envelope id — an ungraceful drop reconnects from the last KNOWN
@@ -1277,7 +1335,10 @@ export class CompanionClient {
1277
1335
  if (env && typeof env.type === 'string')
1278
1336
  this.emit(env);
1279
1337
  }
1280
- async loop(gen) {
1338
+ /** SSE receive loop. `degraded` marks that this loop is the WS transport's
1339
+ * fallback, so its healthy state reads `degraded_sse` (still live) instead
1340
+ * of `connected` — the host can tell "fine" from "fine, but on plan B". */
1341
+ async loop(gen, degraded = false) {
1281
1342
  const id = this.requireSession();
1282
1343
  let backoff = 500;
1283
1344
  let authRefreshes = 0; // consecutive 401→refresh cycles without a healthy connect
@@ -1311,23 +1372,27 @@ export class CompanionClient {
1311
1372
  message: `event stream rejected (${res.status}); the token likely lacks the "events.subscribe" scope`
1312
1373
  }
1313
1374
  });
1375
+ this.setStreamState('stopped');
1314
1376
  break;
1315
1377
  }
1316
1378
  if (!res.ok || !res.body) {
1317
1379
  if (!this.streaming || gen !== this.streamGen)
1318
1380
  break;
1319
- await this.sleep(backoff);
1381
+ this.setStreamState('reconnecting');
1382
+ await this.backoffSleep(backoff);
1320
1383
  backoff = Math.min(backoff * 2, 8000);
1321
1384
  continue;
1322
1385
  }
1323
1386
  backoff = 500; // healthy connection resets backoff
1324
1387
  authRefreshes = 0;
1388
+ this.setStreamState(degraded ? 'degraded_sse' : 'connected');
1325
1389
  await this.consume(res.body, gen);
1326
1390
  }
1327
1391
  catch {
1328
1392
  if (!this.streaming || gen !== this.streamGen)
1329
1393
  break;
1330
- await this.sleep(backoff);
1394
+ this.setStreamState('reconnecting');
1395
+ await this.backoffSleep(backoff);
1331
1396
  backoff = Math.min(backoff * 2, 8000);
1332
1397
  }
1333
1398
  }
@@ -1350,4 +1415,10 @@ export class CompanionClient {
1350
1415
  sleep(ms) {
1351
1416
  return new Promise((res) => setTimeout(res, ms));
1352
1417
  }
1418
+ /** Backoff sleep with additive jitter (0–25%, 0.30.0) so a fleet of embeds
1419
+ * doesn't reconnect in lockstep after a server deploy/restart (thundering
1420
+ * herd) — every reconnect path sleeps through here, never bare sleep(). */
1421
+ backoffSleep(ms) {
1422
+ return this.sleep(ms + Math.floor(Math.random() * ms * 0.25));
1423
+ }
1353
1424
  }
package/dist/errors.d.ts CHANGED
@@ -1,9 +1,22 @@
1
- import type { CompanionErrorCode } from './protocol.js';
1
+ import type { CompanionErrorCode, ControlErrorCode } from './protocol.js';
2
2
  /** Codes the SDK itself synthesizes (status 0 — never sent by the server).
3
3
  * Runtime array so the drift test can assert every `new CompanionError(...)`
4
4
  * call site uses a declared code; APPEND-ONLY like the server vocabulary. */
5
5
  export declare const SDK_SYNTHESIZED_ERROR_CODES: readonly ["reply_timeout", "needs_event_stream", "not_connected", "missing_option", "not_representative", "call_unsupported", "call_connect_failed", "call_dependency_missing", "aborted"];
6
6
  export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number];
7
+ /** `control.error` STREAM codes the SDK itself synthesizes (never sent by the
8
+ * server) — the stream-plane sibling of SDK_SYNTHESIZED_ERROR_CODES above.
9
+ * Runtime array so the drift test can assert every locally-emitted
10
+ * `control.error` envelope uses a declared code; APPEND-ONLY. */
11
+ export declare const SDK_STREAM_ERROR_CODES: readonly ["stream_unauthorized"];
12
+ export type SdkStreamErrorCode = (typeof SDK_STREAM_ERROR_CODES)[number];
13
+ /** `control.error` `code` values a consumer can switch on (0.30.0): the server
14
+ * vocabulary (CONTROL_ERROR_CODES, drift-tested against the server list) plus
15
+ * the SDK-synthesized stream codes — DERIVED from the runtime lists rather
16
+ * than hand-retyped, same doctrine as CompanionErrorCodeValue below. The
17
+ * `(string & {})` arm keeps the type open for codes newer than this SDK build
18
+ * while preserving autocomplete. */
19
+ export type ControlErrorCodeValue = ControlErrorCode | SdkStreamErrorCode | (string & {});
7
20
  /** Thrown by every helper on an HTTP or client-side failure. `status` is the
8
21
  * HTTP status (0 for client-synthesized failures — timeouts, unsupported
9
22
  * environments, missing optional deps); `code` is machine-switchable when
package/dist/errors.js CHANGED
@@ -17,6 +17,13 @@ export const SDK_SYNTHESIZED_ERROR_CODES = [
17
17
  'call_dependency_missing', // optional voice dependency not installed
18
18
  'aborted' // a caller-supplied AbortSignal cancelled the request (0.29.0)
19
19
  ];
20
+ /** `control.error` STREAM codes the SDK itself synthesizes (never sent by the
21
+ * server) — the stream-plane sibling of SDK_SYNTHESIZED_ERROR_CODES above.
22
+ * Runtime array so the drift test can assert every locally-emitted
23
+ * `control.error` envelope uses a declared code; APPEND-ONLY. */
24
+ export const SDK_STREAM_ERROR_CODES = [
25
+ 'stream_unauthorized' // the event stream got a permanent 401/403 and reconnect gave up
26
+ ];
20
27
  export class CompanionError extends Error {
21
28
  status;
22
29
  code;
package/dist/index.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { CompanionClient, type CompanionClientOptions } from './client.js';
2
2
  export { CompanionClient, CompanionError, pouchyBrandIconUrl } from './client.js';
3
- export type { CompanionErrorCodeValue } from './errors.js';
4
- export type { CompanionClientOptions, HelloAck, RecalledMemory, CompanionTurn, CallCredentials, CompanionToolDecl, ToolCallEvent, WorldStateInput, CompanionAvatar, CompanionWallet, WalletBalance, BrandIconSize, EndSessionResult, SendTextReply } from './client.js';
3
+ export type { CompanionErrorCodeValue, ControlErrorCodeValue } from './errors.js';
4
+ export type { CompanionClientOptions, CompanionStreamState, HelloAck, RecalledMemory, CompanionTurn, CallCredentials, CompanionToolDecl, ToolCallEvent, WorldStateInput, CompanionAvatar, CompanionWallet, WalletBalance, BrandIconSize, EndSessionResult, SendTextReply } from './client.js';
5
5
  export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call.js';
6
6
  export type { CompanionCall, CompanionCallOptions, VoiceToolBridge } from './call.js';
7
7
  export { COMPANION_SCOPES, COMPANION_MODALITIES, SENSITIVE_SCOPES, REPRESENT_SCOPES, DEFAULT_SCOPES, DEFAULT_MODALITIES, isSensitiveScope, isRepresentScope, hasScope } from './scopes.js';
8
8
  export type { CompanionScope, CompanionModality } from './scopes.js';
9
- export type { CompanionEnvelope, OutboundType, InboundType, WorldStateEvent, RenderInterfacePayload, InterfaceUpdatePayload, SocialMessagePayload, ConfirmRequestPayload, PendingConfirm, ToolCallPayload, AudioClipPayload, ExpressionPayload, TypingPayload, VoiceInjectPayload, UsagePayload, MessagePayload } from './protocol.js';
10
- export { PROTOCOL_VERSION, INBOUND_TYPES, OUTBOUND_TYPES, COMPANION_ERROR_CODES } from './protocol.js';
11
- export type { CompanionErrorCode } from './protocol.js';
9
+ export type { CompanionEnvelope, OutboundType, InboundType, WorldStateEvent, RenderInterfacePayload, InterfaceUpdatePayload, SocialMessagePayload, ConfirmRequestPayload, PendingConfirm, ToolCallPayload, AudioClipPayload, ExpressionPayload, TypingPayload, VoiceInjectPayload, UsagePayload, MessagePayload, ControlErrorPayload, HelloAckPayload, CallReadyPayload, OutboundPayloadMap } from './protocol.js';
10
+ export { PROTOCOL_VERSION, INBOUND_TYPES, OUTBOUND_TYPES, COMPANION_ERROR_CODES, CONTROL_ERROR_CODES } from './protocol.js';
11
+ export type { CompanionErrorCode, ControlErrorCode } from './protocol.js';
12
12
  /** Create a companion client. */
13
13
  export declare function createCompanion(opts: CompanionClientOptions): CompanionClient;
package/dist/index.js CHANGED
@@ -15,7 +15,9 @@ export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_
15
15
  export { COMPANION_SCOPES, COMPANION_MODALITIES, SENSITIVE_SCOPES, REPRESENT_SCOPES, DEFAULT_SCOPES, DEFAULT_MODALITIES, isSensitiveScope, isRepresentScope, hasScope } from './scopes.js';
16
16
  // Runtime protocol constants — so a host can assert PROTOCOL_VERSION or
17
17
  // enumerate/validate the event vocabulary without hard-coding the strings.
18
- export { PROTOCOL_VERSION, INBOUND_TYPES, OUTBOUND_TYPES, COMPANION_ERROR_CODES } from './protocol.js';
18
+ // CONTROL_ERROR_CODES (0.30.0) is the stream-plane control.error vocabulary
19
+ // a separate, smaller list than the HTTP COMPANION_ERROR_CODES.
20
+ export { PROTOCOL_VERSION, INBOUND_TYPES, OUTBOUND_TYPES, COMPANION_ERROR_CODES, CONTROL_ERROR_CODES } from './protocol.js';
19
21
  /** Create a companion client. */
20
22
  export function createCompanion(opts) {
21
23
  return new CompanionClient(opts);
@@ -12,6 +12,15 @@ export type InboundType = (typeof INBOUND_TYPES)[number];
12
12
  export declare const OUTBOUND_TYPES: readonly ["hello.ack", "companion.message", "companion.audio", "companion.tool_call", "companion.ui_action", "companion.ui_update", "companion.expression", "companion.voice_inject", "companion.confirm_request", "companion.social_message", "companion.typing", "control.call_ready", "control.error", "control.usage"];
13
13
  export type OutboundType = (typeof OUTBOUND_TYPES)[number];
14
14
  export type MessageType = InboundType | OutboundType;
15
+ /** `control.error` STREAM-plane code vocabulary (0.30.0) — a separate, smaller
16
+ * vocabulary from COMPANION_ERROR_CODES below (those tag HTTP `{ok:false}`
17
+ * response bodies; these tag `control.error` envelopes on the event stream,
18
+ * surfaced by `onError`). Self-contained mirror of the server list
19
+ * (drift-tested); APPEND-ONLY. The SDK itself synthesizes one more,
20
+ * `stream_unauthorized` (errors.ts) — the public union `ControlErrorCodeValue`
21
+ * covers both plus a forward-compat string arm. */
22
+ export declare const CONTROL_ERROR_CODES: readonly ["agent_error", "call_mint_failed"];
23
+ export type ControlErrorCode = (typeof CONTROL_ERROR_CODES)[number];
15
24
  /** HTTP error `code` vocabulary — what `CompanionError.code` can hold. A
16
25
  * self-contained mirror of the server's api-error.ts list (drift-tested);
17
26
  * APPEND-ONLY: renaming or removing a code is a breaking SDK change.
@@ -187,3 +196,51 @@ export interface WorldStateEvent<D = unknown> {
187
196
  salience?: number;
188
197
  voiceRelevant?: boolean;
189
198
  }
199
+ /** Payload of a `control.error` event — a typed error on the event stream
200
+ * (surfaced by `onError`). `code` is one of CONTROL_ERROR_CODES, the
201
+ * SDK-synthesized `stream_unauthorized`, or a code newer than this SDK build
202
+ * (the open arm keeps forward compat without losing autocomplete). */
203
+ export interface ControlErrorPayload {
204
+ code: ControlErrorCode | (string & {});
205
+ message: string;
206
+ }
207
+ /** Payload of a `hello.ack` — the handshake reply. On the REST plane this is
208
+ * the `/session` response body (what `connect()` normalizes into `HelloAck`);
209
+ * the event stream's greeting `open` frame carries a partial echo (just
210
+ * `{ resumeCursor }`), so every field is optional at the wire level. */
211
+ export interface HelloAckPayload {
212
+ session?: string;
213
+ grantedScopes?: string[];
214
+ modalities?: string[];
215
+ resumeCursor?: number;
216
+ representative?: boolean;
217
+ visitorPaired?: boolean;
218
+ }
219
+ /** Payload of a `control.call_ready` event — the stream echo of `start_call`'s
220
+ * accept. Deliberately SECRET-FREE (`provider` plus non-secret metadata such
221
+ * as `agentId` / `model` / `voice`); the actual WebRTC credentials only ride
222
+ * the `startCall` HTTP response. */
223
+ export interface CallReadyPayload {
224
+ provider: string;
225
+ [k: string]: unknown;
226
+ }
227
+ /** Per-event payload types, keyed by outbound event name — what `client.on()`
228
+ * narrows envelopes with (0.30.0). Type-only; extending the base Record keeps
229
+ * indexing valid for every OutboundType, so an event added to the vocabulary
230
+ * before it gets a dedicated payload type simply reads as `unknown`. */
231
+ export interface OutboundPayloadMap extends Record<OutboundType, unknown> {
232
+ 'hello.ack': HelloAckPayload;
233
+ 'companion.message': MessagePayload;
234
+ 'companion.audio': AudioClipPayload;
235
+ 'companion.tool_call': ToolCallPayload;
236
+ 'companion.ui_action': RenderInterfacePayload;
237
+ 'companion.ui_update': InterfaceUpdatePayload;
238
+ 'companion.expression': ExpressionPayload;
239
+ 'companion.voice_inject': VoiceInjectPayload;
240
+ 'companion.confirm_request': ConfirmRequestPayload;
241
+ 'companion.social_message': SocialMessagePayload;
242
+ 'companion.typing': TypingPayload;
243
+ 'control.call_ready': CallReadyPayload;
244
+ 'control.error': ControlErrorPayload;
245
+ 'control.usage': UsagePayload;
246
+ }
package/dist/protocol.js CHANGED
@@ -40,6 +40,17 @@ export const OUTBOUND_TYPES = [
40
40
  'control.error',
41
41
  'control.usage' // reserved — not emitted yet
42
42
  ];
43
+ /** `control.error` STREAM-plane code vocabulary (0.30.0) — a separate, smaller
44
+ * vocabulary from COMPANION_ERROR_CODES below (those tag HTTP `{ok:false}`
45
+ * response bodies; these tag `control.error` envelopes on the event stream,
46
+ * surfaced by `onError`). Self-contained mirror of the server list
47
+ * (drift-tested); APPEND-ONLY. The SDK itself synthesizes one more,
48
+ * `stream_unauthorized` (errors.ts) — the public union `ControlErrorCodeValue`
49
+ * covers both plus a forward-compat string arm. */
50
+ export const CONTROL_ERROR_CODES = [
51
+ 'agent_error', // a server-side turn failed after the input was accepted — no reply was produced; safe to re-send
52
+ 'call_mint_failed' // start_call couldn't mint the voice-provider credential — retry later or fall back to text
53
+ ];
43
54
  /** HTTP error `code` vocabulary — what `CompanionError.code` can hold. A
44
55
  * self-contained mirror of the server's api-error.ts list (drift-tested);
45
56
  * APPEND-ONLY: renaming or removing a code is a breaking SDK change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pouchy_ai/companion-sdk",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "description": "Embed the Pouchy companion — chat, voice, tools, memory, live world-state, instant UI, and agent-to-agent messaging — in any app, game, or site.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",