@canonmsg/claude-code-plugin 0.7.0 → 0.8.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,89 @@
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
+ export declare function renderCanonHostInboundContent(message: HostInboundMessage): string;
53
+ export declare function buildHydratedInboundContext(input: {
54
+ agentId: string;
55
+ conversation: CanonConversation | null;
56
+ page?: CanonMessagesPage | null;
57
+ message: HostInboundMessage;
58
+ senderName: string;
59
+ isOwner: boolean;
60
+ }): {
61
+ participantContext: HostInboundParticipantContext;
62
+ behavior?: ResolvedAgentBehaviorPolicy | null;
63
+ workSessions: NonNullable<MessageCreatedPayload['workSessions']>;
64
+ hydratedFromPage: boolean;
65
+ };
66
+ export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
67
+ export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null;
68
+ export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
69
+ conversationId: string;
70
+ agentId: string;
71
+ extraStringFields?: readonly TExtra[];
72
+ }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null>;
73
+ export declare function resolveHostWorkspaceCwd(input: {
74
+ workspaceOptions: HostWorkspaceResolverOption[];
75
+ config: {
76
+ workspaceId?: string;
77
+ retiredWorkspaceConfig?: boolean;
78
+ } | null;
79
+ defaultCwd: string;
80
+ }): string;
81
+ export declare function createConversationMetadataLoader(input: {
82
+ client: CanonClient;
83
+ conversationCache: Map<string, CanonConversation>;
84
+ cacheTtlMs?: number;
85
+ }): {
86
+ refreshConversationCache(force?: boolean): Promise<void>;
87
+ getConversationMeta(conversationId: string): Promise<CanonConversation | null>;
88
+ };
89
+ export {};
@@ -0,0 +1,152 @@
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
+ export function renderCanonHostInboundContent(message) {
36
+ let content = message.text || '';
37
+ const attachment = message.attachments?.[0];
38
+ if (attachment?.kind === 'audio' && attachment.url) {
39
+ const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
40
+ content = content
41
+ ? `[Voice message${duration}: ${attachment.url}]\n${content}`
42
+ : `[Voice message${duration}: ${attachment.url}]`;
43
+ }
44
+ else if (attachment?.kind === 'image' && attachment.url) {
45
+ content = content
46
+ ? `[Image: ${attachment.url}]\n${content}`
47
+ : `[Image: ${attachment.url}]`;
48
+ }
49
+ else if (attachment?.kind === 'file' && attachment.url) {
50
+ const label = attachment.fileName || 'File';
51
+ content = content
52
+ ? `[File: ${label} ${attachment.url}]\n${content}`
53
+ : `[File: ${label} ${attachment.url}]`;
54
+ }
55
+ else if (message.contentType === 'audio' && message.audioUrl) {
56
+ const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
57
+ content = content
58
+ ? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
59
+ : `[Voice message${duration}: ${message.audioUrl}]`;
60
+ }
61
+ else if (message.contentType === 'image' && message.imageUrl) {
62
+ content = content
63
+ ? `[Image: ${message.imageUrl}]\n${content}`
64
+ : `[Image: ${message.imageUrl}]`;
65
+ }
66
+ return content || '[Empty message]';
67
+ }
68
+ export function buildHydratedInboundContext(input) {
69
+ const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
70
+ return {
71
+ participantContext: {
72
+ conversationType: input.conversation?.type ?? 'unknown',
73
+ memberCount: input.conversation?.memberIds?.length ?? null,
74
+ senderType: input.message.senderType ?? 'human',
75
+ senderName: input.senderName,
76
+ isOwner: input.isOwner,
77
+ mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
78
+ recentSenderTypes: history.recentSenderTypes,
79
+ recentHumanCount: history.recentHumanCount,
80
+ recentAgentCount: history.recentAgentCount,
81
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
82
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
83
+ },
84
+ behavior: input.page?.behavior ?? input.conversation?.behavior,
85
+ workSessions: input.page?.workSessions ?? [],
86
+ hydratedFromPage: input.page != null,
87
+ };
88
+ }
89
+ export async function publishHostAgentRuntime(agentId, clientType, runtime) {
90
+ await rtdbWrite(`/agent-runtime/${agentId}`, {
91
+ clientType,
92
+ hostMode: true,
93
+ ...runtime,
94
+ updatedAt: { '.sv': 'timestamp' },
95
+ });
96
+ }
97
+ export function readHostSessionConfig(raw, extraStringFields = []) {
98
+ const baseConfig = readSessionWorkspaceConfig(raw);
99
+ if (!raw || typeof raw !== 'object') {
100
+ return baseConfig;
101
+ }
102
+ const data = raw;
103
+ const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
104
+ const value = normalizeOptionalString(data[field]);
105
+ return value ? [[field, value]] : [];
106
+ }));
107
+ return {
108
+ ...(baseConfig ?? {}),
109
+ ...extraConfig,
110
+ };
111
+ }
112
+ export async function loadHostSessionConfig(input) {
113
+ const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
114
+ return readHostSessionConfig(raw, input.extraStringFields);
115
+ }
116
+ export function resolveHostWorkspaceCwd(input) {
117
+ return resolveConfiguredWorkspaceCwd(input);
118
+ }
119
+ export function createConversationMetadataLoader(input) {
120
+ const cacheTtlMs = input.cacheTtlMs ?? 10_000;
121
+ let conversationCacheLoadedAt = 0;
122
+ async function refreshConversationCache(force = false) {
123
+ if (!force
124
+ && input.conversationCache.size > 0
125
+ && Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
126
+ return;
127
+ }
128
+ const conversations = await input.client.getConversations();
129
+ input.conversationCache.clear();
130
+ for (const conversation of conversations) {
131
+ input.conversationCache.set(conversation.id, conversation);
132
+ }
133
+ conversationCacheLoadedAt = Date.now();
134
+ }
135
+ async function getConversationMeta(conversationId) {
136
+ try {
137
+ await refreshConversationCache();
138
+ const cached = input.conversationCache.get(conversationId);
139
+ if (cached)
140
+ return cached;
141
+ await refreshConversationCache(true);
142
+ return input.conversationCache.get(conversationId) ?? null;
143
+ }
144
+ catch {
145
+ return input.conversationCache.get(conversationId) ?? null;
146
+ }
147
+ }
148
+ return {
149
+ refreshConversationCache,
150
+ getConversationMeta,
151
+ };
152
+ }
package/dist/host.js CHANGED
@@ -24,7 +24,8 @@ 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 { buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPublicWorkspaceOptions, 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';
28
+ import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
28
29
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
29
30
  let workingDir = process.cwd();
30
31
  let workspaceOptions = [];
@@ -36,27 +37,21 @@ function toModelOptions(models) {
36
37
  }));
37
38
  }
38
39
  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
- });
40
+ await publishHostAgentRuntime(agentId, 'claude-code', runtime);
45
41
  }
46
42
  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);
43
+ const config = await loadHostSessionConfig({
44
+ conversationId,
45
+ agentId,
46
+ extraStringFields: ['permissionMode'],
47
+ });
53
48
  return {
54
- ...baseConfig,
55
- permissionMode: permissionMode,
49
+ ...config,
50
+ permissionMode: config?.permissionMode,
56
51
  };
57
52
  }
58
53
  function resolveWorkspaceCwd(config) {
59
- return resolveConfiguredWorkspaceCwd({
54
+ return resolveHostWorkspaceCwd({
60
55
  workspaceOptions,
61
56
  config,
62
57
  defaultCwd: workingDir,
@@ -104,52 +99,14 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
104
99
  supportsNonFinalPermanentMessages: false,
105
100
  };
106
101
  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');
102
+ return buildCanonHostPrompt({
103
+ hostLabel: 'Claude Code',
104
+ buildInboundContextLines,
105
+ ...input,
106
+ });
120
107
  }
121
108
  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}]`;
151
- }
152
- return content || '[Empty message]';
109
+ return renderCanonHostInboundContent(message);
153
110
  }
154
111
  // ── Session factory ─────────────────────────────────────────────────
155
112
  function createSession(conversationId, environment, agentId, client, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
@@ -760,50 +717,23 @@ async function main() {
760
717
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
761
718
  const savedSessionIds = new Map();
762
719
  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([
720
+ const { getConversationMeta } = createConversationMetadataLoader({
721
+ client,
722
+ conversationCache,
723
+ });
724
+ async function loadHydratedInboundContext(input) {
725
+ const [conversation, page] = await Promise.all([
790
726
  getConversationMeta(input.conversationId),
791
- client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
727
+ client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
792
728
  ]);
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',
729
+ return buildHydratedInboundContext({
730
+ agentId,
731
+ conversation,
732
+ page,
733
+ message: input.message,
798
734
  senderName: input.senderName,
799
735
  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
- };
736
+ });
807
737
  }
808
738
  function closeSession(conversationId) {
809
739
  const session = sessions.get(conversationId);
@@ -936,14 +866,19 @@ async function main() {
936
866
  const content = renderInboundContent(m);
937
867
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
938
868
  void (async () => {
939
- const conversation = await getConversationMeta(convoId);
940
- const behavior = payload.behavior ?? conversation?.behavior;
941
- const participantContext = await loadParticipantContext({
869
+ const hydrated = await loadHydratedInboundContext({
942
870
  conversationId: convoId,
943
871
  message: m,
944
872
  senderName: sender,
945
873
  isOwner,
946
874
  });
875
+ const behavior = payload.behavior ?? hydrated.behavior;
876
+ const workSessions = hydrated.hydratedFromPage
877
+ ? hydrated.workSessions
878
+ : Array.isArray(payload.workSessions)
879
+ ? payload.workSessions
880
+ : hydrated.workSessions;
881
+ const participantContext = hydrated.participantContext;
947
882
  const autoReply = decideAutoReply(participantContext, behavior);
948
883
  if (!autoReply.allow) {
949
884
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
@@ -973,6 +908,8 @@ async function main() {
973
908
  conversationId: convoId,
974
909
  participantContext,
975
910
  behavior,
911
+ workSession: m.workSession,
912
+ workSessions,
976
913
  }),
977
914
  },
978
915
  parent_tool_use_id: null,
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.8.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,12 @@
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/core": "^0.7.0",
26
27
  "@modelcontextprotocol/sdk": "^1.12.0"
27
28
  },
28
29
  "engines": {
@@ -47,7 +48,8 @@
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^22.0.0",
50
- "typescript": "~5.7.0"
51
+ "typescript": "~5.7.0",
52
+ "vitest": "^3.0.0"
51
53
  },
52
54
  "license": "MIT"
53
55
  }