@canonmsg/claude-code-plugin 0.4.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,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, 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
  });
@@ -636,12 +654,14 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
636
654
  defaultModel: session.state.model ?? modelList[0]?.value,
637
655
  defaultPermissionMode: 'default',
638
656
  availableModels: modelList,
639
- availableWorkspaces: toPublicWorkspaceOptions(),
657
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
640
658
  }).catch(() => { });
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 ──
@@ -698,7 +724,7 @@ async function main() {
698
724
  defaultModel: runtimeModels[0]?.value,
699
725
  defaultPermissionMode: 'default',
700
726
  availableModels: runtimeModels,
701
- availableWorkspaces: toPublicWorkspaceOptions(),
727
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
702
728
  });
703
729
  }
704
730
  catch (error) {
@@ -714,7 +740,7 @@ async function main() {
714
740
  // ── Session manager ──
715
741
  const sessions = new Map();
716
742
  const pendingSessionCreations = new Map();
717
- /** Saved SDK session IDs for resume after idle eviction */
743
+ /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
718
744
  const savedSessionIds = new Map();
719
745
  const conversationCache = new Map();
720
746
  let conversationCacheLoadedAt = 0;
@@ -745,17 +771,9 @@ async function main() {
745
771
  async function loadParticipantContext(input) {
746
772
  const [conversation, recentMessages] = await Promise.all([
747
773
  getConversationMeta(input.conversationId),
748
- client.getMessages(input.conversationId, 6).catch(() => []),
774
+ client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
749
775
  ]);
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
- }
776
+ const history = buildParticipationHistorySnapshot(recentMessages, agentId);
759
777
  return {
760
778
  conversationType: conversation?.type ?? 'unknown',
761
779
  memberCount: conversation?.memberIds?.length ?? null,
@@ -763,10 +781,11 @@ async function main() {
763
781
  senderName: input.senderName,
764
782
  isOwner: input.isOwner,
765
783
  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,
784
+ recentSenderTypes: history.recentSenderTypes,
785
+ recentHumanCount: history.recentHumanCount,
786
+ recentAgentCount: history.recentAgentCount,
787
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
788
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
770
789
  };
771
790
  }
772
791
  function closeSession(conversationId) {
@@ -774,9 +793,10 @@ async function main() {
774
793
  if (!session)
775
794
  return;
776
795
  session.closed = true;
796
+ releaseConversationEnvironment(session.environment);
777
797
  // Save SDK session ID for future resume
778
798
  if (session.sdkSessionId) {
779
- savedSessionIds.set(conversationId, session.sdkSessionId);
799
+ savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
780
800
  }
781
801
  if (session.streamingTimer)
782
802
  clearTimeout(session.streamingTimer);
@@ -784,6 +804,10 @@ async function main() {
784
804
  clearTimeout(session.idleResetTimer);
785
805
  session.idleResetTimer = null;
786
806
  }
807
+ if (session.finalDeliveryTimer) {
808
+ clearTimeout(session.finalDeliveryTimer);
809
+ session.finalDeliveryTimer = null;
810
+ }
787
811
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
788
812
  clearSessionState(conversationId, agentId).catch(() => { });
789
813
  clearTurnState(conversationId, agentId).catch(() => { });
@@ -823,21 +847,40 @@ async function main() {
823
847
  }
824
848
  const creation = (async () => {
825
849
  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;
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
+ }
841
884
  })();
842
885
  pendingSessionCreations.set(conversationId, creation);
843
886
  try {
@@ -873,19 +916,31 @@ async function main() {
873
916
  const content = renderInboundContent(m);
874
917
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
875
918
  void (async () => {
919
+ const conversation = await getConversationMeta(convoId);
920
+ const behavior = payload.behavior ?? conversation?.behavior;
876
921
  const participantContext = await loadParticipantContext({
877
922
  conversationId: convoId,
878
923
  message: m,
879
924
  senderName: sender,
880
925
  isOwner,
881
926
  });
882
- const autoReply = decideAutoReply(participantContext);
927
+ const autoReply = decideAutoReply(participantContext, behavior);
883
928
  if (!autoReply.allow) {
884
929
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
885
930
  return;
886
931
  }
887
932
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
888
- const session = await getOrCreateSession(convoId);
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
+ }
889
944
  const turnMetadata = normalizeTurnMetadata(m.metadata);
890
945
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
891
946
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
@@ -897,6 +952,7 @@ async function main() {
897
952
  content,
898
953
  conversationId: convoId,
899
954
  participantContext,
955
+ behavior,
900
956
  }),
901
957
  },
902
958
  parent_tool_use_id: null,
@@ -960,6 +1016,8 @@ async function main() {
960
1016
  ...session.state,
961
1017
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
962
1018
  cwd: session.cwd,
1019
+ executionMode: session.environment.mode,
1020
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
963
1021
  hostMode: true,
964
1022
  isActive: true,
965
1023
  }).catch(() => { });
@@ -1019,6 +1077,8 @@ async function main() {
1019
1077
  ...session.state,
1020
1078
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
1021
1079
  cwd: session.cwd,
1080
+ executionMode: session.environment.mode,
1081
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1022
1082
  hostMode: true,
1023
1083
  isActive: true,
1024
1084
  }).catch(() => { });
@@ -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/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.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",
@@ -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.4.0",
26
26
  "@modelcontextprotocol/sdk": "^1.12.0"
27
27
  },
28
28
  "engines": {