@canonmsg/claude-code-plugin 0.4.0 → 0.6.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,33 +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, clearTurnState, DEFAULT_RUNTIME_CAPABILITIES, normalizeTurnMetadata, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, } 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';
29
28
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
30
29
  let workingDir = process.cwd();
31
30
  let workspaceOptions = [];
32
31
  let runtimeModels = [];
33
- function normalizeString(value) {
34
- if (typeof value !== 'string')
35
- return undefined;
36
- const trimmed = value.trim();
37
- return trimmed ? trimmed : undefined;
38
- }
39
- function buildWorkspaceOptions(primaryCwd, configured) {
40
- const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
41
- const seenLabels = new Map();
42
- return uniqueDirs.map((cwd, index) => {
43
- const baseLabel = basename(cwd) || cwd;
44
- const seenCount = (seenLabels.get(baseLabel) ?? 0) + 1;
45
- seenLabels.set(baseLabel, seenCount);
46
- return {
47
- id: index === 0 ? 'default' : `workspace-${index + 1}`,
48
- label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
49
- cwd,
50
- };
51
- });
52
- }
53
32
  function toModelOptions(models) {
54
33
  return models.map((model) => ({
55
34
  value: model.value,
@@ -66,32 +45,22 @@ async function publishAgentRuntime(agentId, runtime) {
66
45
  }
67
46
  async function loadSessionConfig(conversationId, agentId) {
68
47
  const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
48
+ const baseConfig = readSessionWorkspaceConfig(raw);
69
49
  if (!raw || typeof raw !== 'object')
70
- return null;
50
+ return baseConfig;
71
51
  const data = raw;
72
- const permissionMode = normalizeString(data.permissionMode);
52
+ const permissionMode = normalizeOptionalString(data.permissionMode);
73
53
  return {
74
- workspaceId: normalizeString(data.workspaceId),
75
- legacyCwd: normalizeString(data.cwd),
76
- model: normalizeString(data.model),
54
+ ...baseConfig,
77
55
  permissionMode: permissionMode,
78
56
  };
79
57
  }
80
58
  function resolveWorkspaceCwd(config) {
81
- if (config?.workspaceId) {
82
- const workspace = workspaceOptions.find((option) => option.id === config.workspaceId);
83
- if (workspace)
84
- return workspace.cwd;
85
- }
86
- if (config?.legacyCwd) {
87
- const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
88
- if (workspace)
89
- return workspace.cwd;
90
- }
91
- return workspaceOptions[0]?.cwd ?? workingDir;
92
- }
93
- function toPublicWorkspaceOptions() {
94
- return workspaceOptions.map(({ id, label }) => ({ id, label }));
59
+ return resolveConfiguredWorkspaceCwd({
60
+ workspaceOptions,
61
+ config,
62
+ defaultCwd: workingDir,
63
+ });
95
64
  }
96
65
  async function detectRuntimeModels(cwd) {
97
66
  const emptyPrompt = {
@@ -125,24 +94,24 @@ const STREAMING_THROTTLE_MS = 250;
125
94
  const HEARTBEAT_MS = 30_000;
126
95
  const IDLE_CHECK_MS = 60_000;
127
96
  const CONTROL_POLL_MS = 2000;
128
- const COMPLETED_STATE_HOLD_MS = 750;
97
+ const FINAL_DELIVERY_RETRY_MS = 2_000;
129
98
  const CLAUDE_RUNTIME_CAPABILITIES = {
130
99
  ...DEFAULT_RUNTIME_CAPABILITIES,
131
100
  supportsInterrupt: true,
132
101
  supportsQueue: true,
133
102
  supportsInterleave: true,
134
103
  supportsRequiresAction: true,
135
- supportsNonFinalPermanentMessages: true,
104
+ supportsNonFinalPermanentMessages: false,
136
105
  };
137
106
  function buildCanonPrompt(input) {
138
107
  return [
139
108
  'You are connected to Canon messaging through a Claude Code host wrapper.',
140
- 'Reply naturally to Canon participants.',
141
109
  'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
142
110
  'Short intermediate assistant messages may be shown as ephemeral status while you work.',
143
111
  ...buildInboundContextLines(input.participantContext),
112
+ ...buildBehaviorPolicyLines(input.behavior),
144
113
  '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.',
114
+ 'Honor the Canon behavior policy above when deciding how proactively to participate.',
146
115
  `Conversation ID: ${input.conversationId}`,
147
116
  '',
148
117
  'New Canon message:',
@@ -183,7 +152,8 @@ function renderInboundContent(message) {
183
152
  return content || '[Empty message]';
184
153
  }
185
154
  // ── Session factory ─────────────────────────────────────────────────
186
- function createSession(conversationId, cwd, agentId, client, onSessionEnd, config, resumeSessionId) {
155
+ function createSession(conversationId, environment, agentId, client, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
156
+ const { cwd } = environment;
187
157
  // Per-session input stream with message queue
188
158
  let resolveInput = null;
189
159
  let inputDone = false;
@@ -315,6 +285,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
315
285
  session = {
316
286
  conversationId,
317
287
  cwd,
288
+ environment,
318
289
  query: q,
319
290
  sendInput,
320
291
  enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
@@ -327,9 +298,11 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
327
298
  streamingText: '',
328
299
  streamingTimer: null,
329
300
  idleResetTimer: null,
301
+ finalDeliveryTimer: null,
330
302
  seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
331
303
  pendingInputs: [],
332
304
  pendingFinalText: null,
305
+ pendingFinalDeliveryText: null,
333
306
  toolInProgress: false,
334
307
  turnState: 'idle',
335
308
  currentTurnId: null,
@@ -373,30 +346,92 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
373
346
  clearTimeout(session.idleResetTimer);
374
347
  session.idleResetTimer = null;
375
348
  }
349
+ function clearFinalDeliveryTimer() {
350
+ if (!session.finalDeliveryTimer)
351
+ return;
352
+ clearTimeout(session.finalDeliveryTimer);
353
+ session.finalDeliveryTimer = null;
354
+ }
376
355
  function resetTurnToIdle() {
377
356
  if (session.closed)
378
357
  return;
358
+ clearFinalDeliveryTimer();
379
359
  session.currentTurnId = null;
380
360
  session.currentTurnOpenedAt = null;
381
361
  session.lastAcceptedIntent = null;
382
362
  session.pendingFinalText = null;
363
+ session.pendingFinalDeliveryText = null;
383
364
  session.toolInProgress = false;
384
365
  session.turnState = 'idle';
385
366
  writeTurn();
386
367
  drainPendingInput();
387
368
  }
388
- function scheduleIdleTurnReset() {
369
+ function scheduleFinalHandoffReset() {
389
370
  clearIdleResetTimer();
390
371
  session.idleResetTimer = setTimeout(() => {
391
372
  session.idleResetTimer = null;
373
+ client.setTyping(conversationId, false).catch(() => { });
374
+ clearStreaming();
392
375
  resetTurnToIdle();
393
- }, COMPLETED_STATE_HOLD_MS);
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);
394
427
  }
395
428
  function writeState() {
396
429
  writeSessionState(conversationId, agentId, {
397
430
  ...session.state,
398
431
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
399
432
  cwd,
433
+ executionMode: session.environment.mode,
434
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
400
435
  hostMode: true,
401
436
  isActive: true,
402
437
  }).catch(() => { });
@@ -433,6 +468,11 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
433
468
  }
434
469
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
435
470
  session.lastActivity = Date.now();
471
+ if (session.pendingFinalDeliveryText) {
472
+ session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
473
+ writeTurn();
474
+ return;
475
+ }
436
476
  if (session.state.state === 'running' && intent !== 'interrupt') {
437
477
  session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
438
478
  writeTurn();
@@ -471,7 +511,6 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
471
511
  .filter((b) => b.type === 'text')
472
512
  .map((b) => b.text);
473
513
  const fullText = textBlocks.join('\n').trim();
474
- clearStreaming();
475
514
  if (fullText) {
476
515
  session.pendingFinalText = fullText;
477
516
  }
@@ -510,18 +549,14 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
510
549
  session.currentTurnId = randomUUID();
511
550
  session.currentTurnOpenedAt = Date.now();
512
551
  }
552
+ session.state.contextUsage = undefined;
513
553
  session.pendingFinalText = null;
514
554
  session.toolInProgress = false;
515
555
  session.turnState = 'thinking';
516
556
  client.setTyping(conversationId, true, 'thinking').catch(() => { });
517
557
  }
518
558
  else if (state === 'idle') {
519
- clearIdleResetTimer();
520
- session.turnState = 'completed';
521
559
  client.setTyping(conversationId, false).catch(() => { });
522
- clearStreaming();
523
- writeTurn();
524
- scheduleIdleTurnReset();
525
560
  }
526
561
  else if (state === 'requires_action') {
527
562
  clearIdleResetTimer();
@@ -548,42 +583,25 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
548
583
  ? msg.result.trim()
549
584
  : '';
550
585
  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
- }
586
+ const finalDelivered = finalText ? await deliverFinalReply(finalText) : true;
567
587
  try {
568
588
  const usage = await q.getContextUsage();
569
- writeSessionState(conversationId, agentId, {
570
- ...session.state,
571
- state: 'idle',
572
- contextUsage: {
573
- percentage: usage.percentage,
574
- totalTokens: usage.totalTokens,
575
- maxTokens: usage.maxTokens,
576
- },
577
- cwd,
578
- hostMode: true,
579
- isActive: true,
580
- }).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();
581
596
  }
582
597
  catch {
583
598
  // Non-fatal
584
599
  }
585
- if (session.state.state !== 'running' && !session.idleResetTimer) {
586
- drainPendingInput();
600
+ if (finalDelivered) {
601
+ scheduleFinalHandoffReset();
602
+ }
603
+ else if (finalText) {
604
+ scheduleFinalDeliveryRetry(finalText);
587
605
  }
588
606
  break;
589
607
  }
@@ -620,7 +638,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
620
638
  type: 'user',
621
639
  message: {
622
640
  role: 'user',
623
- 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).',
624
642
  },
625
643
  parent_tool_use_id: null,
626
644
  });
@@ -631,17 +649,19 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
631
649
  const modelList = toModelOptions(models);
632
650
  session.availableModels = modelList;
633
651
  runtimeModels = modelList;
634
- await publishAgentRuntime(agentId, {
652
+ onRuntimeDescriptorUpdate({
635
653
  defaultWorkspaceId: workspaceOptions[0]?.id,
636
654
  defaultModel: session.state.model ?? modelList[0]?.value,
637
655
  defaultPermissionMode: 'default',
638
656
  availableModels: modelList,
639
- availableWorkspaces: toPublicWorkspaceOptions(),
640
- }).catch(() => { });
657
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
658
+ });
641
659
  writeSessionState(conversationId, agentId, {
642
660
  ...session.state,
643
661
  availableModels: modelList,
644
662
  cwd,
663
+ executionMode: session.environment.mode,
664
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
645
665
  hostMode: true,
646
666
  isActive: true,
647
667
  }).catch(() => { });
@@ -650,7 +670,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
650
670
  catch {
651
671
  writeState();
652
672
  }
653
- 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})`);
654
675
  };
655
676
  init().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err));
656
677
  return session;
@@ -661,12 +682,17 @@ async function main() {
661
682
  options: {
662
683
  cwd: { type: 'string' },
663
684
  workspace: { type: 'string', multiple: true },
685
+ 'enable-worktrees': { type: 'boolean' },
664
686
  },
665
687
  strict: false,
666
688
  });
667
689
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
668
690
  const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
669
- 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
+ }
670
696
  const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-host]' });
671
697
  console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
672
698
  // ── Canon API client + RTDB auth ──
@@ -690,19 +716,36 @@ async function main() {
690
716
  }
691
717
  console.error(`[canon-host] Authenticated as ${agentId}`);
692
718
  }
719
+ let streamConnected = false;
720
+ let runtimeDescriptor = {
721
+ defaultWorkspaceId: workspaceOptions[0]?.id,
722
+ defaultPermissionMode: 'default',
723
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
724
+ };
725
+ const publishRuntimeHeartbeat = async () => {
726
+ if (!streamConnected)
727
+ return;
728
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
729
+ console.error('[canon-host] Failed to publish agent runtime:', error);
730
+ });
731
+ };
693
732
  // ── Load conversations (informational — sessions created on demand) ──
694
733
  try {
695
734
  runtimeModels = await detectRuntimeModels(workingDir);
696
- await publishAgentRuntime(agentId, {
735
+ runtimeDescriptor = {
697
736
  defaultWorkspaceId: workspaceOptions[0]?.id,
698
737
  defaultModel: runtimeModels[0]?.value,
699
738
  defaultPermissionMode: 'default',
700
739
  availableModels: runtimeModels,
701
- availableWorkspaces: toPublicWorkspaceOptions(),
702
- });
740
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
741
+ };
703
742
  }
704
- catch (error) {
705
- console.error('[canon-host] Failed to publish agent runtime:', error);
743
+ catch {
744
+ runtimeDescriptor = {
745
+ defaultWorkspaceId: workspaceOptions[0]?.id,
746
+ defaultPermissionMode: 'default',
747
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
748
+ };
706
749
  }
707
750
  try {
708
751
  const convos = await client.getConversations();
@@ -714,7 +757,7 @@ async function main() {
714
757
  // ── Session manager ──
715
758
  const sessions = new Map();
716
759
  const pendingSessionCreations = new Map();
717
- /** Saved SDK session IDs for resume after idle eviction */
760
+ /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
718
761
  const savedSessionIds = new Map();
719
762
  const conversationCache = new Map();
720
763
  let conversationCacheLoadedAt = 0;
@@ -745,17 +788,9 @@ async function main() {
745
788
  async function loadParticipantContext(input) {
746
789
  const [conversation, recentMessages] = await Promise.all([
747
790
  getConversationMeta(input.conversationId),
748
- client.getMessages(input.conversationId, 6).catch(() => []),
791
+ client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
749
792
  ]);
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
- }
793
+ const history = buildParticipationHistorySnapshot(recentMessages, agentId);
759
794
  return {
760
795
  conversationType: conversation?.type ?? 'unknown',
761
796
  memberCount: conversation?.memberIds?.length ?? null,
@@ -763,10 +798,11 @@ async function main() {
763
798
  senderName: input.senderName,
764
799
  isOwner: input.isOwner,
765
800
  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,
801
+ recentSenderTypes: history.recentSenderTypes,
802
+ recentHumanCount: history.recentHumanCount,
803
+ recentAgentCount: history.recentAgentCount,
804
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
805
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
770
806
  };
771
807
  }
772
808
  function closeSession(conversationId) {
@@ -774,9 +810,10 @@ async function main() {
774
810
  if (!session)
775
811
  return;
776
812
  session.closed = true;
813
+ releaseConversationEnvironment(session.environment);
777
814
  // Save SDK session ID for future resume
778
815
  if (session.sdkSessionId) {
779
- savedSessionIds.set(conversationId, session.sdkSessionId);
816
+ savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
780
817
  }
781
818
  if (session.streamingTimer)
782
819
  clearTimeout(session.streamingTimer);
@@ -784,6 +821,10 @@ async function main() {
784
821
  clearTimeout(session.idleResetTimer);
785
822
  session.idleResetTimer = null;
786
823
  }
824
+ if (session.finalDeliveryTimer) {
825
+ clearTimeout(session.finalDeliveryTimer);
826
+ session.finalDeliveryTimer = null;
827
+ }
787
828
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
788
829
  clearSessionState(conversationId, agentId).catch(() => { });
789
830
  clearTurnState(conversationId, agentId).catch(() => { });
@@ -823,21 +864,43 @@ async function main() {
823
864
  }
824
865
  const creation = (async () => {
825
866
  const config = await loadSessionConfig(conversationId, agentId);
826
- const resumeId = savedSessionIds.get(conversationId);
827
- const session = createSession(conversationId, resolveWorkspaceCwd(config), agentId, client, (convoId, sdkSessionId) => {
828
- // Called when session processing loop ends — save session ID for resume
829
- if (sdkSessionId)
830
- savedSessionIds.set(convoId, sdkSessionId);
831
- sessions.delete(convoId);
832
- clearSessionState(convoId, agentId).catch(() => { });
833
- clearTurnState(convoId, agentId).catch(() => { });
834
- }, {
835
- model: config?.model ?? runtimeModels[0]?.value,
836
- permissionMode: config?.permissionMode ?? 'default',
837
- availableModels: runtimeModels,
838
- }, resumeId);
839
- sessions.set(conversationId, session);
840
- return session;
867
+ const workspaceCwd = resolveWorkspaceCwd(config);
868
+ const environment = prepareConversationEnvironment({
869
+ agentId,
870
+ conversationId,
871
+ workspaceCwd,
872
+ allowWorktrees,
873
+ });
874
+ try {
875
+ const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
876
+ const resumeId = savedSessionIds.get(resumeKey);
877
+ const session = createSession(conversationId, environment, agentId, client, (convoId, sdkSessionId) => {
878
+ // Called when session processing loop ends — save session ID for resume
879
+ const existing = sessions.get(convoId);
880
+ if (existing) {
881
+ releaseConversationEnvironment(existing.environment);
882
+ if (sdkSessionId) {
883
+ savedSessionIds.set(buildConversationEnvironmentKey(convoId, existing.environment.baseCwd), sdkSessionId);
884
+ }
885
+ }
886
+ sessions.delete(convoId);
887
+ clearSessionState(convoId, agentId).catch(() => { });
888
+ clearTurnState(convoId, agentId).catch(() => { });
889
+ }, (descriptor) => {
890
+ runtimeDescriptor = descriptor;
891
+ void publishRuntimeHeartbeat();
892
+ }, {
893
+ model: config?.model ?? runtimeModels[0]?.value,
894
+ permissionMode: config?.permissionMode ?? 'default',
895
+ availableModels: runtimeModels,
896
+ }, resumeId);
897
+ sessions.set(conversationId, session);
898
+ return session;
899
+ }
900
+ catch (error) {
901
+ releaseConversationEnvironment(environment);
902
+ throw error;
903
+ }
841
904
  })();
842
905
  pendingSessionCreations.set(conversationId, creation);
843
906
  try {
@@ -873,19 +936,31 @@ async function main() {
873
936
  const content = renderInboundContent(m);
874
937
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
875
938
  void (async () => {
939
+ const conversation = await getConversationMeta(convoId);
940
+ const behavior = payload.behavior ?? conversation?.behavior;
876
941
  const participantContext = await loadParticipantContext({
877
942
  conversationId: convoId,
878
943
  message: m,
879
944
  senderName: sender,
880
945
  isOwner,
881
946
  });
882
- const autoReply = decideAutoReply(participantContext);
947
+ const autoReply = decideAutoReply(participantContext, behavior);
883
948
  if (!autoReply.allow) {
884
949
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
885
950
  return;
886
951
  }
887
952
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
888
- const session = await getOrCreateSession(convoId);
953
+ let session;
954
+ try {
955
+ session = await getOrCreateSession(convoId);
956
+ }
957
+ catch (error) {
958
+ const message = error instanceof Error ? error.message : String(error);
959
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
960
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
961
+ await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}`).catch(() => { });
962
+ return;
963
+ }
889
964
  const turnMetadata = normalizeTurnMetadata(m.metadata);
890
965
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
891
966
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
@@ -897,14 +972,23 @@ async function main() {
897
972
  content,
898
973
  conversationId: convoId,
899
974
  participantContext,
975
+ behavior,
900
976
  }),
901
977
  },
902
978
  parent_tool_use_id: null,
903
979
  }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
904
980
  })();
905
981
  },
906
- onConnected: () => console.error('[canon-host] SSE connected'),
907
- onDisconnected: () => console.error('[canon-host] SSE disconnected'),
982
+ onConnected: () => {
983
+ streamConnected = true;
984
+ void publishRuntimeHeartbeat();
985
+ console.error('[canon-host] SSE connected');
986
+ },
987
+ onDisconnected: () => {
988
+ streamConnected = false;
989
+ rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
990
+ console.error('[canon-host] SSE disconnected');
991
+ },
908
992
  onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
909
993
  },
910
994
  });
@@ -960,6 +1044,8 @@ async function main() {
960
1044
  ...session.state,
961
1045
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
962
1046
  cwd: session.cwd,
1047
+ executionMode: session.environment.mode,
1048
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
963
1049
  hostMode: true,
964
1050
  isActive: true,
965
1051
  }).catch(() => { });
@@ -1019,6 +1105,8 @@ async function main() {
1019
1105
  ...session.state,
1020
1106
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1021
1107
  cwd: session.cwd,
1108
+ executionMode: session.environment.mode,
1109
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1022
1110
  hostMode: true,
1023
1111
  isActive: true,
1024
1112
  }).catch(() => { });
@@ -1035,6 +1123,7 @@ async function main() {
1035
1123
  : {}),
1036
1124
  }).catch(() => { });
1037
1125
  }
1126
+ void publishRuntimeHeartbeat();
1038
1127
  }, HEARTBEAT_MS);
1039
1128
  // ── Idle session cleanup ──
1040
1129
  const idleCheck = setInterval(() => {
@@ -1056,6 +1145,7 @@ async function main() {
1056
1145
  clearInterval(heartbeat);
1057
1146
  clearInterval(idleCheck);
1058
1147
  stream.stop();
1148
+ await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
1059
1149
  for (const convoId of [...sessions.keys()]) {
1060
1150
  closeSession(convoId);
1061
1151
  }
@@ -1,4 +1,4 @@
1
- import type { CanonConversation } from '@canonmsg/core';
1
+ import { type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
2
2
  export interface InboundParticipantContext {
3
3
  conversationType: CanonConversation['type'] | 'unknown';
4
4
  memberCount: number | null;
@@ -10,10 +10,11 @@ export interface InboundParticipantContext {
10
10
  recentHumanCount: number;
11
11
  recentAgentCount: number;
12
12
  consecutiveAgentTurns: number;
13
+ currentAgentStreakStartedByHuman: boolean;
13
14
  }
14
15
  export interface AutoReplyDecision {
15
16
  allow: boolean;
16
17
  reason: string;
17
18
  }
18
19
  export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
19
- export declare function decideAutoReply(context: InboundParticipantContext): AutoReplyDecision;
20
+ export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
@@ -1,3 +1,4 @@
1
+ import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
1
2
  function formatRecentSenders(senderTypes) {
2
3
  if (senderTypes.length === 0)
3
4
  return 'none';
@@ -22,29 +23,21 @@ export function buildInboundContextLines(context) {
22
23
  `Recent human messages: ${context.recentHumanCount}`,
23
24
  `Recent agent messages: ${context.recentAgentCount}`,
24
25
  `Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
26
+ `Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
25
27
  ];
26
28
  }
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' };
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
+ };
50
43
  }
package/dist/register.js CHANGED
@@ -33,6 +33,14 @@ if (!values.name || !values.description || !values.phone) {
33
33
  }
34
34
  // Default profile name: sanitized agent name
35
35
  const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
36
+ let existingAgentId;
37
+ try {
38
+ const profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
39
+ existingAgentId = profiles[profileName]?.agentId;
40
+ }
41
+ catch {
42
+ // File doesn't exist yet
43
+ }
36
44
  console.log(`Registering agent "${values.name}" (profile: ${profileName})...`);
37
45
  const result = await registerAndWaitForApproval({
38
46
  name: values.name,
@@ -41,6 +49,7 @@ const result = await registerAndWaitForApproval({
41
49
  developerInfo: 'Claude Code plugin',
42
50
  clientType: 'claude-code',
43
51
  baseUrl: values['base-url'],
52
+ requestedAgentId: existingAgentId,
44
53
  }, {
45
54
  onSubmitted: (requestId) => {
46
55
  console.log(`Registration submitted (request ID: ${requestId}).`);
package/dist/server.js CHANGED
@@ -325,6 +325,18 @@ async function handleInboundMessage(payload) {
325
325
  content_type: m.contentType || 'text',
326
326
  ts: new Date().toISOString(),
327
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
+ }
328
340
  const primaryAttachment = getPrimaryAttachment(m);
329
341
  if (m.imageUrl)
330
342
  meta.image_url = m.imageUrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
25
- "@canonmsg/core": "^0.3.0",
25
+ "@canonmsg/core": "^0.5.0",
26
26
  "@modelcontextprotocol/sdk": "^1.12.0"
27
27
  },
28
28
  "engines": {