@canonmsg/claude-code-plugin 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env node
2
+ import { setDefaultResultOrder } from 'node:dns';
3
+ /**
4
+ * Canon channel MCP server for Claude Code.
5
+ *
6
+ * Connects to Canon's SSE stream for real-time inbound messages and exposes
7
+ * tools for Claude to reply, send messages, list conversations, and set
8
+ * typing indicators.
9
+ */
10
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
13
+ import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, writeSessionState, clearSessionState, renderCanonHostInboundContent, } from '@canonmsg/core';
14
+ import { materializeMessageMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
15
+ import { ApprovalHttpServer } from './approval-server.js';
16
+ import { runCli } from './cli-entry.js';
17
+ import { parseReplyArgs, parseSendContextualMessageArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
18
+ const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
19
+
20
+ USAGE
21
+ canon-channel-server
22
+
23
+ FLAGS
24
+ --help, -h Show this help
25
+ --version, -V Show package version
26
+
27
+ This command is normally launched by Claude Code as an MCP stdio server. For
28
+ phone-controlled local sessions, use canon-claude instead.`;
29
+ // ── MCP server ─────────────────────────────────────────────────────────
30
+ const server = new Server({ name: 'canon-channel', version: '0.1.0' }, {
31
+ capabilities: {
32
+ tools: {},
33
+ experimental: { 'claude/channel': {} },
34
+ },
35
+ });
36
+ let client = null;
37
+ let stream = null;
38
+ let agentContext = null;
39
+ let approvalManager = null;
40
+ let approvalServer = null;
41
+ const conversationCache = new Map();
42
+ const TURN_COMPLETE_METADATA = {
43
+ turnSemantics: 'turn_complete',
44
+ };
45
+ /** Last owner-visible conversation to route approval cards into. */
46
+ let approvalConversationId = null;
47
+ /** Whether the message that most recently triggered work came from the owner. */
48
+ let triggerIsOwner = true;
49
+ function getPrimaryAttachment(message) {
50
+ const attachment = message.attachments?.[0];
51
+ if (attachment?.url)
52
+ return attachment;
53
+ return null;
54
+ }
55
+ function renderInboundContent(message, materialized) {
56
+ return renderCanonHostInboundContent(message, materialized);
57
+ }
58
+ function toolArgumentError(error) {
59
+ return {
60
+ content: [{ type: 'text', text: `Invalid arguments: ${error}` }],
61
+ isError: true,
62
+ };
63
+ }
64
+ // ── Tool definitions ───────────────────────────────────────────────────
65
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
66
+ tools: [
67
+ {
68
+ name: 'reply',
69
+ description: 'Reply to a Canon conversation',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ conversation_id: {
74
+ type: 'string',
75
+ description: 'The conversation ID to reply to',
76
+ },
77
+ text: {
78
+ type: 'string',
79
+ description: 'Message text to send',
80
+ },
81
+ },
82
+ required: ['conversation_id', 'text'],
83
+ },
84
+ },
85
+ {
86
+ name: 'send_message',
87
+ description: 'Send a message to a Canon conversation (supports text, images, audio, video, files)',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {
91
+ conversation_id: {
92
+ type: 'string',
93
+ description: 'The conversation ID',
94
+ },
95
+ text: {
96
+ type: 'string',
97
+ description: 'Message text',
98
+ },
99
+ content_type: {
100
+ type: 'string',
101
+ enum: ['text', 'image', 'audio', 'video', 'file'],
102
+ description: 'Content type (default: text)',
103
+ },
104
+ image_url: {
105
+ type: 'string',
106
+ description: 'Image URL (when content_type is image)',
107
+ },
108
+ audio_url: {
109
+ type: 'string',
110
+ description: 'Audio URL (when content_type is audio)',
111
+ },
112
+ video_url: {
113
+ type: 'string',
114
+ description: 'Video URL (when content_type is video)',
115
+ },
116
+ file_url: {
117
+ type: 'string',
118
+ description: 'File URL (when content_type is file)',
119
+ },
120
+ media_path: {
121
+ type: 'string',
122
+ description: 'Local image/audio/video/file path to upload and send',
123
+ },
124
+ file_name: {
125
+ type: 'string',
126
+ description: 'Optional display filename for file attachments',
127
+ },
128
+ mime_type: {
129
+ type: 'string',
130
+ description: 'Optional MIME type for media_path uploads',
131
+ },
132
+ duration_ms: {
133
+ type: 'number',
134
+ description: 'Optional audio duration in milliseconds for media_path uploads',
135
+ },
136
+ },
137
+ required: ['conversation_id'],
138
+ },
139
+ },
140
+ {
141
+ name: 'send_contextual_message',
142
+ description: 'Send a message into another Canon conversation or contact with private cross-session self-context',
143
+ inputSchema: {
144
+ type: 'object',
145
+ properties: {
146
+ source_conversation_id: {
147
+ type: 'string',
148
+ description: 'The Canon conversation where this cross-session intent originated',
149
+ },
150
+ target_conversation_id: {
151
+ type: 'string',
152
+ description: 'The destination Canon conversation to send into',
153
+ },
154
+ target_user_id: {
155
+ type: 'string',
156
+ description: 'The destination Canon user/contact to reach out to',
157
+ },
158
+ text: {
159
+ type: 'string',
160
+ description: 'Message text to send',
161
+ },
162
+ self_context: {
163
+ type: 'object',
164
+ description: 'Required { type: "cross_session", context: "why I am saying this here" }',
165
+ },
166
+ session_selection: {
167
+ type: 'string',
168
+ enum: ['new', 'continue_latest', 'continue_or_create'],
169
+ description: 'Optional direct-agent session behavior when target_user_id is used',
170
+ },
171
+ message_options: {
172
+ type: 'object',
173
+ description: 'Optional Canon send options for the contextual outbound message',
174
+ },
175
+ },
176
+ required: ['source_conversation_id', 'text', 'self_context'],
177
+ },
178
+ },
179
+ {
180
+ name: 'list_conversations',
181
+ description: "List Canon conversations this agent participates in",
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ limit: {
186
+ type: 'number',
187
+ description: 'Max conversations to return (default: 20)',
188
+ },
189
+ },
190
+ },
191
+ },
192
+ {
193
+ name: 'list_agents',
194
+ description: 'List registered Canon agent profiles and their lock status',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {},
198
+ },
199
+ },
200
+ {
201
+ name: 'set_typing',
202
+ description: 'Show or hide typing indicator in a Canon conversation',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ conversation_id: {
207
+ type: 'string',
208
+ description: 'The conversation ID',
209
+ },
210
+ typing: {
211
+ type: 'boolean',
212
+ description: 'Whether to show typing indicator',
213
+ },
214
+ status: {
215
+ type: 'string',
216
+ enum: ['typing', 'thinking'],
217
+ description: 'Typing status (default: typing). Use "thinking" when processing/reasoning.',
218
+ },
219
+ },
220
+ required: ['conversation_id', 'typing'],
221
+ },
222
+ },
223
+ ],
224
+ }));
225
+ // ── Tool handlers ──────────────────────────────────────────────────────
226
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
227
+ if (!client) {
228
+ return {
229
+ content: [{ type: 'text', text: 'Canon not connected' }],
230
+ isError: true,
231
+ };
232
+ }
233
+ const args = request.params.arguments;
234
+ switch (request.params.name) {
235
+ case 'reply': {
236
+ const parsed = parseReplyArgs(args);
237
+ if (!parsed.ok)
238
+ return toolArgumentError(parsed.error);
239
+ const { conversationId, text } = parsed.value;
240
+ const result = await client.sendMessage(conversationId, text, {
241
+ metadata: TURN_COMPLETE_METADATA,
242
+ });
243
+ return {
244
+ content: [
245
+ {
246
+ type: 'text',
247
+ text: `Message sent (${result.messageId})`,
248
+ },
249
+ ],
250
+ };
251
+ }
252
+ case 'send_message': {
253
+ const parsed = parseSendMessageArgs(args);
254
+ if (!parsed.ok)
255
+ return toolArgumentError(parsed.error);
256
+ const { conversationId, text, contentType, imageUrl, audioUrl, videoUrl, fileUrl, mediaPath, fileName, mimeType, durationMs, } = parsed.value;
257
+ const opts = {};
258
+ const metadata = TURN_COMPLETE_METADATA;
259
+ if (mediaPath) {
260
+ const result = await sendMediaFileMessage(client, conversationId, mediaPath, text, {
261
+ ...(fileName ? { fileName } : {}),
262
+ ...(mimeType ? { mimeType } : {}),
263
+ ...(durationMs != null ? { durationMs } : {}),
264
+ metadata,
265
+ });
266
+ return {
267
+ content: [
268
+ {
269
+ type: 'text',
270
+ text: `Message sent (${result.messageId})`,
271
+ },
272
+ ],
273
+ };
274
+ }
275
+ if (contentType === 'image' && imageUrl) {
276
+ opts.contentType = 'image';
277
+ opts.attachments = [{ kind: 'image', url: imageUrl }];
278
+ }
279
+ else if (contentType === 'audio' && audioUrl) {
280
+ opts.contentType = 'audio';
281
+ opts.attachments = [{
282
+ kind: 'audio',
283
+ url: audioUrl,
284
+ ...(durationMs != null ? { durationMs } : {}),
285
+ ...(mimeType ? { mimeType } : {}),
286
+ }];
287
+ }
288
+ else if (contentType === 'video' && videoUrl) {
289
+ opts.contentType = 'video';
290
+ opts.attachments = [{
291
+ kind: 'video',
292
+ url: videoUrl,
293
+ ...(mimeType ? { mimeType } : {}),
294
+ ...(durationMs != null ? { durationMs } : {}),
295
+ }];
296
+ }
297
+ else if (contentType === 'file' || fileUrl) {
298
+ opts.contentType = 'file';
299
+ if (fileUrl) {
300
+ opts.attachments = [{
301
+ kind: 'file',
302
+ url: fileUrl,
303
+ ...(fileName ? { fileName } : {}),
304
+ ...(mimeType ? { mimeType } : {}),
305
+ }];
306
+ }
307
+ }
308
+ else {
309
+ opts.contentType = contentType;
310
+ }
311
+ opts.metadata = {
312
+ ...(opts.metadata ?? {}),
313
+ ...metadata,
314
+ };
315
+ const result = await client.sendMessage(conversationId, text, opts);
316
+ return {
317
+ content: [
318
+ {
319
+ type: 'text',
320
+ text: `Message sent (${result.messageId})`,
321
+ },
322
+ ],
323
+ };
324
+ }
325
+ case 'send_contextual_message': {
326
+ const parsed = parseSendContextualMessageArgs(args);
327
+ if (!parsed.ok)
328
+ return toolArgumentError(parsed.error);
329
+ const { sourceConversationId, targetConversationId, targetUserId, text, selfContext, sessionSelection, messageOptions, } = parsed.value;
330
+ const contextualMessageOptions = messageOptions;
331
+ const result = await client.sendContextualMessage({
332
+ sourceConversationId,
333
+ ...(targetConversationId ? { targetConversationId } : {}),
334
+ ...(targetUserId ? { targetUserId } : {}),
335
+ text,
336
+ selfContext,
337
+ ...(sessionSelection ? { sessionSelection } : {}),
338
+ ...(contextualMessageOptions
339
+ ? {
340
+ messageOptions: {
341
+ ...contextualMessageOptions,
342
+ metadata: {
343
+ ...(contextualMessageOptions.metadata ?? {}),
344
+ ...TURN_COMPLETE_METADATA,
345
+ },
346
+ },
347
+ }
348
+ : { messageOptions: { metadata: TURN_COMPLETE_METADATA } }),
349
+ });
350
+ return {
351
+ content: [
352
+ {
353
+ type: 'text',
354
+ text: result.status === 'messaged'
355
+ ? `Contextual message sent (${result.messageId}) via self-context ${result.selfContextId}`
356
+ : `Contextual message not sent: ${result.status}`,
357
+ },
358
+ ],
359
+ };
360
+ }
361
+ case 'list_conversations': {
362
+ const rawArgs = args && typeof args === 'object' ? args : {};
363
+ const limit = typeof rawArgs.limit === 'number' && Number.isFinite(rawArgs.limit)
364
+ ? Math.max(1, Math.min(100, Math.floor(rawArgs.limit)))
365
+ : 20;
366
+ const convos = await client.getConversations();
367
+ const slice = convos.slice(0, limit);
368
+ const summary = slice.map((c) => ({
369
+ id: c.id,
370
+ type: c.type,
371
+ name: c.name,
372
+ members: c.memberIds.length,
373
+ lastMessage: c.lastMessage?.text?.slice(0, 100) || null,
374
+ }));
375
+ return {
376
+ content: [
377
+ { type: 'text', text: JSON.stringify(summary, null, 2) },
378
+ ],
379
+ };
380
+ }
381
+ case 'list_agents': {
382
+ const profiles = loadProfiles();
383
+ const entries = Object.entries(profiles).map(([name, p]) => {
384
+ const lock = isProfileLocked(name);
385
+ return {
386
+ profile: name,
387
+ agentName: p.agentName,
388
+ agentId: p.agentId,
389
+ status: getActiveProfile() === name ? 'active (this session)' : lock.locked ? `in use (PID ${lock.pid})` : 'available',
390
+ };
391
+ });
392
+ if (entries.length === 0) {
393
+ return {
394
+ content: [{ type: 'text', text: 'No agents registered. Run /canon-register to register one.' }],
395
+ };
396
+ }
397
+ return {
398
+ content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
399
+ };
400
+ }
401
+ case 'set_typing': {
402
+ const parsed = parseSetTypingArgs(args);
403
+ if (!parsed.ok)
404
+ return toolArgumentError(parsed.error);
405
+ const { conversationId, typing, status } = parsed.value;
406
+ await client.setTyping(conversationId, typing, status);
407
+ return {
408
+ content: [
409
+ {
410
+ type: 'text',
411
+ text: typing ? 'Typing indicator shown' : 'Typing indicator hidden',
412
+ },
413
+ ],
414
+ };
415
+ }
416
+ default:
417
+ return {
418
+ content: [
419
+ { type: 'text', text: `Unknown tool: ${request.params.name}` },
420
+ ],
421
+ isError: true,
422
+ };
423
+ }
424
+ });
425
+ // ── Inbound message handler ────────────────────────────────────────────
426
+ async function handleInboundMessage(payload) {
427
+ const m = payload.message;
428
+ const convo = conversationCache.get(payload.conversationId);
429
+ // Track turn provenance separately from the owner-visible approval route.
430
+ // Non-owner direct chats do not include the owner, so approval cards still go
431
+ // to the last owner conversation while the hook bypasses cached session rules.
432
+ triggerIsOwner = m.isOwner === true;
433
+ if (triggerIsOwner) {
434
+ approvalConversationId = payload.conversationId;
435
+ }
436
+ // Route to approval manager first — if consumed, don't forward to Claude
437
+ if (approvalManager) {
438
+ const consumed = approvalManager.handleMessage(payload.conversationId, {
439
+ senderId: m.senderId,
440
+ metadata: m.metadata,
441
+ });
442
+ if (consumed) {
443
+ console.error(`[canon] Approval reply consumed from ${m.senderName || m.senderId}`);
444
+ return;
445
+ }
446
+ }
447
+ // meta must be Record<string, string> — non-string values cause silent drops
448
+ const meta = {
449
+ conversation_id: payload.conversationId,
450
+ sender_id: m.senderId,
451
+ sender_name: m.senderName || m.senderId,
452
+ sender_type: m.senderType || 'human',
453
+ is_owner: String(m.isOwner ?? false),
454
+ chat_type: convo?.type || 'direct',
455
+ content_type: m.contentType || 'text',
456
+ ts: new Date().toISOString(),
457
+ };
458
+ if (payload.behavior) {
459
+ meta.behavior_style = payload.behavior.participation.style;
460
+ meta.behavior_allow_agent_to_agent = String(payload.behavior.participation.allowAgentToAgent);
461
+ meta.behavior_allow_long_running = String(payload.behavior.participation.allowLongRunningCollaboration);
462
+ meta.behavior_require_group_mention = String(payload.behavior.participation.requireMentionForGroupReplies);
463
+ if (payload.behavior.participation.maxConsecutiveAgentTurns != null) {
464
+ meta.behavior_max_consecutive_agent_turns = String(payload.behavior.participation.maxConsecutiveAgentTurns);
465
+ }
466
+ if (payload.behavior.instructions.length > 0) {
467
+ meta.behavior_instructions = payload.behavior.instructions.join('\n\n');
468
+ }
469
+ }
470
+ if (Array.isArray(payload.selfContexts) && payload.selfContexts.length > 0) {
471
+ if (payload.activeSelfContextId) {
472
+ meta.active_self_context_id = payload.activeSelfContextId;
473
+ }
474
+ meta.self_context_ids = payload.selfContexts
475
+ .map((selfContext) => selfContext.id)
476
+ .join('\n');
477
+ meta.self_context_types = payload.selfContexts
478
+ .map((selfContext) => selfContext.type)
479
+ .join('\n');
480
+ meta.self_context = payload.selfContexts
481
+ .map((selfContext) => selfContext.context)
482
+ .join('\n\n');
483
+ }
484
+ let materialized = [];
485
+ if (m.id && m.attachments?.length) {
486
+ try {
487
+ materialized = await materializeMessageMedia({
488
+ id: m.id,
489
+ attachments: m.attachments ?? [],
490
+ }, {
491
+ agentId: agentContext?.agentId ?? 'claude-code',
492
+ conversationId: payload.conversationId,
493
+ });
494
+ }
495
+ catch (error) {
496
+ console.error(`[canon] Failed to materialize inbound media: ${error instanceof Error ? error.message : error}`);
497
+ }
498
+ }
499
+ const primaryAttachment = getPrimaryAttachment(m);
500
+ if (m.attachments?.length) {
501
+ meta.attachments_json = JSON.stringify(m.attachments);
502
+ }
503
+ if (primaryAttachment?.url) {
504
+ meta.attachment_kind = primaryAttachment.kind;
505
+ meta.attachment_url = primaryAttachment.url;
506
+ }
507
+ if (primaryAttachment?.fileName)
508
+ meta.attachment_file_name = primaryAttachment.fileName;
509
+ if (primaryAttachment?.mimeType)
510
+ meta.attachment_mime_type = primaryAttachment.mimeType;
511
+ if (primaryAttachment?.durationMs != null)
512
+ meta.attachment_duration_ms = String(primaryAttachment.durationMs);
513
+ const primaryMaterialized = materialized.find((attachment) => attachment.index === 0);
514
+ if (primaryMaterialized?.path)
515
+ meta.attachment_path = primaryMaterialized.path;
516
+ if (materialized.length > 0) {
517
+ meta.attachment_paths = materialized
518
+ .map((attachment) => attachment.path)
519
+ .join('\n');
520
+ }
521
+ const renderedContent = renderInboundContent(m, materialized);
522
+ console.error(`[canon] Inbound message from ${meta.sender_name}: "${renderedContent}"`);
523
+ try {
524
+ await server.notification({
525
+ method: 'notifications/claude/channel',
526
+ params: {
527
+ content: renderedContent,
528
+ meta,
529
+ },
530
+ });
531
+ console.error('[canon] Notification sent to Claude Code');
532
+ }
533
+ catch (err) {
534
+ const msg = err instanceof Error ? err.message : String(err);
535
+ console.error(`[canon] Failed to send notification: ${msg}`);
536
+ }
537
+ }
538
+ // ── Start ──────────────────────────────────────────────────────────────
539
+ async function startChannel() {
540
+ let apiKey;
541
+ let profile;
542
+ let baseUrl;
543
+ let profileAgentName;
544
+ try {
545
+ const resolved = resolveCanonAgent({ logPrefix: '[canon]', expectedClientType: 'claude-code' });
546
+ apiKey = resolved.apiKey;
547
+ profile = resolved.profile;
548
+ baseUrl = resolved.baseUrl;
549
+ profileAgentName = resolved.agentName;
550
+ }
551
+ catch (err) {
552
+ console.error(err instanceof Error ? err.message : err);
553
+ return;
554
+ }
555
+ if (profile) {
556
+ console.error(`[canon] Using agent profile: ${profile}`);
557
+ }
558
+ client = new CanonClient(apiKey, baseUrl);
559
+ // Get agent identity — try /agents/me first, fall back to /agents/auth-token
560
+ let agentId;
561
+ try {
562
+ agentContext = await client.getAgentMe();
563
+ agentId = agentContext.agentId;
564
+ console.error(`[canon] Connected as ${agentContext.displayName || agentId}`);
565
+ }
566
+ catch {
567
+ // /agents/me may not be deployed — fall back to auth-token exchange
568
+ try {
569
+ const auth = await client.getAuthToken();
570
+ agentId = auth.agentId;
571
+ console.error(`[canon] Authenticated as ${agentId}`);
572
+ }
573
+ catch (err) {
574
+ const msg = err instanceof Error ? err.message : String(err);
575
+ console.error(`[canon] Failed to authenticate: ${msg}`);
576
+ return;
577
+ }
578
+ }
579
+ // Initialize RTDB auth for session state reporting
580
+ initRTDBAuth(client);
581
+ const runtimeId = buildLocalRuntimeId({
582
+ runtime: 'claude-code',
583
+ profile,
584
+ cwd: process.cwd(),
585
+ launchCommand: ['canon-channel-server'],
586
+ });
587
+ upsertLocalRuntimeEntry({
588
+ id: runtimeId,
589
+ runtime: 'claude-code',
590
+ profile,
591
+ agentId,
592
+ agentName: agentContext?.displayName ?? profileAgentName,
593
+ cwd: process.cwd(),
594
+ launchCommand: ['canon-channel-server'],
595
+ pid: process.pid,
596
+ status: 'embedded',
597
+ reviveCapability: 'embedded',
598
+ surfaceMode: 'channel',
599
+ lastStartedAt: new Date().toISOString(),
600
+ lastHeartbeatAt: new Date().toISOString(),
601
+ });
602
+ // Populate conversation cache
603
+ try {
604
+ const convos = await client.getConversations();
605
+ for (const c of convos)
606
+ conversationCache.set(c.id, c);
607
+ console.error(`[canon] Loaded ${convos.length} conversations`);
608
+ }
609
+ catch {
610
+ // Non-fatal — will be populated as messages arrive
611
+ }
612
+ // Write initial session state
613
+ const initialState = { cwd: process.cwd(), isActive: true };
614
+ for (const convoId of conversationCache.keys()) {
615
+ writeSessionState(convoId, agentId, initialState).catch((err) => console.error(`[canon] Failed to write initial state for ${convoId}:`, err));
616
+ }
617
+ // Start SSE stream
618
+ stream = new CanonStream({
619
+ apiKey,
620
+ agentId,
621
+ handler: {
622
+ onMessage: handleInboundMessage,
623
+ onAgentContext: (ctx) => {
624
+ agentContext = ctx;
625
+ },
626
+ onConnected: () => {
627
+ console.error('[canon] SSE stream connected');
628
+ },
629
+ onDisconnected: () => {
630
+ console.error('[canon] SSE stream disconnected');
631
+ },
632
+ onError: (err) => {
633
+ console.error(`[canon] SSE error: ${err.message}`);
634
+ },
635
+ },
636
+ });
637
+ stream.start().catch((err) => {
638
+ const msg = err instanceof Error ? err.message : String(err);
639
+ console.error(`[canon] SSE start error: ${msg}`);
640
+ });
641
+ // Start approval system
642
+ if (agentContext?.ownerId) {
643
+ approvalManager = new ApprovalManager(client, agentId, agentContext.ownerId);
644
+ approvalServer = new ApprovalHttpServer(approvalManager, () => approvalConversationId, {
645
+ token: process.env.CANON_CLAUDE_APPROVAL_TOKEN,
646
+ getIsOwnerTurn: () => triggerIsOwner,
647
+ });
648
+ approvalServer.start().catch((err) => {
649
+ const msg = err instanceof Error ? err.message : String(err);
650
+ console.error(`[canon] Approval server error: ${msg}`);
651
+ });
652
+ console.error('[canon] Approval hooks must send Authorization: Bearer <token> or x-canon-approval-token.');
653
+ if (!process.env.CANON_CLAUDE_APPROVAL_TOKEN) {
654
+ console.error(`[canon] Generated local approval token: ${approvalServer.authToken}`);
655
+ }
656
+ }
657
+ }
658
+ // ── Main ───────────────────────────────────────────────────────────────
659
+ export async function main() {
660
+ setDefaultResultOrder('ipv4first');
661
+ const transport = new StdioServerTransport();
662
+ await server.connect(transport);
663
+ // Start the Canon channel after MCP connection is established
664
+ await startChannel();
665
+ // Graceful shutdown — release agent lock and clear session state
666
+ const shutdown = () => {
667
+ // Mark all conversations as inactive
668
+ if (agentContext) {
669
+ for (const convoId of conversationCache.keys()) {
670
+ clearSessionState(convoId, agentContext.agentId).catch(() => { });
671
+ }
672
+ }
673
+ approvalManager?.dispose();
674
+ approvalServer?.stop().catch(() => { });
675
+ stream?.stop();
676
+ const runtimeId = buildLocalRuntimeId({
677
+ runtime: 'claude-code',
678
+ profile: getActiveProfile(),
679
+ cwd: process.cwd(),
680
+ launchCommand: ['canon-channel-server'],
681
+ });
682
+ markLocalRuntimeStopped(runtimeId);
683
+ const ap = getActiveProfile();
684
+ if (ap)
685
+ releaseLock(ap);
686
+ getActiveProfileLock()?.release();
687
+ process.exit(0);
688
+ };
689
+ process.on('SIGINT', shutdown);
690
+ process.on('SIGTERM', shutdown);
691
+ process.on('SIGHUP', shutdown);
692
+ }
693
+ runCli(import.meta.url, main, (err) => {
694
+ console.error('[canon] Fatal error:', err);
695
+ process.exit(1);
696
+ }, {
697
+ name: 'canon-channel-server',
698
+ help: HELP,
699
+ });