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