@canonmsg/claude-code-plugin 0.33.0 → 0.34.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.
@@ -8,11 +8,13 @@ export type McpParseResult<T> = {
8
8
  type ContentType = 'text' | 'image' | 'audio' | 'video' | 'file';
9
9
  export interface ReplyArgs {
10
10
  conversationId: string;
11
+ sourceMessageId: string;
11
12
  text: string;
12
13
  }
13
14
  export declare function parseReplyArgs(value: unknown): McpParseResult<ReplyArgs>;
14
15
  export interface SendMessageArgs {
15
16
  conversationId: string;
17
+ sourceMessageId: string;
16
18
  text: string;
17
19
  contentType: ContentType;
18
20
  imageUrl?: string;
@@ -27,6 +29,7 @@ export interface SendMessageArgs {
27
29
  export declare function parseSendMessageArgs(value: unknown): McpParseResult<SendMessageArgs>;
28
30
  export interface SetTypingArgs {
29
31
  conversationId: string;
32
+ sourceMessageId: string;
30
33
  typing: boolean;
31
34
  status?: 'typing' | 'thinking';
32
35
  }
package/dist/mcp-args.js CHANGED
@@ -42,6 +42,9 @@ export function parseReplyArgs(value) {
42
42
  const conversationId = requiredString(value, 'conversation_id', 256);
43
43
  if (!conversationId.ok)
44
44
  return conversationId;
45
+ const sourceMessageId = requiredString(value, 'source_message_id', 256);
46
+ if (!sourceMessageId.ok)
47
+ return sourceMessageId;
45
48
  const text = requiredString(value, 'text');
46
49
  if (!text.ok)
47
50
  return text;
@@ -49,6 +52,7 @@ export function parseReplyArgs(value) {
49
52
  ok: true,
50
53
  value: {
51
54
  conversationId: conversationId.value,
55
+ sourceMessageId: sourceMessageId.value,
52
56
  text: text.value,
53
57
  },
54
58
  };
@@ -59,6 +63,9 @@ export function parseSendMessageArgs(value) {
59
63
  const conversationId = requiredString(value, 'conversation_id', 256);
60
64
  if (!conversationId.ok)
61
65
  return conversationId;
66
+ const sourceMessageId = requiredString(value, 'source_message_id', 256);
67
+ if (!sourceMessageId.ok)
68
+ return sourceMessageId;
62
69
  const contentType = parseContentType(value.content_type);
63
70
  if (!contentType.ok)
64
71
  return contentType;
@@ -74,6 +81,7 @@ export function parseSendMessageArgs(value) {
74
81
  ok: true,
75
82
  value: {
76
83
  conversationId: conversationId.value,
84
+ sourceMessageId: sourceMessageId.value,
77
85
  text: optionalString(value, 'text') ?? '',
78
86
  contentType: contentType.value,
79
87
  ...(imageUrl ? { imageUrl } : {}),
@@ -93,6 +101,9 @@ export function parseSetTypingArgs(value) {
93
101
  const conversationId = requiredString(value, 'conversation_id', 256);
94
102
  if (!conversationId.ok)
95
103
  return conversationId;
104
+ const sourceMessageId = requiredString(value, 'source_message_id', 256);
105
+ if (!sourceMessageId.ok)
106
+ return sourceMessageId;
96
107
  if (typeof value.typing !== 'boolean') {
97
108
  return { ok: false, error: 'typing must be a boolean' };
98
109
  }
@@ -104,6 +115,7 @@ export function parseSetTypingArgs(value) {
104
115
  ok: true,
105
116
  value: {
106
117
  conversationId: conversationId.value,
118
+ sourceMessageId: sourceMessageId.value,
107
119
  typing: value.typing,
108
120
  ...(status ? { status } : {}),
109
121
  },
package/dist/register.js CHANGED
@@ -21,6 +21,7 @@ REQUIRED
21
21
 
22
22
  FLAGS
23
23
  --profile <name> Local profile name in ~/.canon/agents.json
24
+ --agent-id <id> Reconnect this exact transferred agent identity
24
25
  --environment <id> Canon environment ID (or CANON_ENVIRONMENT_ID; default: canon-prod-v1)
25
26
  --base-url <url> Canon API base URL override
26
27
  --stream-url <url> Canon stream URL override
@@ -32,6 +33,7 @@ FLAGS
32
33
  EXAMPLES
33
34
  canon-register --name "My Claude" --description "Claude Code agent" --phone "+15551234567"
34
35
  canon-register --name "Frontend" --description "React work" --phone "+15551234567" --profile frontend
36
+ canon-register --name "Sold Agent" --description "Existing agent" --phone "+15551234567" --agent-id <id>
35
37
 
36
38
  After approval, start it with CANON_AGENT=<profile> canon-claude --cwd /path/to/project.`;
37
39
  const OPTIONS = {
package/dist/server.js CHANGED
@@ -16,7 +16,10 @@ import { ApprovalHttpServer } from './approval-server.js';
16
16
  import { renderClaudeInboundContent } from './canon-user-content.js';
17
17
  import { runCli } from '@canonmsg/core';
18
18
  import { parseReplyArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
19
- import { canonVerbToolDefinitions, createCanonCommunicationBinding, executeCanonVerbTool, isCanonToolVerb, } from '@canonmsg/agent-tools';
19
+ import { canonVerbToolDefinitions, createCanonCommunicationBinding, executeCanonVerbTool, isCanonToolVerb, isCommunicateReplacedVerb, } from '@canonmsg/agent-tools';
20
+ import { isProactiveCommunicationEnabled } from './communication-policy.js';
21
+ import { InboundReplyAuthority, InboundReplyAuthorityError, } from './inbound-reply-authority.js';
22
+ import { AgentContextState } from './agent-context-state.js';
20
23
  const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
21
24
 
22
25
  USAGE
@@ -31,13 +34,12 @@ phone-controlled local sessions, use canon-claude instead.`;
31
34
  // ── MCP server ─────────────────────────────────────────────────────────
32
35
  const server = new Server({ name: 'canon-channel', version: '0.1.0' }, {
33
36
  capabilities: {
34
- tools: {},
37
+ tools: { listChanged: true },
35
38
  experimental: { 'claude/channel': {} },
36
39
  },
37
40
  });
38
41
  let client = null;
39
42
  let stream = null;
40
- let agentContext = null;
41
43
  let approvalManager = null;
42
44
  let approvalServer = null;
43
45
  let rtdb = null;
@@ -46,10 +48,32 @@ const TURN_COMPLETE_METADATA = {
46
48
  turnSemantics: 'turn_complete',
47
49
  };
48
50
  const communicationBinding = createCanonCommunicationBinding();
51
+ const inboundReplyAuthority = new InboundReplyAuthority();
52
+ const agentContextState = new AgentContextState(() => {
53
+ void server.sendToolListChanged().catch((error) => {
54
+ console.error(`[canon] Failed to refresh communication tools: ${error instanceof Error ? error.message : error}`);
55
+ });
56
+ });
49
57
  /** Last owner-visible conversation to route approval cards into. */
50
58
  let approvalConversationId = null;
51
59
  /** Whether the message that most recently triggered work came from the owner. */
52
60
  let triggerIsOwner = true;
61
+ const INBOUND_SOURCE_PROPERTY = {
62
+ type: 'string',
63
+ description: 'The source_message_id from the inbound Canon channel metadata',
64
+ };
65
+ function requireInboundSource(conversationId, sourceMessageId) {
66
+ if (typeof conversationId !== 'string'
67
+ || !conversationId
68
+ || typeof sourceMessageId !== 'string'
69
+ || !sourceMessageId) {
70
+ return null;
71
+ }
72
+ return { conversationId, sourceMessageId };
73
+ }
74
+ function replyAuthorityError() {
75
+ return toolArgumentError('This action must target the exact current inbound Canon message and may only reply once.');
76
+ }
53
77
  function getPrimaryAttachment(message) {
54
78
  const attachment = message.attachments?.[0];
55
79
  if (attachment?.url)
@@ -66,119 +90,144 @@ function toolArgumentError(error) {
66
90
  };
67
91
  }
68
92
  // ── Tool definitions ───────────────────────────────────────────────────
69
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
70
- tools: [
71
- {
72
- name: 'reply',
73
- description: 'Reply to a Canon conversation',
74
- inputSchema: {
75
- type: 'object',
76
- properties: {
77
- conversation_id: {
78
- type: 'string',
79
- description: 'The conversation ID to reply to',
80
- },
81
- text: {
82
- type: 'string',
83
- description: 'Message text to send',
93
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
94
+ await agentContextState.ready;
95
+ return {
96
+ tools: [
97
+ {
98
+ name: 'reply',
99
+ description: 'Reply to the current inbound Canon conversation',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ conversation_id: {
104
+ type: 'string',
105
+ description: 'The conversation ID to reply to',
106
+ },
107
+ source_message_id: INBOUND_SOURCE_PROPERTY,
108
+ text: {
109
+ type: 'string',
110
+ description: 'Message text to send',
111
+ },
84
112
  },
113
+ required: ['conversation_id', 'source_message_id', 'text'],
85
114
  },
86
- required: ['conversation_id', 'text'],
87
115
  },
88
- },
89
- {
90
- name: 'send_message',
91
- description: 'Send a message to a Canon conversation (supports text, images, audio, video, files)',
92
- inputSchema: {
93
- type: 'object',
94
- properties: {
95
- conversation_id: {
96
- type: 'string',
97
- description: 'The conversation ID',
98
- },
99
- text: {
100
- type: 'string',
101
- description: 'Message text',
102
- },
103
- content_type: {
104
- type: 'string',
105
- enum: ['text', 'image', 'audio', 'video', 'file'],
106
- description: 'Content type (default: text)',
107
- },
108
- image_url: {
109
- type: 'string',
110
- description: 'Image URL (when content_type is image)',
111
- },
112
- audio_url: {
113
- type: 'string',
114
- description: 'Audio URL (when content_type is audio)',
115
- },
116
- video_url: {
117
- type: 'string',
118
- description: 'Video URL (when content_type is video)',
119
- },
120
- file_url: {
121
- type: 'string',
122
- description: 'File URL (when content_type is file)',
123
- },
124
- media_path: {
125
- type: 'string',
126
- description: 'Local image/audio/video/file path to upload and send',
127
- },
128
- file_name: {
129
- type: 'string',
130
- description: 'Optional display filename for file attachments',
131
- },
132
- mime_type: {
133
- type: 'string',
134
- description: 'Optional MIME type for media_path uploads',
135
- },
136
- duration_ms: {
137
- type: 'number',
138
- description: 'Optional audio duration in milliseconds for media_path uploads',
116
+ {
117
+ name: 'send_message',
118
+ description: 'Reply with text or media in the current inbound Canon conversation',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ conversation_id: {
123
+ type: 'string',
124
+ description: 'The conversation ID',
125
+ },
126
+ source_message_id: INBOUND_SOURCE_PROPERTY,
127
+ text: {
128
+ type: 'string',
129
+ description: 'Message text',
130
+ },
131
+ content_type: {
132
+ type: 'string',
133
+ enum: ['text', 'image', 'audio', 'video', 'file'],
134
+ description: 'Content type (default: text)',
135
+ },
136
+ image_url: {
137
+ type: 'string',
138
+ description: 'Image URL (when content_type is image)',
139
+ },
140
+ audio_url: {
141
+ type: 'string',
142
+ description: 'Audio URL (when content_type is audio)',
143
+ },
144
+ video_url: {
145
+ type: 'string',
146
+ description: 'Video URL (when content_type is video)',
147
+ },
148
+ file_url: {
149
+ type: 'string',
150
+ description: 'File URL (when content_type is file)',
151
+ },
152
+ media_path: {
153
+ type: 'string',
154
+ description: 'Local image/audio/video/file path to upload and send',
155
+ },
156
+ file_name: {
157
+ type: 'string',
158
+ description: 'Optional display filename for file attachments',
159
+ },
160
+ mime_type: {
161
+ type: 'string',
162
+ description: 'Optional MIME type for media_path uploads',
163
+ },
164
+ duration_ms: {
165
+ type: 'number',
166
+ description: 'Optional audio duration in milliseconds for media_path uploads',
167
+ },
139
168
  },
169
+ required: ['conversation_id', 'source_message_id'],
140
170
  },
141
- required: ['conversation_id'],
142
171
  },
143
- },
144
- {
145
- name: 'list_agents',
146
- description: 'List registered Canon agent profiles and their lock status',
147
- inputSchema: {
148
- type: 'object',
149
- properties: {},
172
+ {
173
+ name: 'list_agents',
174
+ description: 'List registered Canon agent profiles and their lock status',
175
+ inputSchema: {
176
+ type: 'object',
177
+ properties: {},
178
+ },
150
179
  },
151
- },
152
- {
153
- name: 'set_typing',
154
- description: 'Show or hide typing indicator in a Canon conversation',
155
- inputSchema: {
156
- type: 'object',
157
- properties: {
158
- conversation_id: {
159
- type: 'string',
160
- description: 'The conversation ID',
161
- },
162
- typing: {
163
- type: 'boolean',
164
- description: 'Whether to show typing indicator',
165
- },
166
- status: {
167
- type: 'string',
168
- enum: ['typing', 'thinking'],
169
- description: 'Typing status (default: typing). Use "thinking" when processing/reasoning.',
180
+ {
181
+ name: 'set_typing',
182
+ description: 'Show or hide typing indicator in a Canon conversation',
183
+ inputSchema: {
184
+ type: 'object',
185
+ properties: {
186
+ conversation_id: {
187
+ type: 'string',
188
+ description: 'The conversation ID',
189
+ },
190
+ source_message_id: INBOUND_SOURCE_PROPERTY,
191
+ typing: {
192
+ type: 'boolean',
193
+ description: 'Whether to show typing indicator',
194
+ },
195
+ status: {
196
+ type: 'string',
197
+ enum: ['typing', 'thinking'],
198
+ description: 'Typing status (default: typing). Use "thinking" when processing/reasoning.',
199
+ },
170
200
  },
201
+ required: ['conversation_id', 'source_message_id', 'typing'],
171
202
  },
172
- required: ['conversation_id', 'typing'],
173
203
  },
174
- },
175
- // Canonical verbs — projections of canon.verbs.v1 (@canonmsg/agent-tools).
176
- // Channel mode owns no turn, so `no_reply` is an ack-only no-op here: the
177
- // standalone session has no final delivery for it to suppress.
178
- ...canonVerbToolDefinitions(),
179
- ...(agentContext?.outboundPolicy === 'closed' ? [] : communicationBinding.tools),
180
- ],
181
- }));
204
+ // Canonical verbs — projections of canon.verbs.v1 (@canonmsg/agent-tools).
205
+ // Channel mode owns no turn, so `no_reply` is an ack-only no-op here: the
206
+ // standalone session has no final delivery for it to suppress.
207
+ ...canonVerbToolDefinitions({ compactCommunication: true }).map((tool) => ({
208
+ ...tool,
209
+ description: `${tool.description} Use source_message_id from the inbound Canon channel metadata.`,
210
+ inputSchema: {
211
+ ...tool.inputSchema,
212
+ properties: {
213
+ ...(tool.inputSchema.properties ?? {}),
214
+ source_message_id: INBOUND_SOURCE_PROPERTY,
215
+ },
216
+ required: [
217
+ ...new Set([
218
+ ...(tool.inputSchema.required ?? []),
219
+ 'conversationId',
220
+ 'source_message_id',
221
+ ]),
222
+ ],
223
+ },
224
+ })),
225
+ ...(isProactiveCommunicationEnabled(agentContextState.current?.outboundPolicy)
226
+ ? communicationBinding.tools
227
+ : []),
228
+ ],
229
+ };
230
+ });
182
231
  // ── Tool handlers ──────────────────────────────────────────────────────
183
232
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
184
233
  if (!client) {
@@ -193,10 +242,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
193
242
  const parsed = parseReplyArgs(args);
194
243
  if (!parsed.ok)
195
244
  return toolArgumentError(parsed.error);
196
- const { conversationId, text } = parsed.value;
197
- const result = await client.sendMessage(conversationId, text, {
198
- metadata: TURN_COMPLETE_METADATA,
199
- });
245
+ const { conversationId, sourceMessageId, text } = parsed.value;
246
+ let result;
247
+ try {
248
+ result = await inboundReplyAuthority.sendVisibleReply({ conversationId, sourceMessageId }, (replyAuthority) => client.sendMessage(conversationId, text, {
249
+ metadata: {
250
+ ...TURN_COMPLETE_METADATA,
251
+ turnId: `claude-channel:${sourceMessageId}`,
252
+ },
253
+ ...(replyAuthority ? { replyAuthority } : {}),
254
+ }));
255
+ }
256
+ catch (error) {
257
+ if (error instanceof InboundReplyAuthorityError)
258
+ return replyAuthorityError();
259
+ throw error;
260
+ }
200
261
  return {
201
262
  content: [
202
263
  {
@@ -210,25 +271,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
210
271
  const parsed = parseSendMessageArgs(args);
211
272
  if (!parsed.ok)
212
273
  return toolArgumentError(parsed.error);
213
- const { conversationId, text, contentType, imageUrl, audioUrl, videoUrl, fileUrl, mediaPath, fileName, mimeType, durationMs, } = parsed.value;
274
+ const { conversationId, sourceMessageId, text, contentType, imageUrl, audioUrl, videoUrl, fileUrl, mediaPath, fileName, mimeType, durationMs, } = parsed.value;
214
275
  const opts = {};
215
276
  const metadata = TURN_COMPLETE_METADATA;
216
- if (mediaPath) {
217
- const result = await sendMediaFileMessage(client, conversationId, mediaPath, text, {
218
- ...(fileName ? { fileName } : {}),
219
- ...(mimeType ? { mimeType } : {}),
220
- ...(durationMs != null ? { durationMs } : {}),
221
- metadata,
222
- });
223
- return {
224
- content: [
225
- {
226
- type: 'text',
227
- text: `Message sent (${result.messageId})`,
228
- },
229
- ],
230
- };
231
- }
232
277
  if (contentType === 'image' && imageUrl) {
233
278
  opts.contentType = 'image';
234
279
  opts.attachments = [{ kind: 'image', url: imageUrl }];
@@ -269,7 +314,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
269
314
  ...(opts.metadata ?? {}),
270
315
  ...metadata,
271
316
  };
272
- const result = await client.sendMessage(conversationId, text, opts);
317
+ let result;
318
+ try {
319
+ result = await inboundReplyAuthority.sendVisibleReply({ conversationId, sourceMessageId }, (replyAuthority) => mediaPath
320
+ ? sendMediaFileMessage(client, conversationId, mediaPath, text, {
321
+ ...(fileName ? { fileName } : {}),
322
+ ...(mimeType ? { mimeType } : {}),
323
+ ...(durationMs != null ? { durationMs } : {}),
324
+ metadata: {
325
+ ...metadata,
326
+ turnId: `claude-channel:${sourceMessageId}`,
327
+ },
328
+ ...(replyAuthority ? { replyAuthority } : {}),
329
+ })
330
+ : client.sendMessage(conversationId, text, {
331
+ ...opts,
332
+ metadata: {
333
+ ...metadata,
334
+ turnId: `claude-channel:${sourceMessageId}`,
335
+ },
336
+ ...(replyAuthority ? { replyAuthority } : {}),
337
+ }));
338
+ }
339
+ catch (error) {
340
+ if (error instanceof InboundReplyAuthorityError)
341
+ return replyAuthorityError();
342
+ throw error;
343
+ }
273
344
  return {
274
345
  content: [
275
346
  {
@@ -303,7 +374,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
303
374
  const parsed = parseSetTypingArgs(args);
304
375
  if (!parsed.ok)
305
376
  return toolArgumentError(parsed.error);
306
- const { conversationId, typing, status } = parsed.value;
377
+ const { conversationId, sourceMessageId, typing, status } = parsed.value;
378
+ if (!inboundReplyAuthority.validate({ conversationId, sourceMessageId })) {
379
+ return replyAuthorityError();
380
+ }
307
381
  await client.setTyping(conversationId, typing, status);
308
382
  return {
309
383
  content: [
@@ -317,15 +391,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
317
391
  default: {
318
392
  const name = request.params.name;
319
393
  if (communicationBinding.isToolName(name)) {
394
+ if (!isProactiveCommunicationEnabled(agentContextState.current?.outboundPolicy)) {
395
+ return toolArgumentError('Outbound communication is not enabled by the agent operator.');
396
+ }
320
397
  const result = await communicationBinding.execute(client, name, args);
321
398
  return {
322
399
  content: result.content.map((item) => ({ type: 'text', text: item.text })),
323
400
  ...(result.isError ? { isError: true } : {}),
324
401
  };
325
402
  }
326
- if (isCanonToolVerb(name)) {
403
+ if (isCanonToolVerb(name) && !isCommunicateReplacedVerb(name)) {
327
404
  const rawArgs = args && typeof args === 'object' ? { ...args } : {};
328
- const verbResult = await executeCanonVerbTool(client, name, rawArgs);
405
+ const source = requireInboundSource(rawArgs.conversationId, rawArgs.source_message_id);
406
+ delete rawArgs.source_message_id;
407
+ if (!source)
408
+ return replyAuthorityError();
409
+ if (name === 'no_reply') {
410
+ if (!inboundReplyAuthority.consumeNoReply(source))
411
+ return replyAuthorityError();
412
+ }
413
+ else if (!inboundReplyAuthority.validate(source)) {
414
+ return replyAuthorityError();
415
+ }
416
+ const verbResult = await executeCanonVerbTool(client, name, rawArgs, {
417
+ context: {
418
+ conversationId: source.conversationId,
419
+ sourceMessageId: source.sourceMessageId,
420
+ },
421
+ });
329
422
  return {
330
423
  content: verbResult.content.map((item) => ({ type: 'text', text: item.text })),
331
424
  ...(verbResult.isError ? { isError: true } : {}),
@@ -344,6 +437,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
344
437
  async function handleInboundMessage(payload) {
345
438
  const m = payload.message;
346
439
  const convo = conversationCache.get(payload.conversationId);
440
+ const pendingReplySource = inboundReplyAuthority.beginInbound({
441
+ conversationId: payload.conversationId,
442
+ sourceMessageId: m.id,
443
+ ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
444
+ });
347
445
  // Track turn provenance separately from the owner-visible approval route.
348
446
  // Non-owner direct chats do not include the owner, so approval cards still go
349
447
  // to the last owner conversation while the hook bypasses cached session rules.
@@ -362,9 +460,11 @@ async function handleInboundMessage(payload) {
362
460
  return;
363
461
  }
364
462
  }
463
+ inboundReplyAuthority.authorize(pendingReplySource);
365
464
  // meta must be Record<string, string> — non-string values cause silent drops
366
465
  const meta = {
367
466
  conversation_id: payload.conversationId,
467
+ source_message_id: m.id,
368
468
  sender_id: m.senderId,
369
469
  sender_name: m.senderName || m.senderId,
370
470
  sender_type: m.senderType || 'human',
@@ -406,7 +506,7 @@ async function handleInboundMessage(payload) {
406
506
  id: m.id,
407
507
  attachments: m.attachments ?? [],
408
508
  }, {
409
- agentId: agentContext?.agentId ?? 'claude-code',
509
+ agentId: agentContextState.current?.agentId ?? 'claude-code',
410
510
  conversationId: payload.conversationId,
411
511
  });
412
512
  }
@@ -449,6 +549,7 @@ async function handleInboundMessage(payload) {
449
549
  console.error('[canon] Notification sent to Claude Code');
450
550
  }
451
551
  catch (err) {
552
+ inboundReplyAuthority.revoke(pendingReplySource);
452
553
  const msg = err instanceof Error ? err.message : String(err);
453
554
  console.error(`[canon] Failed to send notification: ${msg}`);
454
555
  }
@@ -469,6 +570,7 @@ async function startChannel() {
469
570
  await verifyResolvedAgentEnvironment(resolvedRuntime);
470
571
  }
471
572
  catch (err) {
573
+ agentContextState.settleUnavailable();
472
574
  resolvedRuntime?.lockHandle?.release();
473
575
  console.error(err instanceof Error ? err.message : err);
474
576
  return;
@@ -480,18 +582,21 @@ async function startChannel() {
480
582
  // Get agent identity — try /agents/me first, fall back to /agents/auth-token
481
583
  let agentId;
482
584
  try {
483
- agentContext = await client.getAgentMe();
484
- agentId = agentContext.agentId;
485
- console.error(`[canon] Connected as ${agentContext.displayName || agentId}`);
585
+ const context = await client.getAgentMe();
586
+ agentContextState.set(context);
587
+ agentId = context.agentId;
588
+ console.error(`[canon] Connected as ${context.displayName || agentId}`);
486
589
  }
487
590
  catch {
488
591
  // /agents/me may not be deployed — fall back to auth-token exchange
489
592
  try {
490
593
  const auth = await client.getAuthToken();
491
594
  agentId = auth.agentId;
595
+ agentContextState.settleUnavailable();
492
596
  console.error(`[canon] Authenticated as ${agentId}`);
493
597
  }
494
598
  catch (err) {
599
+ agentContextState.settleUnavailable();
495
600
  const msg = err instanceof Error ? err.message : String(err);
496
601
  console.error(`[canon] Failed to authenticate: ${msg}`);
497
602
  return;
@@ -513,7 +618,7 @@ async function startChannel() {
513
618
  runtime: 'claude-code',
514
619
  profile,
515
620
  agentId,
516
- agentName: agentContext?.displayName ?? profileAgentName,
621
+ agentName: agentContextState.current?.displayName ?? profileAgentName,
517
622
  cwd: process.cwd(),
518
623
  launchCommand: ['canon-channel-server'],
519
624
  pid: process.pid,
@@ -546,7 +651,7 @@ async function startChannel() {
546
651
  handler: {
547
652
  onMessage: handleInboundMessage,
548
653
  onAgentContext: (ctx) => {
549
- agentContext = ctx;
654
+ agentContextState.set(ctx);
550
655
  },
551
656
  onConnected: () => {
552
657
  console.error('[canon] SSE stream connected');
@@ -564,8 +669,8 @@ async function startChannel() {
564
669
  console.error(`[canon] SSE start error: ${msg}`);
565
670
  });
566
671
  // Start approval system
567
- if (agentContext?.ownerId) {
568
- approvalManager = new ApprovalManager(client, agentId, agentContext.ownerId);
672
+ if (agentContextState.current?.ownerId) {
673
+ approvalManager = new ApprovalManager(client, agentId, agentContextState.current.ownerId);
569
674
  approvalServer = new ApprovalHttpServer(approvalManager, () => approvalConversationId, {
570
675
  token: process.env.CANON_CLAUDE_APPROVAL_TOKEN,
571
676
  getIsOwnerTurn: () => triggerIsOwner,
@@ -590,9 +695,9 @@ export async function main() {
590
695
  // Graceful shutdown — release agent lock and clear session state
591
696
  const shutdown = () => {
592
697
  // Mark all conversations as inactive
593
- if (agentContext) {
698
+ if (agentContextState.current) {
594
699
  for (const convoId of conversationCache.keys()) {
595
- rtdb?.clearSessionState(convoId, agentContext.agentId).catch(() => { });
700
+ rtdb?.clearSessionState(convoId, agentContextState.current.agentId).catch(() => { });
596
701
  }
597
702
  }
598
703
  approvalManager?.dispose();