@canonmsg/core 0.6.0 → 0.7.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.
@@ -0,0 +1 @@
1
+ export declare function resolveCanonBaseUrl(input?: string | null): string;
@@ -0,0 +1,9 @@
1
+ import { DEFAULT_BASE_URL } from './constants.js';
2
+ export function resolveCanonBaseUrl(input) {
3
+ if (typeof input !== 'string')
4
+ return DEFAULT_BASE_URL;
5
+ const trimmed = input.trim();
6
+ if (!trimmed)
7
+ return DEFAULT_BASE_URL;
8
+ return trimmed.replace(/\/+$/, '');
9
+ }
package/dist/browser.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { AGENT_CAPABILITIES, } from './types.js';
2
+ export { resolveCanonBaseUrl } from './base-url.js';
3
+ export { DEFAULT_BASE_URL, DEFAULT_STREAM_URL, DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
2
4
  export type { AgentCapabilities, AgentClientType, AgentRuntime, MediaAttachment, MediaAttachmentKind, ModelOption, SessionConfig, WorkspaceOption, } from './types.js';
5
+ export { EXECUTION_ENVIRONMENT_MODES, isExecutionEnvironmentMode, } from './execution-environment-mode.js';
6
+ export type { ExecutionEnvironmentMode } from './execution-environment-mode.js';
3
7
  export type { CanonResolvedWorkSession, CanonWorkSession, CanonWorkSessionContext, CanonWorkSessionConversationRole, CanonWorkSessionDisclosureMode, CanonWorkSessionParticipant, CanonWorkSessionStatus, CreateWorkSessionOptions, SendLinkedMessageOptions, SendLinkedMessageResult, UpdateWorkSessionConversationOptions, WorkSessionPromptRenderOptions, } from './work-session.js';
4
8
  export { buildWorkSessionPromptLines, buildWorkSessionsPromptLines, mergeWorkSessionContexts, } from './work-session.js';
5
9
  export type { AgentBehaviorSettings, ParticipationHistoryMessage, ParticipationHistorySnapshot, ParticipationStyle, ResolvedAgentBehaviorPolicy, } from './policy.js';
package/dist/browser.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { AGENT_CAPABILITIES, } from './types.js';
2
+ export { resolveCanonBaseUrl } from './base-url.js';
3
+ export { DEFAULT_BASE_URL, DEFAULT_STREAM_URL, DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
4
+ export { EXECUTION_ENVIRONMENT_MODES, isExecutionEnvironmentMode, } from './execution-environment-mode.js';
2
5
  export { buildWorkSessionPromptLines, buildWorkSessionsPromptLines, mergeWorkSessionContexts, } from './work-session.js';
3
6
  export { buildParticipationHistorySnapshot, buildParticipationHistorySnapshots, buildBehaviorPolicyLines, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, evaluateParticipationPolicy, getDefaultParticipationPolicy, resolveAgentBehaviorPolicy, } from './policy.js';
4
7
  export { DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, isTurnOpen, normalizeTurnMetadata, normalizeTurnState, resolveTurnMessageSemantics, shouldPromoteConversationMessage, shouldTriggerAgentTurn, } from './turn-protocol.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Browser-safe helpers for Canon execution modes. Separated from
3
+ * `execution-environment.ts` (which pulls in Node-only modules) so both
4
+ * Node hosts and browser/Expo clients can import these primitives.
5
+ */
6
+ export type ExecutionEnvironmentMode = 'worktree' | 'locked';
7
+ export declare const EXECUTION_ENVIRONMENT_MODES: readonly ExecutionEnvironmentMode[];
8
+ export declare function isExecutionEnvironmentMode(value: unknown): value is ExecutionEnvironmentMode;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Browser-safe helpers for Canon execution modes. Separated from
3
+ * `execution-environment.ts` (which pulls in Node-only modules) so both
4
+ * Node hosts and browser/Expo clients can import these primitives.
5
+ */
6
+ export const EXECUTION_ENVIRONMENT_MODES = [
7
+ 'worktree',
8
+ 'locked',
9
+ ];
10
+ export function isExecutionEnvironmentMode(value) {
11
+ return value === 'worktree' || value === 'locked';
12
+ }
@@ -1,5 +1,7 @@
1
1
  import type { WorkspaceOption } from './types.js';
2
- export type ExecutionEnvironmentMode = 'worktree' | 'locked';
2
+ import { EXECUTION_ENVIRONMENT_MODES, isExecutionEnvironmentMode, type ExecutionEnvironmentMode } from './execution-environment-mode.js';
3
+ export { EXECUTION_ENVIRONMENT_MODES, isExecutionEnvironmentMode };
4
+ export type { ExecutionEnvironmentMode };
3
5
  export interface PreparedExecutionEnvironment {
4
6
  cwd: string;
5
7
  baseCwd: string;
@@ -23,8 +25,9 @@ export interface ConfiguredWorkspaceOption extends WorkspaceResolverOption {
23
25
  }
24
26
  export interface SessionWorkspaceConfig {
25
27
  workspaceId?: string;
26
- legacyCwd?: string;
27
28
  model?: string;
29
+ executionMode?: ExecutionEnvironmentMode;
30
+ retiredWorkspaceConfig?: boolean;
28
31
  }
29
32
  export declare function normalizeOptionalString(value: unknown): string | undefined;
30
33
  export declare function isEnabledFlag(value: unknown): boolean;
@@ -37,7 +40,7 @@ export declare function resolveConfiguredWorkspaceCwd(input: {
37
40
  workspaceOptions: WorkspaceResolverOption[];
38
41
  config: {
39
42
  workspaceId?: string;
40
- legacyCwd?: string;
43
+ retiredWorkspaceConfig?: boolean;
41
44
  } | null;
42
45
  defaultCwd: string;
43
46
  }): string;
@@ -56,4 +59,3 @@ export declare function prepareConversationEnvironment(input: {
56
59
  allowWorktrees?: boolean;
57
60
  }): PreparedExecutionEnvironment;
58
61
  export declare function releaseConversationEnvironment(environment: Pick<PreparedExecutionEnvironment, 'lockPath'>): void;
59
- export {};
@@ -3,6 +3,8 @@ import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { basename, dirname, join, relative, resolve } from 'node:path';
5
5
  import { CANON_DIR, isProcessAlive } from './agent-profiles.js';
6
+ import { EXECUTION_ENVIRONMENT_MODES, isExecutionEnvironmentMode, } from './execution-environment-mode.js';
7
+ export { EXECUTION_ENVIRONMENT_MODES, isExecutionEnvironmentMode };
6
8
  export class ExecutionEnvironmentError extends Error {
7
9
  userMessage;
8
10
  constructor(message, userMessage = message) {
@@ -153,48 +155,48 @@ export function buildConfiguredWorkspaceOptions(primaryCwd, configured) {
153
155
  export function buildPublicWorkspaceOptions(workspaceOptions) {
154
156
  return workspaceOptions.map(({ id, label }) => ({ id, label }));
155
157
  }
158
+ function isRetiredWorkspaceId(value) {
159
+ if (!value)
160
+ return false;
161
+ return value === 'default' || /^workspace-\d+$/.test(value);
162
+ }
156
163
  export function readSessionWorkspaceConfig(raw) {
157
164
  if (!raw || typeof raw !== 'object')
158
165
  return null;
159
166
  const data = raw;
167
+ const rawWorkspaceId = normalizeOptionalString(data.workspaceId);
168
+ const stableWorkspaceId = rawWorkspaceId && !isRetiredWorkspaceId(rawWorkspaceId)
169
+ ? rawWorkspaceId
170
+ : undefined;
171
+ const hasRetiredWorkspaceReference = (normalizeOptionalString(data.cwd) !== undefined
172
+ || isRetiredWorkspaceId(rawWorkspaceId));
173
+ const rawExecutionMode = data.executionMode;
174
+ const executionMode = isExecutionEnvironmentMode(rawExecutionMode)
175
+ ? rawExecutionMode
176
+ : undefined;
160
177
  return {
161
- workspaceId: normalizeOptionalString(data.workspaceId),
162
- legacyCwd: normalizeOptionalString(data.cwd),
178
+ workspaceId: stableWorkspaceId,
163
179
  model: normalizeOptionalString(data.model),
180
+ ...(executionMode ? { executionMode } : {}),
181
+ ...(!stableWorkspaceId && hasRetiredWorkspaceReference
182
+ ? { retiredWorkspaceConfig: true }
183
+ : {}),
164
184
  };
165
185
  }
166
- function findWorkspaceByLegacyCwd(workspaceOptions, legacyCwd) {
167
- if (!legacyCwd)
168
- return undefined;
169
- const resolvedLegacyCwd = resolve(legacyCwd);
170
- return workspaceOptions.find((workspace) => resolve(workspace.cwd) === resolvedLegacyCwd);
171
- }
172
186
  export function resolveConfiguredWorkspaceCwd(input) {
173
187
  const fallbackCwd = input.workspaceOptions[0]?.cwd ?? resolve(input.defaultCwd);
174
188
  if (!input.config)
175
189
  return fallbackCwd;
176
- const legacyWorkspace = findWorkspaceByLegacyCwd(input.workspaceOptions, input.config.legacyCwd);
190
+ if (input.config.retiredWorkspaceConfig) {
191
+ throw new ExecutionEnvironmentError('Session config still references a retired workspace format.', 'This Canon coding session was saved with a retired workspace format. Recreate the session or select a current workspace.');
192
+ }
177
193
  const workspaceId = input.config.workspaceId;
178
194
  if (workspaceId) {
179
195
  const workspace = input.workspaceOptions.find((option) => option.id === workspaceId);
180
196
  if (workspace)
181
197
  return workspace.cwd;
182
- if (workspaceId === 'default') {
183
- return fallbackCwd;
184
- }
185
- const legacyWorkspaceMatch = /^workspace-(\d+)$/.exec(workspaceId);
186
- if (legacyWorkspaceMatch) {
187
- const legacyIndex = Number.parseInt(legacyWorkspaceMatch[1] ?? '', 10) - 1;
188
- if (legacyIndex >= 0 && input.workspaceOptions[legacyIndex]) {
189
- return input.workspaceOptions[legacyIndex].cwd;
190
- }
191
- }
192
- if (legacyWorkspace)
193
- return legacyWorkspace.cwd;
194
198
  throw new ExecutionEnvironmentError(`Workspace ${workspaceId} is not configured on this machine.`, 'The workspace saved for this Canon coding session is no longer configured on this machine.');
195
199
  }
196
- if (legacyWorkspace)
197
- return legacyWorkspace.cwd;
198
200
  return fallbackCwd;
199
201
  }
200
202
  export function buildConversationWorktreeSpec(input) {
package/dist/index.d.ts CHANGED
@@ -20,8 +20,9 @@ export { loadProfiles, isProfileLocked, acquireLock, releaseLock, isProcessAlive
20
20
  export type { AgentProfile } from './agent-profiles.js';
21
21
  export { resolveCanonAgent, resolveCanonProfile, getActiveProfile } from './agent-resolver.js';
22
22
  export type { ResolvedAgent } from './agent-resolver.js';
23
- export { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildConversationWorktreeSpec, buildPublicWorkspaceOptions, buildWorkspaceOptionId, isEnabledFlag, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, ExecutionEnvironmentError, prepareConversationEnvironment, releaseConversationEnvironment, } from './execution-environment.js';
23
+ export { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildConversationWorktreeSpec, buildPublicWorkspaceOptions, buildWorkspaceOptionId, EXECUTION_ENVIRONMENT_MODES, isEnabledFlag, isExecutionEnvironmentMode, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, ExecutionEnvironmentError, prepareConversationEnvironment, releaseConversationEnvironment, } from './execution-environment.js';
24
24
  export type { ConfiguredWorkspaceOption, ExecutionEnvironmentMode, PreparedExecutionEnvironment, SessionWorkspaceConfig, } from './execution-environment.js';
25
25
  export { initRTDBAuth, rtdbWrite, rtdbRead, writeSessionState, clearSessionState, writeTurnState, clearTurnState, } from './rtdb-rest.js';
26
26
  export type { SessionStatePayload, TurnStatePayload } from './rtdb-rest.js';
27
27
  export { DEFAULT_BASE_URL, DEFAULT_STREAM_URL, DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
28
+ export { resolveCanonBaseUrl } from './base-url.js';
package/dist/index.js CHANGED
@@ -21,8 +21,10 @@ export { loadProfiles, isProfileLocked, acquireLock, releaseLock, isProcessAlive
21
21
  // Agent resolver
22
22
  export { resolveCanonAgent, resolveCanonProfile, getActiveProfile } from './agent-resolver.js';
23
23
  // Execution environments for host-mode coding sessions
24
- export { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildConversationWorktreeSpec, buildPublicWorkspaceOptions, buildWorkspaceOptionId, isEnabledFlag, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, ExecutionEnvironmentError, prepareConversationEnvironment, releaseConversationEnvironment, } from './execution-environment.js';
24
+ export { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildConversationWorktreeSpec, buildPublicWorkspaceOptions, buildWorkspaceOptionId, EXECUTION_ENVIRONMENT_MODES, isEnabledFlag, isExecutionEnvironmentMode, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, ExecutionEnvironmentError, prepareConversationEnvironment, releaseConversationEnvironment, } from './execution-environment.js';
25
25
  // RTDB REST helpers (token exchange, session state, generic read/write)
26
26
  export { initRTDBAuth, rtdbWrite, rtdbRead, writeSessionState, clearSessionState, writeTurnState, clearTurnState, } from './rtdb-rest.js';
27
27
  // Constants
28
28
  export { DEFAULT_BASE_URL, DEFAULT_STREAM_URL, DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
29
+ // Base URL resolver
30
+ export { resolveCanonBaseUrl } from './base-url.js';
@@ -50,8 +50,25 @@ export interface TurnStatePayload {
50
50
  '.sv': 'timestamp';
51
51
  };
52
52
  }
53
- /** Must be called once before any RTDB operations. */
54
- export declare function initRTDBAuth(client: CanonClient): void;
53
+ interface RTDBAuthOptions {
54
+ rtdbUrl?: string;
55
+ firebaseApiKey?: string;
56
+ }
57
+ interface RTDBClientHandle {
58
+ read(path: string): Promise<unknown>;
59
+ write(path: string, data: unknown): Promise<void>;
60
+ patch(path: string, data: unknown): Promise<void>;
61
+ remove(path: string): Promise<void>;
62
+ writeSessionState(conversationId: string, agentId: string, state: Omit<SessionStatePayload, 'updatedAt'>): Promise<void>;
63
+ clearSessionState(conversationId: string, agentId: string): Promise<void>;
64
+ writeTurnState(conversationId: string, agentId: string, state: Omit<TurnStatePayload, 'updatedAt'>): Promise<void>;
65
+ clearTurnState(conversationId: string, agentId: string): Promise<void>;
66
+ }
67
+ /**
68
+ * Initializes the default RTDB helper and returns a scoped client for callers
69
+ * that need per-runtime auth and base-url isolation.
70
+ */
71
+ export declare function initRTDBAuth(client: CanonClient, options?: RTDBAuthOptions): RTDBClientHandle;
55
72
  /** Generic RTDB REST write (PUT). */
56
73
  export declare function rtdbWrite(path: string, data: unknown): Promise<void>;
57
74
  /** Generic RTDB REST read (GET). */
@@ -67,3 +84,4 @@ export declare function writeSessionState(conversationId: string, agentId: strin
67
84
  export declare function clearSessionState(conversationId: string, agentId: string): Promise<void>;
68
85
  export declare function writeTurnState(conversationId: string, agentId: string, state: Omit<TurnStatePayload, 'updatedAt'>): Promise<void>;
69
86
  export declare function clearTurnState(conversationId: string, agentId: string): Promise<void>;
87
+ export {};
package/dist/rtdb-rest.js CHANGED
@@ -6,163 +6,208 @@
6
6
  * is exchanged for a Firebase ID token before use with RTDB REST.
7
7
  */
8
8
  import { DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from './constants.js';
9
- const RTDB_BASE = process.env.CANON_RTDB_URL || DEFAULT_RTDB_URL;
10
- const FIREBASE_API_KEY = process.env.CANON_FIREBASE_API_KEY || FIREBASE_WEB_API_KEY;
11
- // ── Token management ──────────────────────────────────────────────────
12
- let cachedIdToken = null;
13
- let idTokenExpiresAt = 0;
14
- let tokenClient = null;
15
- /** Must be called once before any RTDB operations. */
16
- export function initRTDBAuth(client) {
17
- tokenClient = client;
9
+ const DEFAULT_RTDB_BASE = normalizeRTDBBase(process.env.CANON_RTDB_URL || DEFAULT_RTDB_URL);
10
+ const DEFAULT_FIREBASE_API_KEY = process.env.CANON_FIREBASE_API_KEY || FIREBASE_WEB_API_KEY;
11
+ let defaultRTDBClient = null;
12
+ function normalizeRTDBBase(url) {
13
+ return url.replace(/\/+$/, '');
18
14
  }
19
- /**
20
- * Exchange a Firebase custom token for an ID token via the Identity Toolkit API.
21
- */
22
- async function exchangeCustomTokenForIdToken(customToken) {
23
- const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${FIREBASE_API_KEY}`;
24
- const res = await fetch(url, {
25
- method: 'POST',
26
- headers: { 'Content-Type': 'application/json' },
27
- body: JSON.stringify({ token: customToken, returnSecureToken: true }),
28
- });
29
- if (!res.ok) {
30
- const text = await res.text();
31
- throw new Error(`Token exchange failed (${res.status}): ${text}`);
32
- }
33
- const data = await res.json();
34
- return { idToken: data.idToken, expiresIn: parseInt(data.expiresIn, 10) };
15
+ function normalizeRTDBPath(path) {
16
+ return path.startsWith('/') ? path : `/${path}`;
35
17
  }
36
- /** Get a valid ID token for RTDB REST, refreshing if expired or expiring soon. */
37
- async function getToken() {
38
- // Refresh if missing, expired, or expiring within 5 minutes
39
- if (!cachedIdToken || Date.now() > idTokenExpiresAt - 5 * 60 * 1000) {
40
- if (!tokenClient)
18
+ function createRTDBClientHandle(client, options) {
19
+ const rtdbBase = normalizeRTDBBase(options?.rtdbUrl || DEFAULT_RTDB_BASE);
20
+ const firebaseApiKey = options?.firebaseApiKey || DEFAULT_FIREBASE_API_KEY;
21
+ let cachedIdToken = null;
22
+ let idTokenExpiresAt = 0;
23
+ let refreshPromise = null;
24
+ async function exchangeCustomTokenForIdToken(customToken) {
25
+ const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${firebaseApiKey}`;
26
+ const res = await fetch(url, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({ token: customToken, returnSecureToken: true }),
30
+ });
31
+ if (!res.ok) {
32
+ const text = await res.text();
33
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
34
+ }
35
+ const data = await res.json();
36
+ return {
37
+ idToken: data.idToken,
38
+ expiresIn: Number.parseInt(data.expiresIn, 10),
39
+ };
40
+ }
41
+ async function getToken(forceRefresh = false) {
42
+ const now = Date.now();
43
+ if (!forceRefresh && cachedIdToken && now <= idTokenExpiresAt - 5 * 60 * 1000) {
44
+ return cachedIdToken;
45
+ }
46
+ if (refreshPromise) {
47
+ return refreshPromise;
48
+ }
49
+ const staleToken = forceRefresh ? null : cachedIdToken;
50
+ refreshPromise = (async () => {
51
+ try {
52
+ const auth = await client.getAuthToken();
53
+ const { idToken, expiresIn } = await exchangeCustomTokenForIdToken(auth.token);
54
+ cachedIdToken = idToken;
55
+ idTokenExpiresAt = Date.now() + (expiresIn * 1000);
56
+ return cachedIdToken;
57
+ }
58
+ catch (err) {
59
+ console.error('[canon] RTDB token refresh failed:', err);
60
+ return staleToken;
61
+ }
62
+ finally {
63
+ refreshPromise = null;
64
+ }
65
+ })();
66
+ return refreshPromise;
67
+ }
68
+ async function request(method, path, data, options = {}) {
69
+ const token = await getToken(false);
70
+ if (!token)
41
71
  return null;
72
+ const url = `${rtdbBase}${normalizeRTDBPath(path)}.json?auth=${encodeURIComponent(token)}`;
73
+ const res = await fetch(url, {
74
+ method,
75
+ ...(method === 'GET'
76
+ ? {}
77
+ : {
78
+ headers: { 'Content-Type': 'application/json' },
79
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
80
+ }),
81
+ });
82
+ if (options.retryUnauthorized !== false
83
+ && (res.status === 401 || res.status === 403)) {
84
+ cachedIdToken = null;
85
+ idTokenExpiresAt = 0;
86
+ const refreshedToken = await getToken(true);
87
+ if (!refreshedToken) {
88
+ return res;
89
+ }
90
+ const retryUrl = `${rtdbBase}${normalizeRTDBPath(path)}.json?auth=${encodeURIComponent(refreshedToken)}`;
91
+ return fetch(retryUrl, {
92
+ method,
93
+ ...(method === 'GET'
94
+ ? {}
95
+ : {
96
+ headers: { 'Content-Type': 'application/json' },
97
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
98
+ }),
99
+ });
100
+ }
101
+ return res;
102
+ }
103
+ async function requireSuccess(method, path, data, errorPrefix) {
104
+ const res = await request(method, path, data);
105
+ if (!res)
106
+ return;
107
+ if (!res.ok) {
108
+ const text = await res.text();
109
+ throw new Error(`${errorPrefix} (${res.status}): ${text}`);
110
+ }
111
+ }
112
+ async function read(path) {
113
+ const res = await request('GET', path);
114
+ if (!res?.ok)
115
+ return null;
116
+ return res.json();
117
+ }
118
+ async function write(path, data) {
119
+ await requireSuccess('PUT', path, data, 'RTDB write failed');
120
+ }
121
+ async function patch(path, data) {
122
+ await requireSuccess('PATCH', path, data, 'RTDB patch failed');
123
+ }
124
+ async function remove(path) {
125
+ await requireSuccess('DELETE', path, undefined, 'RTDB delete failed');
126
+ }
127
+ async function writeSessionStateImpl(conversationId, agentId, state) {
128
+ await write(`/session-state/${conversationId}/${agentId}`, {
129
+ ...state,
130
+ updatedAt: { '.sv': 'timestamp' },
131
+ });
132
+ }
133
+ async function clearSessionStateImpl(conversationId, agentId) {
42
134
  try {
43
- const auth = await tokenClient.getAuthToken();
44
- const { idToken, expiresIn } = await exchangeCustomTokenForIdToken(auth.token);
45
- cachedIdToken = idToken;
46
- idTokenExpiresAt = Date.now() + expiresIn * 1000;
135
+ await write(`/session-state/${conversationId}/${agentId}`, {
136
+ isActive: false,
137
+ updatedAt: { '.sv': 'timestamp' },
138
+ });
47
139
  }
48
- catch (err) {
49
- console.error('[canon] RTDB token refresh failed:', err);
50
- return cachedIdToken; // Return stale token as fallback
140
+ catch (error) {
141
+ console.error('[canon] RTDB clear failed:', error);
51
142
  }
52
143
  }
53
- return cachedIdToken;
144
+ async function writeTurnStateImpl(conversationId, agentId, state) {
145
+ await write(`/turn-state/${conversationId}/${agentId}`, {
146
+ ...state,
147
+ updatedAt: { '.sv': 'timestamp' },
148
+ });
149
+ }
150
+ async function clearTurnStateImpl(conversationId, agentId) {
151
+ try {
152
+ await write(`/turn-state/${conversationId}/${agentId}`, {
153
+ state: 'idle',
154
+ queueDepth: 0,
155
+ updatedAt: { '.sv': 'timestamp' },
156
+ completedAt: { '.sv': 'timestamp' },
157
+ });
158
+ }
159
+ catch (error) {
160
+ console.error('[canon] RTDB turn clear failed:', error);
161
+ }
162
+ }
163
+ return {
164
+ read,
165
+ write,
166
+ patch,
167
+ remove,
168
+ writeSessionState: writeSessionStateImpl,
169
+ clearSessionState: clearSessionStateImpl,
170
+ writeTurnState: writeTurnStateImpl,
171
+ clearTurnState: clearTurnStateImpl,
172
+ };
173
+ }
174
+ /**
175
+ * Initializes the default RTDB helper and returns a scoped client for callers
176
+ * that need per-runtime auth and base-url isolation.
177
+ */
178
+ export function initRTDBAuth(client, options) {
179
+ const scopedClient = createRTDBClientHandle(client, options);
180
+ defaultRTDBClient = scopedClient;
181
+ return scopedClient;
182
+ }
183
+ function getDefaultRTDBClient() {
184
+ return defaultRTDBClient;
54
185
  }
55
186
  // ── RTDB operations ───────────────────────────────────────────────────
56
187
  /** Generic RTDB REST write (PUT). */
57
188
  export async function rtdbWrite(path, data) {
58
- const token = await getToken();
59
- if (!token)
60
- return;
61
- const url = `${RTDB_BASE}${path}.json?auth=${token}`;
62
- const res = await fetch(url, {
63
- method: 'PUT',
64
- headers: { 'Content-Type': 'application/json' },
65
- body: JSON.stringify(data),
66
- });
67
- if (!res.ok) {
68
- const text = await res.text();
69
- throw new Error(`RTDB write failed (${res.status}): ${text}`);
70
- }
189
+ await getDefaultRTDBClient()?.write(path, data);
71
190
  }
72
191
  /** Generic RTDB REST read (GET). */
73
192
  export async function rtdbRead(path) {
74
- const token = await getToken();
75
- if (!token)
76
- return null;
77
- const url = `${RTDB_BASE}${path}.json?auth=${token}`;
78
- const res = await fetch(url);
79
- if (!res.ok)
80
- return null;
81
- return res.json();
193
+ return getDefaultRTDBClient()?.read(path) ?? null;
82
194
  }
83
195
  /**
84
196
  * Write session state to RTDB via REST API.
85
197
  * Path: /session-state/{conversationId}/{agentId}
86
198
  */
87
199
  export async function writeSessionState(conversationId, agentId, state) {
88
- const token = await getToken();
89
- if (!token)
90
- return;
91
- const url = `${RTDB_BASE}/session-state/${conversationId}/${agentId}.json?auth=${token}`;
92
- const body = {
93
- ...state,
94
- updatedAt: { '.sv': 'timestamp' },
95
- };
96
- const res = await fetch(url, {
97
- method: 'PUT',
98
- headers: { 'Content-Type': 'application/json' },
99
- body: JSON.stringify(body),
100
- });
101
- if (!res.ok) {
102
- const text = await res.text();
103
- throw new Error(`RTDB write failed (${res.status}): ${text}`);
104
- }
200
+ await getDefaultRTDBClient()?.writeSessionState(conversationId, agentId, state);
105
201
  }
106
202
  /**
107
203
  * Clear session state from RTDB (full overwrite with isActive: false).
108
204
  */
109
205
  export async function clearSessionState(conversationId, agentId) {
110
- const token = await getToken();
111
- if (!token)
112
- return;
113
- const url = `${RTDB_BASE}/session-state/${conversationId}/${agentId}.json?auth=${token}`;
114
- const body = {
115
- isActive: false,
116
- updatedAt: { '.sv': 'timestamp' },
117
- };
118
- // Use PUT (not PATCH) to clear stale fields like model/cwd
119
- const res = await fetch(url, {
120
- method: 'PUT',
121
- headers: { 'Content-Type': 'application/json' },
122
- body: JSON.stringify(body),
123
- });
124
- if (!res.ok) {
125
- const text = await res.text();
126
- console.error(`[canon] RTDB clear failed (${res.status}): ${text}`);
127
- }
206
+ await getDefaultRTDBClient()?.clearSessionState(conversationId, agentId);
128
207
  }
129
208
  export async function writeTurnState(conversationId, agentId, state) {
130
- const token = await getToken();
131
- if (!token)
132
- return;
133
- const url = `${RTDB_BASE}/turn-state/${conversationId}/${agentId}.json?auth=${token}`;
134
- const body = {
135
- ...state,
136
- updatedAt: { '.sv': 'timestamp' },
137
- };
138
- const res = await fetch(url, {
139
- method: 'PUT',
140
- headers: { 'Content-Type': 'application/json' },
141
- body: JSON.stringify(body),
142
- });
143
- if (!res.ok) {
144
- const text = await res.text();
145
- throw new Error(`RTDB write failed (${res.status}): ${text}`);
146
- }
209
+ await getDefaultRTDBClient()?.writeTurnState(conversationId, agentId, state);
147
210
  }
148
211
  export async function clearTurnState(conversationId, agentId) {
149
- const token = await getToken();
150
- if (!token)
151
- return;
152
- const url = `${RTDB_BASE}/turn-state/${conversationId}/${agentId}.json?auth=${token}`;
153
- const body = {
154
- state: 'idle',
155
- queueDepth: 0,
156
- updatedAt: { '.sv': 'timestamp' },
157
- completedAt: { '.sv': 'timestamp' },
158
- };
159
- const res = await fetch(url, {
160
- method: 'PUT',
161
- headers: { 'Content-Type': 'application/json' },
162
- body: JSON.stringify(body),
163
- });
164
- if (!res.ok) {
165
- const text = await res.text();
166
- console.error(`[canon] RTDB turn clear failed (${res.status}): ${text}`);
167
- }
212
+ await getDefaultRTDBClient()?.clearTurnState(conversationId, agentId);
168
213
  }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import type { ExecutionEnvironmentMode } from './execution-environment-mode.js';
1
2
  import type { ResolvedAgentBehaviorPolicy } from './policy.js';
2
3
  import type { CanonWorkSessionContext } from './work-session.js';
4
+ export type { ExecutionEnvironmentMode };
3
5
  export type MediaAttachmentKind = 'image' | 'audio' | 'file';
4
6
  export interface MediaAttachment {
5
7
  kind: MediaAttachmentKind;
@@ -11,6 +13,10 @@ export interface MediaAttachment {
11
13
  height?: number;
12
14
  durationMs?: number;
13
15
  }
16
+ export interface ForwardedFrom {
17
+ sourceConversationId: string;
18
+ messageId: string;
19
+ }
14
20
  export interface CanonMessage {
15
21
  id: string;
16
22
  senderId: string;
@@ -19,13 +25,12 @@ export interface CanonMessage {
19
25
  isOwner: boolean;
20
26
  contentType: 'text' | 'image' | 'audio' | 'file' | 'contact_card';
21
27
  text: string | null;
22
- imageUrl: string | null;
23
- audioUrl: string | null;
24
- audioDurationMs: number | null;
25
- attachments?: MediaAttachment[];
28
+ attachments: MediaAttachment[];
26
29
  mentions: string[];
27
30
  replyTo: string | null;
28
31
  replyToPosition: number | null;
32
+ forwarded?: boolean;
33
+ forwardedFrom?: ForwardedFrom;
29
34
  workSession?: CanonWorkSessionContext | null;
30
35
  metadata?: Record<string, unknown>;
31
36
  status: 'sent' | 'read';
@@ -59,6 +64,12 @@ export type AgentClientType = 'claude-code' | 'openclaw' | 'codex' | 'generic';
59
64
  export interface AgentCapabilities {
60
65
  supportsModelSwitch: boolean;
61
66
  supportsPermissionMode: boolean;
67
+ /**
68
+ * Whether permissionMode can be changed mid-session. When undefined,
69
+ * defaults to `supportsPermissionMode` — keep the UI chip non-interactive
70
+ * for agents whose approval mode is locked at session creation.
71
+ */
72
+ supportsRuntimePermissionMode?: boolean;
62
73
  supportsEffort: boolean;
63
74
  supportsSessionState: boolean;
64
75
  supportsInterrupt: boolean;
@@ -101,12 +112,11 @@ export interface MessageCreatedPayload {
101
112
  isOwner?: boolean;
102
113
  text?: string;
103
114
  contentType?: 'text' | 'image' | 'audio' | 'file' | 'contact_card';
104
- imageUrl?: string;
105
- audioUrl?: string;
106
- audioDurationMs?: number;
107
115
  attachments?: MediaAttachment[];
108
116
  replyTo?: string;
109
117
  replyToPosition?: number;
118
+ forwarded?: boolean;
119
+ forwardedFrom?: ForwardedFrom;
110
120
  mentions?: string[];
111
121
  createdAt?: string;
112
122
  workSession?: CanonWorkSessionContext | null;
@@ -128,9 +138,6 @@ export interface SendMessageOptions {
128
138
  contentType?: 'text' | 'audio' | 'image' | 'file' | 'contact_card';
129
139
  replyTo?: string;
130
140
  replyToPosition?: number;
131
- audioUrl?: string;
132
- audioDurationMs?: number;
133
- imageUrl?: string;
134
141
  attachments?: MediaAttachment[];
135
142
  contactCardUserId?: string;
136
143
  mentions?: string[];
@@ -186,16 +193,39 @@ export interface SessionConfig {
186
193
  model?: string;
187
194
  permissionMode?: string;
188
195
  workspaceId?: string;
196
+ /**
197
+ * Explicitly selected execution mode. Sessions created before this field
198
+ * existed stay `undefined`; UIs must prompt for a value and plugin hosts
199
+ * fail-closed rather than inferring one.
200
+ */
201
+ executionMode?: ExecutionEnvironmentMode;
189
202
  availableModels?: ModelOption[];
190
203
  workspaceOptions?: WorkspaceOption[];
204
+ availableExecutionModes?: ExecutionEnvironmentMode[];
191
205
  updatedAt?: number;
192
206
  }
207
+ export interface PermissionModeOption {
208
+ value: string;
209
+ label: string;
210
+ }
193
211
  export interface AgentRuntime {
194
212
  clientType?: AgentClientType;
195
213
  hostMode?: boolean;
196
214
  defaultModel?: string;
197
215
  defaultPermissionMode?: string;
216
+ availablePermissionModes?: PermissionModeOption[];
198
217
  defaultWorkspaceId?: string;
218
+ /**
219
+ * Execution modes the host will accept. The runtime advertises this so the
220
+ * app can offer matching choices; it is NOT used to auto-populate missing
221
+ * session-config values.
222
+ */
223
+ availableExecutionModes?: ExecutionEnvironmentMode[];
224
+ /**
225
+ * Reference default surfaced to UI. Treated as advisory only — callers must
226
+ * still have the user confirm a selection before persisting.
227
+ */
228
+ defaultExecutionMode?: ExecutionEnvironmentMode;
199
229
  availableModels?: ModelOption[];
200
230
  availableWorkspaces?: WorkspaceOption[];
201
231
  updatedAt?: number;
package/dist/types.js CHANGED
@@ -12,6 +12,7 @@ export const AGENT_CAPABILITIES = {
12
12
  'claude-code': {
13
13
  supportsModelSwitch: true,
14
14
  supportsPermissionMode: true,
15
+ supportsRuntimePermissionMode: true,
15
16
  supportsEffort: true,
16
17
  supportsSessionState: true,
17
18
  supportsInterrupt: true,
@@ -20,7 +21,8 @@ export const AGENT_CAPABILITIES = {
20
21
  },
21
22
  'codex': {
22
23
  supportsModelSwitch: false,
23
- supportsPermissionMode: false,
24
+ supportsPermissionMode: true,
25
+ supportsRuntimePermissionMode: false,
24
26
  supportsEffort: false,
25
27
  supportsSessionState: true,
26
28
  supportsInterrupt: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -8,10 +8,12 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
+ "default": "./dist/index.js",
11
12
  "types": "./dist/index.d.ts"
12
13
  },
13
14
  "./browser": {
14
15
  "import": "./dist/browser.js",
16
+ "default": "./dist/browser.js",
15
17
  "types": "./dist/browser.d.ts"
16
18
  }
17
19
  },