@canonmsg/core 0.19.2 → 0.20.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.
@@ -2,7 +2,7 @@
2
2
  * Shared agent profile management — loading, locking, and resolution.
3
3
  * Used by both host.ts and server.ts.
4
4
  */
5
- import { readFileSync, renameSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
5
+ import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
6
6
  import { closeSync, openSync } from 'node:fs';
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { join, resolve } from 'node:path';
@@ -14,14 +14,96 @@ export const CANON_DIR = process.env.CANON_HOME
14
14
  export const AGENTS_PATH = join(CANON_DIR, 'agents.json');
15
15
  export const LOCKS_DIR = join(CANON_DIR, 'locks');
16
16
  export const PENDING_REGISTRATIONS_PATH = join(CANON_DIR, 'pending-registrations.json');
17
+ const AGENTS_JSON_BOOTSTRAP_ENV = 'CANON_AGENTS_JSON_BOOTSTRAP';
17
18
  // ── Profile loading ──────────────────────────────────────────────────
19
+ function normalizeBootstrapProfile(name, value) {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
21
+ throw new Error(`${AGENTS_JSON_BOOTSTRAP_ENV} profile "${name}" must be an object`);
22
+ }
23
+ const profile = value;
24
+ const apiKey = profile.apiKey;
25
+ const agentId = profile.agentId;
26
+ const agentName = profile.agentName;
27
+ const registeredAt = profile.registeredAt;
28
+ if (typeof apiKey !== 'string'
29
+ || typeof agentId !== 'string'
30
+ || typeof agentName !== 'string'
31
+ || typeof registeredAt !== 'string'
32
+ || !apiKey
33
+ || !agentId
34
+ || !agentName
35
+ || !registeredAt) {
36
+ throw new Error(`${AGENTS_JSON_BOOTSTRAP_ENV} profile "${name}" must include apiKey, agentId, agentName, and registeredAt`);
37
+ }
38
+ return {
39
+ apiKey,
40
+ agentId,
41
+ agentName,
42
+ registeredAt,
43
+ ...(typeof profile.clientType === 'string' ? { clientType: profile.clientType } : {}),
44
+ ...(typeof profile.baseUrl === 'string' ? { baseUrl: profile.baseUrl } : {}),
45
+ ...(typeof profile.streamUrl === 'string' ? { streamUrl: profile.streamUrl } : {}),
46
+ ...(typeof profile.rtdbUrl === 'string' ? { rtdbUrl: profile.rtdbUrl } : {}),
47
+ ...(typeof profile.runtimeId === 'string' ? { runtimeId: profile.runtimeId } : {}),
48
+ };
49
+ }
50
+ function parseBootstrapProfiles(raw) {
51
+ const trimmed = raw.trim();
52
+ if (!trimmed)
53
+ return {};
54
+ const parse = (text) => JSON.parse(text);
55
+ let parsed;
56
+ try {
57
+ parsed = parse(trimmed);
58
+ }
59
+ catch {
60
+ try {
61
+ parsed = parse(Buffer.from(trimmed, 'base64').toString('utf-8'));
62
+ }
63
+ catch {
64
+ throw new Error(`${AGENTS_JSON_BOOTSTRAP_ENV} must be valid agents.json JSON or base64-encoded JSON`);
65
+ }
66
+ }
67
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
68
+ throw new Error(`${AGENTS_JSON_BOOTSTRAP_ENV} must be a JSON object keyed by profile name`);
69
+ }
70
+ const profiles = {};
71
+ for (const [rawName, value] of Object.entries(parsed)) {
72
+ const name = rawName.trim();
73
+ if (!name) {
74
+ throw new Error(`${AGENTS_JSON_BOOTSTRAP_ENV} contains an empty profile name`);
75
+ }
76
+ profiles[name] = normalizeBootstrapProfile(name, value);
77
+ }
78
+ return profiles;
79
+ }
80
+ function applyBootstrapProfiles(existing) {
81
+ const raw = process.env[AGENTS_JSON_BOOTSTRAP_ENV];
82
+ if (!raw)
83
+ return existing;
84
+ const bootstrapProfiles = parseBootstrapProfiles(raw);
85
+ let changed = false;
86
+ const merged = { ...existing };
87
+ for (const [name, profile] of Object.entries(bootstrapProfiles)) {
88
+ if (merged[name])
89
+ continue;
90
+ merged[name] = profile;
91
+ changed = true;
92
+ }
93
+ if (changed || !existsSync(AGENTS_PATH)) {
94
+ saveProfiles(merged);
95
+ }
96
+ return merged;
97
+ }
18
98
  export function loadProfiles() {
99
+ let profiles;
19
100
  try {
20
- return JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
101
+ profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
21
102
  }
22
103
  catch {
23
- return {};
104
+ profiles = {};
24
105
  }
106
+ return applyBootstrapProfiles(profiles);
25
107
  }
26
108
  function writeJsonFile(path, value, mode = 0o600) {
27
109
  mkdirSync(CANON_DIR, { recursive: true });
@@ -32,7 +32,8 @@ export function resolveCanonProfile(name, opts) {
32
32
  const profiles = loadProfiles();
33
33
  const profile = profiles[profileName];
34
34
  if (!profile) {
35
- throw new Error(`${prefix} Profile "${profileName}" not found in ~/.canon/agents.json`);
35
+ throw new Error(`${prefix} Profile "${profileName}" not found in ~/.canon/agents.json. `
36
+ + 'Set CANON_AGENTS_JSON_BOOTSTRAP, set CANON_API_KEY, or re-run registration against the mounted Canon profile volume.');
36
37
  }
37
38
  if (!profileMatches(profile, opts?.expectedClientType)) {
38
39
  throw new Error(`${prefix} Profile "${profileName}" is registered for ${profile.clientType}, not ${opts?.expectedClientType}`);
@@ -132,8 +132,14 @@ export function buildAgentSessionSnapshot(input) {
132
132
  worktreePath: input.sessionState?.worktreePath,
133
133
  executionFallbackReason: input.sessionState?.executionFallbackReason,
134
134
  turnState: input.turnState?.state,
135
+ turnId: input.turnState?.turnId ?? null,
136
+ turnOpenedAt: input.turnState?.openedAt ?? null,
137
+ turnUpdatedAt: input.turnState?.turnUpdatedAt
138
+ ?? input.turnState?.updatedAt
139
+ ?? input.turnState?.openedAt
140
+ ?? null,
135
141
  supportsQueue: input.turnState?.capabilities?.supportsQueue,
136
- supportsInputInterrupt: input.turnState?.capabilities?.supportsInterrupt,
142
+ supportsInputInterrupt: input.turnState?.capabilities?.supportsInputInterrupt,
137
143
  queueDepth: input.turnState?.queueDepth ?? 0,
138
144
  waitingForInput: input.turnState?.state === 'waiting_input',
139
145
  contextUsage: input.sessionState?.contextUsage,
package/dist/browser.d.ts CHANGED
@@ -12,7 +12,7 @@ export { buildAgentSessionSnapshot } from './agent-session.js';
12
12
  export { CLAUDE_EFFORT_OPTIONS, EXECUTION_MODE_CONTROL_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildFirstPartyCodingRuntimeDescriptor, buildRuntimeEffortControl, buildRuntimeExecutionModeControl, buildRuntimeExecutionModeOptions, buildRuntimeModelControl, buildRuntimePermissionModeControl, buildRuntimeWorkspaceControl, buildRuntimeWorkspaceControlOptions, } from './runtime-descriptor.js';
13
13
  export type { AgentBehaviorSettings, ParticipationHistoryMessage, ParticipationHistorySnapshot, ParticipationStyle, ResolvedAgentBehaviorPolicy, } from './policy.js';
14
14
  export { buildParticipationHistorySnapshot, buildParticipationHistorySnapshots, buildBehaviorPolicyLines, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, evaluateParticipationPolicy, getDefaultParticipationPolicy, resolveAgentBehaviorPolicy, } from './policy.js';
15
- export { DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, isTurnOpen, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
15
+ export { DEFAULT_RUNTIME_CAPABILITIES, ACTIVE_TURN_STALE_THRESHOLD_MS, FINAL_MESSAGE_HANDOFF_MS, WAITING_INPUT_STALE_THRESHOLD_MS, getTurnStateStaleThresholdMs, isTurnOpen, isTurnStateStale, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
16
16
  export type { DeliveryIntent, InboundDisposition, RuntimeCapabilities, TriggerDecision, TurnLifecycleState, TurnMessageSemantics, TurnMetadata, TurnState, } from './turn-protocol.js';
17
17
  export { buildApprovalReply, buildApprovalRequest, buildApprovalOutcome, generateApprovalId, parseTextApprovalReply, redactSecrets, } from './approval-format.js';
18
18
  export type { ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalNativeRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, SessionRule, ApprovalResult, ApprovalConfig, } from './approval-types.js';
package/dist/browser.js CHANGED
@@ -8,7 +8,7 @@ export { buildSelfContextPromptLines, normalizeSelfContexts, } from './self-cont
8
8
  export { buildAgentSessionSnapshot } from './agent-session.js';
9
9
  export { CLAUDE_EFFORT_OPTIONS, EXECUTION_MODE_CONTROL_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildFirstPartyCodingRuntimeDescriptor, buildRuntimeEffortControl, buildRuntimeExecutionModeControl, buildRuntimeExecutionModeOptions, buildRuntimeModelControl, buildRuntimePermissionModeControl, buildRuntimeWorkspaceControl, buildRuntimeWorkspaceControlOptions, } from './runtime-descriptor.js';
10
10
  export { buildParticipationHistorySnapshot, buildParticipationHistorySnapshots, buildBehaviorPolicyLines, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, evaluateParticipationPolicy, getDefaultParticipationPolicy, resolveAgentBehaviorPolicy, } from './policy.js';
11
- export { DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, isTurnOpen, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
11
+ export { DEFAULT_RUNTIME_CAPABILITIES, ACTIVE_TURN_STALE_THRESHOLD_MS, FINAL_MESSAGE_HANDOFF_MS, WAITING_INPUT_STALE_THRESHOLD_MS, getTurnStateStaleThresholdMs, isTurnOpen, isTurnStateStale, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
12
12
  export { buildApprovalReply, buildApprovalRequest, buildApprovalOutcome, generateApprovalId, parseTextApprovalReply, redactSecrets, } from './approval-format.js';
13
13
  export { DEFAULT_APPROVAL_CONFIG, parseApprovalRequestMetadata, parseApprovalReplyMetadata, parseSessionRule, } from './approval-types.js';
14
14
  export { buildRuntimeInputOutcome, buildRuntimeInputReply, buildRuntimeInputRequest, buildPlanApprovalReply, buildPlanApprovalRequest, buildQuestionReply, buildQuestionRequest, parseRuntimeInputOutcomeMetadata, parseRuntimeInputReplyMetadata, parseRuntimeInputRequestMetadata, } from './runtime-cards.js';
package/dist/index.d.ts CHANGED
@@ -9,11 +9,11 @@ export type { ConfiguredWorkspaceRoot, WorkspaceDiscoveryResult, } from './works
9
9
  export { CanonClient, CanonApiError } from './client.js';
10
10
  export { buildAgentSessionSnapshot } from './agent-session.js';
11
11
  export { CLAUDE_EFFORT_OPTIONS, EXECUTION_MODE_CONTROL_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildFirstPartyCodingRuntimeDescriptor, buildRuntimeEffortControl, buildRuntimeExecutionModeControl, buildRuntimeExecutionModeOptions, buildRuntimeModelControl, buildRuntimePermissionModeControl, buildRuntimeWorkspaceControl, buildRuntimeWorkspaceControlOptions, } from './runtime-descriptor.js';
12
- export { CanonStream } from './stream.js';
13
- export type { StreamHandler } from './stream.js';
12
+ export { CanonStream, CanonStreamError } from './stream.js';
13
+ export type { CanonStreamErrorPayload, StreamHandler } from './stream.js';
14
14
  export type { PolicyRole, ParticipationStyle, RepresentationMode, PermissionLevel, ConversationScope, AgentBehaviorSettings, Participant, Relationship, ContextOverlay, BehaviorProfile, AdmissionPolicy, RuntimeControlPolicy, ActionApprovalPolicy, ParticipationPolicy, ResolvedPolicy, ResolvedTurnEligibility, ResolvedAgentBehaviorPolicy, ParticipationHistoryMessage, ParticipationHistorySnapshot, ParticipationDecisionInput, ParticipationDecision, } from './policy.js';
15
15
  export { buildParticipationHistorySnapshot, buildParticipationHistorySnapshots, buildBehaviorPolicyLines, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, evaluateParticipationPolicy, getDefaultParticipationPolicy, resolveAgentBehaviorPolicy, } from './policy.js';
16
- export { DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, FINAL_MESSAGE_HANDOFF_MS, isTurnOpen, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
16
+ export { DEFAULT_RUNTIME_CAPABILITIES, ACTIVE_TURN_STALE_THRESHOLD_MS, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, FINAL_MESSAGE_HANDOFF_MS, WAITING_INPUT_STALE_THRESHOLD_MS, getTurnStateStaleThresholdMs, isTurnOpen, isTurnStateStale, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
17
17
  export type { DeliveryIntent, TurnMessageSemantics, InboundDisposition, TurnLifecycleState, RuntimeCapabilities, HostAdmissionActionCapabilities, TurnState, TurnMetadata, TriggerDecision, } from './turn-protocol.js';
18
18
  export { ackRegistrationApproval, registerAndWaitForApproval, submitRegistrationRequest, waitForRegistrationApproval, } from './registration.js';
19
19
  export { ApprovalManager } from './approval-manager.js';
package/dist/index.js CHANGED
@@ -9,10 +9,10 @@ export { CanonClient, CanonApiError } from './client.js';
9
9
  export { buildAgentSessionSnapshot } from './agent-session.js';
10
10
  export { CLAUDE_EFFORT_OPTIONS, EXECUTION_MODE_CONTROL_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildFirstPartyCodingRuntimeDescriptor, buildRuntimeEffortControl, buildRuntimeExecutionModeControl, buildRuntimeExecutionModeOptions, buildRuntimeModelControl, buildRuntimePermissionModeControl, buildRuntimeWorkspaceControl, buildRuntimeWorkspaceControlOptions, } from './runtime-descriptor.js';
11
11
  // Stream
12
- export { CanonStream } from './stream.js';
12
+ export { CanonStream, CanonStreamError } from './stream.js';
13
13
  export { buildParticipationHistorySnapshot, buildParticipationHistorySnapshots, buildBehaviorPolicyLines, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, evaluateParticipationPolicy, getDefaultParticipationPolicy, resolveAgentBehaviorPolicy, } from './policy.js';
14
14
  // Turn protocol
15
- export { DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, FINAL_MESSAGE_HANDOFF_MS, isTurnOpen, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
15
+ export { DEFAULT_RUNTIME_CAPABILITIES, ACTIVE_TURN_STALE_THRESHOLD_MS, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, FINAL_MESSAGE_HANDOFF_MS, WAITING_INPUT_STALE_THRESHOLD_MS, getTurnStateStaleThresholdMs, isTurnOpen, isTurnStateStale, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
16
16
  // Registration
17
17
  export { ackRegistrationApproval, registerAndWaitForApproval, submitRegistrationRequest, waitForRegistrationApproval, } from './registration.js';
18
18
  // Approval
@@ -45,6 +45,9 @@ export interface TurnStatePayload {
45
45
  openedAt?: number | {
46
46
  '.sv': 'timestamp';
47
47
  };
48
+ turnUpdatedAt?: number | {
49
+ '.sv': 'timestamp';
50
+ } | null;
48
51
  completedAt?: number | {
49
52
  '.sv': 'timestamp';
50
53
  } | null;
@@ -79,6 +82,13 @@ export interface AgentSessionSnapshotPatch {
79
82
  executionFallbackReason?: string | null;
80
83
  state?: null;
81
84
  turnState?: TurnLifecycleState | null;
85
+ turnId?: string | null;
86
+ turnOpenedAt?: number | {
87
+ '.sv': 'timestamp';
88
+ } | null;
89
+ turnUpdatedAt?: number | {
90
+ '.sv': 'timestamp';
91
+ } | null;
82
92
  supportsQueue?: boolean | null;
83
93
  supportsInputInterrupt?: boolean | null;
84
94
  queueDepth?: number;
package/dist/rtdb-rest.js CHANGED
@@ -199,17 +199,30 @@ function createRTDBClientHandle(client, options) {
199
199
  }
200
200
  }
201
201
  async function writeTurnStateImpl(conversationId, agentId, state) {
202
+ const isOpenTurn = state.state === 'thinking'
203
+ || state.state === 'streaming'
204
+ || state.state === 'tool'
205
+ || state.state === 'waiting_input';
206
+ const turnOpenedAt = state.openedAt ?? null;
207
+ const turnUpdatedAt = state.turnUpdatedAt !== undefined
208
+ ? state.turnUpdatedAt
209
+ : isOpenTurn
210
+ ? turnOpenedAt ?? { '.sv': 'timestamp' }
211
+ : null;
202
212
  await write(`/turn-state/${conversationId}/${agentId}`, {
203
213
  ...state,
204
214
  updatedAt: { '.sv': 'timestamp' },
205
215
  });
206
216
  await patch(buildAgentSessionPath(conversationId, agentId), {
207
217
  turnState: state.state,
218
+ turnId: state.turnId ?? null,
219
+ turnOpenedAt,
220
+ turnUpdatedAt,
208
221
  ...(state.capabilities?.supportsQueue !== undefined
209
222
  ? { supportsQueue: state.capabilities.supportsQueue }
210
223
  : {}),
211
- ...(state.capabilities?.supportsInterrupt !== undefined
212
- ? { supportsInputInterrupt: state.capabilities.supportsInterrupt }
224
+ ...(state.capabilities?.supportsInputInterrupt !== undefined
225
+ ? { supportsInputInterrupt: state.capabilities.supportsInputInterrupt }
213
226
  : {}),
214
227
  queueDepth: state.queueDepth,
215
228
  waitingForInput: state.state === 'waiting_input',
@@ -227,6 +240,9 @@ function createRTDBClientHandle(client, options) {
227
240
  });
228
241
  await patch(buildAgentSessionPath(conversationId, agentId), {
229
242
  turnState: 'idle',
243
+ turnId: null,
244
+ turnOpenedAt: null,
245
+ turnUpdatedAt: null,
230
246
  queueDepth: 0,
231
247
  waitingForInput: false,
232
248
  updatedAt: { '.sv': 'timestamp' },
package/dist/stream.d.ts CHANGED
@@ -1,4 +1,18 @@
1
1
  import type { AgentContext, ContactAddedPayload, ContactApprovedPayload, ContactRemovedPayload, ContactRequestPayload, ConversationUpdatedPayload, MessageCreatedPayload, TypingPayload, PresencePayload, RuntimeUpdatedPayload, TurnUpdatedPayload } from './types.js';
2
+ export interface CanonStreamErrorPayload {
3
+ code?: string;
4
+ message?: string;
5
+ retryAfterMs?: number;
6
+ activeConnections?: number;
7
+ maxConnections?: number;
8
+ [key: string]: unknown;
9
+ }
10
+ export declare class CanonStreamError extends Error {
11
+ readonly code?: string;
12
+ readonly retryAfterMs?: number;
13
+ readonly payload: CanonStreamErrorPayload;
14
+ constructor(payload: CanonStreamErrorPayload, fallbackMessage: string);
15
+ }
2
16
  export type StreamHandler = {
3
17
  onMessage: (payload: MessageCreatedPayload) => void;
4
18
  onAgentContext?: (ctx: AgentContext) => void;
@@ -35,6 +49,7 @@ export declare class CanonStream {
35
49
  private lastEventId;
36
50
  private reconnectAttempt;
37
51
  private reconnectTimer;
52
+ private nextRetryAfterMs;
38
53
  constructor(opts: {
39
54
  apiKey: string;
40
55
  agentId: string;
@@ -49,6 +64,7 @@ export declare class CanonStream {
49
64
  private connect;
50
65
  private readStream;
51
66
  private processFrame;
67
+ private handleStreamError;
52
68
  private handleAgentContext;
53
69
  private handleMessageCreated;
54
70
  private handleTyping;
package/dist/stream.js CHANGED
@@ -1,5 +1,19 @@
1
1
  import { DEFAULT_STREAM_URL } from './constants.js';
2
2
  const MAX_BACKOFF_MS = 30_000;
3
+ export class CanonStreamError extends Error {
4
+ code;
5
+ retryAfterMs;
6
+ payload;
7
+ constructor(payload, fallbackMessage) {
8
+ super(payload.message || fallbackMessage);
9
+ this.name = 'CanonStreamError';
10
+ this.code = typeof payload.code === 'string' ? payload.code : undefined;
11
+ this.retryAfterMs = typeof payload.retryAfterMs === 'number' && Number.isFinite(payload.retryAfterMs)
12
+ ? Math.max(0, payload.retryAfterMs)
13
+ : undefined;
14
+ this.payload = payload;
15
+ }
16
+ }
3
17
  /**
4
18
  * Manages a persistent SSE connection to Canon's stream service.
5
19
  *
@@ -16,6 +30,7 @@ export class CanonStream {
16
30
  lastEventId = null;
17
31
  reconnectAttempt = 0;
18
32
  reconnectTimer = null;
33
+ nextRetryAfterMs = null;
19
34
  constructor(opts) {
20
35
  this.apiKey = opts.apiKey;
21
36
  this.agentId = opts.agentId;
@@ -83,7 +98,6 @@ export class CanonStream {
83
98
  if (!res.ok) {
84
99
  throw new Error(`SSE connect failed: ${res.status} ${res.statusText}`);
85
100
  }
86
- this.handler.onConnected?.();
87
101
  await this.readStream(res);
88
102
  }
89
103
  catch (err) {
@@ -157,6 +171,10 @@ export class CanonStream {
157
171
  this.reconnectAttempt = 0;
158
172
  this.handleAgentContext(data);
159
173
  break;
174
+ case 'connected':
175
+ this.reconnectAttempt = 0;
176
+ this.handler.onConnected?.();
177
+ break;
160
178
  case 'message.created':
161
179
  this.reconnectAttempt = 0;
162
180
  this.handleMessageCreated(data);
@@ -211,12 +229,31 @@ export class CanonStream {
211
229
  break;
212
230
  case 'error':
213
231
  // Don't reset backoff — error events mean something is wrong
214
- this.handler.onError?.(new Error(`Stream error: ${data}`));
232
+ this.handleStreamError(data);
215
233
  break;
216
234
  default:
217
235
  break;
218
236
  }
219
237
  }
238
+ handleStreamError(raw) {
239
+ let error;
240
+ try {
241
+ const parsed = JSON.parse(raw);
242
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
243
+ error = new CanonStreamError(parsed, `Stream error: ${raw}`);
244
+ }
245
+ else {
246
+ error = new Error(`Stream error: ${raw}`);
247
+ }
248
+ }
249
+ catch {
250
+ error = new Error(`Stream error: ${raw}`);
251
+ }
252
+ if (error instanceof CanonStreamError && typeof error.retryAfterMs === 'number') {
253
+ this.nextRetryAfterMs = error.retryAfterMs;
254
+ }
255
+ this.handler.onError?.(error);
256
+ }
220
257
  handleAgentContext(raw) {
221
258
  try {
222
259
  const ctx = JSON.parse(raw);
@@ -332,10 +369,16 @@ export class CanonStream {
332
369
  scheduleReconnect() {
333
370
  if (!this.running)
334
371
  return;
335
- const base = Math.min(1000 * Math.pow(2, this.reconnectAttempt), MAX_BACKOFF_MS);
372
+ const retryAfterMs = this.nextRetryAfterMs;
373
+ this.nextRetryAfterMs = null;
374
+ const base = typeof retryAfterMs === 'number'
375
+ ? retryAfterMs
376
+ : Math.min(1000 * Math.pow(2, this.reconnectAttempt), MAX_BACKOFF_MS);
336
377
  const jitter = Math.random() * 0.25 * base;
337
378
  const delay = base + jitter;
338
- this.reconnectAttempt++;
379
+ if (typeof retryAfterMs !== 'number') {
380
+ this.reconnectAttempt++;
381
+ }
339
382
  this.reconnectTimer = setTimeout(() => {
340
383
  this.reconnectTimer = null;
341
384
  this.connect();
@@ -1,15 +1,18 @@
1
1
  export type DeliveryIntent = 'queue' | 'interrupt' | 'interleave' | 'stop';
2
2
  export type TurnMessageSemantics = 'progress' | 'turn_complete' | 'control';
3
- export type InboundDisposition = 'queued' | 'accepted_now' | 'interleaved' | 'trigger_suppressed' | 'rejected';
3
+ export type InboundDisposition = 'queued' | 'accepted_now' | 'interleaved' | 'trigger_suppressed' | 'rejected' | 'cancelled';
4
4
  export type TurnLifecycleState = 'idle' | 'thinking' | 'streaming' | 'tool' | 'waiting_input' | 'completed' | 'interrupted';
5
5
  export interface RuntimeCapabilities {
6
6
  supportsInterrupt: boolean;
7
+ supportsInputInterrupt: boolean;
7
8
  supportsQueue: boolean;
8
9
  supportsInterleave: boolean;
9
10
  supportsRequiresAction: boolean;
10
11
  supportsNonFinalPermanentMessages: boolean;
11
12
  }
12
13
  export declare const FINAL_MESSAGE_HANDOFF_MS = 750;
14
+ export declare const ACTIVE_TURN_STALE_THRESHOLD_MS: number;
15
+ export declare const WAITING_INPUT_STALE_THRESHOLD_MS: number;
13
16
  export interface TurnState {
14
17
  turnId?: string | null;
15
18
  state: TurnLifecycleState;
@@ -19,6 +22,7 @@ export interface TurnState {
19
22
  activeMessageIds?: string[];
20
23
  capabilities?: RuntimeCapabilities;
21
24
  openedAt?: number;
25
+ turnUpdatedAt?: number | null;
22
26
  updatedAt?: number;
23
27
  completedAt?: number | null;
24
28
  }
@@ -56,19 +60,21 @@ export declare const HOST_ADMISSION_ACTION_CAPABILITIES: HostAdmissionActionCapa
56
60
  export declare const HOST_ADMISSION_ACTIONS_DISABLED: HostAdmissionActionCapabilities;
57
61
  export declare function normalizeTurnMetadata(metadata: unknown): TurnMetadata | null;
58
62
  export declare function normalizeTurnState(value: unknown): TurnState | null;
59
- export declare function isTurnOpen(turnState: Pick<TurnState, 'state'> | null | undefined): boolean;
63
+ export declare function getTurnStateStaleThresholdMs(state: TurnLifecycleState): number;
64
+ export declare function isTurnStateStale(turnState: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null | undefined): boolean;
65
+ export declare function isTurnOpen(turnState: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null | undefined): boolean;
60
66
  export declare function resolveTurnMessageSemantics(input: {
61
67
  senderType: 'human' | 'ai_agent';
62
68
  metadata?: unknown;
63
- senderTurnState?: Pick<TurnState, 'state'> | null;
69
+ senderTurnState?: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null;
64
70
  }): TurnMessageSemantics;
65
71
  export declare function shouldPromoteConversationMessage(input: {
66
72
  senderType: 'human' | 'ai_agent';
67
73
  metadata?: unknown;
68
- senderTurnState?: Pick<TurnState, 'state'> | null;
74
+ senderTurnState?: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null;
69
75
  }): boolean;
70
76
  export declare function shouldTriggerAgentTurn(input: {
71
77
  senderType: 'human' | 'ai_agent';
72
78
  metadata?: unknown;
73
- senderTurnState?: Pick<TurnState, 'state'> | null;
79
+ senderTurnState?: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null;
74
80
  }): TriggerDecision;
@@ -1,4 +1,6 @@
1
1
  export const FINAL_MESSAGE_HANDOFF_MS = 750;
2
+ export const ACTIVE_TURN_STALE_THRESHOLD_MS = 30 * 60 * 1000;
3
+ export const WAITING_INPUT_STALE_THRESHOLD_MS = 12 * 60 * 60 * 1000;
2
4
  const TURN_STATES = [
3
5
  'idle',
4
6
  'thinking',
@@ -21,6 +23,7 @@ const DELIVERY_INTENTS = [
21
23
  ];
22
24
  export const DEFAULT_RUNTIME_CAPABILITIES = {
23
25
  supportsInterrupt: false,
26
+ supportsInputInterrupt: false,
24
27
  supportsQueue: true,
25
28
  supportsInterleave: false,
26
29
  supportsRequiresAction: false,
@@ -73,6 +76,7 @@ export function normalizeTurnMetadata(metadata) {
73
76
  || metadata.inboundDisposition === 'interleaved'
74
77
  || metadata.inboundDisposition === 'trigger_suppressed'
75
78
  || metadata.inboundDisposition === 'rejected'
79
+ || metadata.inboundDisposition === 'cancelled'
76
80
  ? metadata.inboundDisposition
77
81
  : undefined;
78
82
  if (!turnId && !turnSemantics && !deliveryIntent && typeof metadata.turnComplete !== 'boolean'
@@ -110,6 +114,7 @@ export function normalizeTurnState(value) {
110
114
  capabilities: isRecord(value.capabilities)
111
115
  ? {
112
116
  supportsInterrupt: Boolean(value.capabilities.supportsInterrupt),
117
+ supportsInputInterrupt: Boolean(value.capabilities.supportsInputInterrupt),
113
118
  supportsQueue: value.capabilities.supportsQueue !== false,
114
119
  supportsInterleave: Boolean(value.capabilities.supportsInterleave),
115
120
  supportsRequiresAction: Boolean(value.capabilities.supportsRequiresAction),
@@ -117,16 +122,41 @@ export function normalizeTurnState(value) {
117
122
  }
118
123
  : undefined,
119
124
  openedAt: typeof value.openedAt === 'number' ? value.openedAt : undefined,
125
+ turnUpdatedAt: typeof value.turnUpdatedAt === 'number' ? value.turnUpdatedAt : null,
120
126
  updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
121
127
  completedAt: typeof value.completedAt === 'number' ? value.completedAt : null,
122
128
  };
123
129
  }
130
+ export function getTurnStateStaleThresholdMs(state) {
131
+ return state === 'waiting_input'
132
+ ? WAITING_INPUT_STALE_THRESHOLD_MS
133
+ : ACTIVE_TURN_STALE_THRESHOLD_MS;
134
+ }
135
+ export function isTurnStateStale(turnState) {
136
+ if (!turnState)
137
+ return false;
138
+ if (turnState.state !== 'thinking'
139
+ && turnState.state !== 'streaming'
140
+ && turnState.state !== 'tool'
141
+ && turnState.state !== 'waiting_input') {
142
+ return false;
143
+ }
144
+ const updatedAt = turnState.turnUpdatedAt
145
+ ?? turnState.updatedAt
146
+ ?? turnState.openedAt;
147
+ if (updatedAt == null)
148
+ return false;
149
+ return Date.now() - updatedAt >= getTurnStateStaleThresholdMs(turnState.state);
150
+ }
124
151
  export function isTurnOpen(turnState) {
125
152
  if (!turnState)
126
153
  return false;
127
- return turnState.state !== 'idle'
154
+ const open = turnState.state !== 'idle'
128
155
  && turnState.state !== 'completed'
129
156
  && turnState.state !== 'interrupted';
157
+ if (!open)
158
+ return false;
159
+ return !isTurnStateStale(turnState);
130
160
  }
131
161
  export function resolveTurnMessageSemantics(input) {
132
162
  const turnMetadata = normalizeTurnMetadata(input.metadata);
package/dist/types.d.ts CHANGED
@@ -751,6 +751,9 @@ export interface AgentSessionSnapshot {
751
751
  worktreePath?: string | null;
752
752
  executionFallbackReason?: string | null;
753
753
  turnState?: TurnLifecycleState;
754
+ turnId?: string | null;
755
+ turnOpenedAt?: number | null;
756
+ turnUpdatedAt?: number | null;
754
757
  supportsQueue?: boolean;
755
758
  supportsInputInterrupt?: boolean;
756
759
  queueDepth: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/core",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "Canon core — shared types, REST client, SSE stream, and registration for Canon messaging",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",