@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.
- package/dist/host-runtime.d.ts +109 -0
- package/dist/host-runtime.js +171 -0
- package/dist/host.js +106 -111
- package/dist/server.js +94 -0
- package/package.json +7 -4
|
@@ -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 {
|
|
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
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const permissionMode = normalizeOptionalString(data.permissionMode);
|
|
44
|
+
const config = await loadHostSessionConfig({
|
|
45
|
+
conversationId,
|
|
46
|
+
agentId,
|
|
47
|
+
extraStringFields: ['permissionMode'],
|
|
48
|
+
});
|
|
53
49
|
return {
|
|
54
|
-
...
|
|
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
|
|
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
|
-
'
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
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.
|
|
763
|
+
client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
792
764
|
]);
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
765
|
+
return buildHydratedInboundContext({
|
|
766
|
+
agentId,
|
|
767
|
+
conversation,
|
|
768
|
+
page,
|
|
769
|
+
message: input.message,
|
|
798
770
|
senderName: input.senderName,
|
|
799
771
|
isOwner: input.isOwner,
|
|
800
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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:
|
|
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.
|
|
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/
|
|
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
|
}
|