@canonmsg/claude-code-plugin 0.3.0 → 0.5.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
@@ -23,32 +23,12 @@ setDefaultResultOrder('ipv4first');
23
23
  */
24
24
  import { parseArgs } from 'node:util';
25
25
  import { randomUUID } from 'node:crypto';
26
- import { basename, resolve } from 'node:path';
27
26
  import { query } from '@anthropic-ai/claude-agent-sdk';
28
- import { CanonClient, CanonStream, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, } from '@canonmsg/core';
27
+ import { buildParticipationHistorySnapshot, buildBehaviorPolicyLines, buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPublicWorkspaceOptions, isEnabledFlag, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
28
+ import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
29
29
  let workingDir = process.cwd();
30
30
  let workspaceOptions = [];
31
31
  let runtimeModels = [];
32
- function normalizeString(value) {
33
- if (typeof value !== 'string')
34
- return undefined;
35
- const trimmed = value.trim();
36
- return trimmed ? trimmed : undefined;
37
- }
38
- function buildWorkspaceOptions(primaryCwd, configured) {
39
- const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
40
- const seenLabels = new Map();
41
- return uniqueDirs.map((cwd, index) => {
42
- const baseLabel = basename(cwd) || cwd;
43
- const seenCount = (seenLabels.get(baseLabel) ?? 0) + 1;
44
- seenLabels.set(baseLabel, seenCount);
45
- return {
46
- id: index === 0 ? 'default' : `workspace-${index + 1}`,
47
- label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
48
- cwd,
49
- };
50
- });
51
- }
52
32
  function toModelOptions(models) {
53
33
  return models.map((model) => ({
54
34
  value: model.value,
@@ -65,32 +45,22 @@ async function publishAgentRuntime(agentId, runtime) {
65
45
  }
66
46
  async function loadSessionConfig(conversationId, agentId) {
67
47
  const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
48
+ const baseConfig = readSessionWorkspaceConfig(raw);
68
49
  if (!raw || typeof raw !== 'object')
69
- return null;
50
+ return baseConfig;
70
51
  const data = raw;
71
- const permissionMode = normalizeString(data.permissionMode);
52
+ const permissionMode = normalizeOptionalString(data.permissionMode);
72
53
  return {
73
- workspaceId: normalizeString(data.workspaceId),
74
- legacyCwd: normalizeString(data.cwd),
75
- model: normalizeString(data.model),
54
+ ...baseConfig,
76
55
  permissionMode: permissionMode,
77
56
  };
78
57
  }
79
58
  function resolveWorkspaceCwd(config) {
80
- if (config?.workspaceId) {
81
- const workspace = workspaceOptions.find((option) => option.id === config.workspaceId);
82
- if (workspace)
83
- return workspace.cwd;
84
- }
85
- if (config?.legacyCwd) {
86
- const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
87
- if (workspace)
88
- return workspace.cwd;
89
- }
90
- return workspaceOptions[0]?.cwd ?? workingDir;
91
- }
92
- function toPublicWorkspaceOptions() {
93
- return workspaceOptions.map(({ id, label }) => ({ id, label }));
59
+ return resolveConfiguredWorkspaceCwd({
60
+ workspaceOptions,
61
+ config,
62
+ defaultCwd: workingDir,
63
+ });
94
64
  }
95
65
  async function detectRuntimeModels(cwd) {
96
66
  const emptyPrompt = {
@@ -124,18 +94,25 @@ const STREAMING_THROTTLE_MS = 250;
124
94
  const HEARTBEAT_MS = 30_000;
125
95
  const IDLE_CHECK_MS = 60_000;
126
96
  const CONTROL_POLL_MS = 2000;
97
+ const FINAL_DELIVERY_RETRY_MS = 2_000;
98
+ const CLAUDE_RUNTIME_CAPABILITIES = {
99
+ ...DEFAULT_RUNTIME_CAPABILITIES,
100
+ supportsInterrupt: true,
101
+ supportsQueue: true,
102
+ supportsInterleave: true,
103
+ supportsRequiresAction: true,
104
+ supportsNonFinalPermanentMessages: false,
105
+ };
127
106
  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
107
  return [
132
108
  'You are connected to Canon messaging through a Claude Code host wrapper.',
133
- 'Reply naturally to the Canon user.',
134
109
  'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
135
110
  'Short intermediate assistant messages may be shown as ephemeral status while you work.',
136
- ownerLine,
111
+ ...buildInboundContextLines(input.participantContext),
112
+ ...buildBehaviorPolicyLines(input.behavior),
113
+ 'Canon participants may be humans or AI agents.',
114
+ 'Honor the Canon behavior policy above when deciding how proactively to participate.',
137
115
  `Conversation ID: ${input.conversationId}`,
138
- `Sender: ${input.senderName}`,
139
116
  '',
140
117
  'New Canon message:',
141
118
  input.content,
@@ -143,7 +120,25 @@ function buildCanonPrompt(input) {
143
120
  }
144
121
  function renderInboundContent(message) {
145
122
  let content = message.text || '';
146
- if (message.contentType === 'audio' && message.audioUrl) {
123
+ const attachment = message.attachments?.[0];
124
+ if (attachment?.kind === 'audio' && attachment.url) {
125
+ const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
126
+ content = content
127
+ ? `[Voice message${duration}: ${attachment.url}]\n${content}`
128
+ : `[Voice message${duration}: ${attachment.url}]`;
129
+ }
130
+ else if (attachment?.kind === 'image' && attachment.url) {
131
+ content = content
132
+ ? `[Image: ${attachment.url}]\n${content}`
133
+ : `[Image: ${attachment.url}]`;
134
+ }
135
+ else if (attachment?.kind === 'file' && attachment.url) {
136
+ const label = attachment.fileName || 'File';
137
+ content = content
138
+ ? `[File: ${label} ${attachment.url}]\n${content}`
139
+ : `[File: ${label} ${attachment.url}]`;
140
+ }
141
+ else if (message.contentType === 'audio' && message.audioUrl) {
147
142
  const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
148
143
  content = content
149
144
  ? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
@@ -157,7 +152,8 @@ function renderInboundContent(message) {
157
152
  return content || '[Empty message]';
158
153
  }
159
154
  // ── Session factory ─────────────────────────────────────────────────
160
- function createSession(conversationId, cwd, agentId, client, onSessionEnd, config, resumeSessionId) {
155
+ function createSession(conversationId, environment, agentId, client, onSessionEnd, config, resumeSessionId) {
156
+ const { cwd } = environment;
161
157
  // Per-session input stream with message queue
162
158
  let resolveInput = null;
163
159
  let inputDone = false;
@@ -173,6 +169,9 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
173
169
  messageQueue.push(msg);
174
170
  }
175
171
  };
172
+ let enqueueInboundMessage = (msg, _intent = 'queue', _sourceMessageId, _markAccepted = false) => {
173
+ sendInput(msg);
174
+ };
176
175
  const inputStream = {
177
176
  [Symbol.asyncIterator]() {
178
177
  return {
@@ -232,6 +231,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
232
231
  type: 'claude_question',
233
232
  questionId,
234
233
  questions,
234
+ turnSemantics: 'control',
235
+ replyBehavior: 'suppress_auto_reply',
235
236
  },
236
237
  });
237
238
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
@@ -249,7 +250,12 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
249
250
  try {
250
251
  const planId = randomUUID();
251
252
  await client.sendMessage(conversationId, 'Plan ready for review.', {
252
- metadata: { type: 'plan_approval', planId },
253
+ metadata: {
254
+ type: 'plan_approval',
255
+ planId,
256
+ turnSemantics: 'control',
257
+ replyBehavior: 'suppress_auto_reply',
258
+ },
253
259
  });
254
260
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
255
261
  const reply = await waitForReply();
@@ -279,8 +285,10 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
279
285
  session = {
280
286
  conversationId,
281
287
  cwd,
288
+ environment,
282
289
  query: q,
283
290
  sendInput,
291
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
284
292
  state: {
285
293
  model: config.model,
286
294
  permissionMode: config.permissionMode,
@@ -289,7 +297,17 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
289
297
  availableModels: config.availableModels,
290
298
  streamingText: '',
291
299
  streamingTimer: null,
300
+ idleResetTimer: null,
301
+ finalDeliveryTimer: null,
292
302
  seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
303
+ pendingInputs: [],
304
+ pendingFinalText: null,
305
+ pendingFinalDeliveryText: null,
306
+ toolInProgress: false,
307
+ turnState: 'idle',
308
+ currentTurnId: null,
309
+ currentTurnOpenedAt: null,
310
+ lastAcceptedIntent: null,
293
311
  lastActivity: Date.now(),
294
312
  closed: false,
295
313
  sdkSessionId: resumeSessionId || null,
@@ -322,15 +340,157 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
322
340
  session.streamingText = '';
323
341
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
324
342
  }
343
+ function clearIdleResetTimer() {
344
+ if (!session.idleResetTimer)
345
+ return;
346
+ clearTimeout(session.idleResetTimer);
347
+ session.idleResetTimer = null;
348
+ }
349
+ function clearFinalDeliveryTimer() {
350
+ if (!session.finalDeliveryTimer)
351
+ return;
352
+ clearTimeout(session.finalDeliveryTimer);
353
+ session.finalDeliveryTimer = null;
354
+ }
355
+ function resetTurnToIdle() {
356
+ if (session.closed)
357
+ return;
358
+ clearFinalDeliveryTimer();
359
+ session.currentTurnId = null;
360
+ session.currentTurnOpenedAt = null;
361
+ session.lastAcceptedIntent = null;
362
+ session.pendingFinalText = null;
363
+ session.pendingFinalDeliveryText = null;
364
+ session.toolInProgress = false;
365
+ session.turnState = 'idle';
366
+ writeTurn();
367
+ drainPendingInput();
368
+ }
369
+ function scheduleFinalHandoffReset() {
370
+ clearIdleResetTimer();
371
+ session.idleResetTimer = setTimeout(() => {
372
+ session.idleResetTimer = null;
373
+ client.setTyping(conversationId, false).catch(() => { });
374
+ clearStreaming();
375
+ resetTurnToIdle();
376
+ }, FINAL_MESSAGE_HANDOFF_MS);
377
+ }
378
+ function markPendingFinalDelivery(finalText) {
379
+ clearIdleResetTimer();
380
+ session.pendingFinalText = finalText;
381
+ session.pendingFinalDeliveryText = finalText;
382
+ session.state.state = 'running';
383
+ session.turnState = 'streaming';
384
+ session.streamingText = finalText;
385
+ flushStreaming();
386
+ writeState();
387
+ writeTurn();
388
+ client.setTyping(conversationId, true, 'typing').catch(() => { });
389
+ }
390
+ async function deliverFinalReply(finalText) {
391
+ try {
392
+ await client.sendMessage(conversationId, finalText, {
393
+ metadata: {
394
+ turnId: session.currentTurnId,
395
+ turnSemantics: 'turn_complete',
396
+ turnComplete: true,
397
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
398
+ },
399
+ });
400
+ session.state.state = 'idle';
401
+ session.pendingFinalDeliveryText = null;
402
+ clearFinalDeliveryTimer();
403
+ writeState();
404
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
405
+ return true;
406
+ }
407
+ catch (err) {
408
+ markPendingFinalDelivery(finalText);
409
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
410
+ return false;
411
+ }
412
+ }
413
+ function scheduleFinalDeliveryRetry(finalText) {
414
+ if (session.closed || session.pendingFinalDeliveryText !== finalText)
415
+ return;
416
+ clearFinalDeliveryTimer();
417
+ session.finalDeliveryTimer = setTimeout(() => {
418
+ session.finalDeliveryTimer = null;
419
+ void deliverFinalReply(finalText).then((sent) => {
420
+ if (sent) {
421
+ scheduleFinalHandoffReset();
422
+ return;
423
+ }
424
+ scheduleFinalDeliveryRetry(finalText);
425
+ });
426
+ }, FINAL_DELIVERY_RETRY_MS);
427
+ }
325
428
  function writeState() {
326
429
  writeSessionState(conversationId, agentId, {
327
430
  ...session.state,
328
431
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
329
432
  cwd,
433
+ executionMode: session.environment.mode,
434
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
330
435
  hostMode: true,
331
436
  isActive: true,
332
437
  }).catch(() => { });
333
438
  }
439
+ function writeTurn() {
440
+ writeTurnState(conversationId, agentId, {
441
+ turnId: session.currentTurnId,
442
+ state: session.turnState,
443
+ queueDepth: session.pendingInputs.length,
444
+ currentSpeakerId: agentId,
445
+ lastAcceptedIntent: session.lastAcceptedIntent,
446
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
447
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
448
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
449
+ ? { completedAt: { '.sv': 'timestamp' } }
450
+ : {}),
451
+ }).catch(() => { });
452
+ }
453
+ async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
454
+ if (!markAccepted || !sourceMessageId)
455
+ return;
456
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
457
+ }
458
+ function drainPendingInput() {
459
+ if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
460
+ return;
461
+ const next = session.pendingInputs.shift();
462
+ if (!next)
463
+ return;
464
+ session.lastAcceptedIntent = next.intent;
465
+ writeTurn();
466
+ void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
467
+ .finally(() => sendInput(next.msg));
468
+ }
469
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
470
+ session.lastActivity = Date.now();
471
+ if (session.pendingFinalDeliveryText) {
472
+ session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
473
+ writeTurn();
474
+ return;
475
+ }
476
+ if (session.state.state === 'running' && intent !== 'interrupt') {
477
+ session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
478
+ writeTurn();
479
+ return;
480
+ }
481
+ if (session.state.state === 'running' && intent === 'interrupt') {
482
+ session.pendingInputs.unshift({ msg, intent, sourceMessageId, markAccepted });
483
+ session.turnState = 'interrupted';
484
+ writeTurn();
485
+ void session.query.interrupt().catch(() => { });
486
+ return;
487
+ }
488
+ session.lastAcceptedIntent = intent;
489
+ writeTurn();
490
+ void markQueuedMessageAccepted(sourceMessageId, markAccepted)
491
+ .finally(() => sendInput(msg));
492
+ };
493
+ writeTurn();
334
494
  // ── Process SDK output messages ──
335
495
  const processMessages = async () => {
336
496
  try {
@@ -351,21 +511,29 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
351
511
  .filter((b) => b.type === 'text')
352
512
  .map((b) => b.text);
353
513
  const fullText = textBlocks.join('\n').trim();
354
- clearStreaming();
355
514
  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
- }
515
+ session.pendingFinalText = fullText;
363
516
  }
364
517
  break;
365
518
  }
366
519
  case 'stream_event': {
367
520
  const event = msg.event;
368
- if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
521
+ if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
522
+ session.toolInProgress = true;
523
+ session.turnState = 'tool';
524
+ writeTurn();
525
+ }
526
+ else if (event?.type === 'content_block_stop' && session.toolInProgress) {
527
+ session.toolInProgress = false;
528
+ if (session.state.state === 'running') {
529
+ session.turnState = 'thinking';
530
+ writeTurn();
531
+ }
532
+ }
533
+ else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
534
+ session.toolInProgress = false;
535
+ session.turnState = 'streaming';
536
+ writeTurn();
369
537
  onStreamDelta(event.delta.text);
370
538
  }
371
539
  break;
@@ -376,16 +544,32 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
376
544
  const state = msg.state;
377
545
  session.state.state = state;
378
546
  if (state === 'running') {
547
+ clearIdleResetTimer();
548
+ if (!session.currentTurnId) {
549
+ session.currentTurnId = randomUUID();
550
+ session.currentTurnOpenedAt = Date.now();
551
+ }
552
+ session.state.contextUsage = undefined;
553
+ session.pendingFinalText = null;
554
+ session.toolInProgress = false;
555
+ session.turnState = 'thinking';
379
556
  client.setTyping(conversationId, true, 'thinking').catch(() => { });
380
557
  }
381
558
  else if (state === 'idle') {
382
559
  client.setTyping(conversationId, false).catch(() => { });
383
- clearStreaming();
384
560
  }
385
561
  else if (state === 'requires_action') {
562
+ clearIdleResetTimer();
563
+ if (!session.currentTurnId) {
564
+ session.currentTurnId = randomUUID();
565
+ session.currentTurnOpenedAt = Date.now();
566
+ }
567
+ session.toolInProgress = false;
568
+ session.turnState = 'waiting_input';
386
569
  client.setTyping(conversationId, false).catch(() => { });
387
570
  }
388
571
  writeState();
572
+ writeTurn();
389
573
  }
390
574
  break;
391
575
  }
@@ -395,24 +579,30 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
395
579
  if (resultSessionId)
396
580
  session.sdkSessionId = resultSessionId;
397
581
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
582
+ const resultText = typeof msg.result === 'string'
583
+ ? msg.result.trim()
584
+ : '';
585
+ const finalText = resultText || session.pendingFinalText?.trim() || null;
586
+ const finalDelivered = finalText ? await deliverFinalReply(finalText) : true;
398
587
  try {
399
588
  const usage = await q.getContextUsage();
400
- writeSessionState(conversationId, agentId, {
401
- ...session.state,
402
- state: 'idle',
403
- contextUsage: {
404
- percentage: usage.percentage,
405
- totalTokens: usage.totalTokens,
406
- maxTokens: usage.maxTokens,
407
- },
408
- cwd,
409
- hostMode: true,
410
- isActive: true,
411
- }).catch(() => { });
589
+ session.state.contextUsage = {
590
+ percentage: usage.percentage,
591
+ totalTokens: usage.totalTokens,
592
+ maxTokens: usage.maxTokens,
593
+ };
594
+ session.state.state = finalDelivered ? 'idle' : session.state.state;
595
+ writeState();
412
596
  }
413
597
  catch {
414
598
  // Non-fatal
415
599
  }
600
+ if (finalDelivered) {
601
+ scheduleFinalHandoffReset();
602
+ }
603
+ else if (finalText) {
604
+ scheduleFinalDeliveryRetry(finalText);
605
+ }
416
606
  break;
417
607
  }
418
608
  default:
@@ -448,7 +638,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
448
638
  type: 'user',
449
639
  message: {
450
640
  role: 'user',
451
- content: 'You are connected to Canon messaging. Messages from users will arrive as follow-up messages. Respond naturally — your text responses will be sent back to the conversation. You have full Claude Code capabilities (file read/write, bash, etc).',
641
+ content: 'You are connected to Canon messaging. Follow-up messages in this session come from Canon participants, and your text responses will be sent back to the conversation. You have full Claude Code capabilities (file read/write, bash, etc).',
452
642
  },
453
643
  parent_tool_use_id: null,
454
644
  });
@@ -464,12 +654,14 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
464
654
  defaultModel: session.state.model ?? modelList[0]?.value,
465
655
  defaultPermissionMode: 'default',
466
656
  availableModels: modelList,
467
- availableWorkspaces: toPublicWorkspaceOptions(),
657
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
468
658
  }).catch(() => { });
469
659
  writeSessionState(conversationId, agentId, {
470
660
  ...session.state,
471
661
  availableModels: modelList,
472
662
  cwd,
663
+ executionMode: session.environment.mode,
664
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
473
665
  hostMode: true,
474
666
  isActive: true,
475
667
  }).catch(() => { });
@@ -478,7 +670,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
478
670
  catch {
479
671
  writeState();
480
672
  }
481
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${resumeSessionId ? 'resumed' : 'started'} (cwd: ${cwd})`);
673
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${resumeSessionId ? 'resumed' : 'started'} `
674
+ + `(mode: ${environment.mode}, cwd: ${cwd})`);
482
675
  };
483
676
  init().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err));
484
677
  return session;
@@ -489,12 +682,17 @@ async function main() {
489
682
  options: {
490
683
  cwd: { type: 'string' },
491
684
  workspace: { type: 'string', multiple: true },
685
+ 'enable-worktrees': { type: 'boolean' },
492
686
  },
493
687
  strict: false,
494
688
  });
495
689
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
496
690
  const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
497
- workspaceOptions = buildWorkspaceOptions(workingDir, configuredWorkspaces);
691
+ workspaceOptions = buildConfiguredWorkspaceOptions(workingDir, configuredWorkspaces);
692
+ const allowWorktrees = isEnabledFlag(args['enable-worktrees'] ?? process.env.CANON_ENABLE_WORKTREES);
693
+ if (!allowWorktrees) {
694
+ console.error('[canon-host] Worktree isolation is disabled; sessions will lock their selected workspace unless explicitly enabled.');
695
+ }
498
696
  const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-host]' });
499
697
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
500
698
  // ── Canon API client + RTDB auth ──
@@ -526,7 +724,7 @@ async function main() {
526
724
  defaultModel: runtimeModels[0]?.value,
527
725
  defaultPermissionMode: 'default',
528
726
  availableModels: runtimeModels,
529
- availableWorkspaces: toPublicWorkspaceOptions(),
727
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
530
728
  });
531
729
  }
532
730
  catch (error) {
@@ -542,21 +740,77 @@ async function main() {
542
740
  // ── Session manager ──
543
741
  const sessions = new Map();
544
742
  const pendingSessionCreations = new Map();
545
- /** Saved SDK session IDs for resume after idle eviction */
743
+ /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
546
744
  const savedSessionIds = new Map();
745
+ const conversationCache = new Map();
746
+ let conversationCacheLoadedAt = 0;
747
+ async function refreshConversationCache(force = false) {
748
+ if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
749
+ return;
750
+ }
751
+ const conversations = await client.getConversations();
752
+ conversationCache.clear();
753
+ for (const conversation of conversations) {
754
+ conversationCache.set(conversation.id, conversation);
755
+ }
756
+ conversationCacheLoadedAt = Date.now();
757
+ }
758
+ async function getConversationMeta(conversationId) {
759
+ try {
760
+ await refreshConversationCache();
761
+ const cached = conversationCache.get(conversationId);
762
+ if (cached)
763
+ return cached;
764
+ await refreshConversationCache(true);
765
+ return conversationCache.get(conversationId) ?? null;
766
+ }
767
+ catch {
768
+ return conversationCache.get(conversationId) ?? null;
769
+ }
770
+ }
771
+ async function loadParticipantContext(input) {
772
+ const [conversation, recentMessages] = await Promise.all([
773
+ getConversationMeta(input.conversationId),
774
+ client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
775
+ ]);
776
+ const history = buildParticipationHistorySnapshot(recentMessages, agentId);
777
+ return {
778
+ conversationType: conversation?.type ?? 'unknown',
779
+ memberCount: conversation?.memberIds?.length ?? null,
780
+ senderType: input.message.senderType ?? 'human',
781
+ senderName: input.senderName,
782
+ isOwner: input.isOwner,
783
+ mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
784
+ recentSenderTypes: history.recentSenderTypes,
785
+ recentHumanCount: history.recentHumanCount,
786
+ recentAgentCount: history.recentAgentCount,
787
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
788
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
789
+ };
790
+ }
547
791
  function closeSession(conversationId) {
548
792
  const session = sessions.get(conversationId);
549
793
  if (!session)
550
794
  return;
551
795
  session.closed = true;
796
+ releaseConversationEnvironment(session.environment);
552
797
  // Save SDK session ID for future resume
553
798
  if (session.sdkSessionId) {
554
- savedSessionIds.set(conversationId, session.sdkSessionId);
799
+ savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
555
800
  }
556
801
  if (session.streamingTimer)
557
802
  clearTimeout(session.streamingTimer);
803
+ if (session.idleResetTimer) {
804
+ clearTimeout(session.idleResetTimer);
805
+ session.idleResetTimer = null;
806
+ }
807
+ if (session.finalDeliveryTimer) {
808
+ clearTimeout(session.finalDeliveryTimer);
809
+ session.finalDeliveryTimer = null;
810
+ }
558
811
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
559
812
  clearSessionState(conversationId, agentId).catch(() => { });
813
+ clearTurnState(conversationId, agentId).catch(() => { });
560
814
  client.setTyping(conversationId, false).catch(() => { });
561
815
  try {
562
816
  session.query.close();
@@ -593,20 +847,40 @@ async function main() {
593
847
  }
594
848
  const creation = (async () => {
595
849
  const config = await loadSessionConfig(conversationId, agentId);
596
- const resumeId = savedSessionIds.get(conversationId);
597
- const session = createSession(conversationId, resolveWorkspaceCwd(config), agentId, client, (convoId, sdkSessionId) => {
598
- // Called when session processing loop ends — save session ID for resume
599
- if (sdkSessionId)
600
- savedSessionIds.set(convoId, sdkSessionId);
601
- sessions.delete(convoId);
602
- clearSessionState(convoId, agentId).catch(() => { });
603
- }, {
604
- model: config?.model ?? runtimeModels[0]?.value,
605
- permissionMode: config?.permissionMode ?? 'default',
606
- availableModels: runtimeModels,
607
- }, resumeId);
608
- sessions.set(conversationId, session);
609
- return session;
850
+ const workspaceCwd = resolveWorkspaceCwd(config);
851
+ const environment = prepareConversationEnvironment({
852
+ agentId,
853
+ conversationId,
854
+ workspaceCwd,
855
+ allowWorktrees,
856
+ });
857
+ try {
858
+ const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
859
+ const resumeId = savedSessionIds.get(resumeKey);
860
+ const session = createSession(conversationId, environment, agentId, client, (convoId, sdkSessionId) => {
861
+ // Called when session processing loop ends — save session ID for resume
862
+ const existing = sessions.get(convoId);
863
+ if (existing) {
864
+ releaseConversationEnvironment(existing.environment);
865
+ if (sdkSessionId) {
866
+ savedSessionIds.set(buildConversationEnvironmentKey(convoId, existing.environment.baseCwd), sdkSessionId);
867
+ }
868
+ }
869
+ sessions.delete(convoId);
870
+ clearSessionState(convoId, agentId).catch(() => { });
871
+ clearTurnState(convoId, agentId).catch(() => { });
872
+ }, {
873
+ model: config?.model ?? runtimeModels[0]?.value,
874
+ permissionMode: config?.permissionMode ?? 'default',
875
+ availableModels: runtimeModels,
876
+ }, resumeId);
877
+ sessions.set(conversationId, session);
878
+ return session;
879
+ }
880
+ catch (error) {
881
+ releaseConversationEnvironment(environment);
882
+ throw error;
883
+ }
610
884
  })();
611
885
  pendingSessionCreations.set(conversationId, creation);
612
886
  try {
@@ -641,22 +915,48 @@ async function main() {
641
915
  const sender = m.senderName || m.senderId;
642
916
  const content = renderInboundContent(m);
643
917
  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
918
  void (async () => {
646
- const session = await getOrCreateSession(convoId);
647
- session.sendInput({
919
+ const conversation = await getConversationMeta(convoId);
920
+ const behavior = payload.behavior ?? conversation?.behavior;
921
+ const participantContext = await loadParticipantContext({
922
+ conversationId: convoId,
923
+ message: m,
924
+ senderName: sender,
925
+ isOwner,
926
+ });
927
+ const autoReply = decideAutoReply(participantContext, behavior);
928
+ if (!autoReply.allow) {
929
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
930
+ return;
931
+ }
932
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
933
+ let session;
934
+ try {
935
+ session = await getOrCreateSession(convoId);
936
+ }
937
+ catch (error) {
938
+ const message = error instanceof Error ? error.message : String(error);
939
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
940
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
941
+ await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}`).catch(() => { });
942
+ return;
943
+ }
944
+ const turnMetadata = normalizeTurnMetadata(m.metadata);
945
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
946
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
947
+ session.enqueueInbound({
648
948
  type: 'user',
649
949
  message: {
650
950
  role: 'user',
651
951
  content: buildCanonPrompt({
652
952
  content,
653
953
  conversationId: convoId,
654
- isOwner,
655
- senderName: sender,
954
+ participantContext,
955
+ behavior,
656
956
  }),
657
957
  },
658
958
  parent_tool_use_id: null,
659
- });
959
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
660
960
  })();
661
961
  },
662
962
  onConnected: () => console.error('[canon-host] SSE connected'),
@@ -716,6 +1016,8 @@ async function main() {
716
1016
  ...session.state,
717
1017
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
718
1018
  cwd: session.cwd,
1019
+ executionMode: session.environment.mode,
1020
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
719
1021
  hostMode: true,
720
1022
  isActive: true,
721
1023
  }).catch(() => { });
@@ -733,6 +1035,17 @@ async function main() {
733
1035
  if (session && !session.closed) {
734
1036
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Interrupt signal`);
735
1037
  try {
1038
+ session.turnState = 'interrupted';
1039
+ writeTurnState(convoId, agentId, {
1040
+ turnId: session.currentTurnId,
1041
+ state: 'interrupted',
1042
+ queueDepth: session.pendingInputs.length,
1043
+ currentSpeakerId: agentId,
1044
+ lastAcceptedIntent: session.lastAcceptedIntent,
1045
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1046
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
1047
+ completedAt: { '.sv': 'timestamp' },
1048
+ }).catch(() => { });
736
1049
  await session.query.interrupt();
737
1050
  if (session.streamingTimer) {
738
1051
  clearTimeout(session.streamingTimer);
@@ -764,9 +1077,23 @@ async function main() {
764
1077
  ...session.state,
765
1078
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
766
1079
  cwd: session.cwd,
1080
+ executionMode: session.environment.mode,
1081
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
767
1082
  hostMode: true,
768
1083
  isActive: true,
769
1084
  }).catch(() => { });
1085
+ writeTurnState(session.conversationId, agentId, {
1086
+ turnId: session.currentTurnId,
1087
+ state: session.turnState,
1088
+ queueDepth: session.pendingInputs.length,
1089
+ currentSpeakerId: agentId,
1090
+ lastAcceptedIntent: session.lastAcceptedIntent,
1091
+ capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1092
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
1093
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
1094
+ ? { completedAt: { '.sv': 'timestamp' } }
1095
+ : {}),
1096
+ }).catch(() => { });
770
1097
  }
771
1098
  }, HEARTBEAT_MS);
772
1099
  // ── Idle session cleanup ──
@@ -0,0 +1,20 @@
1
+ import { type CanonConversation, type ResolvedAgentBehaviorPolicy } 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
+ currentAgentStreakStartedByHuman: boolean;
14
+ }
15
+ export interface AutoReplyDecision {
16
+ allow: boolean;
17
+ reason: string;
18
+ }
19
+ export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
20
+ export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
@@ -0,0 +1,43 @@
1
+ import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
2
+ function formatRecentSenders(senderTypes) {
3
+ if (senderTypes.length === 0)
4
+ return 'none';
5
+ return senderTypes.map((senderType) => (senderType === 'ai_agent' ? 'agent' : 'human')).join(' -> ');
6
+ }
7
+ export function buildInboundContextLines(context) {
8
+ const conversationTypeLabel = context.conversationType === 'unknown'
9
+ ? 'unknown'
10
+ : `${context.conversationType}${context.memberCount ? ` (${context.memberCount} members)` : ''}`;
11
+ const senderRole = context.isOwner
12
+ ? 'The latest sender is the verified human owner of this Canon agent.'
13
+ : context.senderType === 'ai_agent'
14
+ ? 'The latest sender is another AI agent in Canon.'
15
+ : 'The latest sender is a human Canon participant.';
16
+ return [
17
+ senderRole,
18
+ `Latest sender name: ${context.senderName}`,
19
+ `Latest sender type: ${context.senderType}`,
20
+ `Conversation type: ${conversationTypeLabel}`,
21
+ `Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
22
+ `Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
23
+ `Recent human messages: ${context.recentHumanCount}`,
24
+ `Recent agent messages: ${context.recentAgentCount}`,
25
+ `Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
26
+ `Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
27
+ ];
28
+ }
29
+ export function decideAutoReply(context, behavior) {
30
+ const decision = evaluateParticipationPolicy(behavior ?? resolveAgentBehaviorPolicy(), {
31
+ conversationType: context.conversationType,
32
+ senderType: context.senderType,
33
+ isOwner: context.isOwner,
34
+ mentionedAgent: context.mentionedAgent,
35
+ recentHumanCount: context.recentHumanCount,
36
+ consecutiveAgentTurns: context.consecutiveAgentTurns,
37
+ currentAgentStreakStartedByHuman: context.currentAgentStreakStartedByHuman,
38
+ });
39
+ return {
40
+ allow: decision.allow,
41
+ reason: decision.reason,
42
+ };
43
+ }
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,36 @@ async function handleInboundMessage(payload) {
261
325
  content_type: m.contentType || 'text',
262
326
  ts: new Date().toISOString(),
263
327
  };
328
+ if (payload.behavior) {
329
+ meta.behavior_style = payload.behavior.participation.style;
330
+ meta.behavior_allow_agent_to_agent = String(payload.behavior.participation.allowAgentToAgent);
331
+ meta.behavior_allow_long_running = String(payload.behavior.participation.allowLongRunningCollaboration);
332
+ meta.behavior_require_group_mention = String(payload.behavior.participation.requireMentionForGroupReplies);
333
+ if (payload.behavior.participation.maxConsecutiveAgentTurns != null) {
334
+ meta.behavior_max_consecutive_agent_turns = String(payload.behavior.participation.maxConsecutiveAgentTurns);
335
+ }
336
+ if (payload.behavior.instructions.length > 0) {
337
+ meta.behavior_instructions = payload.behavior.instructions.join('\n\n');
338
+ }
339
+ }
340
+ const primaryAttachment = getPrimaryAttachment(m);
264
341
  if (m.imageUrl)
265
342
  meta.image_url = m.imageUrl;
266
343
  if (m.audioUrl)
267
344
  meta.audio_url = m.audioUrl;
268
- console.error(`[canon] Inbound message from ${meta.sender_name}: "${m.text}"`);
345
+ if (primaryAttachment?.url) {
346
+ meta.attachment_kind = primaryAttachment.kind;
347
+ meta.attachment_url = primaryAttachment.url;
348
+ }
349
+ if (primaryAttachment?.fileName)
350
+ meta.attachment_file_name = primaryAttachment.fileName;
351
+ const renderedContent = renderInboundContent(m);
352
+ console.error(`[canon] Inbound message from ${meta.sender_name}: "${renderedContent}"`);
269
353
  try {
270
354
  await server.notification({
271
355
  method: 'notifications/claude/channel',
272
356
  params: {
273
- content: m.text || '',
357
+ content: renderedContent,
274
358
  meta,
275
359
  },
276
360
  });
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.5.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.4.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
- }