@canonmsg/claude-code-plugin 0.3.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { setDefaultResultOrder } from 'node:dns';
3
+ setDefaultResultOrder('ipv4first');
4
+ /**
5
+ * CLI script for Canon agent registration.
6
+ * Invoked by the /canon-register skill.
7
+ *
8
+ * Usage:
9
+ * node register.js --name "My Agent" --description "What it does" --phone "+15551234567" [--profile "my-agent"]
10
+ *
11
+ * On approval, saves the agent to ~/.canon/agents.json under the profile name.
12
+ */
13
+ import { registerAndWaitForApproval } from '@canonmsg/core';
14
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { parseArgs } from 'node:util';
18
+ const CANON_DIR = join(homedir(), '.canon');
19
+ const AGENTS_PATH = join(CANON_DIR, 'agents.json');
20
+ const { values } = parseArgs({
21
+ options: {
22
+ name: { type: 'string' },
23
+ description: { type: 'string' },
24
+ phone: { type: 'string' },
25
+ profile: { type: 'string' },
26
+ 'base-url': { type: 'string' },
27
+ },
28
+ strict: true,
29
+ });
30
+ if (!values.name || !values.description || !values.phone) {
31
+ console.error('Usage: node register.js --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]');
32
+ process.exit(1);
33
+ }
34
+ // Default profile name: sanitized agent name
35
+ const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
36
+ console.log(`Registering agent "${values.name}" (profile: ${profileName})...`);
37
+ const result = await registerAndWaitForApproval({
38
+ name: values.name,
39
+ description: values.description,
40
+ ownerPhone: values.phone,
41
+ developerInfo: 'Claude Code plugin',
42
+ clientType: 'claude-code',
43
+ baseUrl: values['base-url'],
44
+ }, {
45
+ onSubmitted: (requestId) => {
46
+ console.log(`Registration submitted (request ID: ${requestId}).`);
47
+ console.log('Waiting for approval in Canon app...');
48
+ },
49
+ onPollUpdate: () => {
50
+ process.stdout.write('.');
51
+ },
52
+ });
53
+ console.log(''); // newline after dots
54
+ switch (result.status) {
55
+ case 'approved': {
56
+ // Save to ~/.canon/agents.json
57
+ mkdirSync(CANON_DIR, { recursive: true });
58
+ let profiles = {};
59
+ try {
60
+ profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
61
+ }
62
+ catch {
63
+ // File doesn't exist yet
64
+ }
65
+ profiles[profileName] = {
66
+ apiKey: result.apiKey,
67
+ agentId: result.agentId,
68
+ agentName: result.agentName,
69
+ registeredAt: new Date().toISOString(),
70
+ };
71
+ writeFileSync(AGENTS_PATH, JSON.stringify(profiles, null, 2));
72
+ console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
73
+ console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
74
+ console.log('Restart Claude Code to connect.');
75
+ break;
76
+ }
77
+ case 'rejected':
78
+ console.log('Registration was rejected.');
79
+ process.exit(1);
80
+ break;
81
+ case 'timeout':
82
+ console.log('Registration timed out (5 minutes). Try again later.');
83
+ process.exit(1);
84
+ break;
85
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Lightweight RTDB REST helper for session state.
3
+ *
4
+ * Uses Firebase Realtime Database REST API to avoid adding firebase-admin
5
+ * as a dependency to the MCP plugin. The custom token from /agents/auth-token
6
+ * is exchanged for a Firebase ID token before use with RTDB REST.
7
+ */
8
+ import { type CanonClient } from '@canonapp/core';
9
+ export interface SessionStatePayload {
10
+ model?: string;
11
+ permissionMode?: string;
12
+ effort?: string;
13
+ cwd?: string;
14
+ hostMode?: boolean;
15
+ isActive: boolean;
16
+ updatedAt: {
17
+ '.sv': 'timestamp';
18
+ };
19
+ }
20
+ /** Must be called once before any RTDB operations. */
21
+ export declare function initRTDBAuth(client: CanonClient): void;
22
+ /** Generic RTDB REST write (PUT). */
23
+ export declare function rtdbWrite(path: string, data: unknown): Promise<void>;
24
+ /** Generic RTDB REST read (GET). */
25
+ export declare function rtdbRead(path: string): Promise<unknown>;
26
+ /**
27
+ * Write session state to RTDB via REST API.
28
+ * Path: /session-state/{conversationId}/{agentId}
29
+ */
30
+ export declare function writeSessionState(conversationId: string, agentId: string, state: Omit<SessionStatePayload, 'updatedAt'>): Promise<void>;
31
+ /**
32
+ * Clear session state from RTDB (full overwrite with isActive: false).
33
+ */
34
+ export declare function clearSessionState(conversationId: string, agentId: string): Promise<void>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Lightweight RTDB REST helper for session state.
3
+ *
4
+ * Uses Firebase Realtime Database REST API to avoid adding firebase-admin
5
+ * as a dependency to the MCP plugin. The custom token from /agents/auth-token
6
+ * is exchanged for a Firebase ID token before use with RTDB REST.
7
+ */
8
+ import { DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from '@canonapp/core';
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;
18
+ }
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) };
35
+ }
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)
41
+ return null;
42
+ try {
43
+ const auth = await tokenClient.getAuthToken();
44
+ const { idToken, expiresIn } = await exchangeCustomTokenForIdToken(auth.token);
45
+ cachedIdToken = idToken;
46
+ idTokenExpiresAt = Date.now() + expiresIn * 1000;
47
+ }
48
+ catch (err) {
49
+ console.error('[canon] RTDB token refresh failed:', err);
50
+ return cachedIdToken; // Return stale token as fallback
51
+ }
52
+ }
53
+ return cachedIdToken;
54
+ }
55
+ // ── RTDB operations ───────────────────────────────────────────────────
56
+ /** Generic RTDB REST write (PUT). */
57
+ 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
+ }
71
+ }
72
+ /** Generic RTDB REST read (GET). */
73
+ 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();
82
+ }
83
+ /**
84
+ * Write session state to RTDB via REST API.
85
+ * Path: /session-state/{conversationId}/{agentId}
86
+ */
87
+ 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
+ }
105
+ }
106
+ /**
107
+ * Clear session state from RTDB (full overwrite with isActive: false).
108
+ */
109
+ 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
+ }
128
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ import { setDefaultResultOrder } from 'node:dns';
3
+ setDefaultResultOrder('ipv4first');
4
+ /**
5
+ * Canon channel MCP server for Claude Code.
6
+ *
7
+ * Connects to Canon's SSE stream for real-time inbound messages and exposes
8
+ * tools for Claude to reply, send messages, list conversations, and set
9
+ * typing indicators.
10
+ */
11
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
14
+ import { CanonClient, CanonStream, ApprovalManager, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, writeSessionState, clearSessionState, } from '@canonmsg/core';
15
+ import { ApprovalHttpServer } from './approval-server.js';
16
+ // ── MCP server ─────────────────────────────────────────────────────────
17
+ const server = new Server({ name: 'canon-channel', version: '0.1.0' }, {
18
+ capabilities: {
19
+ tools: {},
20
+ experimental: { 'claude/channel': {} },
21
+ },
22
+ });
23
+ let client = null;
24
+ let stream = null;
25
+ let agentContext = null;
26
+ let approvalManager = null;
27
+ let approvalServer = null;
28
+ const conversationCache = new Map();
29
+ /** The conversation that most recently triggered work (for approval routing) */
30
+ let triggerConversationId = null;
31
+ // ── Tool definitions ───────────────────────────────────────────────────
32
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
33
+ tools: [
34
+ {
35
+ name: 'reply',
36
+ description: 'Reply to a Canon conversation',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: {
40
+ conversation_id: {
41
+ type: 'string',
42
+ description: 'The conversation ID to reply to',
43
+ },
44
+ text: {
45
+ type: 'string',
46
+ description: 'Message text to send',
47
+ },
48
+ },
49
+ required: ['conversation_id', 'text'],
50
+ },
51
+ },
52
+ {
53
+ name: 'send_message',
54
+ description: 'Send a message to a Canon conversation (supports text, images, audio)',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ conversation_id: {
59
+ type: 'string',
60
+ description: 'The conversation ID',
61
+ },
62
+ text: {
63
+ type: 'string',
64
+ description: 'Message text',
65
+ },
66
+ content_type: {
67
+ type: 'string',
68
+ enum: ['text', 'image', 'audio'],
69
+ description: 'Content type (default: text)',
70
+ },
71
+ image_url: {
72
+ type: 'string',
73
+ description: 'Image URL (when content_type is image)',
74
+ },
75
+ audio_url: {
76
+ type: 'string',
77
+ description: 'Audio URL (when content_type is audio)',
78
+ },
79
+ },
80
+ required: ['conversation_id', 'text'],
81
+ },
82
+ },
83
+ {
84
+ name: 'list_conversations',
85
+ description: "List Canon conversations this agent participates in",
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ limit: {
90
+ type: 'number',
91
+ description: 'Max conversations to return (default: 20)',
92
+ },
93
+ },
94
+ },
95
+ },
96
+ {
97
+ name: 'list_agents',
98
+ description: 'List registered Canon agent profiles and their lock status',
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {},
102
+ },
103
+ },
104
+ {
105
+ name: 'set_typing',
106
+ description: 'Show or hide typing indicator in a Canon conversation',
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ conversation_id: {
111
+ type: 'string',
112
+ description: 'The conversation ID',
113
+ },
114
+ typing: {
115
+ type: 'boolean',
116
+ description: 'Whether to show typing indicator',
117
+ },
118
+ status: {
119
+ type: 'string',
120
+ enum: ['typing', 'thinking'],
121
+ description: 'Typing status (default: typing). Use "thinking" when processing/reasoning.',
122
+ },
123
+ },
124
+ required: ['conversation_id', 'typing'],
125
+ },
126
+ },
127
+ ],
128
+ }));
129
+ // ── Tool handlers ──────────────────────────────────────────────────────
130
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
131
+ if (!client) {
132
+ return {
133
+ content: [{ type: 'text', text: 'Canon not connected' }],
134
+ isError: true,
135
+ };
136
+ }
137
+ const args = request.params.arguments;
138
+ switch (request.params.name) {
139
+ case 'reply': {
140
+ const conversationId = args.conversation_id;
141
+ const text = args.text;
142
+ const result = await client.sendMessage(conversationId, text);
143
+ return {
144
+ content: [
145
+ {
146
+ type: 'text',
147
+ text: `Message sent (${result.messageId})`,
148
+ },
149
+ ],
150
+ };
151
+ }
152
+ case 'send_message': {
153
+ const conversationId = args.conversation_id;
154
+ const text = args.text;
155
+ const contentType = args.content_type || 'text';
156
+ const opts = {
157
+ contentType: contentType,
158
+ };
159
+ if (args.image_url)
160
+ opts.imageUrl = args.image_url;
161
+ if (args.audio_url)
162
+ opts.audioUrl = args.audio_url;
163
+ const result = await client.sendMessage(conversationId, text, opts);
164
+ return {
165
+ content: [
166
+ {
167
+ type: 'text',
168
+ text: `Message sent (${result.messageId})`,
169
+ },
170
+ ],
171
+ };
172
+ }
173
+ case 'list_conversations': {
174
+ const limit = args.limit || 20;
175
+ const convos = await client.getConversations();
176
+ const slice = convos.slice(0, limit);
177
+ const summary = slice.map((c) => ({
178
+ id: c.id,
179
+ type: c.type,
180
+ name: c.name,
181
+ members: c.memberIds.length,
182
+ lastMessage: c.lastMessage?.text?.slice(0, 100) || null,
183
+ }));
184
+ return {
185
+ content: [
186
+ { type: 'text', text: JSON.stringify(summary, null, 2) },
187
+ ],
188
+ };
189
+ }
190
+ case 'list_agents': {
191
+ const profiles = loadProfiles();
192
+ const entries = Object.entries(profiles).map(([name, p]) => {
193
+ const lock = isProfileLocked(name);
194
+ return {
195
+ profile: name,
196
+ agentName: p.agentName,
197
+ agentId: p.agentId,
198
+ status: getActiveProfile() === name ? 'active (this session)' : lock.locked ? `in use (PID ${lock.pid})` : 'available',
199
+ };
200
+ });
201
+ if (entries.length === 0) {
202
+ return {
203
+ content: [{ type: 'text', text: 'No agents registered. Run /canon-register to register one.' }],
204
+ };
205
+ }
206
+ return {
207
+ content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
208
+ };
209
+ }
210
+ case 'set_typing': {
211
+ const conversationId = args.conversation_id;
212
+ const typing = args.typing;
213
+ const status = args.status;
214
+ await client.setTyping(conversationId, typing, status);
215
+ return {
216
+ content: [
217
+ {
218
+ type: 'text',
219
+ text: typing ? 'Typing indicator shown' : 'Typing indicator hidden',
220
+ },
221
+ ],
222
+ };
223
+ }
224
+ default:
225
+ return {
226
+ content: [
227
+ { type: 'text', text: `Unknown tool: ${request.params.name}` },
228
+ ],
229
+ isError: true,
230
+ };
231
+ }
232
+ });
233
+ // ── Inbound message handler ────────────────────────────────────────────
234
+ async function handleInboundMessage(payload) {
235
+ const m = payload.message;
236
+ const convo = conversationCache.get(payload.conversationId);
237
+ // Track trigger conversation — updated when the owner sends a message
238
+ if (m.isOwner) {
239
+ triggerConversationId = payload.conversationId;
240
+ }
241
+ // Route to approval manager first — if consumed, don't forward to Claude
242
+ if (approvalManager) {
243
+ const consumed = approvalManager.handleMessage(payload.conversationId, {
244
+ senderId: m.senderId,
245
+ text: m.text ?? undefined,
246
+ metadata: m.metadata,
247
+ });
248
+ if (consumed) {
249
+ console.error(`[canon] Approval reply consumed from ${m.senderName || m.senderId}`);
250
+ return;
251
+ }
252
+ }
253
+ // meta must be Record<string, string> — non-string values cause silent drops
254
+ const meta = {
255
+ conversation_id: payload.conversationId,
256
+ sender_id: m.senderId,
257
+ sender_name: m.senderName || m.senderId,
258
+ sender_type: m.senderType || 'human',
259
+ is_owner: String(m.isOwner ?? false),
260
+ chat_type: convo?.type || 'direct',
261
+ content_type: m.contentType || 'text',
262
+ ts: new Date().toISOString(),
263
+ };
264
+ if (m.imageUrl)
265
+ meta.image_url = m.imageUrl;
266
+ if (m.audioUrl)
267
+ meta.audio_url = m.audioUrl;
268
+ console.error(`[canon] Inbound message from ${meta.sender_name}: "${m.text}"`);
269
+ try {
270
+ await server.notification({
271
+ method: 'notifications/claude/channel',
272
+ params: {
273
+ content: m.text || '',
274
+ meta,
275
+ },
276
+ });
277
+ console.error('[canon] Notification sent to Claude Code');
278
+ }
279
+ catch (err) {
280
+ const msg = err instanceof Error ? err.message : String(err);
281
+ console.error(`[canon] Failed to send notification: ${msg}`);
282
+ }
283
+ }
284
+ // ── Start ──────────────────────────────────────────────────────────────
285
+ async function startChannel() {
286
+ let apiKey;
287
+ let profile;
288
+ try {
289
+ const resolved = resolveCanonAgent({ logPrefix: '[canon]' });
290
+ apiKey = resolved.apiKey;
291
+ profile = resolved.profile;
292
+ }
293
+ catch (err) {
294
+ console.error(err instanceof Error ? err.message : err);
295
+ return;
296
+ }
297
+ if (profile) {
298
+ console.error(`[canon] Using agent profile: ${profile}`);
299
+ }
300
+ client = new CanonClient(apiKey);
301
+ // Get agent identity — try /agents/me first, fall back to /agents/auth-token
302
+ let agentId;
303
+ try {
304
+ agentContext = await client.getAgentMe();
305
+ agentId = agentContext.agentId;
306
+ console.error(`[canon] Connected as ${agentContext.displayName || agentId}`);
307
+ }
308
+ catch {
309
+ // /agents/me may not be deployed — fall back to auth-token exchange
310
+ try {
311
+ const auth = await client.getAuthToken();
312
+ agentId = auth.agentId;
313
+ console.error(`[canon] Authenticated as ${agentId}`);
314
+ }
315
+ catch (err) {
316
+ const msg = err instanceof Error ? err.message : String(err);
317
+ console.error(`[canon] Failed to authenticate: ${msg}`);
318
+ return;
319
+ }
320
+ }
321
+ // Initialize RTDB auth for session state reporting
322
+ initRTDBAuth(client);
323
+ // Populate conversation cache
324
+ try {
325
+ const convos = await client.getConversations();
326
+ for (const c of convos)
327
+ conversationCache.set(c.id, c);
328
+ console.error(`[canon] Loaded ${convos.length} conversations`);
329
+ }
330
+ catch {
331
+ // Non-fatal — will be populated as messages arrive
332
+ }
333
+ // Write initial session state
334
+ const initialState = { cwd: process.cwd(), isActive: true };
335
+ for (const convoId of conversationCache.keys()) {
336
+ writeSessionState(convoId, agentId, initialState).catch((err) => console.error(`[canon] Failed to write initial state for ${convoId}:`, err));
337
+ }
338
+ // Start SSE stream
339
+ stream = new CanonStream({
340
+ apiKey,
341
+ agentId,
342
+ handler: {
343
+ onMessage: handleInboundMessage,
344
+ onAgentContext: (ctx) => {
345
+ agentContext = ctx;
346
+ },
347
+ onConnected: () => {
348
+ console.error('[canon] SSE stream connected');
349
+ },
350
+ onDisconnected: () => {
351
+ console.error('[canon] SSE stream disconnected');
352
+ },
353
+ onError: (err) => {
354
+ console.error(`[canon] SSE error: ${err.message}`);
355
+ },
356
+ },
357
+ });
358
+ stream.start().catch((err) => {
359
+ const msg = err instanceof Error ? err.message : String(err);
360
+ console.error(`[canon] SSE start error: ${msg}`);
361
+ });
362
+ // Start approval system
363
+ if (agentContext?.ownerId) {
364
+ approvalManager = new ApprovalManager(client, agentId, agentContext.ownerId);
365
+ approvalServer = new ApprovalHttpServer(approvalManager, () => triggerConversationId);
366
+ approvalServer.start().catch((err) => {
367
+ const msg = err instanceof Error ? err.message : String(err);
368
+ console.error(`[canon] Approval server error: ${msg}`);
369
+ });
370
+ }
371
+ }
372
+ // ── Main ───────────────────────────────────────────────────────────────
373
+ async function main() {
374
+ const transport = new StdioServerTransport();
375
+ await server.connect(transport);
376
+ // Start the Canon channel after MCP connection is established
377
+ await startChannel();
378
+ // Graceful shutdown — release agent lock and clear session state
379
+ const shutdown = () => {
380
+ // Mark all conversations as inactive
381
+ if (agentContext) {
382
+ for (const convoId of conversationCache.keys()) {
383
+ clearSessionState(convoId, agentContext.agentId).catch(() => { });
384
+ }
385
+ }
386
+ approvalManager?.dispose();
387
+ approvalServer?.stop().catch(() => { });
388
+ stream?.stop();
389
+ const ap = getActiveProfile();
390
+ if (ap)
391
+ releaseLock(ap);
392
+ process.exit(0);
393
+ };
394
+ process.on('SIGINT', shutdown);
395
+ process.on('SIGTERM', shutdown);
396
+ }
397
+ main().catch((err) => {
398
+ console.error('[canon] Fatal error:', err);
399
+ process.exit(1);
400
+ });
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Setup script for the Canon Claude Code plugin.
4
+ *
5
+ * Run after `npm install -g @canon/claude-code-plugin` to:
6
+ * 1. Install the /canon-register and /canon-configure skills
7
+ * 2. Show the user how to add the MCP server to their .mcp.json
8
+ */
9
+ export {};