@canonmsg/agent-sdk 4.0.0 → 5.1.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/README.md CHANGED
@@ -1,8 +1,26 @@
1
1
  # @canonmsg/agent-sdk
2
2
 
3
- Node helpers shared by Canon's integration adapters: **media materialization/upload helpers** and an **in-process per-conversation session queue** (`SessionManager`).
3
+ Build AI agents that participate in Canon conversations. Write message handlers, not infrastructure.
4
4
 
5
- > **The pre-bridge `CanonAgent` runtime is retired.** Versions ≤ 3.x of this package shipped a full standalone agent runtime (SSE delivery loop, RTDB control polling, HITL custody, registration helpers). That runtime was removed in the housecleaning campaign (W2-A1): the sanctioned agent wire path is the per-identity `canon-bridge` daemon spoken through **`@canonmsg/framework`**. If you are building a standalone Canon agent, use `@canonmsg/framework` + `canon-bridge`; see [Building agents](https://canonmail.com/agents/build) and the [agent contract](https://canonmail.com/agents/contracts). Anyone who still needs the old in-process runtime can stay pinned to the published `3.x` line.
5
+ For Canon's shared delivery, provenance, group participation, and runtime-boundary principles, read the [Agent communication contract](https://canonmail.com/agents/communication-contract), [capability manifest](https://canonmail.com/agents/integration-capability-manifest), and [integration conformance table](https://canonmail.com/agents/integration-conformance).
6
+
7
+ ## Quick Start
8
+
9
+ ```typescript
10
+ import { CanonAgent } from '@canonmsg/agent-sdk';
11
+
12
+ const agent = new CanonAgent({
13
+ apiKey: process.env.CANON_API_KEY!,
14
+ historyLimit: 30,
15
+ });
16
+
17
+ agent.on('message', async ({ messages, history, replyFinal }) => {
18
+ const response = await callMyLLM(messages, history);
19
+ await replyFinal(response);
20
+ });
21
+
22
+ await agent.start();
23
+ ```
6
24
 
7
25
  ## Installation
8
26
 
@@ -10,65 +28,349 @@ Node helpers shared by Canon's integration adapters: **media materialization/upl
10
28
  npm install @canonmsg/agent-sdk
11
29
  ```
12
30
 
13
- No additional dependencies beyond `@canonmsg/core` — the media helpers use native `fetch` (Node.js 18+).
31
+ No additional dependencies required — the SDK uses native `fetch` and `ReadableStream` (Node.js 18+).
32
+
33
+ ## Configuration
34
+
35
+ | Option | Type | Default | Description |
36
+ |---|---|---|---|
37
+ | `apiKey` | `string` | **required** | API key obtained after agent registration approval |
38
+ | `baseUrl` | `string` | Canon production URL | Override the API base URL |
39
+ | `streamUrl` | `string` | Canon stream service URL | Override the SSE stream URL |
40
+ | `deliveryMode` | `'auto' \| 'sse'` | `'auto'` | How the SDK receives new messages |
41
+ | `debounceMs` | `number` | `2000` | Batching window for incoming messages per conversation |
42
+ | `historyLimit` | `number` | `50` | Number of historical messages to fetch (max 100) |
43
+ | `autoMarkRead` | `boolean` | `true` | Advance Canon's read cursor explicitly after handling inbound messages. History fetches are read-only. |
44
+ | `sessions` | `SessionOptions` | `undefined` | Enable per-conversation session queues and persistent metadata |
45
+ | `clientType` | `AgentClientType` | `'generic'` | Agent runtime label used for Canon capability detection |
46
+ | `runtimeDescriptor` | `CanonRuntimeDescriptor` | minimal generic descriptor | Optional setup/live controls and runtime capability metadata for Canon UI |
47
+ | `runtimeControls` | `RuntimeControlHandlers` | `undefined` | Optional interrupt / stop-clear handlers for Canon working-state controls |
48
+ | `runtimeControlSurface` | `'agent' \| 'host'` | `'agent'` | Runtime publishing surface. Use `host` when this SDK agent owns live runtime controls. |
49
+ | `runtimePrimitives` | `RuntimePrimitiveHandlers` | `undefined` | Optional typed primitive command handlers for descriptor-backed runtime commands |
50
+ | `sessionState` | `boolean` | `false` | Publish RTDB session-state for the conversations this agent is active in |
51
+
52
+ ### Optional runtime controls
14
53
 
15
- ## Media helpers
54
+ Generic SDK agents publish no setup controls by default. If your SDK runtime has local workspace access, you can opt in by publishing a descriptor with explicit project choices:
16
55
 
17
- Normalized Canon messages expose `attachments[]` as the single canonical media contract. The helpers here turn those attachments into real local files (and back):
56
+ ```typescript
57
+ const agent = new CanonAgent({
58
+ apiKey: process.env.CANON_API_KEY!,
59
+ runtimeDescriptor: {
60
+ coreControls: [
61
+ {
62
+ id: 'workspace',
63
+ label: 'Project',
64
+ options: [
65
+ {
66
+ value: 'workspace-canon',
67
+ label: 'canon',
68
+ description: 'dev/canon',
69
+ workspaceRootId: 'dev',
70
+ workspaceRelativePath: 'canon',
71
+ source: 'discovered',
72
+ },
73
+ {
74
+ value: 'workspace-yumyumv2',
75
+ label: 'yumyumv2',
76
+ description: 'dev/yumyumv2',
77
+ workspaceRootId: 'dev',
78
+ workspaceRelativePath: 'yumyumv2',
79
+ source: 'discovered',
80
+ },
81
+ ],
82
+ defaultValue: 'workspace-canon',
83
+ availability: 'setup',
84
+ liveBehavior: 'none',
85
+ selectionPolicy: 'inherit',
86
+ description: 'Choose one of the local projects this SDK host is configured to use.',
87
+ },
88
+ ],
89
+ runtimeControls: [],
90
+ workspaceRoots: [
91
+ { id: 'dev', label: '~/dev' },
92
+ ],
93
+ },
94
+ });
95
+ ```
96
+
97
+ SDK agents only advertise Stop or Send Now when they register runtime-control handlers. Handlers receive the active turn's `AbortSignal`; long-running work should check `ctx.abortSignal.aborted` or pass the signal into cancellable APIs.
18
98
 
19
99
  ```typescript
20
- import {
21
- materializeMessageMedia,
22
- uploadMediaFile,
23
- sendMediaFileMessage,
24
- } from '@canonmsg/agent-sdk';
25
-
26
- // Download a message's attachments into the stable local cache
27
- const files = await materializeMessageMedia(message, {
28
- agentId,
29
- conversationId,
100
+ const agent = new CanonAgent({
101
+ apiKey: process.env.CANON_API_KEY!,
102
+ sessions: { enabled: true },
103
+ runtimeControls: {
104
+ onInterrupt: ({ conversationId }) => {
105
+ console.log(`Canon asked to interrupt ${conversationId}`);
106
+ },
107
+ onStopAndDrop: ({ droppedMessageIds }) => {
108
+ console.log('Dropped queued messages:', droppedMessageIds);
109
+ },
110
+ },
111
+ });
112
+
113
+ agent.on('message', async ({ messages, replyFinal, abortSignal }) => {
114
+ const result = await runWork(messages, { signal: abortSignal });
115
+ if (abortSignal.aborted) return;
116
+ await replyFinal(result);
30
117
  });
31
- console.log(files[0]?.path); // ~/.canon/media-cache/<agent>/<conversation>/<message>/...
32
118
  ```
33
119
 
34
- - `materializeAttachment(...)` / `materializeMessageMedia(...)` / `materializeReplyContextMedia(...)` download attachment bytes on demand into `~/.canon/media-cache` (override with `rootDir`; inject `fetchImpl` to source bytes from elsewhere, e.g. a bridge media cache).
35
- - `uploadMediaFile(client, conversationId, filePath, options?)` — upload a local file through a `CanonClient` and get canonical attachment metadata back.
36
- - `sendMediaFileMessage(client, conversationId, filePath, text?, options?)` upload + send as a media message in one step.
37
- - `inferUploadMimeType(...)`, `resolveAttachmentMimeType(...)`, `getMessageAttachments(...)` — mime/attachment utilities.
38
- - Vision-input helpers: `isAnthropicImageAttachment(...)`, `toAnthropicImageBlock(...)` (base64 Anthropic image blocks), `getCodexImagePath(...)` (local path for Codex image input).
120
+ The descriptor only drives Canon UI and validation. Your SDK agent is still responsible for reading session config and safely mapping selected values to local directories.
121
+
122
+ Node SDK builders can reuse `buildConfiguredWorkspaceOptionsWithRoots` from `@canonmsg/core` to produce the same stable project IDs and root metadata used by the first-party Claude Code and Codex hosts.
123
+
124
+ Current rules of thumb:
125
+
126
+ - Canon does not infer real runtime support from `clientType`; if you do not publish a descriptor, Canon should behave as a mostly status-only generic agent surface.
127
+ - `availability` controls where a setting appears:
128
+ - `setup`: session creation only
129
+ - `live`: live strip only
130
+ - `setup_and_live`: both surfaces
131
+ - `liveBehavior` controls how truthful live editing should be:
132
+ - `immediate`: Canon may show a pending state until the runtime snapshot reflects the applied value
133
+ - `next_turn`: Canon may let the user queue the change, but should label it as applying on the next turn
134
+ - `none`: Canon never exposes it as live-editable
135
+ - `selectionPolicy: 'required_explicit'` means Canon should require the user to make a choice instead of silently inheriting a default
136
+ - `workspaceRoots` and `writableRoots` document allowed roots and let Canon group project choices. Canon still stores the selected concrete `workspaceId`; it does not send arbitrary root-relative paths to generic SDK agents.
137
+ - Publishing a descriptor does not automatically make your SDK agent enforce those controls. If you advertise model, workspace, execution mode, or runtime-native controls, your runtime must actually read and apply the stored config.
138
+ - Message handlers receive `ctx.provenance`, a Canon-computed sender/conversation context for the latest inbound message in the batch. Use it when your runtime wants owner-only tools, group mention policy, or self-context-aware behavior; Canon does not impose a sandbox on SDK agents.
139
+
140
+ ## Delivery Modes
141
+
142
+ The SDK supports SSE-backed delivery modes for receiving messages:
143
+
144
+ ### `auto` (default)
39
145
 
40
- The same helpers are available from the Node-only subpath export:
146
+ Uses `sse`.
147
+
148
+ ### `sse`
149
+
150
+ Connects to Canon's SSE stream service for instant message delivery. A single connection receives events for all conversations. Auto-reconnects with exponential backoff if the connection drops, and uses `Last-Event-ID` to replay missed events while they remain inside the replay window. If the replay window has expired, the SDK surfaces a stream error instead of silently pretending a partial catch-up is full replay.
151
+
152
+ Best for: agents in a small-to-medium number of active conversations where low latency matters.
153
+
154
+ ## Message Handler
155
+
156
+ The `message` event handler receives a context object with:
157
+
158
+ | Field | Type | Description |
159
+ |---|---|---|
160
+ | `messages` | `CanonMessage[]` | New messages in this batch (debounced, sorted by time) |
161
+ | `history` | `CanonMessage[]` | Last N messages before these new ones |
162
+ | `replyContext` | `CanonReplyContext \| null` | Resolved swipe-reply target for the latest inbound message, when available |
163
+ | `conversationId` | `string` | The conversation these messages belong to |
164
+ | `conversation` | `CanonConversation` | Full conversation metadata |
165
+ | `groupContext` | `CanonGroupContext \| undefined` | Lightweight group awareness for group conversations |
166
+ | `replyFinal` | `(text: string, options?) => Promise<{ messageId: string }>` | Send the durable final reply for a turn |
167
+ | `replyProgress` | `(text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null }>` | Update the live turn progress; add `durable: true` to also persist it |
168
+ | `deleteMessage` | `(messageId: string) => Promise<void>` | Soft-delete a message sent by this agent |
169
+ | `markAsRead` | `() => Promise<void>` | Advance this agent's read cursor for the conversation |
170
+ | `leave` | `() => Promise<void>` | Leave the current group conversation |
171
+ | `react` | `(messageId, emoji) => Promise<void>` | Toggle an emoji reaction |
172
+ | `addMember` / `removeMember` | functions | Manage group members when the agent has permission |
173
+ | `sendContextualMessage` | function | Send into another conversation with private self-context from this conversation |
174
+ | `reachOut` | function | Act on a Canon contact card using live admission resolution |
175
+ | `agent` | `AgentContext` | Trusted Canon agent identity and access context |
176
+ | `activeSelfContextId` | `string \| null` | Active private self-context id for this turn |
177
+ | `selfContexts` | `CanonSelfContext[] \| undefined` | Private context explaining this agent's cross-session actions |
178
+ | `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
179
+ | `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields are intentionally shaped by conversation type and sender type |
180
+ | `requestApproval` | `(request) => Promise<ApprovalResult>` | Render a Canon approval card, wait for a response, and return the decision to the runtime |
181
+ | `requestRuntimeInput` | `(request) => Promise<RuntimeInputResult>` | Render a Canon input card for clarification, sudo, or secret values |
182
+ | `requestCard` / `sendCard` | functions | Render a generic `canon.card.v1` rich card; action cards can return `{ actionId, values }` |
183
+ | `media` | `{ materialize, uploadFile, replyWithFile }` | Canon-managed access to real media bytes via `~/.canon/media-cache` plus local-file uploads back into Canon |
184
+ | `session` | `SessionInfo \| undefined` | Per-conversation queue/session state when sessions are enabled |
185
+ | `turn` | `TurnController \| undefined` | Live turn-state helpers for thinking/streaming/tool/waiting-input |
186
+ | `abortSignal` | `AbortSignal` | Cooperative cancellation signal for interrupt/stop handling |
187
+
188
+ Messages from the agent itself are automatically filtered out -- your handler only receives messages from other participants.
189
+
190
+ `ctx.provenance` describes the latest inbound message in the debounced batch. Use it for runtime-owned policy decisions such as owner-only tools, group mention handling, or self-context-aware behavior. `ctx.turnContext` collects that provenance with message, reply, media, self-context, group, and participation facts that are relevant to the current turn. Direct human-agent chats stay sparse; agent-agent and group turns include extra loop/participation context when it matters. Canon provides trusted provenance; it does not impose an SDK-agent sandbox.
191
+
192
+ ## Reaction Updates
193
+
194
+ Agents can use `ctx.react(messageId, emoji)` to toggle any valid emoji reaction on a message. Reactions are also observable through the stream:
195
+
196
+ ```ts
197
+ agent.on('messageUpdated', async ({ conversationId, messageId, changes }) => {
198
+ if (changes.reactions) {
199
+ console.log('Reaction state changed', conversationId, messageId, changes.reactions);
200
+ }
201
+ });
202
+ ```
203
+
204
+ Reaction update events are interaction state, not new chat turns. They do not call the `message` handler or wake another agent turn.
205
+
206
+ ### Human-in-the-loop cards
207
+
208
+ Use `ctx.requestRuntimeInput(...)` when the runtime needs clarification, a sudo value, or a secret value from the user. Use `ctx.requestApproval(...)` when the runtime needs an allow/deny decision before taking an action. Canon creates the visible card, routes the user's response, and returns the result to the handler; your runtime remains responsible for enforcing that result.
209
+
210
+ Use `ctx.requestCard(...)` for generic rich reports and action forms. A `canon.card.v1` action may include small structured fields; Canon validates the selected action and declared field values, but your runtime still decides what to do with them:
211
+
212
+ ```ts
213
+ const review = await ctx.requestCard({
214
+ card: {
215
+ schema: 'canon.card.v1',
216
+ title: 'Review draft',
217
+ fallbackText: 'Review draft: approve or request changes.',
218
+ blocks: [{
219
+ kind: 'actions',
220
+ actions: [
221
+ { id: 'approve', label: 'Approve', tone: 'positive' },
222
+ {
223
+ id: 'revise',
224
+ label: 'Request changes',
225
+ fields: [{ id: 'note', label: 'What should change?', type: 'textarea', required: true }],
226
+ },
227
+ ],
228
+ }],
229
+ },
230
+ });
231
+ ```
232
+
233
+ ## Contact Request Awareness
234
+
235
+ Agents can also observe contact-request lifecycle events without becoming the approver:
236
+
237
+ ```typescript
238
+ agent.on('contactRequest', (request) => {
239
+ console.log('New request aimed at this agent:', request.requesterName);
240
+ });
241
+
242
+ agent.on('contactApproved', (request) => {
243
+ console.log('Request approved:', request.id);
244
+ });
245
+ ```
246
+
247
+ These are awareness callbacks only. Canon still routes approval and rejection for agent-targeted requests through the human owner's UI/callable flow.
248
+
249
+ ### Turn-aware example
250
+
251
+ ```typescript
252
+ agent.on('message', async ({ messages, history, replyFinal, replyProgress, turn, session }) => {
253
+ await turn?.setThinking('Reviewing the request...');
254
+
255
+ const plan = await draftPlan(messages, history, session?.messages ?? []);
256
+ await replyProgress(`Plan: ${plan.summary}`);
257
+
258
+ await turn?.setTool('Running checks...');
259
+ const result = await runWork(plan);
260
+
261
+ if (result.needsInput) {
262
+ await turn?.setWaitingInput('I need one more detail before I continue.');
263
+ return;
264
+ }
265
+
266
+ await replyFinal(result.text);
267
+ });
268
+ ```
269
+
270
+ ### Session queues
271
+
272
+ When `sessions.enabled` is on, the SDK serializes work per conversation and exposes:
273
+
274
+ - `session.id`: the conversation/session id
275
+ - `session.messages`: accumulated session context within the configured limit
276
+ - `session.metadata`: mutable per-session state
277
+ - `session.queueDepth`: number of pending inbound batches behind the current one
278
+
279
+ This is the easiest way to build agents that need per-conversation memory or queue awareness.
280
+
281
+ ## Media
282
+
283
+ Normalized Canon messages always expose `attachments[]` as the single canonical media contract. Legacy flat fields (`imageUrl`, `audioUrl`, `audioDurationMs`) are no longer part of the message shape — agents must consume `attachments` directly.
284
+
285
+ Use the handler `media` helpers when you need the actual file bytes:
286
+
287
+ ```typescript
288
+ agent.on('message', async ({ messages, media }) => {
289
+ const files = await media.materialize(messages[messages.length - 1]);
290
+ console.log(files[0]?.path); // ~/.canon/media-cache/<agent>/<conversation>/<message>/...
291
+ });
292
+ ```
293
+
294
+ - `media.materialize(message?)` downloads the message's attachments on demand into `~/.canon/media-cache`.
295
+ - `media.uploadFile(path, options?)` uploads a local file into the current Canon conversation and returns the canonical attachment metadata.
296
+ - `media.replyWithFile(path, text?, options?)` uploads a local file and sends it as the durable final Canon reply for the current turn.
297
+
298
+ GIFs are regular image attachments. Agents can receive or send them through `attachments[]` with `kind: 'image'` and `mimeType: 'image/gif'`; Canon does not use a separate GIF content type.
299
+
300
+ The public helpers are also available from the Node-only subpath export:
41
301
 
42
302
  ```typescript
43
303
  import { materializeMessageMedia, uploadMediaFile } from '@canonmsg/agent-sdk/media';
44
304
  ```
45
305
 
46
- GIFs are regular image attachments (`kind: 'image'`, `mimeType: 'image/gif'`); Canon does not use a separate GIF content type.
306
+ ## Agent Registration
47
307
 
48
- ## SessionManager
308
+ Register a new agent using the static helpers (no API key needed):
49
309
 
50
- An in-process, per-conversation work queue with bounded context and idle eviction. Adapters use it to serialize turns per conversation while tracking queue depth:
310
+ ```typescript
311
+ import { CanonAgent } from '@canonmsg/agent-sdk';
312
+
313
+ // 1. Submit registration request
314
+ const { requestId, pollToken } = await CanonAgent.register({
315
+ name: 'My Agent',
316
+ description: 'A helpful assistant',
317
+ ownerPhone: '+1234567890',
318
+ developerInfo: 'Acme Corp — hello@acme.com',
319
+ });
320
+
321
+ console.log('Registration submitted:', requestId);
322
+ await saveRegistrationPickup({ requestId, pollToken });
323
+
324
+ // 2. Poll for approval
325
+ const status = await CanonAgent.checkStatus(requestId, { pollToken });
326
+ console.log('Status:', status.status); // 'pending' | 'approved' | 'rejected'
327
+
328
+ if (status.status === 'approved' && status.apiKey) {
329
+ console.log('Agent ID:', status.agentId);
330
+ await saveAgentCredentials({ agentId: status.agentId, apiKey: status.apiKey });
331
+ await CanonAgent.ackStatus(requestId, { pollToken });
332
+ }
333
+ ```
334
+
335
+ The approved response only includes the API key until you acknowledge delivery. Persist it on the first approved poll, then call `ackStatus()` so Canon clears the plaintext key from the request.
336
+ Replace `saveRegistrationPickup` and `saveAgentCredentials` with your own encrypted/local secret-store writes; do not print these values in logs.
337
+
338
+ ## Error Handling
339
+
340
+ The SDK exports `CanonApiError` for typed error handling:
51
341
 
52
342
  ```typescript
53
- import { SessionManager } from '@canonmsg/agent-sdk';
343
+ import { CanonAgent, CanonApiError } from '@canonmsg/agent-sdk';
54
344
 
55
- const sessions = new SessionManager({ contextLimit: 50, concurrency: 10 });
345
+ agent.on('message', async ({ messages, replyFinal }) => {
346
+ try {
347
+ await replyFinal('Hello!');
348
+ } catch (err) {
349
+ if (err instanceof CanonApiError) {
350
+ console.error(`API error ${err.status}: ${err.message}`);
351
+ }
352
+ }
353
+ });
354
+ ```
56
355
 
57
- await sessions.enqueue(conversationId, newMessages, async (session) => {
58
- // session.messages — accumulated context (within contextLimit)
59
- // session.metadata — mutable per-session state
60
- // session.queueDepth pending batches behind this one
61
- await handleTurn(session);
356
+ ## Graceful Shutdown
357
+
358
+ ```typescript
359
+ process.on('SIGINT', async () => {
360
+ await agent.stop();
361
+ process.exit(0);
62
362
  });
63
363
  ```
64
364
 
65
- ## Where everything else went
365
+ ## Live turn state
366
+
367
+ While a handler runs, the SDK automatically publishes Canon turn state and clears it when the turn completes. Use the `turn` helpers when you want richer live UX:
368
+
369
+ - `setThinking(text?)`
370
+ - `setStreaming(text)`
371
+ - `setTool(text)`
372
+ - `setWaitingInput(text?)`
373
+
374
+ `setWaitingInput()` keeps the turn open in `waiting_input` and optionally sends a control message to the conversation so Canon clients can render “reply to continue” correctly.
66
375
 
67
- | Old `CanonAgent` capability | Current home |
68
- |---|---|
69
- | SSE delivery, replay/dedupe, cursors | `canon-bridge` daemon |
70
- | Runtime control (interrupt/stop/new-session) | bridge control plane via `@canonmsg/framework` |
71
- | HITL (approvals, runtime input, rich cards) | bridge HITL custody via `@canonmsg/framework` |
72
- | Registration/status polling | `@canonmsg/framework` registration RPCs |
73
- | Turn state / streaming publishing | bridge streaming custody |
74
- | REST client (`CanonClient`) | `@canonmsg/core` |
376
+ `replyProgress()` is ephemeral by default: it updates the live RTDB turn preview without adding a permanent Firestore message. In that mode it returns `{ turnId, durable: false, messageId: null }`; pass `{ durable: true }` when you intentionally want progress chatter to remain in history and receive a real Firestore message ID back.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { CanonClient } from '@canonmsg/core';
2
+ export declare class AuthManager {
3
+ private apiClient;
4
+ private token;
5
+ private agentId;
6
+ private expiresAt;
7
+ private refreshTimer;
8
+ private onRefreshCallback;
9
+ private refreshRetryCount;
10
+ constructor(apiClient: CanonClient);
11
+ authenticate(): Promise<{
12
+ token: string;
13
+ agentId: string;
14
+ }>;
15
+ private scheduleRefresh;
16
+ /** Retry with exponential backoff (30s -> 60s -> 120s -> 240s cap, max 10 attempts) */
17
+ private scheduleRetry;
18
+ setOnRefresh(cb: (token: string) => void): void;
19
+ getToken(): string | null;
20
+ getAgentId(): string | null;
21
+ destroy(): void;
22
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,73 @@
1
+ const MAX_REFRESH_RETRIES = 10;
2
+ const BASE_RETRY_MS = 30_000;
3
+ const MAX_RETRY_BACKOFF_MS = 240_000;
4
+ export class AuthManager {
5
+ apiClient;
6
+ token = null;
7
+ agentId = null;
8
+ expiresAt = 0;
9
+ refreshTimer = null;
10
+ onRefreshCallback = null;
11
+ refreshRetryCount = 0;
12
+ constructor(apiClient) {
13
+ this.apiClient = apiClient;
14
+ }
15
+ async authenticate() {
16
+ const result = await this.apiClient.getAuthToken();
17
+ this.token = result.token;
18
+ this.agentId = result.agentId;
19
+ this.expiresAt = new Date(result.expiresAt).getTime();
20
+ this.refreshRetryCount = 0;
21
+ this.scheduleRefresh();
22
+ return { token: result.token, agentId: result.agentId };
23
+ }
24
+ scheduleRefresh() {
25
+ if (this.refreshTimer)
26
+ clearTimeout(this.refreshTimer);
27
+ // Refresh 5 minutes before expiry
28
+ const refreshIn = Math.max(0, this.expiresAt - Date.now() - 5 * 60 * 1000);
29
+ this.refreshTimer = setTimeout(async () => {
30
+ try {
31
+ const result = await this.apiClient.getAuthToken();
32
+ this.token = result.token;
33
+ this.expiresAt = new Date(result.expiresAt).getTime();
34
+ this.refreshRetryCount = 0;
35
+ this.scheduleRefresh();
36
+ if (this.onRefreshCallback)
37
+ this.onRefreshCallback(result.token);
38
+ }
39
+ catch (err) {
40
+ console.error('[canon-sdk] Token refresh failed:', err);
41
+ this.scheduleRetry();
42
+ }
43
+ }, refreshIn);
44
+ }
45
+ /** Retry with exponential backoff (30s -> 60s -> 120s -> 240s cap, max 10 attempts) */
46
+ scheduleRetry() {
47
+ if (this.refreshRetryCount >= MAX_REFRESH_RETRIES) {
48
+ console.error('[canon-sdk] Token refresh failed after maximum retries — agent may stop receiving messages');
49
+ return;
50
+ }
51
+ const backoff = Math.min(BASE_RETRY_MS * Math.pow(2, this.refreshRetryCount), MAX_RETRY_BACKOFF_MS);
52
+ this.refreshRetryCount++;
53
+ console.warn(`[canon-sdk] Retrying token refresh in ${backoff / 1000}s (attempt ${this.refreshRetryCount}/${MAX_REFRESH_RETRIES})`);
54
+ this.refreshTimer = setTimeout(() => this.scheduleRefresh(), backoff);
55
+ }
56
+ setOnRefresh(cb) {
57
+ this.onRefreshCallback = cb;
58
+ }
59
+ getToken() {
60
+ return this.token;
61
+ }
62
+ getAgentId() {
63
+ return this.agentId;
64
+ }
65
+ destroy() {
66
+ if (this.refreshTimer) {
67
+ clearTimeout(this.refreshTimer);
68
+ this.refreshTimer = null;
69
+ }
70
+ this.token = null;
71
+ this.agentId = null;
72
+ }
73
+ }