@canonmsg/codex-plugin 0.4.0 → 0.6.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/adapter.d.ts CHANGED
@@ -36,7 +36,7 @@ export declare class CodexConversationAdapter {
36
36
  private readonly codexBin;
37
37
  private model;
38
38
  private readonly sandbox;
39
- private readonly approvalPolicy;
39
+ private readonly legacyApprovalPolicy;
40
40
  private readonly codexProfile;
41
41
  private readonly addDirs;
42
42
  private readonly configOverrides;
@@ -63,7 +63,7 @@ export declare class CodexConversationAdapter {
63
63
  setModel(model: string | null): void;
64
64
  isRunning(): boolean;
65
65
  interrupt(): Promise<void>;
66
- runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void): Promise<CodexTurnResult>;
66
+ runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[]): Promise<CodexTurnResult>;
67
67
  private buildArgs;
68
68
  private clearActiveProcess;
69
69
  }
package/dist/adapter.js CHANGED
@@ -5,7 +5,7 @@ export class CodexConversationAdapter {
5
5
  codexBin;
6
6
  model;
7
7
  sandbox;
8
- approvalPolicy;
8
+ legacyApprovalPolicy;
9
9
  codexProfile;
10
10
  addDirs;
11
11
  configOverrides;
@@ -21,7 +21,7 @@ export class CodexConversationAdapter {
21
21
  this.codexBin = opts.codexBin ?? 'codex';
22
22
  this.model = opts.model ?? null;
23
23
  this.sandbox = opts.sandbox ?? null;
24
- this.approvalPolicy = opts.approvalPolicy ?? null;
24
+ this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
25
25
  this.codexProfile = opts.codexProfile ?? null;
26
26
  this.addDirs = opts.addDirs ?? [];
27
27
  this.configOverrides = opts.configOverrides ?? [];
@@ -47,11 +47,11 @@ export class CodexConversationAdapter {
47
47
  this.child.kill('SIGKILL');
48
48
  }, 5_000);
49
49
  }
50
- async runTurn(prompt, onEvent, onLog) {
50
+ async runTurn(prompt, onEvent, onLog, imagePaths = []) {
51
51
  if (this.child) {
52
52
  throw new Error('A Codex turn is already in progress for this conversation');
53
53
  }
54
- const args = this.buildArgs(prompt);
54
+ const args = this.buildArgs(prompt, imagePaths);
55
55
  const child = spawn(this.codexBin, args, {
56
56
  cwd: this.cwd,
57
57
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -141,7 +141,7 @@ export class CodexConversationAdapter {
141
141
  });
142
142
  });
143
143
  }
144
- buildArgs(prompt) {
144
+ buildArgs(prompt, imagePaths = []) {
145
145
  if (this.threadId) {
146
146
  const args = ['exec', 'resume', '--json', '--skip-git-repo-check'];
147
147
  if (this.model) {
@@ -159,13 +159,16 @@ export class CodexConversationAdapter {
159
159
  if (this.bypassApprovalsAndSandbox) {
160
160
  args.push('--dangerously-bypass-approvals-and-sandbox');
161
161
  }
162
+ for (const imagePath of imagePaths) {
163
+ args.push('-i', imagePath);
164
+ }
162
165
  args.push(this.threadId, prompt);
163
166
  return args;
164
167
  }
165
168
  const args = ['exec', '--json', '--color', 'never', '-C', this.cwd, '--skip-git-repo-check'];
166
169
  const execMode = resolveExecMode({
167
170
  sandbox: this.sandbox,
168
- approvalPolicy: this.approvalPolicy,
171
+ approvalPolicy: this.legacyApprovalPolicy,
169
172
  fullAuto: this.fullAuto,
170
173
  bypassApprovalsAndSandbox: this.bypassApprovalsAndSandbox,
171
174
  });
@@ -190,6 +193,9 @@ export class CodexConversationAdapter {
190
193
  if (execMode.bypassApprovalsAndSandbox) {
191
194
  args.push('--dangerously-bypass-approvals-and-sandbox');
192
195
  }
196
+ for (const imagePath of imagePaths) {
197
+ args.push('-i', imagePath);
198
+ }
193
199
  args.push(prompt);
194
200
  return args;
195
201
  }
@@ -231,15 +237,18 @@ function resolveExecMode(input) {
231
237
  if (input.fullAuto) {
232
238
  return { fullAuto: true, bypassApprovalsAndSandbox: false };
233
239
  }
234
- // Newer Codex CLI releases no longer accept --ask-for-approval for `exec`.
235
- // Preserve the old Canon example (`--sandbox workspace-write --ask-for-approval never`)
236
- // by translating it to the supported `--full-auto` mode.
237
- if (input.approvalPolicy === 'never' &&
238
- (input.sandbox === 'workspace-write' || input.sandbox == null)) {
240
+ if (shouldTranslateLegacyApprovalMode(input)) {
239
241
  return { fullAuto: true, bypassApprovalsAndSandbox: false };
240
242
  }
241
243
  return { fullAuto: false, bypassApprovalsAndSandbox: false };
242
244
  }
245
+ function shouldTranslateLegacyApprovalMode(input) {
246
+ // Newer Codex CLI releases no longer accept --ask-for-approval for `exec`.
247
+ // Keep the compatibility shim isolated here so the rest of the adapter only
248
+ // deals with the supported execution switches.
249
+ return input.approvalPolicy === 'never'
250
+ && (input.sandbox === 'workspace-write' || input.sandbox == null);
251
+ }
243
252
  function isIgnorableCodexLog(line) {
244
253
  return [
245
254
  'Reading additional input from stdin...',
@@ -0,0 +1,108 @@
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/claude-code-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/codex-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 are
54
+ * referenced by short placeholders — their actual bytes are delivered to the
55
+ * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
56
+ * blocks). URLs are intentionally *not* inlined, since the harness never
57
+ * needs to refetch and earlier `[Image: <url>]` inlining caused vision
58
+ * models to see a string about an image instead of the image itself.
59
+ *
60
+ * `materialized` may be passed so non-image attachments can reference a
61
+ * local path the agent can Read. Without it we fall back to an unadorned
62
+ * placeholder; the vision path still works because image args carry the
63
+ * file path directly.
64
+ */
65
+ export declare function renderCanonHostInboundContent(message: HostInboundMessage, materialized?: ReadonlyArray<{
66
+ kind: 'image' | 'audio' | 'file';
67
+ path: string;
68
+ fileName?: string;
69
+ durationMs?: number;
70
+ index: number;
71
+ }>): string;
72
+ export declare function buildHydratedInboundContext(input: {
73
+ agentId: string;
74
+ conversation: CanonConversation | null;
75
+ page?: CanonMessagesPage | null;
76
+ message: HostInboundMessage;
77
+ senderName: string;
78
+ isOwner: boolean;
79
+ }): {
80
+ participantContext: HostInboundParticipantContext;
81
+ behavior?: ResolvedAgentBehaviorPolicy | null;
82
+ workSessions: NonNullable<MessageCreatedPayload['workSessions']>;
83
+ hydratedFromPage: boolean;
84
+ };
85
+ export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
86
+ export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null;
87
+ export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
88
+ conversationId: string;
89
+ agentId: string;
90
+ extraStringFields?: readonly TExtra[];
91
+ }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null>;
92
+ export declare function resolveHostWorkspaceCwd(input: {
93
+ workspaceOptions: HostWorkspaceResolverOption[];
94
+ config: {
95
+ workspaceId?: string;
96
+ retiredWorkspaceConfig?: boolean;
97
+ } | null;
98
+ defaultCwd: string;
99
+ }): string;
100
+ export declare function createConversationMetadataLoader(input: {
101
+ client: CanonClient;
102
+ conversationCache: Map<string, CanonConversation>;
103
+ cacheTtlMs?: number;
104
+ }): {
105
+ refreshConversationCache(force?: boolean): Promise<void>;
106
+ getConversationMeta(conversationId: string): Promise<CanonConversation | null>;
107
+ };
108
+ export {};
@@ -0,0 +1,170 @@
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/claude-code-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/codex-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 are
37
+ * referenced by short placeholders — their actual bytes are delivered to the
38
+ * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
39
+ * blocks). URLs are intentionally *not* inlined, since the harness never
40
+ * needs to refetch and earlier `[Image: <url>]` inlining caused vision
41
+ * models to see a string about an image instead of the image itself.
42
+ *
43
+ * `materialized` may be passed so non-image attachments can reference a
44
+ * local path the agent can Read. Without it we fall back to an unadorned
45
+ * placeholder; the vision path still works because image args carry the
46
+ * file path directly.
47
+ */
48
+ export function renderCanonHostInboundContent(message, materialized) {
49
+ const body = message.text || '';
50
+ const placeholders = [];
51
+ const attachments = message.attachments ?? [];
52
+ if (attachments.length > 0) {
53
+ for (let i = 0; i < attachments.length; i += 1) {
54
+ const att = attachments[i];
55
+ const mat = materialized?.find((m) => m.index === i) ?? null;
56
+ placeholders.push(describeAttachment(att, mat));
57
+ }
58
+ }
59
+ else if (message.contentType === 'audio' && message.audioUrl) {
60
+ const duration = message.audioDurationMs
61
+ ? ` (${Math.round(message.audioDurationMs / 1000)}s)`
62
+ : '';
63
+ placeholders.push(`[Voice message${duration}]`);
64
+ }
65
+ else if (message.contentType === 'image' && message.imageUrl) {
66
+ placeholders.push('[Image attached]');
67
+ }
68
+ const rendered = [...placeholders, body].filter(Boolean).join('\n');
69
+ return rendered || '[Empty message]';
70
+ }
71
+ function describeAttachment(attachment, materialized) {
72
+ if (attachment.kind === 'image') {
73
+ return '[Image attached]';
74
+ }
75
+ if (attachment.kind === 'audio') {
76
+ const durationMs = materialized?.durationMs ?? attachment.durationMs;
77
+ const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
78
+ const ref = materialized?.path ? ` ${materialized.path}` : '';
79
+ return `[Voice message${duration}${ref}]`;
80
+ }
81
+ // file
82
+ const label = materialized?.fileName ?? attachment.fileName ?? 'File';
83
+ const ref = materialized?.path ? ` ${materialized.path}` : '';
84
+ return `[File: ${label}${ref}]`;
85
+ }
86
+ export function buildHydratedInboundContext(input) {
87
+ const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
88
+ return {
89
+ participantContext: {
90
+ conversationType: input.conversation?.type ?? 'unknown',
91
+ memberCount: input.conversation?.memberIds?.length ?? null,
92
+ senderType: input.message.senderType ?? 'human',
93
+ senderName: input.senderName,
94
+ isOwner: input.isOwner,
95
+ mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
96
+ recentSenderTypes: history.recentSenderTypes,
97
+ recentHumanCount: history.recentHumanCount,
98
+ recentAgentCount: history.recentAgentCount,
99
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
100
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
101
+ },
102
+ behavior: input.page?.behavior ?? input.conversation?.behavior,
103
+ workSessions: input.page?.workSessions ?? [],
104
+ hydratedFromPage: input.page != null,
105
+ };
106
+ }
107
+ export async function publishHostAgentRuntime(agentId, clientType, runtime) {
108
+ await rtdbWrite(`/agent-runtime/${agentId}`, {
109
+ clientType,
110
+ hostMode: true,
111
+ ...runtime,
112
+ updatedAt: { '.sv': 'timestamp' },
113
+ });
114
+ }
115
+ export function readHostSessionConfig(raw, extraStringFields = []) {
116
+ const baseConfig = readSessionWorkspaceConfig(raw);
117
+ if (!raw || typeof raw !== 'object') {
118
+ return baseConfig;
119
+ }
120
+ const data = raw;
121
+ const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
122
+ const value = normalizeOptionalString(data[field]);
123
+ return value ? [[field, value]] : [];
124
+ }));
125
+ return {
126
+ ...(baseConfig ?? {}),
127
+ ...extraConfig,
128
+ };
129
+ }
130
+ export async function loadHostSessionConfig(input) {
131
+ const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
132
+ return readHostSessionConfig(raw, input.extraStringFields);
133
+ }
134
+ export function resolveHostWorkspaceCwd(input) {
135
+ return resolveConfiguredWorkspaceCwd(input);
136
+ }
137
+ export function createConversationMetadataLoader(input) {
138
+ const cacheTtlMs = input.cacheTtlMs ?? 10_000;
139
+ let conversationCacheLoadedAt = 0;
140
+ async function refreshConversationCache(force = false) {
141
+ if (!force
142
+ && input.conversationCache.size > 0
143
+ && Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
144
+ return;
145
+ }
146
+ const conversations = await input.client.getConversations();
147
+ input.conversationCache.clear();
148
+ for (const conversation of conversations) {
149
+ input.conversationCache.set(conversation.id, conversation);
150
+ }
151
+ conversationCacheLoadedAt = Date.now();
152
+ }
153
+ async function getConversationMeta(conversationId) {
154
+ try {
155
+ await refreshConversationCache();
156
+ const cached = input.conversationCache.get(conversationId);
157
+ if (cached)
158
+ return cached;
159
+ await refreshConversationCache(true);
160
+ return input.conversationCache.get(conversationId) ?? null;
161
+ }
162
+ catch {
163
+ return input.conversationCache.get(conversationId) ?? null;
164
+ }
165
+ }
166
+ return {
167
+ refreshConversationCache,
168
+ getConversationMeta,
169
+ };
170
+ }
package/dist/host.js CHANGED
@@ -3,7 +3,9 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  setDefaultResultOrder('ipv4first');
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { parseArgs } from 'node:util';
6
- import { buildParticipationHistorySnapshot, buildBehaviorPolicyLines, buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, ExecutionEnvironmentError, isEnabledFlag, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
6
+ import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
7
+ import { buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, isEnabledFlag, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
8
+ import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
7
9
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
8
10
  import { CodexConversationAdapter, } from './adapter.js';
9
11
  import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -37,71 +39,35 @@ function normalizeRuntimeTurnState(value) {
37
39
  return null;
38
40
  }
39
41
  async function publishAgentRuntime(agentId, runtime) {
40
- await rtdbWrite(`/agent-runtime/${agentId}`, {
41
- clientType: 'codex',
42
- hostMode: true,
43
- ...runtime,
44
- updatedAt: { '.sv': 'timestamp' },
45
- });
42
+ await publishHostAgentRuntime(agentId, 'codex', runtime);
46
43
  }
47
44
  async function loadSessionConfig(conversationId, agentId) {
48
- const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
49
- return readSessionWorkspaceConfig(raw);
45
+ return loadHostSessionConfig({ conversationId, agentId });
46
+ }
47
+ const SESSION_EXECUTION_MODE_REQUIRED = 'Session execution mode required; please select a mode before starting the session.';
48
+ function requireSessionExecutionMode(config) {
49
+ const mode = config?.executionMode;
50
+ if (!mode) {
51
+ throw new ExecutionEnvironmentError(SESSION_EXECUTION_MODE_REQUIRED, SESSION_EXECUTION_MODE_REQUIRED);
52
+ }
53
+ return mode;
50
54
  }
51
55
  function resolveWorkspaceCwd(config) {
52
- return resolveConfiguredWorkspaceCwd({
56
+ return resolveHostWorkspaceCwd({
53
57
  workspaceOptions,
54
58
  config,
55
59
  defaultCwd: workingDir,
56
60
  });
57
61
  }
58
62
  function buildCanonPrompt(input) {
59
- return [
60
- 'You are connected to Canon messaging through a Codex host wrapper.',
61
- 'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
62
- 'Short intermediate assistant messages may be shown as ephemeral status while you work.',
63
- ...buildInboundContextLines(input.participantContext),
64
- ...buildBehaviorPolicyLines(input.behavior),
65
- 'Canon participants may be humans or AI agents.',
66
- 'Honor the Canon behavior policy above when deciding how proactively to participate.',
67
- `Conversation ID: ${input.conversationId}`,
68
- '',
69
- 'New Canon message:',
70
- input.content,
71
- ].join('\n');
63
+ return buildCanonHostPrompt({
64
+ hostLabel: 'Codex',
65
+ buildInboundContextLines,
66
+ ...input,
67
+ });
72
68
  }
73
- function renderInboundContent(message) {
74
- let content = message.text || '';
75
- const attachment = message.attachments?.[0];
76
- if (attachment?.kind === 'audio' && attachment.url) {
77
- const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
78
- content = content
79
- ? `[Voice message${duration}: ${attachment.url}]\n${content}`
80
- : `[Voice message${duration}: ${attachment.url}]`;
81
- }
82
- else if (attachment?.kind === 'image' && attachment.url) {
83
- content = content
84
- ? `[Image: ${attachment.url}]\n${content}`
85
- : `[Image: ${attachment.url}]`;
86
- }
87
- else if (attachment?.kind === 'file' && attachment.url) {
88
- const label = attachment.fileName || 'File';
89
- content = content
90
- ? `[File: ${label} ${attachment.url}]\n${content}`
91
- : `[File: ${label} ${attachment.url}]`;
92
- }
93
- else if (message.contentType === 'audio' && message.audioUrl) {
94
- const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
95
- content = content
96
- ? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
97
- : `[Voice message${duration}: ${message.audioUrl}]`;
98
- }
99
- else if (message.contentType === 'image' && message.imageUrl) {
100
- content = content
101
- ? `[Image: ${message.imageUrl}]\n${content}`
102
- : `[Image: ${message.imageUrl}]`;
103
- }
104
- return content || '[Empty message]';
69
+ function renderInboundContent(message, materialized) {
70
+ return renderCanonHostInboundContent(message, materialized);
105
71
  }
106
72
  function summarizeCommand(command) {
107
73
  const trimmed = command.trim();
@@ -173,31 +139,10 @@ async function main() {
173
139
  const sessions = new Map();
174
140
  const pendingSessionCreations = new Map();
175
141
  const conversationCache = new Map();
176
- let conversationCacheLoadedAt = 0;
177
- async function refreshConversationCache(force = false) {
178
- if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
179
- return;
180
- }
181
- const conversations = await client.getConversations();
182
- conversationCache.clear();
183
- for (const conversation of conversations) {
184
- conversationCache.set(conversation.id, conversation);
185
- }
186
- conversationCacheLoadedAt = Date.now();
187
- }
188
- async function getConversationMeta(conversationId) {
189
- try {
190
- await refreshConversationCache();
191
- const cached = conversationCache.get(conversationId);
192
- if (cached)
193
- return cached;
194
- await refreshConversationCache(true);
195
- return conversationCache.get(conversationId) ?? null;
196
- }
197
- catch {
198
- return conversationCache.get(conversationId) ?? null;
199
- }
200
- }
142
+ const { getConversationMeta } = createConversationMetadataLoader({
143
+ client,
144
+ conversationCache,
145
+ });
201
146
  async function loadSenderRuntimeState(conversationId, senderId) {
202
147
  try {
203
148
  const [turnState, sessionState] = await Promise.all([
@@ -210,25 +155,21 @@ async function main() {
210
155
  return null;
211
156
  }
212
157
  }
213
- async function loadParticipantContext(input) {
214
- const [conversation, recentMessages] = await Promise.all([
158
+ async function loadHydratedInboundContext(input) {
159
+ const [conversation, page] = await Promise.all([
215
160
  getConversationMeta(input.conversationId),
216
- client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
161
+ input.hydratedPage
162
+ ? Promise.resolve(input.hydratedPage)
163
+ : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
217
164
  ]);
218
- const history = buildParticipationHistorySnapshot(recentMessages, agentId);
219
- return {
220
- conversationType: conversation?.type ?? 'unknown',
221
- memberCount: conversation?.memberIds?.length ?? null,
222
- senderType: input.message.senderType ?? 'human',
165
+ return buildHydratedInboundContext({
166
+ agentId,
167
+ conversation,
168
+ page,
169
+ message: input.message,
223
170
  senderName: input.senderName,
224
171
  isOwner: input.isOwner,
225
- mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
226
- recentSenderTypes: history.recentSenderTypes,
227
- recentHumanCount: history.recentHumanCount,
228
- recentAgentCount: history.recentAgentCount,
229
- consecutiveAgentTurns: history.consecutiveAgentTurns,
230
- currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
231
- };
172
+ });
232
173
  }
233
174
  function writeState(session) {
234
175
  writeSessionState(session.conversationId, agentId, {
@@ -309,12 +250,16 @@ async function main() {
309
250
  }
310
251
  const creation = (async () => {
311
252
  const config = await loadSessionConfig(conversationId, agentId);
253
+ const sessionExecutionMode = requireSessionExecutionMode(config);
254
+ if (sessionExecutionMode === 'worktree' && !allowWorktrees) {
255
+ 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.');
256
+ }
312
257
  const workspaceCwd = resolveWorkspaceCwd(config);
313
258
  const environment = prepareConversationEnvironment({
314
259
  agentId,
315
260
  conversationId,
316
261
  workspaceCwd,
317
- allowWorktrees,
262
+ allowWorktrees: sessionExecutionMode === 'worktree',
318
263
  });
319
264
  try {
320
265
  const sessionCwd = environment.cwd;
@@ -371,8 +316,8 @@ async function main() {
371
316
  pendingSessionCreations.delete(conversationId);
372
317
  }
373
318
  }
374
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false) {
375
- const nextPrompt = { prompt, intent, sourceMessageId, markAccepted };
319
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = []) {
320
+ const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths };
376
321
  if (toFront) {
377
322
  session.queue.unshift(nextPrompt);
378
323
  }
@@ -384,15 +329,39 @@ async function main() {
384
329
  void runNextTurn(session);
385
330
  }
386
331
  async function enqueueInboundMessage(input) {
387
- const content = renderInboundContent(input.message);
388
- const conversation = await getConversationMeta(input.conversationId);
389
- const behavior = input.behavior ?? conversation?.behavior;
390
- const participantContext = await loadParticipantContext({
332
+ let materialized = [];
333
+ if (input.message.id) {
334
+ try {
335
+ materialized = await materializeMessageMedia({
336
+ id: input.message.id,
337
+ attachments: input.message.attachments,
338
+ imageUrl: input.message.imageUrl ?? null,
339
+ audioUrl: input.message.audioUrl ?? null,
340
+ audioDurationMs: input.message.audioDurationMs ?? null,
341
+ }, { agentId, conversationId: input.conversationId });
342
+ }
343
+ catch (error) {
344
+ console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
345
+ }
346
+ }
347
+ const imagePaths = materialized
348
+ .map((attachment) => getCodexImagePath(attachment))
349
+ .filter((path) => path !== null);
350
+ const content = renderInboundContent(input.message, materialized);
351
+ const hydrated = await loadHydratedInboundContext({
391
352
  conversationId: input.conversationId,
392
353
  message: input.message,
393
354
  senderName: input.senderName,
394
355
  isOwner: input.isOwner,
356
+ hydratedPage: input.hydratedPage,
395
357
  });
358
+ const behavior = input.behavior ?? hydrated.behavior;
359
+ const workSessions = hydrated.hydratedFromPage
360
+ ? hydrated.workSessions
361
+ : Array.isArray(input.workSessions)
362
+ ? input.workSessions
363
+ : hydrated.workSessions;
364
+ const participantContext = hydrated.participantContext;
396
365
  const autoReply = decideAutoReply(participantContext, behavior);
397
366
  if (!autoReply.allow) {
398
367
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
@@ -418,16 +387,18 @@ async function main() {
418
387
  conversationId: input.conversationId,
419
388
  participantContext,
420
389
  behavior,
390
+ workSession: input.message.workSession,
391
+ workSessions,
421
392
  });
422
393
  if (session.running && deliveryIntent === 'interrupt') {
423
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted);
394
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths);
424
395
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
425
396
  await session.adapter.interrupt().catch(() => { });
426
397
  clearStreaming(input.conversationId);
427
398
  client.setTyping(input.conversationId, false).catch(() => { });
428
399
  return;
429
400
  }
430
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted);
401
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths);
431
402
  }
432
403
  async function runNextTurn(session) {
433
404
  if (session.running || session.closed)
@@ -453,6 +424,7 @@ async function main() {
453
424
  updatedAt: { '.sv': 'timestamp' },
454
425
  }).catch(() => { });
455
426
  try {
427
+ const turnImagePaths = nextTurn.imagePaths ?? [];
456
428
  const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
457
429
  session.lastActivity = Date.now();
458
430
  if (event.type === 'thread.started') {
@@ -486,7 +458,7 @@ async function main() {
486
458
  }
487
459
  }, (line) => {
488
460
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
489
- });
461
+ }, turnImagePaths);
490
462
  if (result.threadId) {
491
463
  saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
492
464
  }
@@ -563,10 +535,14 @@ async function main() {
563
535
  }
564
536
  let controlStopped = false;
565
537
  let streamConnected = false;
538
+ const hostAvailableExecutionModes = allowWorktrees
539
+ ? [...EXECUTION_ENVIRONMENT_MODES]
540
+ : ['locked'];
566
541
  let runtimeDescriptor = {
567
542
  defaultWorkspaceId: workspaceOptions[0]?.id,
568
543
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
569
544
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
545
+ availableExecutionModes: hostAvailableExecutionModes,
570
546
  };
571
547
  const publishRuntimeHeartbeat = async () => {
572
548
  if (!streamConnected)
@@ -589,6 +565,7 @@ async function main() {
589
565
  senderName: message.senderName || message.senderId,
590
566
  isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
591
567
  behavior: payload.behavior,
568
+ workSessions: payload.workSessions,
592
569
  });
593
570
  },
594
571
  onConnected: () => {
@@ -609,12 +586,14 @@ async function main() {
609
586
  defaultWorkspaceId: workspaceOptions[0]?.id,
610
587
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
611
588
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
589
+ availableExecutionModes: hostAvailableExecutionModes,
612
590
  };
613
591
  }
614
592
  catch {
615
593
  runtimeDescriptor = {
616
594
  defaultWorkspaceId: workspaceOptions[0]?.id,
617
595
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
596
+ availableExecutionModes: hostAvailableExecutionModes,
618
597
  };
619
598
  }
620
599
  try {
@@ -650,6 +629,8 @@ async function main() {
650
629
  senderName: latestMessage.senderId,
651
630
  isOwner: ownerId != null && latestMessage.senderId === ownerId,
652
631
  behavior: latestPage.behavior,
632
+ workSessions: latestPage.workSessions,
633
+ hydratedPage: latestPage,
653
634
  });
654
635
  }
655
636
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/host.js",
@@ -22,7 +22,8 @@
22
22
  "prepack": "npm run build"
23
23
  },
24
24
  "dependencies": {
25
- "@canonmsg/core": "^0.5.0"
25
+ "@canonmsg/agent-sdk": "^0.8.0",
26
+ "@canonmsg/core": "^0.7.0"
26
27
  },
27
28
  "engines": {
28
29
  "node": ">=18.0.0"