@canonmsg/core 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,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,6 +132,12 @@ 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
142
  supportsInputInterrupt: input.turnState?.capabilities?.supportsInputInterrupt,
137
143
  queueDepth: input.turnState?.queueDepth ?? 0,
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/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type CanonMessage, type CanonConversation, type CanonContact, type CanonContactRequest, type CanonMessagesPage, type CanonResolveAdmissionResult, type AgentContext, type AddMemberResult, type CreateContactRequestResult, type MediaAttachment, type SendMessageOptions, type CreateConversationOptions, type RegistrationStatus, type SetStreamingOptions } from './types.js';
2
+ import type { RuntimeInputKind } from './runtime-cards.js';
2
3
  import type { SendContextualMessageOptions, SendContextualMessageResult } from './self-context.js';
3
4
  import type { InboundDisposition } from './turn-protocol.js';
4
5
  /**
@@ -53,6 +54,38 @@ export declare class CanonClient {
53
54
  setStreaming(options: SetStreamingOptions): Promise<void>;
54
55
  clearStreaming(conversationId: string): Promise<void>;
55
56
  setTyping(conversationId: string, typing: boolean, status?: 'thinking' | 'typing'): Promise<void>;
57
+ createRuntimeInputRequest(options: {
58
+ conversationId: string;
59
+ inputId: string;
60
+ kind: RuntimeInputKind;
61
+ expiresAt: number;
62
+ }): Promise<{
63
+ success: true;
64
+ inputId: string;
65
+ expiresAt: number;
66
+ }>;
67
+ consumeRuntimeInputResponse(options: {
68
+ conversationId: string;
69
+ inputId: string;
70
+ cancel?: boolean;
71
+ }): Promise<{
72
+ status: 'pending';
73
+ inputId: string;
74
+ expiresAt?: number;
75
+ } | {
76
+ status: 'submitted';
77
+ inputId: string;
78
+ kind: RuntimeInputKind;
79
+ value: string;
80
+ } | {
81
+ status: 'cancelled';
82
+ inputId: string;
83
+ kind: RuntimeInputKind;
84
+ } | {
85
+ status: 'timeout';
86
+ inputId: string;
87
+ kind: RuntimeInputKind;
88
+ }>;
56
89
  static register(baseUrl: string | undefined, body: {
57
90
  name: string;
58
91
  description: string;
package/dist/client.js CHANGED
@@ -319,6 +319,26 @@ export class CanonClient {
319
319
  if (!res.ok)
320
320
  throw new CanonApiError(res.status, await res.text());
321
321
  }
322
+ async createRuntimeInputRequest(options) {
323
+ const res = await fetch(`${this.baseUrl}/runtime-input/request`, {
324
+ method: 'POST',
325
+ headers: this.authHeaders(),
326
+ body: JSON.stringify(options),
327
+ });
328
+ if (!res.ok)
329
+ throw new CanonApiError(res.status, await res.text());
330
+ return res.json();
331
+ }
332
+ async consumeRuntimeInputResponse(options) {
333
+ const res = await fetch(`${this.baseUrl}/runtime-input/consume`, {
334
+ method: 'POST',
335
+ headers: this.authHeaders(),
336
+ body: JSON.stringify(options),
337
+ });
338
+ if (!res.ok)
339
+ throw new CanonApiError(res.status, await res.text());
340
+ return res.json();
341
+ }
322
342
  // ── Static unauthenticated registration endpoints ────────────────────
323
343
  static async register(baseUrl, body) {
324
344
  const url = baseUrl || DEFAULT_BASE_URL;
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,12 +199,25 @@ 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
  : {}),
@@ -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();
@@ -11,6 +11,8 @@ export interface RuntimeCapabilities {
11
11
  supportsNonFinalPermanentMessages: boolean;
12
12
  }
13
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;
14
16
  export interface TurnState {
15
17
  turnId?: string | null;
16
18
  state: TurnLifecycleState;
@@ -20,6 +22,7 @@ export interface TurnState {
20
22
  activeMessageIds?: string[];
21
23
  capabilities?: RuntimeCapabilities;
22
24
  openedAt?: number;
25
+ turnUpdatedAt?: number | null;
23
26
  updatedAt?: number;
24
27
  completedAt?: number | null;
25
28
  }
@@ -57,19 +60,21 @@ export declare const HOST_ADMISSION_ACTION_CAPABILITIES: HostAdmissionActionCapa
57
60
  export declare const HOST_ADMISSION_ACTIONS_DISABLED: HostAdmissionActionCapabilities;
58
61
  export declare function normalizeTurnMetadata(metadata: unknown): TurnMetadata | null;
59
62
  export declare function normalizeTurnState(value: unknown): TurnState | null;
60
- 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;
61
66
  export declare function resolveTurnMessageSemantics(input: {
62
67
  senderType: 'human' | 'ai_agent';
63
68
  metadata?: unknown;
64
- senderTurnState?: Pick<TurnState, 'state'> | null;
69
+ senderTurnState?: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null;
65
70
  }): TurnMessageSemantics;
66
71
  export declare function shouldPromoteConversationMessage(input: {
67
72
  senderType: 'human' | 'ai_agent';
68
73
  metadata?: unknown;
69
- senderTurnState?: Pick<TurnState, 'state'> | null;
74
+ senderTurnState?: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null;
70
75
  }): boolean;
71
76
  export declare function shouldTriggerAgentTurn(input: {
72
77
  senderType: 'human' | 'ai_agent';
73
78
  metadata?: unknown;
74
- senderTurnState?: Pick<TurnState, 'state'> | null;
79
+ senderTurnState?: Pick<TurnState, 'state' | 'turnUpdatedAt' | 'updatedAt' | 'openedAt'> | null;
75
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',
@@ -120,16 +122,41 @@ export function normalizeTurnState(value) {
120
122
  }
121
123
  : undefined,
122
124
  openedAt: typeof value.openedAt === 'number' ? value.openedAt : undefined,
125
+ turnUpdatedAt: typeof value.turnUpdatedAt === 'number' ? value.turnUpdatedAt : null,
123
126
  updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
124
127
  completedAt: typeof value.completedAt === 'number' ? value.completedAt : null,
125
128
  };
126
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
+ }
127
151
  export function isTurnOpen(turnState) {
128
152
  if (!turnState)
129
153
  return false;
130
- return turnState.state !== 'idle'
154
+ const open = turnState.state !== 'idle'
131
155
  && turnState.state !== 'completed'
132
156
  && turnState.state !== 'interrupted';
157
+ if (!open)
158
+ return false;
159
+ return !isTurnStateStale(turnState);
133
160
  }
134
161
  export function resolveTurnMessageSemantics(input) {
135
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.3",
3
+ "version": "0.20.1",
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",