@canonmsg/claude-code-plugin 0.8.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.
@@ -49,7 +49,27 @@ export declare function buildCanonHostPrompt(input: {
49
49
  workSessions?: MessageCreatedPayload['workSessions'];
50
50
  buildInboundContextLines: (context: HostInboundParticipantContext) => string[];
51
51
  }): string;
52
- export declare function renderCanonHostInboundContent(message: HostInboundMessage): 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;
53
73
  export declare function buildHydratedInboundContext(input: {
54
74
  agentId: string;
55
75
  conversation: CanonConversation | null;
@@ -32,38 +32,57 @@ export function buildCanonHostPrompt(input) {
32
32
  input.content,
33
33
  ].join('\n');
34
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}]`;
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
+ }
54
59
  }
55
60
  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}]`;
61
+ const duration = message.audioDurationMs
62
+ ? ` (${Math.round(message.audioDurationMs / 1000)}s)`
63
+ : '';
64
+ placeholders.push(`[Voice message${duration}]`);
60
65
  }
61
66
  else if (message.contentType === 'image' && message.imageUrl) {
62
- content = content
63
- ? `[Image: ${message.imageUrl}]\n${content}`
64
- : `[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}]`;
65
81
  }
66
- return content || '[Empty message]';
82
+ // file
83
+ const label = materialized?.fileName ?? attachment.fileName ?? 'File';
84
+ const ref = materialized?.path ? ` ${materialized.path}` : '';
85
+ return `[File: ${label}${ref}]`;
67
86
  }
68
87
  export function buildHydratedInboundContext(input) {
69
88
  const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
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 { 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';
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';
28
29
  import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
29
30
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
30
31
  let workingDir = process.cwd();
@@ -50,6 +51,14 @@ async function loadSessionConfig(conversationId, agentId) {
50
51
  permissionMode: config?.permissionMode,
51
52
  };
52
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
+ }
53
62
  function resolveWorkspaceCwd(config) {
54
63
  return resolveHostWorkspaceCwd({
55
64
  workspaceOptions,
@@ -105,8 +114,28 @@ function buildCanonPrompt(input) {
105
114
  ...input,
106
115
  });
107
116
  }
108
- function renderInboundContent(message) {
109
- return renderCanonHostInboundContent(message);
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;
133
+ }
134
+ const imageBlocks = await Promise.all(imageAttachments.map((attachment) => toAnthropicImageBlock(attachment)));
135
+ return [
136
+ { type: 'text', text: input.promptText },
137
+ ...imageBlocks,
138
+ ];
110
139
  }
111
140
  // ── Session factory ─────────────────────────────────────────────────
112
141
  function createSession(conversationId, environment, agentId, client, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
@@ -612,6 +641,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
612
641
  defaultPermissionMode: 'default',
613
642
  availableModels: modelList,
614
643
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
644
+ availableExecutionModes: config.availableExecutionModes,
615
645
  });
616
646
  writeSessionState(conversationId, agentId, {
617
647
  ...session.state,
@@ -674,10 +704,14 @@ async function main() {
674
704
  console.error(`[canon-host] Authenticated as ${agentId}`);
675
705
  }
676
706
  let streamConnected = false;
707
+ const hostAvailableExecutionModes = allowWorktrees
708
+ ? [...EXECUTION_ENVIRONMENT_MODES]
709
+ : ['locked'];
677
710
  let runtimeDescriptor = {
678
711
  defaultWorkspaceId: workspaceOptions[0]?.id,
679
712
  defaultPermissionMode: 'default',
680
713
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
714
+ availableExecutionModes: hostAvailableExecutionModes,
681
715
  };
682
716
  const publishRuntimeHeartbeat = async () => {
683
717
  if (!streamConnected)
@@ -695,6 +729,7 @@ async function main() {
695
729
  defaultPermissionMode: 'default',
696
730
  availableModels: runtimeModels,
697
731
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
732
+ availableExecutionModes: hostAvailableExecutionModes,
698
733
  };
699
734
  }
700
735
  catch {
@@ -702,6 +737,7 @@ async function main() {
702
737
  defaultWorkspaceId: workspaceOptions[0]?.id,
703
738
  defaultPermissionMode: 'default',
704
739
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
740
+ availableExecutionModes: hostAvailableExecutionModes,
705
741
  };
706
742
  }
707
743
  try {
@@ -794,12 +830,16 @@ async function main() {
794
830
  }
795
831
  const creation = (async () => {
796
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
+ }
797
837
  const workspaceCwd = resolveWorkspaceCwd(config);
798
838
  const environment = prepareConversationEnvironment({
799
839
  agentId,
800
840
  conversationId,
801
841
  workspaceCwd,
802
- allowWorktrees,
842
+ allowWorktrees: sessionExecutionMode === 'worktree',
803
843
  });
804
844
  try {
805
845
  const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
@@ -823,6 +863,7 @@ async function main() {
823
863
  model: config?.model ?? runtimeModels[0]?.value,
824
864
  permissionMode: config?.permissionMode ?? 'default',
825
865
  availableModels: runtimeModels,
866
+ availableExecutionModes: hostAvailableExecutionModes,
826
867
  }, resumeId);
827
868
  sessions.set(conversationId, session);
828
869
  return session;
@@ -863,9 +904,24 @@ async function main() {
863
904
  }
864
905
  }
865
906
  const sender = m.senderName || m.senderId;
866
- const content = renderInboundContent(m);
867
907
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
868
908
  void (async () => {
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);
869
925
  const hydrated = await loadHydratedInboundContext({
870
926
  conversationId: convoId,
871
927
  message: m,
@@ -899,18 +955,20 @@ async function main() {
899
955
  const turnMetadata = normalizeTurnMetadata(m.metadata);
900
956
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
901
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 });
902
967
  session.enqueueInbound({
903
968
  type: 'user',
904
969
  message: {
905
970
  role: 'user',
906
- content: buildCanonPrompt({
907
- content,
908
- conversationId: convoId,
909
- participantContext,
910
- behavior,
911
- workSession: m.workSession,
912
- workSessions,
913
- }),
971
+ content: messageContent,
914
972
  },
915
973
  parent_tool_use_id: null,
916
974
  }, deliveryIntent, m.id ?? null, shouldMarkAccepted);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.8.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",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
26
+ "@canonmsg/agent-sdk": "^0.8.0",
26
27
  "@canonmsg/core": "^0.7.0",
27
28
  "@modelcontextprotocol/sdk": "^1.12.0"
28
29
  },