@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 +2 -2
- package/dist/adapter.js +20 -11
- package/dist/host-runtime.d.ts +108 -0
- package/dist/host-runtime.js +170 -0
- package/dist/host.js +86 -105
- package/package.json +3 -2
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
'
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
214
|
-
const [conversation,
|
|
158
|
+
async function loadHydratedInboundContext(input) {
|
|
159
|
+
const [conversation, page] = await Promise.all([
|
|
215
160
|
getConversationMeta(input.conversationId),
|
|
216
|
-
|
|
161
|
+
input.hydratedPage
|
|
162
|
+
? Promise.resolve(input.hydratedPage)
|
|
163
|
+
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
217
164
|
]);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
165
|
+
return buildHydratedInboundContext({
|
|
166
|
+
agentId,
|
|
167
|
+
conversation,
|
|
168
|
+
page,
|
|
169
|
+
message: input.message,
|
|
223
170
|
senderName: input.senderName,
|
|
224
171
|
isOwner: input.isOwner,
|
|
225
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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.
|
|
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/
|
|
25
|
+
"@canonmsg/agent-sdk": "^0.8.0",
|
|
26
|
+
"@canonmsg/core": "^0.7.0"
|
|
26
27
|
},
|
|
27
28
|
"engines": {
|
|
28
29
|
"node": ">=18.0.0"
|