@canonmsg/claude-code-plugin 0.3.0 → 0.4.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/host.js CHANGED
@@ -25,7 +25,8 @@ import { parseArgs } from 'node:util';
25
25
  import { randomUUID } from 'node:crypto';
26
26
  import { basename, resolve } from 'node:path';
27
27
  import { query } from '@anthropic-ai/claude-agent-sdk';
28
- import { CanonClient, CanonStream, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, } from '@canonmsg/core';
28
+ import { CanonClient, CanonStream, clearTurnState, DEFAULT_RUNTIME_CAPABILITIES, normalizeTurnMetadata, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, } from '@canonmsg/core';
29
+ import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
29
30
  let workingDir = process.cwd();
30
31
  let workspaceOptions = [];
31
32
  let runtimeModels = [];
@@ -124,18 +125,25 @@ const STREAMING_THROTTLE_MS = 250;
124
125
  const HEARTBEAT_MS = 30_000;
125
126
  const IDLE_CHECK_MS = 60_000;
126
127
  const CONTROL_POLL_MS = 2000;
128
+ const COMPLETED_STATE_HOLD_MS = 750;
129
+ const CLAUDE_RUNTIME_CAPABILITIES = {
130
+ ...DEFAULT_RUNTIME_CAPABILITIES,
131
+ supportsInterrupt: true,
132
+ supportsQueue: true,
133
+ supportsInterleave: true,
134
+ supportsRequiresAction: true,
135
+ supportsNonFinalPermanentMessages: true,
136
+ };
127
137
  function buildCanonPrompt(input) {
128
- const ownerLine = input.isOwner
129
- ? 'The sender is the verified human owner of this Canon agent.'
130
- : 'The sender is a Canon user in this conversation.';
131
138
  return [
132
139
  'You are connected to Canon messaging through a Claude Code host wrapper.',
133
- 'Reply naturally to the Canon user.',
140
+ 'Reply naturally to Canon participants.',
134
141
  'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
135
142
  'Short intermediate assistant messages may be shown as ephemeral status while you work.',
136
- ownerLine,
143
+ ...buildInboundContextLines(input.participantContext),
144
+ 'Canon participants may be humans or AI agents.',
145
+ 'If the latest message came from another AI agent, avoid agent-only ping-pong and only reply when directly addressed, when a human is clearly steering, or when a reply is genuinely necessary.',
137
146
  `Conversation ID: ${input.conversationId}`,
138
- `Sender: ${input.senderName}`,
139
147
  '',
140
148
  'New Canon message:',
141
149
  input.content,
@@ -143,7 +151,25 @@ function buildCanonPrompt(input) {
143
151
  }
144
152
  function renderInboundContent(message) {
145
153
  let content = message.text || '';
146
- if (message.contentType === 'audio' && message.audioUrl) {
154
+ const attachment = message.attachments?.[0];
155
+ if (attachment?.kind === 'audio' && attachment.url) {
156
+ const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
157
+ content = content
158
+ ? `[Voice message${duration}: ${attachment.url}]\n${content}`
159
+ : `[Voice message${duration}: ${attachment.url}]`;
160
+ }
161
+ else if (attachment?.kind === 'image' && attachment.url) {
162
+ content = content
163
+ ? `[Image: ${attachment.url}]\n${content}`
164
+ : `[Image: ${attachment.url}]`;
165
+ }
166
+ else if (attachment?.kind === 'file' && attachment.url) {
167
+ const label = attachment.fileName || 'File';
168
+ content = content
169
+ ? `[File: ${label} ${attachment.url}]\n${content}`
170
+ : `[File: ${label} ${attachment.url}]`;
171
+ }
172
+ else if (message.contentType === 'audio' && message.audioUrl) {
147
173
  const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
148
174
  content = content
149
175
  ? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
@@ -173,6 +199,9 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
173
199
  messageQueue.push(msg);
174
200
  }
175
201
  };
202
+ let enqueueInboundMessage = (msg, _intent = 'queue', _sourceMessageId, _markAccepted = false) => {
203
+ sendInput(msg);
204
+ };
176
205
  const inputStream = {
177
206
  [Symbol.asyncIterator]() {
178
207
  return {
@@ -232,6 +261,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
232
261
  type: 'claude_question',
233
262
  questionId,
234
263
  questions,
264
+ turnSemantics: 'control',
265
+ replyBehavior: 'suppress_auto_reply',
235
266
  },
236
267
  });
237
268
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
@@ -249,7 +280,12 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
249
280
  try {
250
281
  const planId = randomUUID();
251
282
  await client.sendMessage(conversationId, 'Plan ready for review.', {
252
- metadata: { type: 'plan_approval', planId },
283
+ metadata: {
284
+ type: 'plan_approval',
285
+ planId,
286
+ turnSemantics: 'control',
287
+ replyBehavior: 'suppress_auto_reply',
288
+ },
253
289
  });
254
290
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
255
291
  const reply = await waitForReply();
@@ -281,6 +317,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
281
317
  cwd,
282
318
  query: q,
283
319
  sendInput,
320
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
284
321
  state: {
285
322
  model: config.model,
286
323
  permissionMode: config.permissionMode,
@@ -289,7 +326,15 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
289
326
  availableModels: config.availableModels,
290
327
  streamingText: '',
291
328
  streamingTimer: null,
329
+ idleResetTimer: null,
292
330
  seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
331
+ pendingInputs: [],
332
+ pendingFinalText: null,
333
+ toolInProgress: false,
334
+ turnState: 'idle',
335
+ currentTurnId: null,
336
+ currentTurnOpenedAt: null,
337
+ lastAcceptedIntent: null,
293
338
  lastActivity: Date.now(),
294
339
  closed: false,
295
340
  sdkSessionId: resumeSessionId || null,
@@ -322,6 +367,31 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
322
367
  session.streamingText = '';
323
368
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
324
369
  }
370
+ function clearIdleResetTimer() {
371
+ if (!session.idleResetTimer)
372
+ return;
373
+ clearTimeout(session.idleResetTimer);
374
+ session.idleResetTimer = null;
375
+ }
376
+ function resetTurnToIdle() {
377
+ if (session.closed)
378
+ return;
379
+ session.currentTurnId = null;
380
+ session.currentTurnOpenedAt = null;
381
+ session.lastAcceptedIntent = null;
382
+ session.pendingFinalText = null;
383
+ session.toolInProgress = false;
384
+ session.turnState = 'idle';
385
+ writeTurn();
386
+ drainPendingInput();
387
+ }
388
+ function scheduleIdleTurnReset() {
389
+ clearIdleResetTimer();
390
+ session.idleResetTimer = setTimeout(() => {
391
+ session.idleResetTimer = null;
392
+ resetTurnToIdle();
393
+ }, COMPLETED_STATE_HOLD_MS);
394
+ }
325
395
  function writeState() {
326
396
  writeSessionState(conversationId, agentId, {
327
397
  ...session.state,
@@ -331,6 +401,56 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
331
401
  isActive: true,
332
402
  }).catch(() => { });
333
403
  }
404
+ function writeTurn() {
405
+ writeTurnState(conversationId, agentId, {
406
+ turnId: session.currentTurnId,
407
+ state: session.turnState,
408
+ queueDepth: session.pendingInputs.length,
409
+ currentSpeakerId: agentId,
410
+ lastAcceptedIntent: session.lastAcceptedIntent,
411
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
412
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
413
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
414
+ ? { completedAt: { '.sv': 'timestamp' } }
415
+ : {}),
416
+ }).catch(() => { });
417
+ }
418
+ async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
419
+ if (!markAccepted || !sourceMessageId)
420
+ return;
421
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
422
+ }
423
+ function drainPendingInput() {
424
+ if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
425
+ return;
426
+ const next = session.pendingInputs.shift();
427
+ if (!next)
428
+ return;
429
+ session.lastAcceptedIntent = next.intent;
430
+ writeTurn();
431
+ void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
432
+ .finally(() => sendInput(next.msg));
433
+ }
434
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
435
+ session.lastActivity = Date.now();
436
+ if (session.state.state === 'running' && intent !== 'interrupt') {
437
+ session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
438
+ writeTurn();
439
+ return;
440
+ }
441
+ if (session.state.state === 'running' && intent === 'interrupt') {
442
+ session.pendingInputs.unshift({ msg, intent, sourceMessageId, markAccepted });
443
+ session.turnState = 'interrupted';
444
+ writeTurn();
445
+ void session.query.interrupt().catch(() => { });
446
+ return;
447
+ }
448
+ session.lastAcceptedIntent = intent;
449
+ writeTurn();
450
+ void markQueuedMessageAccepted(sourceMessageId, markAccepted)
451
+ .finally(() => sendInput(msg));
452
+ };
453
+ writeTurn();
334
454
  // ── Process SDK output messages ──
335
455
  const processMessages = async () => {
336
456
  try {
@@ -353,19 +473,28 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
353
473
  const fullText = textBlocks.join('\n').trim();
354
474
  clearStreaming();
355
475
  if (fullText) {
356
- try {
357
- await client.sendMessage(conversationId, fullText);
358
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent reply (${fullText.length} chars)`);
359
- }
360
- catch (err) {
361
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send reply:`, err);
362
- }
476
+ session.pendingFinalText = fullText;
363
477
  }
364
478
  break;
365
479
  }
366
480
  case 'stream_event': {
367
481
  const event = msg.event;
368
- if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
482
+ if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
483
+ session.toolInProgress = true;
484
+ session.turnState = 'tool';
485
+ writeTurn();
486
+ }
487
+ else if (event?.type === 'content_block_stop' && session.toolInProgress) {
488
+ session.toolInProgress = false;
489
+ if (session.state.state === 'running') {
490
+ session.turnState = 'thinking';
491
+ writeTurn();
492
+ }
493
+ }
494
+ else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
495
+ session.toolInProgress = false;
496
+ session.turnState = 'streaming';
497
+ writeTurn();
369
498
  onStreamDelta(event.delta.text);
370
499
  }
371
500
  break;
@@ -376,16 +505,36 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
376
505
  const state = msg.state;
377
506
  session.state.state = state;
378
507
  if (state === 'running') {
508
+ clearIdleResetTimer();
509
+ if (!session.currentTurnId) {
510
+ session.currentTurnId = randomUUID();
511
+ session.currentTurnOpenedAt = Date.now();
512
+ }
513
+ session.pendingFinalText = null;
514
+ session.toolInProgress = false;
515
+ session.turnState = 'thinking';
379
516
  client.setTyping(conversationId, true, 'thinking').catch(() => { });
380
517
  }
381
518
  else if (state === 'idle') {
519
+ clearIdleResetTimer();
520
+ session.turnState = 'completed';
382
521
  client.setTyping(conversationId, false).catch(() => { });
383
522
  clearStreaming();
523
+ writeTurn();
524
+ scheduleIdleTurnReset();
384
525
  }
385
526
  else if (state === 'requires_action') {
527
+ clearIdleResetTimer();
528
+ if (!session.currentTurnId) {
529
+ session.currentTurnId = randomUUID();
530
+ session.currentTurnOpenedAt = Date.now();
531
+ }
532
+ session.toolInProgress = false;
533
+ session.turnState = 'waiting_input';
386
534
  client.setTyping(conversationId, false).catch(() => { });
387
535
  }
388
536
  writeState();
537
+ writeTurn();
389
538
  }
390
539
  break;
391
540
  }
@@ -395,6 +544,26 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
395
544
  if (resultSessionId)
396
545
  session.sdkSessionId = resultSessionId;
397
546
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
547
+ const resultText = typeof msg.result === 'string'
548
+ ? msg.result.trim()
549
+ : '';
550
+ const finalText = resultText || session.pendingFinalText?.trim() || null;
551
+ if (finalText) {
552
+ try {
553
+ await client.sendMessage(conversationId, finalText, {
554
+ metadata: {
555
+ turnId: session.currentTurnId,
556
+ turnSemantics: 'turn_complete',
557
+ turnComplete: true,
558
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
559
+ },
560
+ });
561
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
562
+ }
563
+ catch (err) {
564
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
565
+ }
566
+ }
398
567
  try {
399
568
  const usage = await q.getContextUsage();
400
569
  writeSessionState(conversationId, agentId, {
@@ -413,6 +582,9 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
413
582
  catch {
414
583
  // Non-fatal
415
584
  }
585
+ if (session.state.state !== 'running' && !session.idleResetTimer) {
586
+ drainPendingInput();
587
+ }
416
588
  break;
417
589
  }
418
590
  default:
@@ -544,6 +716,59 @@ async function main() {
544
716
  const pendingSessionCreations = new Map();
545
717
  /** Saved SDK session IDs for resume after idle eviction */
546
718
  const savedSessionIds = new Map();
719
+ const conversationCache = new Map();
720
+ let conversationCacheLoadedAt = 0;
721
+ async function refreshConversationCache(force = false) {
722
+ if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
723
+ return;
724
+ }
725
+ const conversations = await client.getConversations();
726
+ conversationCache.clear();
727
+ for (const conversation of conversations) {
728
+ conversationCache.set(conversation.id, conversation);
729
+ }
730
+ conversationCacheLoadedAt = Date.now();
731
+ }
732
+ async function getConversationMeta(conversationId) {
733
+ try {
734
+ await refreshConversationCache();
735
+ const cached = conversationCache.get(conversationId);
736
+ if (cached)
737
+ return cached;
738
+ await refreshConversationCache(true);
739
+ return conversationCache.get(conversationId) ?? null;
740
+ }
741
+ catch {
742
+ return conversationCache.get(conversationId) ?? null;
743
+ }
744
+ }
745
+ async function loadParticipantContext(input) {
746
+ const [conversation, recentMessages] = await Promise.all([
747
+ getConversationMeta(input.conversationId),
748
+ client.getMessages(input.conversationId, 6).catch(() => []),
749
+ ]);
750
+ const recentSenderTypes = recentMessages
751
+ .filter((message) => message.senderId !== agentId)
752
+ .map((message) => message.senderType);
753
+ let consecutiveAgentTurns = 0;
754
+ for (const senderType of recentSenderTypes) {
755
+ if (senderType !== 'ai_agent')
756
+ break;
757
+ consecutiveAgentTurns += 1;
758
+ }
759
+ return {
760
+ conversationType: conversation?.type ?? 'unknown',
761
+ memberCount: conversation?.memberIds?.length ?? null,
762
+ senderType: input.message.senderType ?? 'human',
763
+ senderName: input.senderName,
764
+ isOwner: input.isOwner,
765
+ mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
766
+ recentSenderTypes,
767
+ recentHumanCount: recentSenderTypes.filter((senderType) => senderType === 'human').length,
768
+ recentAgentCount: recentSenderTypes.filter((senderType) => senderType === 'ai_agent').length,
769
+ consecutiveAgentTurns,
770
+ };
771
+ }
547
772
  function closeSession(conversationId) {
548
773
  const session = sessions.get(conversationId);
549
774
  if (!session)
@@ -555,8 +780,13 @@ async function main() {
555
780
  }
556
781
  if (session.streamingTimer)
557
782
  clearTimeout(session.streamingTimer);
783
+ if (session.idleResetTimer) {
784
+ clearTimeout(session.idleResetTimer);
785
+ session.idleResetTimer = null;
786
+ }
558
787
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
559
788
  clearSessionState(conversationId, agentId).catch(() => { });
789
+ clearTurnState(conversationId, agentId).catch(() => { });
560
790
  client.setTyping(conversationId, false).catch(() => { });
561
791
  try {
562
792
  session.query.close();
@@ -600,6 +830,7 @@ async function main() {
600
830
  savedSessionIds.set(convoId, sdkSessionId);
601
831
  sessions.delete(convoId);
602
832
  clearSessionState(convoId, agentId).catch(() => { });
833
+ clearTurnState(convoId, agentId).catch(() => { });
603
834
  }, {
604
835
  model: config?.model ?? runtimeModels[0]?.value,
605
836
  permissionMode: config?.permissionMode ?? 'default',
@@ -641,22 +872,35 @@ async function main() {
641
872
  const sender = m.senderName || m.senderId;
642
873
  const content = renderInboundContent(m);
643
874
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
644
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}"`);
645
875
  void (async () => {
876
+ const participantContext = await loadParticipantContext({
877
+ conversationId: convoId,
878
+ message: m,
879
+ senderName: sender,
880
+ isOwner,
881
+ });
882
+ const autoReply = decideAutoReply(participantContext);
883
+ if (!autoReply.allow) {
884
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
885
+ return;
886
+ }
887
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
646
888
  const session = await getOrCreateSession(convoId);
647
- session.sendInput({
889
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
890
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
891
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
892
+ session.enqueueInbound({
648
893
  type: 'user',
649
894
  message: {
650
895
  role: 'user',
651
896
  content: buildCanonPrompt({
652
897
  content,
653
898
  conversationId: convoId,
654
- isOwner,
655
- senderName: sender,
899
+ participantContext,
656
900
  }),
657
901
  },
658
902
  parent_tool_use_id: null,
659
- });
903
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
660
904
  })();
661
905
  },
662
906
  onConnected: () => console.error('[canon-host] SSE connected'),
@@ -733,6 +977,17 @@ async function main() {
733
977
  if (session && !session.closed) {
734
978
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Interrupt signal`);
735
979
  try {
980
+ session.turnState = 'interrupted';
981
+ writeTurnState(convoId, agentId, {
982
+ turnId: session.currentTurnId,
983
+ state: 'interrupted',
984
+ queueDepth: session.pendingInputs.length,
985
+ currentSpeakerId: agentId,
986
+ lastAcceptedIntent: session.lastAcceptedIntent,
987
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
988
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
989
+ completedAt: { '.sv': 'timestamp' },
990
+ }).catch(() => { });
736
991
  await session.query.interrupt();
737
992
  if (session.streamingTimer) {
738
993
  clearTimeout(session.streamingTimer);
@@ -767,6 +1022,18 @@ async function main() {
767
1022
  hostMode: true,
768
1023
  isActive: true,
769
1024
  }).catch(() => { });
1025
+ writeTurnState(session.conversationId, agentId, {
1026
+ turnId: session.currentTurnId,
1027
+ state: session.turnState,
1028
+ queueDepth: session.pendingInputs.length,
1029
+ currentSpeakerId: agentId,
1030
+ lastAcceptedIntent: session.lastAcceptedIntent,
1031
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1032
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
1033
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
1034
+ ? { completedAt: { '.sv': 'timestamp' } }
1035
+ : {}),
1036
+ }).catch(() => { });
770
1037
  }
771
1038
  }, HEARTBEAT_MS);
772
1039
  // ── Idle session cleanup ──
@@ -0,0 +1,19 @@
1
+ import type { CanonConversation } from '@canonmsg/core';
2
+ export interface InboundParticipantContext {
3
+ conversationType: CanonConversation['type'] | 'unknown';
4
+ memberCount: number | null;
5
+ senderType: 'human' | 'ai_agent';
6
+ senderName: string;
7
+ isOwner: boolean;
8
+ mentionedAgent: boolean;
9
+ recentSenderTypes: Array<'human' | 'ai_agent'>;
10
+ recentHumanCount: number;
11
+ recentAgentCount: number;
12
+ consecutiveAgentTurns: number;
13
+ }
14
+ export interface AutoReplyDecision {
15
+ allow: boolean;
16
+ reason: string;
17
+ }
18
+ export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
19
+ export declare function decideAutoReply(context: InboundParticipantContext): AutoReplyDecision;
@@ -0,0 +1,50 @@
1
+ function formatRecentSenders(senderTypes) {
2
+ if (senderTypes.length === 0)
3
+ return 'none';
4
+ return senderTypes.map((senderType) => (senderType === 'ai_agent' ? 'agent' : 'human')).join(' -> ');
5
+ }
6
+ export function buildInboundContextLines(context) {
7
+ const conversationTypeLabel = context.conversationType === 'unknown'
8
+ ? 'unknown'
9
+ : `${context.conversationType}${context.memberCount ? ` (${context.memberCount} members)` : ''}`;
10
+ const senderRole = context.isOwner
11
+ ? 'The latest sender is the verified human owner of this Canon agent.'
12
+ : context.senderType === 'ai_agent'
13
+ ? 'The latest sender is another AI agent in Canon.'
14
+ : 'The latest sender is a human Canon participant.';
15
+ return [
16
+ senderRole,
17
+ `Latest sender name: ${context.senderName}`,
18
+ `Latest sender type: ${context.senderType}`,
19
+ `Conversation type: ${conversationTypeLabel}`,
20
+ `Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
21
+ `Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
22
+ `Recent human messages: ${context.recentHumanCount}`,
23
+ `Recent agent messages: ${context.recentAgentCount}`,
24
+ `Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
25
+ ];
26
+ }
27
+ export function decideAutoReply(context) {
28
+ if (context.senderType !== 'ai_agent') {
29
+ return { allow: true, reason: 'latest sender is human' };
30
+ }
31
+ if (context.isOwner) {
32
+ return { allow: true, reason: 'owner messages always pass through' };
33
+ }
34
+ if (context.mentionedAgent) {
35
+ return { allow: true, reason: 'another agent explicitly addressed this agent' };
36
+ }
37
+ if (context.conversationType === 'group') {
38
+ return {
39
+ allow: false,
40
+ reason: 'suppressing group agent auto-reply without a direct mention',
41
+ };
42
+ }
43
+ if (context.consecutiveAgentTurns >= 2 && context.recentHumanCount === 0) {
44
+ return {
45
+ allow: false,
46
+ reason: 'suppressing likely agent-only loop in a direct conversation',
47
+ };
48
+ }
49
+ return { allow: true, reason: 'direct agent message allowed' };
50
+ }
package/dist/register.js CHANGED
File without changes
package/dist/server.js CHANGED
@@ -28,6 +28,43 @@ let approvalServer = null;
28
28
  const conversationCache = new Map();
29
29
  /** The conversation that most recently triggered work (for approval routing) */
30
30
  let triggerConversationId = null;
31
+ function getPrimaryAttachment(message) {
32
+ const attachment = message.attachments?.[0];
33
+ if (attachment?.url)
34
+ return attachment;
35
+ if (message.audioUrl) {
36
+ return {
37
+ kind: 'audio',
38
+ url: message.audioUrl,
39
+ durationMs: message.audioDurationMs,
40
+ };
41
+ }
42
+ if (message.imageUrl) {
43
+ return {
44
+ kind: 'image',
45
+ url: message.imageUrl,
46
+ };
47
+ }
48
+ return null;
49
+ }
50
+ function renderInboundContent(message) {
51
+ let content = message.text || '';
52
+ const attachment = getPrimaryAttachment(message);
53
+ if (attachment?.kind === 'audio') {
54
+ const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
55
+ return content
56
+ ? `[Voice message${duration}: ${attachment.url}]\n${content}`
57
+ : `[Voice message${duration}: ${attachment.url}]`;
58
+ }
59
+ if (attachment?.kind === 'image') {
60
+ return content ? `[Image: ${attachment.url}]\n${content}` : `[Image: ${attachment.url}]`;
61
+ }
62
+ if (attachment?.kind === 'file') {
63
+ const label = attachment.fileName || 'File';
64
+ return content ? `[File: ${label} ${attachment.url}]\n${content}` : `[File: ${label} ${attachment.url}]`;
65
+ }
66
+ return content;
67
+ }
31
68
  // ── Tool definitions ───────────────────────────────────────────────────
32
69
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
33
70
  tools: [
@@ -51,7 +88,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
51
88
  },
52
89
  {
53
90
  name: 'send_message',
54
- description: 'Send a message to a Canon conversation (supports text, images, audio)',
91
+ description: 'Send a message to a Canon conversation (supports text, images, audio, files)',
55
92
  inputSchema: {
56
93
  type: 'object',
57
94
  properties: {
@@ -65,7 +102,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
65
102
  },
66
103
  content_type: {
67
104
  type: 'string',
68
- enum: ['text', 'image', 'audio'],
105
+ enum: ['text', 'image', 'audio', 'file'],
69
106
  description: 'Content type (default: text)',
70
107
  },
71
108
  image_url: {
@@ -76,8 +113,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
76
113
  type: 'string',
77
114
  description: 'Audio URL (when content_type is audio)',
78
115
  },
116
+ file_url: {
117
+ type: 'string',
118
+ description: 'File URL (when content_type is file)',
119
+ },
120
+ file_name: {
121
+ type: 'string',
122
+ description: 'Optional display filename for file attachments',
123
+ },
79
124
  },
80
- required: ['conversation_id', 'text'],
125
+ required: ['conversation_id'],
81
126
  },
82
127
  },
83
128
  {
@@ -151,15 +196,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
151
196
  }
152
197
  case 'send_message': {
153
198
  const conversationId = args.conversation_id;
154
- const text = args.text;
199
+ const text = typeof args.text === 'string' ? args.text : '';
155
200
  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;
201
+ const opts = {};
202
+ const imageUrl = typeof args.image_url === 'string' ? args.image_url : null;
203
+ const audioUrl = typeof args.audio_url === 'string' ? args.audio_url : null;
204
+ const fileUrl = typeof args.file_url === 'string' ? args.file_url : null;
205
+ const fileName = typeof args.file_name === 'string' ? args.file_name : null;
206
+ if (contentType === 'image' && imageUrl) {
207
+ opts.contentType = 'image';
208
+ opts.attachments = [{ kind: 'image', url: imageUrl }];
209
+ }
210
+ else if (contentType === 'audio' && audioUrl) {
211
+ opts.contentType = 'audio';
212
+ opts.attachments = [{ kind: 'audio', url: audioUrl }];
213
+ }
214
+ else if (contentType === 'file' || fileUrl) {
215
+ opts.contentType = 'file';
216
+ if (fileUrl) {
217
+ opts.attachments = [{
218
+ kind: 'file',
219
+ url: fileUrl,
220
+ ...(fileName ? { fileName } : {}),
221
+ }];
222
+ }
223
+ }
224
+ else {
225
+ opts.contentType = contentType;
226
+ }
163
227
  const result = await client.sendMessage(conversationId, text, opts);
164
228
  return {
165
229
  content: [
@@ -261,16 +325,24 @@ async function handleInboundMessage(payload) {
261
325
  content_type: m.contentType || 'text',
262
326
  ts: new Date().toISOString(),
263
327
  };
328
+ const primaryAttachment = getPrimaryAttachment(m);
264
329
  if (m.imageUrl)
265
330
  meta.image_url = m.imageUrl;
266
331
  if (m.audioUrl)
267
332
  meta.audio_url = m.audioUrl;
268
- console.error(`[canon] Inbound message from ${meta.sender_name}: "${m.text}"`);
333
+ if (primaryAttachment?.url) {
334
+ meta.attachment_kind = primaryAttachment.kind;
335
+ meta.attachment_url = primaryAttachment.url;
336
+ }
337
+ if (primaryAttachment?.fileName)
338
+ meta.attachment_file_name = primaryAttachment.fileName;
339
+ const renderedContent = renderInboundContent(m);
340
+ console.error(`[canon] Inbound message from ${meta.sender_name}: "${renderedContent}"`);
269
341
  try {
270
342
  await server.notification({
271
343
  method: 'notifications/claude/channel',
272
344
  params: {
273
- content: m.text || '',
345
+ content: renderedContent,
274
346
  meta,
275
347
  },
276
348
  });
package/dist/setup.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Setup script for the Canon Claude Code plugin.
4
4
  *
5
- * Run after `npm install -g @canon/claude-code-plugin` to:
5
+ * Run after `npm install -g @canonmsg/claude-code-plugin` to:
6
6
  * 1. Install the /canon-register and /canon-configure skills
7
7
  * 2. Show the user how to add the MCP server to their .mcp.json
8
8
  */
package/dist/setup.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Setup script for the Canon Claude Code plugin.
4
4
  *
5
- * Run after `npm install -g @canon/claude-code-plugin` to:
5
+ * Run after `npm install -g @canonmsg/claude-code-plugin` to:
6
6
  * 1. Install the /canon-register and /canon-configure skills
7
7
  * 2. Show the user how to add the MCP server to their .mcp.json
8
8
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
@@ -16,13 +16,13 @@
16
16
  ".claude-plugin"
17
17
  ],
18
18
  "scripts": {
19
- "build": "tsc",
19
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
20
20
  "dev": "tsc --watch",
21
- "prepublishOnly": "npm run build"
21
+ "prepack": "npm run build"
22
22
  },
23
23
  "dependencies": {
24
- "@anthropic-ai/claude-agent-sdk": "^0.2.92",
25
- "@canonmsg/core": "^0.2.2",
24
+ "@anthropic-ai/claude-agent-sdk": "^0.2.101",
25
+ "@canonmsg/core": "^0.3.0",
26
26
  "@modelcontextprotocol/sdk": "^1.12.0"
27
27
  },
28
28
  "engines": {
@@ -1,21 +0,0 @@
1
- /**
2
- * Shared agent profile management — loading, locking, and resolution.
3
- * Used by both host.ts and server.ts.
4
- */
5
- export interface AgentProfile {
6
- apiKey: string;
7
- agentId: string;
8
- agentName: string;
9
- registeredAt: string;
10
- }
11
- export declare const CANON_DIR: string;
12
- export declare const AGENTS_PATH: string;
13
- export declare const LOCKS_DIR: string;
14
- export declare function loadProfiles(): Record<string, AgentProfile>;
15
- export declare function isProcessAlive(pid: number): boolean;
16
- export declare function isProfileLocked(profile: string): {
17
- locked: boolean;
18
- pid?: number;
19
- };
20
- export declare function acquireLock(profile: string): void;
21
- export declare function releaseLock(profile: string): void;
@@ -1,63 +0,0 @@
1
- /**
2
- * Shared agent profile management — loading, locking, and resolution.
3
- * Used by both host.ts and server.ts.
4
- */
5
- import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
6
- import { join } from 'node:path';
7
- import { homedir } from 'node:os';
8
- // ── Paths ────────────────────────────────────────────────────────────
9
- export const CANON_DIR = join(homedir(), '.canon');
10
- export const AGENTS_PATH = join(CANON_DIR, 'agents.json');
11
- export const LOCKS_DIR = join(CANON_DIR, 'locks');
12
- // ── Profile loading ──────────────────────────────────────────────────
13
- export function loadProfiles() {
14
- try {
15
- return JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
16
- }
17
- catch {
18
- return {};
19
- }
20
- }
21
- // ── Process locking ──────────────────────────────────────────────────
22
- export function isProcessAlive(pid) {
23
- try {
24
- process.kill(pid, 0);
25
- return true;
26
- }
27
- catch {
28
- return false;
29
- }
30
- }
31
- export function isProfileLocked(profile) {
32
- const lockPath = join(LOCKS_DIR, `${profile}.lock`);
33
- try {
34
- const pid = parseInt(readFileSync(lockPath, 'utf-8').trim());
35
- if (isProcessAlive(pid))
36
- return { locked: true, pid };
37
- // Stale lock — clean it up
38
- try {
39
- unlinkSync(lockPath);
40
- }
41
- catch { }
42
- }
43
- catch {
44
- // No lock file
45
- }
46
- return { locked: false };
47
- }
48
- export function acquireLock(profile) {
49
- mkdirSync(LOCKS_DIR, { recursive: true });
50
- const lockPath = join(LOCKS_DIR, `${profile}.lock`);
51
- const check = isProfileLocked(profile);
52
- if (check.locked) {
53
- throw new Error(`Agent "${profile}" is in use by another session (PID ${check.pid})`);
54
- }
55
- writeFileSync(lockPath, String(process.pid));
56
- }
57
- export function releaseLock(profile) {
58
- const lockPath = join(LOCKS_DIR, `${profile}.lock`);
59
- try {
60
- unlinkSync(lockPath);
61
- }
62
- catch { }
63
- }
@@ -1,34 +0,0 @@
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>;
package/dist/rtdb-rest.js DELETED
@@ -1,128 +0,0 @@
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
- }