@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 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 approvalPolicy;
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
- 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 ?? [];
@@ -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.approvalPolicy,
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
- // 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)) {
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 { 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 { 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 rtdbWrite(`/agent-runtime/${agentId}`, {
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
- const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
49
- return readSessionWorkspaceConfig(raw);
44
+ return loadHostSessionConfig({ conversationId, agentId });
50
45
  }
51
46
  function resolveWorkspaceCwd(config) {
52
- return resolveConfiguredWorkspaceCwd({
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
- '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');
54
+ return buildCanonHostPrompt({
55
+ hostLabel: 'Codex',
56
+ buildInboundContextLines,
57
+ ...input,
58
+ });
72
59
  }
73
60
  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]';
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
- 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
- }
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 loadParticipantContext(input) {
214
- const [conversation, recentMessages] = await Promise.all([
149
+ async function loadHydratedInboundContext(input) {
150
+ const [conversation, page] = await Promise.all([
215
151
  getConversationMeta(input.conversationId),
216
- client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
152
+ input.hydratedPage
153
+ ? Promise.resolve(input.hydratedPage)
154
+ : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
217
155
  ]);
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',
156
+ return buildHydratedInboundContext({
157
+ agentId,
158
+ conversation,
159
+ page,
160
+ message: input.message,
223
161
  senderName: input.senderName,
224
162
  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
- };
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 conversation = await getConversationMeta(input.conversationId);
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: () => console.error('[canon-codex] SSE connected'),
581
- onDisconnected: () => console.error('[canon-codex] SSE disconnected'),
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
- await publishAgentRuntime(agentId, {
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 (error) {
593
- console.error('[canon-codex] Failed to publish agent runtime:', error);
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.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.4.0"
25
+ "@canonmsg/core": "^0.7.0"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=18.0.0"