@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.
- package/dist/host-runtime.d.ts +21 -1
- package/dist/host-runtime.js +46 -27
- package/dist/host.js +71 -13
- package/package.json +2 -1
package/dist/host-runtime.d.ts
CHANGED
|
@@ -49,7 +49,27 @@ export declare function buildCanonHostPrompt(input: {
|
|
|
49
49
|
workSessions?: MessageCreatedPayload['workSessions'];
|
|
50
50
|
buildInboundContextLines: (context: HostInboundParticipantContext) => string[];
|
|
51
51
|
}): string;
|
|
52
|
-
|
|
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;
|
package/dist/host-runtime.js
CHANGED
|
@@ -32,38 +32,57 @@ export function buildCanonHostPrompt(input) {
|
|
|
32
32
|
input.content,
|
|
33
33
|
].join('\n');
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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.
|
|
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
|
},
|