@canonmsg/claude-code-plugin 0.7.0 → 0.9.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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Host-runtime helpers, inlined from @canonmsg/core.
3
+ *
4
+ * These helpers glue Canon host wrappers (Codex, Claude Code) to the Canon
5
+ * RTDB session/runtime surface. They used to live in
6
+ * `@canonmsg/core/src/host-runtime/` but were moved into each plugin in
7
+ * media-parity PR C so core does not have to carry host-specific concerns.
8
+ *
9
+ * Keep this file in lockstep with the equivalent file in
10
+ * `packages/codex-plugin/src/host-runtime.ts`. If you change the
11
+ * behavior here, update that copy too and adjust the shared golden
12
+ * fixture test (`packages/claude-code-plugin/src/host-runtime.test.ts`).
13
+ */
14
+ import { type AgentClientType, type AgentRuntime, type CanonClient, type CanonConversation, type CanonMessage, type CanonMessagesPage, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type SessionWorkspaceConfig } from '@canonmsg/core';
15
+ export interface HostInboundParticipantContext {
16
+ conversationType: CanonConversation['type'] | 'unknown';
17
+ memberCount: number | null;
18
+ senderType: 'human' | 'ai_agent';
19
+ senderName: string;
20
+ isOwner: boolean;
21
+ mentionedAgent: boolean;
22
+ recentSenderTypes: Array<'human' | 'ai_agent'>;
23
+ recentHumanCount: number;
24
+ recentAgentCount: number;
25
+ consecutiveAgentTurns: number;
26
+ currentAgentStreakStartedByHuman: boolean;
27
+ }
28
+ type HostInboundMessage = {
29
+ text?: string | null;
30
+ contentType?: CanonMessage['contentType'] | null;
31
+ audioUrl?: string | null;
32
+ audioDurationMs?: number | null;
33
+ imageUrl?: string | null;
34
+ attachments?: CanonMessage['attachments'];
35
+ senderType?: CanonMessage['senderType'];
36
+ mentions?: string[] | null;
37
+ };
38
+ interface HostWorkspaceResolverOption {
39
+ id: string;
40
+ cwd: string;
41
+ }
42
+ export declare function buildCanonHostPrompt(input: {
43
+ hostLabel: string;
44
+ content: string;
45
+ conversationId: string;
46
+ participantContext: HostInboundParticipantContext;
47
+ behavior?: ResolvedAgentBehaviorPolicy | null;
48
+ workSession?: MessageCreatedPayload['message']['workSession'];
49
+ workSessions?: MessageCreatedPayload['workSessions'];
50
+ buildInboundContextLines: (context: HostInboundParticipantContext) => string[];
51
+ }): string;
52
+ /**
53
+ * Render the **text portion** of an inbound Canon message. Images and other
54
+ * binary attachments are referenced by short placeholders — their actual
55
+ * bytes are delivered to the host as native vision/media inputs (Anthropic
56
+ * image blocks, Codex `-i` file args). URLs are intentionally *not* inlined,
57
+ * since the harness never needs to refetch and earlier `[Image: <url>]`
58
+ * inlining caused vision models to see a string about an image instead of
59
+ * the image itself.
60
+ *
61
+ * `materialized` may be passed so non-image attachments can reference a
62
+ * local path the agent can Read. Without it we fall back to an unadorned
63
+ * placeholder; the vision path still works because image blocks carry the
64
+ * bytes directly.
65
+ */
66
+ export declare function renderCanonHostInboundContent(message: HostInboundMessage, materialized?: ReadonlyArray<{
67
+ kind: 'image' | 'audio' | 'file';
68
+ path: string;
69
+ fileName?: string;
70
+ durationMs?: number;
71
+ index: number;
72
+ }>): string;
73
+ export declare function buildHydratedInboundContext(input: {
74
+ agentId: string;
75
+ conversation: CanonConversation | null;
76
+ page?: CanonMessagesPage | null;
77
+ message: HostInboundMessage;
78
+ senderName: string;
79
+ isOwner: boolean;
80
+ }): {
81
+ participantContext: HostInboundParticipantContext;
82
+ behavior?: ResolvedAgentBehaviorPolicy | null;
83
+ workSessions: NonNullable<MessageCreatedPayload['workSessions']>;
84
+ hydratedFromPage: boolean;
85
+ };
86
+ export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
87
+ export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null;
88
+ export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
89
+ conversationId: string;
90
+ agentId: string;
91
+ extraStringFields?: readonly TExtra[];
92
+ }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null>;
93
+ export declare function resolveHostWorkspaceCwd(input: {
94
+ workspaceOptions: HostWorkspaceResolverOption[];
95
+ config: {
96
+ workspaceId?: string;
97
+ retiredWorkspaceConfig?: boolean;
98
+ } | null;
99
+ defaultCwd: string;
100
+ }): string;
101
+ export declare function createConversationMetadataLoader(input: {
102
+ client: CanonClient;
103
+ conversationCache: Map<string, CanonConversation>;
104
+ cacheTtlMs?: number;
105
+ }): {
106
+ refreshConversationCache(force?: boolean): Promise<void>;
107
+ getConversationMeta(conversationId: string): Promise<CanonConversation | null>;
108
+ };
109
+ export {};
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Host-runtime helpers, inlined from @canonmsg/core.
3
+ *
4
+ * These helpers glue Canon host wrappers (Codex, Claude Code) to the Canon
5
+ * RTDB session/runtime surface. They used to live in
6
+ * `@canonmsg/core/src/host-runtime/` but were moved into each plugin in
7
+ * media-parity PR C so core does not have to carry host-specific concerns.
8
+ *
9
+ * Keep this file in lockstep with the equivalent file in
10
+ * `packages/codex-plugin/src/host-runtime.ts`. If you change the
11
+ * behavior here, update that copy too and adjust the shared golden
12
+ * fixture test (`packages/claude-code-plugin/src/host-runtime.test.ts`).
13
+ */
14
+ import { buildBehaviorPolicyLines, buildParticipationHistorySnapshot, buildWorkSessionsPromptLines, mergeWorkSessionContexts, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, rtdbRead, rtdbWrite, } from '@canonmsg/core';
15
+ export function buildCanonHostPrompt(input) {
16
+ const resolvedWorkSessions = mergeWorkSessionContexts(input.workSession, input.workSessions);
17
+ return [
18
+ `You are connected to Canon messaging through a ${input.hostLabel} host wrapper.`,
19
+ 'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
20
+ 'Short intermediate assistant messages may be shown as ephemeral status while you work.',
21
+ ...input.buildInboundContextLines(input.participantContext),
22
+ ...buildBehaviorPolicyLines(input.behavior),
23
+ ...buildWorkSessionsPromptLines(resolvedWorkSessions),
24
+ 'Canon participants may be humans or AI agents.',
25
+ 'Honor the Canon behavior policy above when deciding how proactively to participate.',
26
+ ...(resolvedWorkSessions.length > 0
27
+ ? ['Honor the Canon work-session context above within its stated disclosure limits.']
28
+ : []),
29
+ `Conversation ID: ${input.conversationId}`,
30
+ '',
31
+ 'New Canon message:',
32
+ input.content,
33
+ ].join('\n');
34
+ }
35
+ /**
36
+ * Render the **text portion** of an inbound Canon message. Images and other
37
+ * binary attachments are referenced by short placeholders — their actual
38
+ * bytes are delivered to the host as native vision/media inputs (Anthropic
39
+ * image blocks, Codex `-i` file args). URLs are intentionally *not* inlined,
40
+ * since the harness never needs to refetch and earlier `[Image: <url>]`
41
+ * inlining caused vision models to see a string about an image instead of
42
+ * the image itself.
43
+ *
44
+ * `materialized` may be passed so non-image attachments can reference a
45
+ * local path the agent can Read. Without it we fall back to an unadorned
46
+ * placeholder; the vision path still works because image blocks carry the
47
+ * bytes directly.
48
+ */
49
+ export function renderCanonHostInboundContent(message, materialized) {
50
+ const body = message.text || '';
51
+ const placeholders = [];
52
+ const attachments = message.attachments ?? [];
53
+ if (attachments.length > 0) {
54
+ for (let i = 0; i < attachments.length; i += 1) {
55
+ const att = attachments[i];
56
+ const mat = materialized?.find((m) => m.index === i) ?? null;
57
+ placeholders.push(describeAttachment(att, mat));
58
+ }
59
+ }
60
+ else if (message.contentType === 'audio' && message.audioUrl) {
61
+ const duration = message.audioDurationMs
62
+ ? ` (${Math.round(message.audioDurationMs / 1000)}s)`
63
+ : '';
64
+ placeholders.push(`[Voice message${duration}]`);
65
+ }
66
+ else if (message.contentType === 'image' && message.imageUrl) {
67
+ placeholders.push('[Image attached]');
68
+ }
69
+ const rendered = [...placeholders, body].filter(Boolean).join('\n');
70
+ return rendered || '[Empty message]';
71
+ }
72
+ function describeAttachment(attachment, materialized) {
73
+ if (attachment.kind === 'image') {
74
+ return '[Image attached]';
75
+ }
76
+ if (attachment.kind === 'audio') {
77
+ const durationMs = materialized?.durationMs ?? attachment.durationMs;
78
+ const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
79
+ const ref = materialized?.path ? ` ${materialized.path}` : '';
80
+ return `[Voice message${duration}${ref}]`;
81
+ }
82
+ // file
83
+ const label = materialized?.fileName ?? attachment.fileName ?? 'File';
84
+ const ref = materialized?.path ? ` ${materialized.path}` : '';
85
+ return `[File: ${label}${ref}]`;
86
+ }
87
+ export function buildHydratedInboundContext(input) {
88
+ const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
89
+ return {
90
+ participantContext: {
91
+ conversationType: input.conversation?.type ?? 'unknown',
92
+ memberCount: input.conversation?.memberIds?.length ?? null,
93
+ senderType: input.message.senderType ?? 'human',
94
+ senderName: input.senderName,
95
+ isOwner: input.isOwner,
96
+ mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
97
+ recentSenderTypes: history.recentSenderTypes,
98
+ recentHumanCount: history.recentHumanCount,
99
+ recentAgentCount: history.recentAgentCount,
100
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
101
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
102
+ },
103
+ behavior: input.page?.behavior ?? input.conversation?.behavior,
104
+ workSessions: input.page?.workSessions ?? [],
105
+ hydratedFromPage: input.page != null,
106
+ };
107
+ }
108
+ export async function publishHostAgentRuntime(agentId, clientType, runtime) {
109
+ await rtdbWrite(`/agent-runtime/${agentId}`, {
110
+ clientType,
111
+ hostMode: true,
112
+ ...runtime,
113
+ updatedAt: { '.sv': 'timestamp' },
114
+ });
115
+ }
116
+ export function readHostSessionConfig(raw, extraStringFields = []) {
117
+ const baseConfig = readSessionWorkspaceConfig(raw);
118
+ if (!raw || typeof raw !== 'object') {
119
+ return baseConfig;
120
+ }
121
+ const data = raw;
122
+ const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
123
+ const value = normalizeOptionalString(data[field]);
124
+ return value ? [[field, value]] : [];
125
+ }));
126
+ return {
127
+ ...(baseConfig ?? {}),
128
+ ...extraConfig,
129
+ };
130
+ }
131
+ export async function loadHostSessionConfig(input) {
132
+ const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
133
+ return readHostSessionConfig(raw, input.extraStringFields);
134
+ }
135
+ export function resolveHostWorkspaceCwd(input) {
136
+ return resolveConfiguredWorkspaceCwd(input);
137
+ }
138
+ export function createConversationMetadataLoader(input) {
139
+ const cacheTtlMs = input.cacheTtlMs ?? 10_000;
140
+ let conversationCacheLoadedAt = 0;
141
+ async function refreshConversationCache(force = false) {
142
+ if (!force
143
+ && input.conversationCache.size > 0
144
+ && Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
145
+ return;
146
+ }
147
+ const conversations = await input.client.getConversations();
148
+ input.conversationCache.clear();
149
+ for (const conversation of conversations) {
150
+ input.conversationCache.set(conversation.id, conversation);
151
+ }
152
+ conversationCacheLoadedAt = Date.now();
153
+ }
154
+ async function getConversationMeta(conversationId) {
155
+ try {
156
+ await refreshConversationCache();
157
+ const cached = input.conversationCache.get(conversationId);
158
+ if (cached)
159
+ return cached;
160
+ await refreshConversationCache(true);
161
+ return input.conversationCache.get(conversationId) ?? null;
162
+ }
163
+ catch {
164
+ return input.conversationCache.get(conversationId) ?? null;
165
+ }
166
+ }
167
+ return {
168
+ refreshConversationCache,
169
+ getConversationMeta,
170
+ };
171
+ }
package/dist/host.js CHANGED
@@ -24,7 +24,9 @@ setDefaultResultOrder('ipv4first');
24
24
  import { parseArgs } from 'node:util';
25
25
  import { randomUUID } from 'node:crypto';
26
26
  import { query } from '@anthropic-ai/claude-agent-sdk';
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';
27
+ import { isAnthropicImageAttachment, materializeMessageMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
28
+ import { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, isEnabledFlag, 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
+ import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
28
30
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
29
31
  let workingDir = process.cwd();
30
32
  let workspaceOptions = [];
@@ -36,27 +38,29 @@ function toModelOptions(models) {
36
38
  }));
37
39
  }
38
40
  async function publishAgentRuntime(agentId, runtime) {
39
- await rtdbWrite(`/agent-runtime/${agentId}`, {
40
- clientType: 'claude-code',
41
- hostMode: true,
42
- ...runtime,
43
- updatedAt: { '.sv': 'timestamp' },
44
- });
41
+ await publishHostAgentRuntime(agentId, 'claude-code', runtime);
45
42
  }
46
43
  async function loadSessionConfig(conversationId, agentId) {
47
- const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
48
- const baseConfig = readSessionWorkspaceConfig(raw);
49
- if (!raw || typeof raw !== 'object')
50
- return baseConfig;
51
- const data = raw;
52
- const permissionMode = normalizeOptionalString(data.permissionMode);
44
+ const config = await loadHostSessionConfig({
45
+ conversationId,
46
+ agentId,
47
+ extraStringFields: ['permissionMode'],
48
+ });
53
49
  return {
54
- ...baseConfig,
55
- permissionMode: permissionMode,
50
+ ...config,
51
+ permissionMode: config?.permissionMode,
56
52
  };
57
53
  }
54
+ const SESSION_EXECUTION_MODE_REQUIRED = 'Session execution mode required; please select a mode before starting the session.';
55
+ function requireSessionExecutionMode(config) {
56
+ const mode = config?.executionMode;
57
+ if (!mode) {
58
+ throw new ExecutionEnvironmentError(SESSION_EXECUTION_MODE_REQUIRED, SESSION_EXECUTION_MODE_REQUIRED);
59
+ }
60
+ return mode;
61
+ }
58
62
  function resolveWorkspaceCwd(config) {
59
- return resolveConfiguredWorkspaceCwd({
63
+ return resolveHostWorkspaceCwd({
60
64
  workspaceOptions,
61
65
  config,
62
66
  defaultCwd: workingDir,
@@ -104,52 +108,34 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
104
108
  supportsNonFinalPermanentMessages: false,
105
109
  };
106
110
  function buildCanonPrompt(input) {
107
- return [
108
- 'You are connected to Canon messaging through a Claude Code host wrapper.',
109
- 'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
110
- 'Short intermediate assistant messages may be shown as ephemeral status while you work.',
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.',
115
- `Conversation ID: ${input.conversationId}`,
116
- '',
117
- 'New Canon message:',
118
- input.content,
119
- ].join('\n');
111
+ return buildCanonHostPrompt({
112
+ hostLabel: 'Claude Code',
113
+ buildInboundContextLines,
114
+ ...input,
115
+ });
120
116
  }
121
- function renderInboundContent(message) {
122
- let content = message.text || '';
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) {
142
- const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
143
- content = content
144
- ? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
145
- : `[Voice message${duration}: ${message.audioUrl}]`;
146
- }
147
- else if (message.contentType === 'image' && message.imageUrl) {
148
- content = content
149
- ? `[Image: ${message.imageUrl}]\n${content}`
150
- : `[Image: ${message.imageUrl}]`;
117
+ function renderInboundContent(message, materialized) {
118
+ return renderCanonHostInboundContent(message, materialized);
119
+ }
120
+ /**
121
+ * Build the multimodal `content` payload handed to the Claude Code SDK.
122
+ *
123
+ * Images are delivered as native Anthropic `image` blocks (base64) so the
124
+ * vision model sees pixels, not a text reference to a URL. Non-image
125
+ * attachments (audio, files) are referenced by their local path inside the
126
+ * text prompt — see `renderCanonHostInboundContent` — so the agent can
127
+ * Read them on demand.
128
+ */
129
+ async function buildCanonUserContent(input) {
130
+ const imageAttachments = input.materialized.filter(isAnthropicImageAttachment);
131
+ if (imageAttachments.length === 0) {
132
+ return input.promptText;
151
133
  }
152
- return content || '[Empty message]';
134
+ const imageBlocks = await Promise.all(imageAttachments.map((attachment) => toAnthropicImageBlock(attachment)));
135
+ return [
136
+ { type: 'text', text: input.promptText },
137
+ ...imageBlocks,
138
+ ];
153
139
  }
154
140
  // ── Session factory ─────────────────────────────────────────────────
155
141
  function createSession(conversationId, environment, agentId, client, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
@@ -655,6 +641,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
655
641
  defaultPermissionMode: 'default',
656
642
  availableModels: modelList,
657
643
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
644
+ availableExecutionModes: config.availableExecutionModes,
658
645
  });
659
646
  writeSessionState(conversationId, agentId, {
660
647
  ...session.state,
@@ -717,10 +704,14 @@ async function main() {
717
704
  console.error(`[canon-host] Authenticated as ${agentId}`);
718
705
  }
719
706
  let streamConnected = false;
707
+ const hostAvailableExecutionModes = allowWorktrees
708
+ ? [...EXECUTION_ENVIRONMENT_MODES]
709
+ : ['locked'];
720
710
  let runtimeDescriptor = {
721
711
  defaultWorkspaceId: workspaceOptions[0]?.id,
722
712
  defaultPermissionMode: 'default',
723
713
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
714
+ availableExecutionModes: hostAvailableExecutionModes,
724
715
  };
725
716
  const publishRuntimeHeartbeat = async () => {
726
717
  if (!streamConnected)
@@ -738,6 +729,7 @@ async function main() {
738
729
  defaultPermissionMode: 'default',
739
730
  availableModels: runtimeModels,
740
731
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
732
+ availableExecutionModes: hostAvailableExecutionModes,
741
733
  };
742
734
  }
743
735
  catch {
@@ -745,6 +737,7 @@ async function main() {
745
737
  defaultWorkspaceId: workspaceOptions[0]?.id,
746
738
  defaultPermissionMode: 'default',
747
739
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
740
+ availableExecutionModes: hostAvailableExecutionModes,
748
741
  };
749
742
  }
750
743
  try {
@@ -760,50 +753,23 @@ async function main() {
760
753
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
761
754
  const savedSessionIds = new Map();
762
755
  const conversationCache = new Map();
763
- let conversationCacheLoadedAt = 0;
764
- async function refreshConversationCache(force = false) {
765
- if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
766
- return;
767
- }
768
- const conversations = await client.getConversations();
769
- conversationCache.clear();
770
- for (const conversation of conversations) {
771
- conversationCache.set(conversation.id, conversation);
772
- }
773
- conversationCacheLoadedAt = Date.now();
774
- }
775
- async function getConversationMeta(conversationId) {
776
- try {
777
- await refreshConversationCache();
778
- const cached = conversationCache.get(conversationId);
779
- if (cached)
780
- return cached;
781
- await refreshConversationCache(true);
782
- return conversationCache.get(conversationId) ?? null;
783
- }
784
- catch {
785
- return conversationCache.get(conversationId) ?? null;
786
- }
787
- }
788
- async function loadParticipantContext(input) {
789
- const [conversation, recentMessages] = await Promise.all([
756
+ const { getConversationMeta } = createConversationMetadataLoader({
757
+ client,
758
+ conversationCache,
759
+ });
760
+ async function loadHydratedInboundContext(input) {
761
+ const [conversation, page] = await Promise.all([
790
762
  getConversationMeta(input.conversationId),
791
- client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
763
+ client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
792
764
  ]);
793
- const history = buildParticipationHistorySnapshot(recentMessages, agentId);
794
- return {
795
- conversationType: conversation?.type ?? 'unknown',
796
- memberCount: conversation?.memberIds?.length ?? null,
797
- senderType: input.message.senderType ?? 'human',
765
+ return buildHydratedInboundContext({
766
+ agentId,
767
+ conversation,
768
+ page,
769
+ message: input.message,
798
770
  senderName: input.senderName,
799
771
  isOwner: input.isOwner,
800
- mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
801
- recentSenderTypes: history.recentSenderTypes,
802
- recentHumanCount: history.recentHumanCount,
803
- recentAgentCount: history.recentAgentCount,
804
- consecutiveAgentTurns: history.consecutiveAgentTurns,
805
- currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
806
- };
772
+ });
807
773
  }
808
774
  function closeSession(conversationId) {
809
775
  const session = sessions.get(conversationId);
@@ -864,12 +830,16 @@ async function main() {
864
830
  }
865
831
  const creation = (async () => {
866
832
  const config = await loadSessionConfig(conversationId, agentId);
833
+ const sessionExecutionMode = requireSessionExecutionMode(config);
834
+ if (sessionExecutionMode === 'worktree' && !allowWorktrees) {
835
+ throw new ExecutionEnvironmentError('This host does not allow worktree sessions (launched without --enable-worktrees).', 'This Canon host was started without worktree isolation enabled. Choose "Lock the workspace" or restart the host with --enable-worktrees.');
836
+ }
867
837
  const workspaceCwd = resolveWorkspaceCwd(config);
868
838
  const environment = prepareConversationEnvironment({
869
839
  agentId,
870
840
  conversationId,
871
841
  workspaceCwd,
872
- allowWorktrees,
842
+ allowWorktrees: sessionExecutionMode === 'worktree',
873
843
  });
874
844
  try {
875
845
  const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
@@ -893,6 +863,7 @@ async function main() {
893
863
  model: config?.model ?? runtimeModels[0]?.value,
894
864
  permissionMode: config?.permissionMode ?? 'default',
895
865
  availableModels: runtimeModels,
866
+ availableExecutionModes: hostAvailableExecutionModes,
896
867
  }, resumeId);
897
868
  sessions.set(conversationId, session);
898
869
  return session;
@@ -933,17 +904,37 @@ async function main() {
933
904
  }
934
905
  }
935
906
  const sender = m.senderName || m.senderId;
936
- const content = renderInboundContent(m);
937
907
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
938
908
  void (async () => {
939
- const conversation = await getConversationMeta(convoId);
940
- const behavior = payload.behavior ?? conversation?.behavior;
941
- const participantContext = await loadParticipantContext({
909
+ let materialized = [];
910
+ if (m.id) {
911
+ try {
912
+ materialized = await materializeMessageMedia({
913
+ id: m.id,
914
+ attachments: m.attachments,
915
+ imageUrl: m.imageUrl ?? null,
916
+ audioUrl: m.audioUrl ?? null,
917
+ audioDurationMs: m.audioDurationMs ?? null,
918
+ }, { agentId, conversationId: convoId });
919
+ }
920
+ catch (error) {
921
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
922
+ }
923
+ }
924
+ const content = renderInboundContent(m, materialized);
925
+ const hydrated = await loadHydratedInboundContext({
942
926
  conversationId: convoId,
943
927
  message: m,
944
928
  senderName: sender,
945
929
  isOwner,
946
930
  });
931
+ const behavior = payload.behavior ?? hydrated.behavior;
932
+ const workSessions = hydrated.hydratedFromPage
933
+ ? hydrated.workSessions
934
+ : Array.isArray(payload.workSessions)
935
+ ? payload.workSessions
936
+ : hydrated.workSessions;
937
+ const participantContext = hydrated.participantContext;
947
938
  const autoReply = decideAutoReply(participantContext, behavior);
948
939
  if (!autoReply.allow) {
949
940
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
@@ -964,16 +955,20 @@ async function main() {
964
955
  const turnMetadata = normalizeTurnMetadata(m.metadata);
965
956
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
966
957
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
958
+ const promptText = buildCanonPrompt({
959
+ content,
960
+ conversationId: convoId,
961
+ participantContext,
962
+ behavior,
963
+ workSession: m.workSession,
964
+ workSessions,
965
+ });
966
+ const messageContent = await buildCanonUserContent({ promptText, materialized });
967
967
  session.enqueueInbound({
968
968
  type: 'user',
969
969
  message: {
970
970
  role: 'user',
971
- content: buildCanonPrompt({
972
- content,
973
- conversationId: convoId,
974
- participantContext,
975
- behavior,
976
- }),
971
+ content: messageContent,
977
972
  },
978
973
  parent_tool_use_id: null,
979
974
  }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
package/dist/server.js CHANGED
@@ -125,6 +125,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
125
125
  required: ['conversation_id'],
126
126
  },
127
127
  },
128
+ {
129
+ name: 'send_linked_message',
130
+ description: 'Send a message into another Canon conversation under an existing or lazily created shared work session',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ source_conversation_id: {
135
+ type: 'string',
136
+ description: 'The originating conversation for the shared task',
137
+ },
138
+ target_conversation_id: {
139
+ type: 'string',
140
+ description: 'The destination conversation to send into',
141
+ },
142
+ text: {
143
+ type: 'string',
144
+ description: 'Message text to send',
145
+ },
146
+ work_session_id: {
147
+ type: 'string',
148
+ description: 'Reuse an existing Canon work session when known',
149
+ },
150
+ create_work_session: {
151
+ type: 'object',
152
+ description: 'Optional initial work-session fields when creating a new shared task',
153
+ },
154
+ source_context: {
155
+ type: 'object',
156
+ description: 'Optional scoped context patch for the source conversation',
157
+ },
158
+ target_context: {
159
+ type: 'object',
160
+ description: 'Optional scoped context patch for the target conversation',
161
+ },
162
+ message_options: {
163
+ type: 'object',
164
+ description: 'Optional Canon send options for the linked outbound message',
165
+ },
166
+ },
167
+ required: ['source_conversation_id', 'target_conversation_id', 'text'],
168
+ },
169
+ },
128
170
  {
129
171
  name: 'list_conversations',
130
172
  description: "List Canon conversations this agent participates in",
@@ -234,6 +276,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
234
276
  ],
235
277
  };
236
278
  }
279
+ case 'send_linked_message': {
280
+ const sourceConversationId = args.source_conversation_id;
281
+ const targetConversationId = args.target_conversation_id;
282
+ const text = args.text;
283
+ const workSessionId = typeof args.work_session_id === 'string' ? args.work_session_id : undefined;
284
+ const createWorkSession = typeof args.create_work_session === 'object' && args.create_work_session != null
285
+ ? args.create_work_session
286
+ : undefined;
287
+ const sourceContext = typeof args.source_context === 'object' && args.source_context != null
288
+ ? args.source_context
289
+ : undefined;
290
+ const targetContext = typeof args.target_context === 'object' && args.target_context != null
291
+ ? args.target_context
292
+ : undefined;
293
+ const messageOptions = typeof args.message_options === 'object' && args.message_options != null
294
+ ? args.message_options
295
+ : undefined;
296
+ const result = await client.sendLinkedMessage({
297
+ sourceConversationId,
298
+ targetConversationId,
299
+ text,
300
+ ...(workSessionId ? { workSessionId } : {}),
301
+ ...(createWorkSession
302
+ ? {
303
+ createWorkSession: createWorkSession,
304
+ }
305
+ : {}),
306
+ ...(sourceContext
307
+ ? {
308
+ sourceContext: sourceContext,
309
+ }
310
+ : {}),
311
+ ...(targetContext
312
+ ? {
313
+ targetContext: targetContext,
314
+ }
315
+ : {}),
316
+ ...(messageOptions
317
+ ? {
318
+ messageOptions: messageOptions,
319
+ }
320
+ : {}),
321
+ });
322
+ return {
323
+ content: [
324
+ {
325
+ type: 'text',
326
+ text: `Linked message sent (${result.messageId}) via work session ${result.workSessionId}${result.workSessionCreated ? ' [created]' : ''}`,
327
+ },
328
+ ],
329
+ };
330
+ }
237
331
  case 'list_conversations': {
238
332
  const limit = args.limit || 20;
239
333
  const convos = await client.getConversations();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -18,11 +18,13 @@
18
18
  "scripts": {
19
19
  "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
20
20
  "dev": "tsc --watch",
21
- "prepack": "npm run build"
21
+ "prepack": "npm run build",
22
+ "test": "vitest run"
22
23
  },
23
24
  "dependencies": {
24
25
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
25
- "@canonmsg/core": "^0.6.0",
26
+ "@canonmsg/agent-sdk": "^0.8.0",
27
+ "@canonmsg/core": "^0.7.0",
26
28
  "@modelcontextprotocol/sdk": "^1.12.0"
27
29
  },
28
30
  "engines": {
@@ -47,7 +49,8 @@
47
49
  },
48
50
  "devDependencies": {
49
51
  "@types/node": "^22.0.0",
50
- "typescript": "~5.7.0"
52
+ "typescript": "~5.7.0",
53
+ "vitest": "^3.0.0"
51
54
  },
52
55
  "license": "MIT"
53
56
  }