@canonmsg/claude-code-plugin 0.12.7 → 0.13.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
@@ -26,11 +26,11 @@ import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
27
  import { query, } from '@anthropic-ai/claude-agent-sdk';
28
28
  import { isAnthropicImageAttachment, materializeMessageMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
29
- import { CLAUDE_PERMISSION_MODE_OPTIONS, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
29
+ import { CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
30
30
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
31
31
  import { createStreamingBuffer } from './streaming-buffer.js';
32
32
  import { runCli } from './cli-entry.js';
33
- import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, shouldDeliverClaudeFinal, } from './session-state.js';
33
+ import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
34
34
  const HELP = `canon-claude — run a local Claude Code agent host for Canon
35
35
 
36
36
  USAGE
@@ -154,26 +154,9 @@ function buildClaudeRuntimeDescriptor(input) {
154
154
  availability: ['always'],
155
155
  dispatch: { kind: 'open_details', target: 'effort' },
156
156
  },
157
- {
158
- id: 'stop',
159
- label: 'Stop',
160
- description: 'Interrupt the current Claude turn.',
161
- aliases: ['stop'],
162
- category: 'turn',
163
- placements: ['composer_slash', 'command_palette'],
164
- availability: ['busy'],
165
- dispatch: { kind: 'signal', signal: 'interrupt' },
166
- },
167
- {
168
- id: 'stop-and-clear-queue',
169
- label: 'Stop & clear queue',
170
- description: 'Interrupt the current Claude turn and drop queued Canon messages.',
171
- aliases: ['stop-clear', 'clear-queue'],
172
- category: 'turn',
173
- placements: ['composer_slash', 'command_palette', 'session_strip'],
174
- availability: ['busy_with_queue'],
175
- dispatch: { kind: 'signal', signal: 'stop_and_drop' },
176
- },
157
+ RUNTIME_STOP_ACTION,
158
+ RUNTIME_STOP_AND_DROP_ACTION,
159
+ RUNTIME_NEW_SESSION_ACTION,
177
160
  ],
178
161
  });
179
162
  }
@@ -654,6 +637,9 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
654
637
  const questions = input.questions ?? [];
655
638
  const questionCard = buildQuestionRequest(questionId, questions);
656
639
  await client.sendMessage(conversationId, questionCard.text, {
640
+ ...(session.activeSelfContextId
641
+ ? { selfContextId: session.activeSelfContextId }
642
+ : {}),
657
643
  metadata: {
658
644
  ...questionCard.metadata,
659
645
  turnSemantics: 'control',
@@ -699,6 +685,9 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
699
685
  summary: 'Claude has prepared a plan and is asking permission to continue.',
700
686
  });
701
687
  await client.sendMessage(conversationId, planApproval.text, {
688
+ ...(session.activeSelfContextId
689
+ ? { selfContextId: session.activeSelfContextId }
690
+ : {}),
702
691
  metadata: {
703
692
  ...planApproval.metadata,
704
693
  turnSemantics: 'control',
@@ -760,6 +749,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
760
749
  turnState: 'idle',
761
750
  currentTurnId: null,
762
751
  currentTurnOpenedAt: null,
752
+ activeSelfContextId: null,
763
753
  lastAcceptedIntent: null,
764
754
  lastActivity: Date.now(),
765
755
  typingKeepaliveTimer: null,
@@ -815,15 +805,9 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
815
805
  if (session.closed)
816
806
  return;
817
807
  clearFinalDeliveryTimer();
818
- session.currentTurnId = null;
819
- session.currentTurnOpenedAt = null;
820
- session.lastAcceptedIntent = null;
821
- session.pendingFinalText = null;
822
- session.pendingFinalDelivery = null;
823
- session.activeInput = null;
824
- session.toolInProgress = false;
825
- session.turnState = 'idle';
808
+ resetClaudeCompletedTurnState(session);
826
809
  stopVisibleWorkSignal();
810
+ writeState();
827
811
  writeTurn();
828
812
  drainPendingInput();
829
813
  }
@@ -875,6 +859,9 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
875
859
  try {
876
860
  await client.sendMessage(conversationId, finalText, {
877
861
  messageId: finalMessageId,
862
+ ...(session.activeSelfContextId
863
+ ? { selfContextId: session.activeSelfContextId }
864
+ : {}),
878
865
  metadata: {
879
866
  turnId: session.currentTurnId,
880
867
  turnKey: deliverTurn.turnKey,
@@ -1560,19 +1547,20 @@ export async function main() {
1560
1547
  isOwner: input.isOwner,
1561
1548
  });
1562
1549
  }
1563
- function closeSession(conversationId) {
1550
+ function closeSession(conversationId, options = {}) {
1564
1551
  const session = sessions.get(conversationId);
1565
1552
  if (!session)
1566
1553
  return;
1554
+ const saveResume = options.saveResume !== false;
1567
1555
  session.closed = true;
1568
1556
  if (session.typingKeepaliveTimer) {
1569
1557
  clearInterval(session.typingKeepaliveTimer);
1570
1558
  session.typingKeepaliveTimer = null;
1571
1559
  }
1572
1560
  releaseConversationEnvironment(session.environment);
1573
- // Save SDK session ID for future resume
1574
- if (session.sdkSessionId) {
1575
- savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
1561
+ const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
1562
+ if (saveResume && session.sdkSessionId) {
1563
+ savedSessionIds.set(resumeKey, session.sdkSessionId);
1576
1564
  saveRuntimeSessionState(runtimeId, {
1577
1565
  conversationId,
1578
1566
  baseCwd: session.environment.baseCwd,
@@ -1580,6 +1568,27 @@ export async function main() {
1580
1568
  claudeSessionId: session.sdkSessionId,
1581
1569
  });
1582
1570
  }
1571
+ else {
1572
+ const existingState = loadRuntimeSessionState(runtimeId, {
1573
+ conversationId,
1574
+ baseCwd: session.environment.baseCwd,
1575
+ executionMode: session.environment.mode,
1576
+ });
1577
+ savedSessionIds.delete(resumeKey);
1578
+ clearRuntimeSessionState(runtimeId, {
1579
+ conversationId,
1580
+ baseCwd: session.environment.baseCwd,
1581
+ executionMode: session.environment.mode,
1582
+ });
1583
+ if (existingState?.lastInboundMessageId) {
1584
+ saveRuntimeSessionState(runtimeId, {
1585
+ conversationId,
1586
+ baseCwd: session.environment.baseCwd,
1587
+ executionMode: session.environment.mode,
1588
+ lastInboundMessageId: existingState.lastInboundMessageId,
1589
+ });
1590
+ }
1591
+ }
1583
1592
  if (session.streamingTimer)
1584
1593
  clearTimeout(session.streamingTimer);
1585
1594
  session.streamingText = '';
@@ -1600,7 +1609,22 @@ export async function main() {
1600
1609
  }
1601
1610
  catch { }
1602
1611
  sessions.delete(conversationId);
1603
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session suspended (sessionId saved for resume)`);
1612
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
1613
+ }
1614
+ async function resetRuntimeSession(session) {
1615
+ const conversationId = session.conversationId;
1616
+ const droppedInputs = session.pendingInputs.splice(0);
1617
+ await markSessionPendingInputsRejected(conversationId, droppedInputs);
1618
+ if (session.activeInput) {
1619
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
1620
+ }
1621
+ session.pendingFinalText = null;
1622
+ session.pendingFinalDelivery = null;
1623
+ session.turnState = 'interrupted';
1624
+ if (session.state.state === 'running') {
1625
+ await session.query.interrupt().catch(() => { });
1626
+ }
1627
+ closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
1604
1628
  }
1605
1629
  async function markSessionPendingInputsRejected(conversationId, inputs) {
1606
1630
  await Promise.all(inputs.map((input) => {
@@ -1757,7 +1781,7 @@ export async function main() {
1757
1781
  isOwner,
1758
1782
  });
1759
1783
  const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
1760
- const workSessions = input.hydratedPage?.workSessions ?? hydrated.workSessions;
1784
+ const selfContexts = input.hydratedPage?.selfContexts ?? hydrated.selfContexts;
1761
1785
  const participantContext = hydrated.participantContext;
1762
1786
  const autoReply = decideAutoReply(participantContext, behavior);
1763
1787
  if (!autoReply.allow) {
@@ -1773,6 +1797,7 @@ export async function main() {
1773
1797
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1774
1798
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
1775
1799
  await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1800
+ ...(selfContexts[0]?.id ? { selfContextId: selfContexts[0].id } : {}),
1776
1801
  metadata: {
1777
1802
  turnSemantics: 'turn_complete',
1778
1803
  turnComplete: true,
@@ -1782,6 +1807,7 @@ export async function main() {
1782
1807
  return;
1783
1808
  }
1784
1809
  const turnMetadata = normalizeTurnMetadata(m.metadata);
1810
+ session.activeSelfContextId = selfContexts[0]?.id ?? null;
1785
1811
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
1786
1812
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
1787
1813
  const promptText = buildCanonPrompt({
@@ -1789,8 +1815,7 @@ export async function main() {
1789
1815
  conversationId: input.conversationId,
1790
1816
  participantContext,
1791
1817
  behavior,
1792
- workSession: m.workSession,
1793
- workSessions,
1818
+ selfContexts,
1794
1819
  session,
1795
1820
  });
1796
1821
  const messageContent = await buildCanonUserContent({ promptText, materialized });
@@ -1902,11 +1927,11 @@ export async function main() {
1902
1927
  isOwner,
1903
1928
  });
1904
1929
  const behavior = payload.behavior ?? hydrated.behavior;
1905
- const workSessions = hydrated.hydratedFromPage
1906
- ? hydrated.workSessions
1907
- : Array.isArray(payload.workSessions)
1908
- ? payload.workSessions
1909
- : hydrated.workSessions;
1930
+ const selfContexts = hydrated.hydratedFromPage
1931
+ ? hydrated.selfContexts
1932
+ : Array.isArray(payload.selfContexts)
1933
+ ? payload.selfContexts
1934
+ : hydrated.selfContexts;
1910
1935
  const participantContext = hydrated.participantContext;
1911
1936
  const autoReply = decideAutoReply(participantContext, behavior);
1912
1937
  if (!autoReply.allow) {
@@ -1923,6 +1948,7 @@ export async function main() {
1923
1948
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1924
1949
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
1925
1950
  await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1951
+ ...(selfContexts[0]?.id ? { selfContextId: selfContexts[0].id } : {}),
1926
1952
  metadata: {
1927
1953
  turnSemantics: 'turn_complete',
1928
1954
  turnComplete: true,
@@ -1932,6 +1958,7 @@ export async function main() {
1932
1958
  return;
1933
1959
  }
1934
1960
  const turnMetadata = normalizeTurnMetadata(m.metadata);
1961
+ session.activeSelfContextId = selfContexts[0]?.id ?? null;
1935
1962
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
1936
1963
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
1937
1964
  const promptText = buildCanonPrompt({
@@ -1939,8 +1966,7 @@ export async function main() {
1939
1966
  conversationId: convoId,
1940
1967
  participantContext,
1941
1968
  behavior,
1942
- workSession: m.workSession,
1943
- workSessions,
1969
+ selfContexts,
1944
1970
  session,
1945
1971
  });
1946
1972
  const messageContent = await buildCanonUserContent({ promptText, materialized });
@@ -2042,10 +2068,17 @@ export async function main() {
2042
2068
  if (sig && typeof sig === 'object') {
2043
2069
  const s = sig;
2044
2070
  const ts = s.updatedAt ?? 0;
2045
- if ((s.type === 'interrupt' || s.type === 'stop_and_drop') && ts > (lastSeenSignal.get(convoId) ?? 0)) {
2071
+ if ((s.type === 'interrupt' || s.type === 'stop_and_drop' || s.type === 'new_session')
2072
+ && ts > (lastSeenSignal.get(convoId) ?? 0)) {
2046
2073
  lastSeenSignal.set(convoId, ts);
2047
2074
  const session = sessions.get(convoId);
2048
2075
  if (session && !session.closed) {
2076
+ if (s.type === 'new_session') {
2077
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2078
+ await resetRuntimeSession(session);
2079
+ await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
2080
+ continue;
2081
+ }
2049
2082
  const isActive = session.state.state === 'running';
2050
2083
  if (!isActive && (s.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2051
2084
  await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
package/dist/server.js CHANGED
@@ -10,7 +10,8 @@ import { setDefaultResultOrder } from 'node:dns';
10
10
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
12
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
13
- import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, writeSessionState, clearSessionState, } from '@canonmsg/core';
13
+ import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, writeSessionState, clearSessionState, renderCanonHostInboundContent, } from '@canonmsg/core';
14
+ import { materializeMessageMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
14
15
  import { ApprovalHttpServer } from './approval-server.js';
15
16
  import { runCli } from './cli-entry.js';
16
17
  const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
@@ -49,31 +50,8 @@ function getPrimaryAttachment(message) {
49
50
  return attachment;
50
51
  return null;
51
52
  }
52
- function renderInboundContent(message) {
53
- const content = message.text || '';
54
- const attachment = getPrimaryAttachment(message);
55
- // MCP channel mode does not yet materialize media into local files. Surface
56
- // a placeholder that tells the model media *was* attached without inlining a
57
- // Firebase Storage URL: the model cannot fetch it, and reasoning about a
58
- // URL-as-string leads to hallucinated image descriptions. The `attachment_*`
59
- // fields on the structured `meta` sidechannel still carry the URL for UI /
60
- // telemetry consumers.
61
- let placeholder = null;
62
- if (attachment?.kind === 'audio') {
63
- const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
64
- placeholder = `[Voice message${duration} — ask sender to resend if you need the audio]`;
65
- }
66
- else if (attachment?.kind === 'image') {
67
- placeholder = '[Image attached — not yet materialized into this session; ask sender to describe if needed]';
68
- }
69
- else if (attachment?.kind === 'file') {
70
- const label = attachment.fileName || 'File';
71
- placeholder = `[File: ${label} — not yet materialized into this session]`;
72
- }
73
- if (placeholder) {
74
- return content ? `${placeholder}\n${content}` : placeholder;
75
- }
76
- return content;
53
+ function renderInboundContent(message, materialized) {
54
+ return renderCanonHostInboundContent(message, materialized);
77
55
  }
78
56
  // ── Tool definitions ───────────────────────────────────────────────────
79
57
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -127,54 +105,58 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
127
105
  type: 'string',
128
106
  description: 'File URL (when content_type is file)',
129
107
  },
108
+ media_path: {
109
+ type: 'string',
110
+ description: 'Local image/audio/file path to upload and send',
111
+ },
130
112
  file_name: {
131
113
  type: 'string',
132
114
  description: 'Optional display filename for file attachments',
133
115
  },
116
+ mime_type: {
117
+ type: 'string',
118
+ description: 'Optional MIME type for media_path uploads',
119
+ },
120
+ duration_ms: {
121
+ type: 'number',
122
+ description: 'Optional audio duration in milliseconds for media_path uploads',
123
+ },
134
124
  },
135
125
  required: ['conversation_id'],
136
126
  },
137
127
  },
138
128
  {
139
- name: 'send_linked_message',
140
- description: 'Send a message into another Canon conversation under an existing or lazily created shared work session',
129
+ name: 'send_contextual_message',
130
+ description: 'Send a message into another Canon conversation or contact with private cross-session self-context',
141
131
  inputSchema: {
142
132
  type: 'object',
143
133
  properties: {
144
134
  source_conversation_id: {
145
135
  type: 'string',
146
- description: 'The originating conversation for the shared task',
136
+ description: 'The Canon conversation where this cross-session intent originated',
147
137
  },
148
138
  target_conversation_id: {
149
139
  type: 'string',
150
- description: 'The destination conversation to send into',
140
+ description: 'The destination Canon conversation to send into',
151
141
  },
152
- text: {
142
+ target_user_id: {
153
143
  type: 'string',
154
- description: 'Message text to send',
144
+ description: 'The destination Canon user/contact to reach out to',
155
145
  },
156
- work_session_id: {
146
+ text: {
157
147
  type: 'string',
158
- description: 'Reuse an existing Canon work session when known',
159
- },
160
- create_work_session: {
161
- type: 'object',
162
- description: 'Optional initial work-session fields when creating a new shared task',
163
- },
164
- source_context: {
165
- type: 'object',
166
- description: 'Optional scoped context patch for the source conversation',
148
+ description: 'Message text to send',
167
149
  },
168
- target_context: {
150
+ self_context: {
169
151
  type: 'object',
170
- description: 'Optional scoped context patch for the target conversation',
152
+ description: 'Required { type: "cross_session", context: "why I am saying this here" }',
171
153
  },
172
154
  message_options: {
173
155
  type: 'object',
174
- description: 'Optional Canon send options for the linked outbound message',
156
+ description: 'Optional Canon send options for the contextual outbound message',
175
157
  },
176
158
  },
177
- required: ['source_conversation_id', 'target_conversation_id', 'text'],
159
+ required: ['source_conversation_id', 'text', 'self_context'],
178
160
  },
179
161
  },
180
162
  {
@@ -256,14 +238,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
256
238
  const imageUrl = typeof args.image_url === 'string' ? args.image_url : null;
257
239
  const audioUrl = typeof args.audio_url === 'string' ? args.audio_url : null;
258
240
  const fileUrl = typeof args.file_url === 'string' ? args.file_url : null;
241
+ const mediaPath = typeof args.media_path === 'string' ? args.media_path : null;
259
242
  const fileName = typeof args.file_name === 'string' ? args.file_name : null;
243
+ const mimeType = typeof args.mime_type === 'string' ? args.mime_type : null;
244
+ const durationMs = typeof args.duration_ms === 'number' ? args.duration_ms : null;
245
+ const metadata = TURN_COMPLETE_METADATA;
246
+ if (mediaPath) {
247
+ const result = await sendMediaFileMessage(client, conversationId, mediaPath, text, {
248
+ ...(fileName ? { fileName } : {}),
249
+ ...(mimeType ? { mimeType } : {}),
250
+ ...(durationMs != null ? { durationMs } : {}),
251
+ metadata,
252
+ });
253
+ return {
254
+ content: [
255
+ {
256
+ type: 'text',
257
+ text: `Message sent (${result.messageId})`,
258
+ },
259
+ ],
260
+ };
261
+ }
260
262
  if (contentType === 'image' && imageUrl) {
261
263
  opts.contentType = 'image';
262
264
  opts.attachments = [{ kind: 'image', url: imageUrl }];
263
265
  }
264
266
  else if (contentType === 'audio' && audioUrl) {
265
267
  opts.contentType = 'audio';
266
- opts.attachments = [{ kind: 'audio', url: audioUrl }];
268
+ opts.attachments = [{
269
+ kind: 'audio',
270
+ url: audioUrl,
271
+ ...(durationMs != null ? { durationMs } : {}),
272
+ ...(mimeType ? { mimeType } : {}),
273
+ }];
267
274
  }
268
275
  else if (contentType === 'file' || fileUrl) {
269
276
  opts.contentType = 'file';
@@ -272,6 +279,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
272
279
  kind: 'file',
273
280
  url: fileUrl,
274
281
  ...(fileName ? { fileName } : {}),
282
+ ...(mimeType ? { mimeType } : {}),
275
283
  }];
276
284
  }
277
285
  }
@@ -280,7 +288,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
280
288
  }
281
289
  opts.metadata = {
282
290
  ...(opts.metadata ?? {}),
283
- ...TURN_COMPLETE_METADATA,
291
+ ...metadata,
284
292
  };
285
293
  const result = await client.sendMessage(conversationId, text, opts);
286
294
  return {
@@ -292,50 +300,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
292
300
  ],
293
301
  };
294
302
  }
295
- case 'send_linked_message': {
303
+ case 'send_contextual_message': {
296
304
  const sourceConversationId = args.source_conversation_id;
297
- const targetConversationId = args.target_conversation_id;
298
- const text = args.text;
299
- const workSessionId = typeof args.work_session_id === 'string' ? args.work_session_id : undefined;
300
- const createWorkSession = typeof args.create_work_session === 'object' && args.create_work_session != null
301
- ? args.create_work_session
302
- : undefined;
303
- const sourceContext = typeof args.source_context === 'object' && args.source_context != null
304
- ? args.source_context
305
+ const targetConversationId = typeof args.target_conversation_id === 'string'
306
+ ? args.target_conversation_id
305
307
  : undefined;
306
- const targetContext = typeof args.target_context === 'object' && args.target_context != null
307
- ? args.target_context
308
+ const targetUserId = typeof args.target_user_id === 'string' ? args.target_user_id : undefined;
309
+ const text = args.text;
310
+ const selfContext = typeof args.self_context === 'object' && args.self_context != null
311
+ ? args.self_context
308
312
  : undefined;
309
313
  const messageOptions = typeof args.message_options === 'object' && args.message_options != null
310
314
  ? args.message_options
311
315
  : undefined;
312
- const linkedMessageOptions = messageOptions;
313
- const result = await client.sendLinkedMessage({
316
+ const contextualMessageOptions = messageOptions;
317
+ const result = await client.sendContextualMessage({
314
318
  sourceConversationId,
315
- targetConversationId,
319
+ ...(targetConversationId ? { targetConversationId } : {}),
320
+ ...(targetUserId ? { targetUserId } : {}),
316
321
  text,
317
- ...(workSessionId ? { workSessionId } : {}),
318
- ...(createWorkSession
319
- ? {
320
- createWorkSession: createWorkSession,
321
- }
322
- : {}),
323
- ...(sourceContext
324
- ? {
325
- sourceContext: sourceContext,
326
- }
327
- : {}),
328
- ...(targetContext
329
- ? {
330
- targetContext: targetContext,
331
- }
332
- : {}),
333
- ...(linkedMessageOptions
322
+ selfContext: selfContext,
323
+ ...(contextualMessageOptions
334
324
  ? {
335
325
  messageOptions: {
336
- ...linkedMessageOptions,
326
+ ...contextualMessageOptions,
337
327
  metadata: {
338
- ...(linkedMessageOptions.metadata ?? {}),
328
+ ...(contextualMessageOptions.metadata ?? {}),
339
329
  ...TURN_COMPLETE_METADATA,
340
330
  },
341
331
  },
@@ -346,7 +336,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
346
336
  content: [
347
337
  {
348
338
  type: 'text',
349
- text: `Linked message sent (${result.messageId}) via work session ${result.workSessionId}${result.workSessionCreated ? ' [created]' : ''}`,
339
+ text: result.status === 'messaged'
340
+ ? `Contextual message sent (${result.messageId}) via self-context ${result.selfContextId}`
341
+ : `Contextual message not sent: ${result.status}`,
350
342
  },
351
343
  ],
352
344
  };
@@ -454,58 +446,30 @@ async function handleInboundMessage(payload) {
454
446
  meta.behavior_instructions = payload.behavior.instructions.join('\n\n');
455
447
  }
456
448
  }
457
- if (m.workSession) {
458
- meta.work_session_id = m.workSession.id;
459
- if (m.workSession.title)
460
- meta.work_session_title = m.workSession.title;
461
- if (m.workSession.objective)
462
- meta.work_session_objective = m.workSession.objective;
463
- if (m.workSession.status)
464
- meta.work_session_status = m.workSession.status;
465
- if (m.workSession.currentConversationRole) {
466
- meta.work_session_role = m.workSession.currentConversationRole;
467
- }
468
- if (m.workSession.representation) {
469
- meta.work_session_representation = m.workSession.representation;
470
- }
471
- if (m.workSession.disclosure) {
472
- meta.work_session_disclosure = m.workSession.disclosure;
473
- }
474
- if (m.workSession.summary)
475
- meta.work_session_summary = m.workSession.summary;
476
- if (m.workSession.sourceConversationLabel) {
477
- meta.work_session_source_conversation = m.workSession.sourceConversationLabel;
478
- }
479
- else if (m.workSession.sourceConversationId) {
480
- meta.work_session_source_conversation = m.workSession.sourceConversationId;
481
- }
482
- if (m.workSession.sourceParticipantLabel) {
483
- meta.work_session_source_participant = m.workSession.sourceParticipantLabel;
484
- }
485
- else if (m.workSession.sourceParticipantId) {
486
- meta.work_session_source_participant = m.workSession.sourceParticipantId;
487
- }
488
- if (Array.isArray(m.workSession.visibleFacts) && m.workSession.visibleFacts.length > 0) {
489
- meta.work_session_visible_facts = m.workSession.visibleFacts.join('\n\n');
490
- }
491
- if (Array.isArray(m.workSession.pendingQuestions)
492
- && m.workSession.pendingQuestions.length > 0) {
493
- meta.work_session_pending_questions = m.workSession.pendingQuestions.join('\n\n');
494
- }
495
- if (Array.isArray(m.workSession.disclosureNotes)
496
- && m.workSession.disclosureNotes.length > 0) {
497
- meta.work_session_disclosure_notes = m.workSession.disclosureNotes.join('\n\n');
498
- }
499
- }
500
- if (Array.isArray(payload.workSessions) && payload.workSessions.length > 0) {
501
- meta.active_work_session_ids = payload.workSessions
502
- .map((workSession) => workSession.id)
449
+ if (Array.isArray(payload.selfContexts) && payload.selfContexts.length > 0) {
450
+ meta.self_context_ids = payload.selfContexts
451
+ .map((selfContext) => selfContext.id)
503
452
  .join('\n');
504
- const titled = payload.workSessions
505
- .map((workSession) => workSession.title || workSession.id)
506
- .filter((value) => value.length > 0);
507
- if (titled.length > 0) {
508
- meta.active_work_session_titles = titled.join('\n');
453
+ meta.self_context_types = payload.selfContexts
454
+ .map((selfContext) => selfContext.type)
455
+ .join('\n');
456
+ meta.self_context = payload.selfContexts
457
+ .map((selfContext) => selfContext.context)
458
+ .join('\n\n');
459
+ }
460
+ let materialized = [];
461
+ if (m.id && m.attachments?.length) {
462
+ try {
463
+ materialized = await materializeMessageMedia({
464
+ id: m.id,
465
+ attachments: m.attachments ?? [],
466
+ }, {
467
+ agentId: agentContext?.agentId ?? 'claude-code',
468
+ conversationId: payload.conversationId,
469
+ });
470
+ }
471
+ catch (error) {
472
+ console.error(`[canon] Failed to materialize inbound media: ${error instanceof Error ? error.message : error}`);
509
473
  }
510
474
  }
511
475
  const primaryAttachment = getPrimaryAttachment(m);
@@ -515,7 +479,14 @@ async function handleInboundMessage(payload) {
515
479
  }
516
480
  if (primaryAttachment?.fileName)
517
481
  meta.attachment_file_name = primaryAttachment.fileName;
518
- const renderedContent = renderInboundContent(m);
482
+ if (primaryAttachment?.mimeType)
483
+ meta.attachment_mime_type = primaryAttachment.mimeType;
484
+ if (primaryAttachment?.durationMs != null)
485
+ meta.attachment_duration_ms = String(primaryAttachment.durationMs);
486
+ const primaryMaterialized = materialized.find((attachment) => attachment.index === 0);
487
+ if (primaryMaterialized?.path)
488
+ meta.attachment_path = primaryMaterialized.path;
489
+ const renderedContent = renderInboundContent(m, materialized);
519
490
  console.error(`[canon] Inbound message from ${meta.sender_name}: "${renderedContent}"`);
520
491
  try {
521
492
  await server.notification({
@@ -1,5 +1,5 @@
1
1
  import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
- import type { DeliveryIntent, ModelOption } from '@canonmsg/core';
2
+ import type { DeliveryIntent, ModelOption, TurnLifecycleState } from '@canonmsg/core';
3
3
  export type ClaudeInputKind = 'seed' | 'canon';
4
4
  export interface ClaudeInputEnvelope {
5
5
  kind: ClaudeInputKind;
@@ -19,6 +19,19 @@ export interface ClaudePendingFinalDelivery {
19
19
  text: string;
20
20
  messageId: string;
21
21
  }
22
+ export interface ClaudeCompletedTurnState {
23
+ state: {
24
+ state?: 'idle' | 'running' | 'requires_action';
25
+ };
26
+ pendingFinalText: string | null;
27
+ pendingFinalDelivery: ClaudePendingFinalDelivery | null;
28
+ activeInput: ClaudeInputEnvelope | null;
29
+ toolInProgress: boolean;
30
+ turnState: TurnLifecycleState;
31
+ currentTurnId: string | null;
32
+ currentTurnOpenedAt: number | null;
33
+ lastAcceptedIntent: DeliveryIntent | null;
34
+ }
22
35
  export declare function createClaudeInputEnvelope(input: {
23
36
  kind: ClaudeInputKind;
24
37
  msg: SDKUserMessage;
@@ -36,6 +49,7 @@ export declare function shouldDeliverClaudeFinal(input: {
36
49
  finalizedTurnKeys: ReadonlySet<string>;
37
50
  interruptedTurnKeys: ReadonlySet<string>;
38
51
  }): boolean;
52
+ export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
39
53
  export declare function mergeClaudeModelOptions(input: {
40
54
  previous: ReadonlyArray<ModelOption>;
41
55
  next: ReadonlyArray<ModelOption>;
@@ -29,6 +29,17 @@ export function shouldDeliverClaudeFinal(input) {
29
29
  return false;
30
30
  return true;
31
31
  }
32
+ export function resetClaudeCompletedTurnState(session) {
33
+ session.state.state = 'idle';
34
+ session.currentTurnId = null;
35
+ session.currentTurnOpenedAt = null;
36
+ session.lastAcceptedIntent = null;
37
+ session.pendingFinalText = null;
38
+ session.pendingFinalDelivery = null;
39
+ session.activeInput = null;
40
+ session.toolInProgress = false;
41
+ session.turnState = 'idle';
42
+ }
32
43
  export function mergeClaudeModelOptions(input) {
33
44
  const byValue = new Map();
34
45
  for (const option of input.previous) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.12.7",
3
+ "version": "0.13.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/index.js",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
34
- "@canonmsg/agent-sdk": "^1.1.2",
35
- "@canonmsg/core": "^0.15.4",
34
+ "@canonmsg/agent-sdk": "^1.2.0",
35
+ "@canonmsg/core": "^0.16.0",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {