@canonmsg/codex-plugin 0.3.0 → 0.5.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/README.md +8 -0
- package/dist/adapter.d.ts +1 -1
- package/dist/adapter.js +11 -8
- package/dist/host-runtime.d.ts +89 -0
- package/dist/host-runtime.js +152 -0
- package/dist/host.js +73 -104
- package/dist/register.js +9 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ canon-codex --cwd /path/to/project
|
|
|
22
22
|
|
|
23
23
|
Registration saves a Canon profile in `~/.canon/agents.json`, the same shared profile store used by the Claude Code integration and supported by the OpenClaw plugin.
|
|
24
24
|
|
|
25
|
+
If the terminal closes or the machine restarts, the agent goes offline until you start the host again. To bring back the same registered agent, rerun `canon-codex --cwd /path/to/project`. Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`.
|
|
26
|
+
|
|
25
27
|
You do not need a git repo for host mode. The plugin passes `--skip-git-repo-check` to Codex, so any readable working directory is valid.
|
|
26
28
|
|
|
27
29
|
## What v1 supports
|
|
@@ -71,6 +73,12 @@ If you installed the package only inside this repo and not globally, run the bui
|
|
|
71
73
|
node packages/codex-plugin/dist/host.js --cwd /path/to/project --full-auto
|
|
72
74
|
```
|
|
73
75
|
|
|
76
|
+
If `canon-codex` starts but cannot find the `codex` binary, either fix your `PATH` or launch with an explicit binary path:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
canon-codex --cwd /path/to/project --codex-bin /absolute/path/to/codex
|
|
80
|
+
```
|
|
81
|
+
|
|
74
82
|
If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Rerun registration for the same profile to overwrite `~/.canon/agents.json`, then restart the host:
|
|
75
83
|
|
|
76
84
|
```bash
|
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;
|
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 ?? [];
|
|
@@ -165,7 +165,7 @@ export class CodexConversationAdapter {
|
|
|
165
165
|
const args = ['exec', '--json', '--color', 'never', '-C', this.cwd, '--skip-git-repo-check'];
|
|
166
166
|
const execMode = resolveExecMode({
|
|
167
167
|
sandbox: this.sandbox,
|
|
168
|
-
approvalPolicy: this.
|
|
168
|
+
approvalPolicy: this.legacyApprovalPolicy,
|
|
169
169
|
fullAuto: this.fullAuto,
|
|
170
170
|
bypassApprovalsAndSandbox: this.bypassApprovalsAndSandbox,
|
|
171
171
|
});
|
|
@@ -231,15 +231,18 @@ function resolveExecMode(input) {
|
|
|
231
231
|
if (input.fullAuto) {
|
|
232
232
|
return { fullAuto: true, bypassApprovalsAndSandbox: false };
|
|
233
233
|
}
|
|
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)) {
|
|
234
|
+
if (shouldTranslateLegacyApprovalMode(input)) {
|
|
239
235
|
return { fullAuto: true, bypassApprovalsAndSandbox: false };
|
|
240
236
|
}
|
|
241
237
|
return { fullAuto: false, bypassApprovalsAndSandbox: false };
|
|
242
238
|
}
|
|
239
|
+
function shouldTranslateLegacyApprovalMode(input) {
|
|
240
|
+
// Newer Codex CLI releases no longer accept --ask-for-approval for `exec`.
|
|
241
|
+
// Keep the compatibility shim isolated here so the rest of the adapter only
|
|
242
|
+
// deals with the supported execution switches.
|
|
243
|
+
return input.approvalPolicy === 'never'
|
|
244
|
+
&& (input.sandbox === 'workspace-write' || input.sandbox == null);
|
|
245
|
+
}
|
|
243
246
|
function isIgnorableCodexLog(line) {
|
|
244
247
|
return [
|
|
245
248
|
'Reading additional input from stdin...',
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
export declare function renderCanonHostInboundContent(message: HostInboundMessage): string;
|
|
53
|
+
export declare function buildHydratedInboundContext(input: {
|
|
54
|
+
agentId: string;
|
|
55
|
+
conversation: CanonConversation | null;
|
|
56
|
+
page?: CanonMessagesPage | null;
|
|
57
|
+
message: HostInboundMessage;
|
|
58
|
+
senderName: string;
|
|
59
|
+
isOwner: boolean;
|
|
60
|
+
}): {
|
|
61
|
+
participantContext: HostInboundParticipantContext;
|
|
62
|
+
behavior?: ResolvedAgentBehaviorPolicy | null;
|
|
63
|
+
workSessions: NonNullable<MessageCreatedPayload['workSessions']>;
|
|
64
|
+
hydratedFromPage: boolean;
|
|
65
|
+
};
|
|
66
|
+
export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
|
|
67
|
+
export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null;
|
|
68
|
+
export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
|
|
69
|
+
conversationId: string;
|
|
70
|
+
agentId: string;
|
|
71
|
+
extraStringFields?: readonly TExtra[];
|
|
72
|
+
}): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null>;
|
|
73
|
+
export declare function resolveHostWorkspaceCwd(input: {
|
|
74
|
+
workspaceOptions: HostWorkspaceResolverOption[];
|
|
75
|
+
config: {
|
|
76
|
+
workspaceId?: string;
|
|
77
|
+
retiredWorkspaceConfig?: boolean;
|
|
78
|
+
} | null;
|
|
79
|
+
defaultCwd: string;
|
|
80
|
+
}): string;
|
|
81
|
+
export declare function createConversationMetadataLoader(input: {
|
|
82
|
+
client: CanonClient;
|
|
83
|
+
conversationCache: Map<string, CanonConversation>;
|
|
84
|
+
cacheTtlMs?: number;
|
|
85
|
+
}): {
|
|
86
|
+
refreshConversationCache(force?: boolean): Promise<void>;
|
|
87
|
+
getConversationMeta(conversationId: string): Promise<CanonConversation | null>;
|
|
88
|
+
};
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
export function renderCanonHostInboundContent(message) {
|
|
36
|
+
let content = message.text || '';
|
|
37
|
+
const attachment = message.attachments?.[0];
|
|
38
|
+
if (attachment?.kind === 'audio' && attachment.url) {
|
|
39
|
+
const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
|
|
40
|
+
content = content
|
|
41
|
+
? `[Voice message${duration}: ${attachment.url}]\n${content}`
|
|
42
|
+
: `[Voice message${duration}: ${attachment.url}]`;
|
|
43
|
+
}
|
|
44
|
+
else if (attachment?.kind === 'image' && attachment.url) {
|
|
45
|
+
content = content
|
|
46
|
+
? `[Image: ${attachment.url}]\n${content}`
|
|
47
|
+
: `[Image: ${attachment.url}]`;
|
|
48
|
+
}
|
|
49
|
+
else if (attachment?.kind === 'file' && attachment.url) {
|
|
50
|
+
const label = attachment.fileName || 'File';
|
|
51
|
+
content = content
|
|
52
|
+
? `[File: ${label} ${attachment.url}]\n${content}`
|
|
53
|
+
: `[File: ${label} ${attachment.url}]`;
|
|
54
|
+
}
|
|
55
|
+
else if (message.contentType === 'audio' && message.audioUrl) {
|
|
56
|
+
const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
|
|
57
|
+
content = content
|
|
58
|
+
? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
|
|
59
|
+
: `[Voice message${duration}: ${message.audioUrl}]`;
|
|
60
|
+
}
|
|
61
|
+
else if (message.contentType === 'image' && message.imageUrl) {
|
|
62
|
+
content = content
|
|
63
|
+
? `[Image: ${message.imageUrl}]\n${content}`
|
|
64
|
+
: `[Image: ${message.imageUrl}]`;
|
|
65
|
+
}
|
|
66
|
+
return content || '[Empty message]';
|
|
67
|
+
}
|
|
68
|
+
export function buildHydratedInboundContext(input) {
|
|
69
|
+
const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
|
|
70
|
+
return {
|
|
71
|
+
participantContext: {
|
|
72
|
+
conversationType: input.conversation?.type ?? 'unknown',
|
|
73
|
+
memberCount: input.conversation?.memberIds?.length ?? null,
|
|
74
|
+
senderType: input.message.senderType ?? 'human',
|
|
75
|
+
senderName: input.senderName,
|
|
76
|
+
isOwner: input.isOwner,
|
|
77
|
+
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
|
|
78
|
+
recentSenderTypes: history.recentSenderTypes,
|
|
79
|
+
recentHumanCount: history.recentHumanCount,
|
|
80
|
+
recentAgentCount: history.recentAgentCount,
|
|
81
|
+
consecutiveAgentTurns: history.consecutiveAgentTurns,
|
|
82
|
+
currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
|
|
83
|
+
},
|
|
84
|
+
behavior: input.page?.behavior ?? input.conversation?.behavior,
|
|
85
|
+
workSessions: input.page?.workSessions ?? [],
|
|
86
|
+
hydratedFromPage: input.page != null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export async function publishHostAgentRuntime(agentId, clientType, runtime) {
|
|
90
|
+
await rtdbWrite(`/agent-runtime/${agentId}`, {
|
|
91
|
+
clientType,
|
|
92
|
+
hostMode: true,
|
|
93
|
+
...runtime,
|
|
94
|
+
updatedAt: { '.sv': 'timestamp' },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export function readHostSessionConfig(raw, extraStringFields = []) {
|
|
98
|
+
const baseConfig = readSessionWorkspaceConfig(raw);
|
|
99
|
+
if (!raw || typeof raw !== 'object') {
|
|
100
|
+
return baseConfig;
|
|
101
|
+
}
|
|
102
|
+
const data = raw;
|
|
103
|
+
const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
|
|
104
|
+
const value = normalizeOptionalString(data[field]);
|
|
105
|
+
return value ? [[field, value]] : [];
|
|
106
|
+
}));
|
|
107
|
+
return {
|
|
108
|
+
...(baseConfig ?? {}),
|
|
109
|
+
...extraConfig,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function loadHostSessionConfig(input) {
|
|
113
|
+
const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
|
|
114
|
+
return readHostSessionConfig(raw, input.extraStringFields);
|
|
115
|
+
}
|
|
116
|
+
export function resolveHostWorkspaceCwd(input) {
|
|
117
|
+
return resolveConfiguredWorkspaceCwd(input);
|
|
118
|
+
}
|
|
119
|
+
export function createConversationMetadataLoader(input) {
|
|
120
|
+
const cacheTtlMs = input.cacheTtlMs ?? 10_000;
|
|
121
|
+
let conversationCacheLoadedAt = 0;
|
|
122
|
+
async function refreshConversationCache(force = false) {
|
|
123
|
+
if (!force
|
|
124
|
+
&& input.conversationCache.size > 0
|
|
125
|
+
&& Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const conversations = await input.client.getConversations();
|
|
129
|
+
input.conversationCache.clear();
|
|
130
|
+
for (const conversation of conversations) {
|
|
131
|
+
input.conversationCache.set(conversation.id, conversation);
|
|
132
|
+
}
|
|
133
|
+
conversationCacheLoadedAt = Date.now();
|
|
134
|
+
}
|
|
135
|
+
async function getConversationMeta(conversationId) {
|
|
136
|
+
try {
|
|
137
|
+
await refreshConversationCache();
|
|
138
|
+
const cached = input.conversationCache.get(conversationId);
|
|
139
|
+
if (cached)
|
|
140
|
+
return cached;
|
|
141
|
+
await refreshConversationCache(true);
|
|
142
|
+
return input.conversationCache.get(conversationId) ?? null;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return input.conversationCache.get(conversationId) ?? null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
refreshConversationCache,
|
|
150
|
+
getConversationMeta,
|
|
151
|
+
};
|
|
152
|
+
}
|
package/dist/host.js
CHANGED
|
@@ -3,7 +3,8 @@ 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 { buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, 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';
|
|
7
|
+
import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
|
|
7
8
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
8
9
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
9
10
|
import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
@@ -37,71 +38,27 @@ function normalizeRuntimeTurnState(value) {
|
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
async function publishAgentRuntime(agentId, runtime) {
|
|
40
|
-
await
|
|
41
|
-
clientType: 'codex',
|
|
42
|
-
hostMode: true,
|
|
43
|
-
...runtime,
|
|
44
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
45
|
-
});
|
|
41
|
+
await publishHostAgentRuntime(agentId, 'codex', runtime);
|
|
46
42
|
}
|
|
47
43
|
async function loadSessionConfig(conversationId, agentId) {
|
|
48
|
-
|
|
49
|
-
return readSessionWorkspaceConfig(raw);
|
|
44
|
+
return loadHostSessionConfig({ conversationId, agentId });
|
|
50
45
|
}
|
|
51
46
|
function resolveWorkspaceCwd(config) {
|
|
52
|
-
return
|
|
47
|
+
return resolveHostWorkspaceCwd({
|
|
53
48
|
workspaceOptions,
|
|
54
49
|
config,
|
|
55
50
|
defaultCwd: workingDir,
|
|
56
51
|
});
|
|
57
52
|
}
|
|
58
53
|
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');
|
|
54
|
+
return buildCanonHostPrompt({
|
|
55
|
+
hostLabel: 'Codex',
|
|
56
|
+
buildInboundContextLines,
|
|
57
|
+
...input,
|
|
58
|
+
});
|
|
72
59
|
}
|
|
73
60
|
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]';
|
|
61
|
+
return renderCanonHostInboundContent(message);
|
|
105
62
|
}
|
|
106
63
|
function summarizeCommand(command) {
|
|
107
64
|
const trimmed = command.trim();
|
|
@@ -173,31 +130,10 @@ async function main() {
|
|
|
173
130
|
const sessions = new Map();
|
|
174
131
|
const pendingSessionCreations = new Map();
|
|
175
132
|
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
|
-
}
|
|
133
|
+
const { getConversationMeta } = createConversationMetadataLoader({
|
|
134
|
+
client,
|
|
135
|
+
conversationCache,
|
|
136
|
+
});
|
|
201
137
|
async function loadSenderRuntimeState(conversationId, senderId) {
|
|
202
138
|
try {
|
|
203
139
|
const [turnState, sessionState] = await Promise.all([
|
|
@@ -210,25 +146,21 @@ async function main() {
|
|
|
210
146
|
return null;
|
|
211
147
|
}
|
|
212
148
|
}
|
|
213
|
-
async function
|
|
214
|
-
const [conversation,
|
|
149
|
+
async function loadHydratedInboundContext(input) {
|
|
150
|
+
const [conversation, page] = await Promise.all([
|
|
215
151
|
getConversationMeta(input.conversationId),
|
|
216
|
-
|
|
152
|
+
input.hydratedPage
|
|
153
|
+
? Promise.resolve(input.hydratedPage)
|
|
154
|
+
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
217
155
|
]);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
156
|
+
return buildHydratedInboundContext({
|
|
157
|
+
agentId,
|
|
158
|
+
conversation,
|
|
159
|
+
page,
|
|
160
|
+
message: input.message,
|
|
223
161
|
senderName: input.senderName,
|
|
224
162
|
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
|
-
};
|
|
163
|
+
});
|
|
232
164
|
}
|
|
233
165
|
function writeState(session) {
|
|
234
166
|
writeSessionState(session.conversationId, agentId, {
|
|
@@ -385,14 +317,20 @@ async function main() {
|
|
|
385
317
|
}
|
|
386
318
|
async function enqueueInboundMessage(input) {
|
|
387
319
|
const content = renderInboundContent(input.message);
|
|
388
|
-
const
|
|
389
|
-
const behavior = input.behavior ?? conversation?.behavior;
|
|
390
|
-
const participantContext = await loadParticipantContext({
|
|
320
|
+
const hydrated = await loadHydratedInboundContext({
|
|
391
321
|
conversationId: input.conversationId,
|
|
392
322
|
message: input.message,
|
|
393
323
|
senderName: input.senderName,
|
|
394
324
|
isOwner: input.isOwner,
|
|
325
|
+
hydratedPage: input.hydratedPage,
|
|
395
326
|
});
|
|
327
|
+
const behavior = input.behavior ?? hydrated.behavior;
|
|
328
|
+
const workSessions = hydrated.hydratedFromPage
|
|
329
|
+
? hydrated.workSessions
|
|
330
|
+
: Array.isArray(input.workSessions)
|
|
331
|
+
? input.workSessions
|
|
332
|
+
: hydrated.workSessions;
|
|
333
|
+
const participantContext = hydrated.participantContext;
|
|
396
334
|
const autoReply = decideAutoReply(participantContext, behavior);
|
|
397
335
|
if (!autoReply.allow) {
|
|
398
336
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
@@ -418,6 +356,8 @@ async function main() {
|
|
|
418
356
|
conversationId: input.conversationId,
|
|
419
357
|
participantContext,
|
|
420
358
|
behavior,
|
|
359
|
+
workSession: input.message.workSession,
|
|
360
|
+
workSessions,
|
|
421
361
|
});
|
|
422
362
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
423
363
|
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted);
|
|
@@ -561,6 +501,20 @@ async function main() {
|
|
|
561
501
|
}
|
|
562
502
|
}
|
|
563
503
|
}
|
|
504
|
+
let controlStopped = false;
|
|
505
|
+
let streamConnected = false;
|
|
506
|
+
let runtimeDescriptor = {
|
|
507
|
+
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
508
|
+
...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
|
|
509
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
510
|
+
};
|
|
511
|
+
const publishRuntimeHeartbeat = async () => {
|
|
512
|
+
if (!streamConnected)
|
|
513
|
+
return;
|
|
514
|
+
await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
|
|
515
|
+
console.error('[canon-codex] Failed to publish agent runtime:', error);
|
|
516
|
+
});
|
|
517
|
+
};
|
|
564
518
|
const stream = new CanonStream({
|
|
565
519
|
apiKey,
|
|
566
520
|
agentId,
|
|
@@ -575,22 +529,34 @@ async function main() {
|
|
|
575
529
|
senderName: message.senderName || message.senderId,
|
|
576
530
|
isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
|
|
577
531
|
behavior: payload.behavior,
|
|
532
|
+
workSessions: payload.workSessions,
|
|
578
533
|
});
|
|
579
534
|
},
|
|
580
|
-
onConnected: () =>
|
|
581
|
-
|
|
535
|
+
onConnected: () => {
|
|
536
|
+
streamConnected = true;
|
|
537
|
+
void publishRuntimeHeartbeat();
|
|
538
|
+
console.error('[canon-codex] SSE connected');
|
|
539
|
+
},
|
|
540
|
+
onDisconnected: () => {
|
|
541
|
+
streamConnected = false;
|
|
542
|
+
rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
|
|
543
|
+
console.error('[canon-codex] SSE disconnected');
|
|
544
|
+
},
|
|
582
545
|
onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
|
|
583
546
|
},
|
|
584
547
|
});
|
|
585
548
|
try {
|
|
586
|
-
|
|
549
|
+
runtimeDescriptor = {
|
|
587
550
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
588
551
|
...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
|
|
589
552
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
590
|
-
}
|
|
553
|
+
};
|
|
591
554
|
}
|
|
592
|
-
catch
|
|
593
|
-
|
|
555
|
+
catch {
|
|
556
|
+
runtimeDescriptor = {
|
|
557
|
+
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
558
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
559
|
+
};
|
|
594
560
|
}
|
|
595
561
|
try {
|
|
596
562
|
const conversations = await client.getConversations();
|
|
@@ -625,6 +591,8 @@ async function main() {
|
|
|
625
591
|
senderName: latestMessage.senderId,
|
|
626
592
|
isOwner: ownerId != null && latestMessage.senderId === ownerId,
|
|
627
593
|
behavior: latestPage.behavior,
|
|
594
|
+
workSessions: latestPage.workSessions,
|
|
595
|
+
hydratedPage: latestPage,
|
|
628
596
|
});
|
|
629
597
|
}
|
|
630
598
|
}
|
|
@@ -634,7 +602,6 @@ async function main() {
|
|
|
634
602
|
await stream.start().catch((error) => {
|
|
635
603
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
636
604
|
});
|
|
637
|
-
let controlStopped = false;
|
|
638
605
|
const lastSeenControl = new Map();
|
|
639
606
|
const lastSeenSignal = new Map();
|
|
640
607
|
const pollControl = async () => {
|
|
@@ -700,6 +667,7 @@ async function main() {
|
|
|
700
667
|
writeState(session);
|
|
701
668
|
writeTurn(session);
|
|
702
669
|
}
|
|
670
|
+
void publishRuntimeHeartbeat();
|
|
703
671
|
}, HEARTBEAT_MS);
|
|
704
672
|
const idleCheck = setInterval(() => {
|
|
705
673
|
const now = Date.now();
|
|
@@ -719,6 +687,7 @@ async function main() {
|
|
|
719
687
|
clearInterval(heartbeat);
|
|
720
688
|
clearInterval(idleCheck);
|
|
721
689
|
stream.stop();
|
|
690
|
+
await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
|
|
722
691
|
for (const session of [...sessions.values()]) {
|
|
723
692
|
await session.adapter.interrupt().catch(() => { });
|
|
724
693
|
closeSession(session.conversationId);
|
package/dist/register.js
CHANGED
|
@@ -23,6 +23,14 @@ if (!values.name || !values.description || !values.phone) {
|
|
|
23
23
|
process.exit(1);
|
|
24
24
|
}
|
|
25
25
|
const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
26
|
+
let existingAgentId;
|
|
27
|
+
try {
|
|
28
|
+
const profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
|
|
29
|
+
existingAgentId = profiles[profileName]?.agentId;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// No existing profile state.
|
|
33
|
+
}
|
|
26
34
|
console.log(`Registering Codex agent "${values.name}" (profile: ${profileName})...`);
|
|
27
35
|
const result = await registerAndWaitForApproval({
|
|
28
36
|
name: values.name,
|
|
@@ -31,6 +39,7 @@ const result = await registerAndWaitForApproval({
|
|
|
31
39
|
developerInfo: 'Codex host plugin',
|
|
32
40
|
clientType: 'codex',
|
|
33
41
|
baseUrl: values['base-url'],
|
|
42
|
+
requestedAgentId: existingAgentId,
|
|
34
43
|
}, {
|
|
35
44
|
onSubmitted: (requestId) => {
|
|
36
45
|
console.log(`Registration submitted (request ID: ${requestId}).`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/host.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"prepack": "npm run build"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@canonmsg/core": "^0.
|
|
25
|
+
"@canonmsg/core": "^0.7.0"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": ">=18.0.0"
|