@canonmsg/codex-plugin 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapter.d.ts CHANGED
@@ -60,10 +60,12 @@ export declare class CodexConversationAdapter {
60
60
  bypassApprovalsAndSandbox?: boolean;
61
61
  });
62
62
  getThreadId(): string | null;
63
+ clearThreadId(): void;
63
64
  setModel(model: string | null): void;
64
65
  isRunning(): boolean;
65
66
  interrupt(): Promise<void>;
66
67
  runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void, imagePaths?: readonly string[]): Promise<CodexTurnResult>;
67
68
  private buildArgs;
69
+ private canResumeWithCurrentPolicy;
68
70
  private clearActiveProcess;
69
71
  }
package/dist/adapter.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
+ import { isRecoverableCodexThreadError } from './error-format.js';
3
4
  export class CodexConversationAdapter {
4
5
  cwd;
5
6
  codexBin;
@@ -31,6 +32,9 @@ export class CodexConversationAdapter {
31
32
  getThreadId() {
32
33
  return this.threadId;
33
34
  }
35
+ clearThreadId() {
36
+ this.threadId = null;
37
+ }
34
38
  setModel(model) {
35
39
  this.model = model;
36
40
  }
@@ -118,6 +122,8 @@ export class CodexConversationAdapter {
118
122
  if (isIgnorableCodexLog(trimmed))
119
123
  return;
120
124
  lastErrorText = trimmed;
125
+ if (isRecoverableCodexThreadError(trimmed))
126
+ return;
121
127
  onLog?.(trimmed);
122
128
  });
123
129
  return await new Promise((resolve, reject) => {
@@ -142,7 +148,7 @@ export class CodexConversationAdapter {
142
148
  });
143
149
  }
144
150
  buildArgs(prompt, imagePaths = []) {
145
- if (this.threadId) {
151
+ if (this.threadId && this.canResumeWithCurrentPolicy()) {
146
152
  const args = ['exec', 'resume', '--json', '--skip-git-repo-check'];
147
153
  if (this.model) {
148
154
  args.push('-m', this.model);
@@ -165,10 +171,12 @@ export class CodexConversationAdapter {
165
171
  args.push(this.threadId, prompt);
166
172
  return args;
167
173
  }
174
+ if (this.threadId) {
175
+ this.threadId = null;
176
+ }
168
177
  const args = ['exec', '--json', '--color', 'never', '-C', this.cwd, '--skip-git-repo-check'];
169
178
  const execMode = resolveExecMode({
170
179
  sandbox: this.sandbox,
171
- approvalPolicy: this.legacyApprovalPolicy,
172
180
  fullAuto: this.fullAuto,
173
181
  bypassApprovalsAndSandbox: this.bypassApprovalsAndSandbox,
174
182
  });
@@ -199,6 +207,12 @@ export class CodexConversationAdapter {
199
207
  args.push(prompt);
200
208
  return args;
201
209
  }
210
+ canResumeWithCurrentPolicy() {
211
+ if (this.bypassApprovalsAndSandbox || this.fullAuto) {
212
+ return true;
213
+ }
214
+ return this.sandbox === null;
215
+ }
202
216
  clearActiveProcess() {
203
217
  if (this.interruptTimer) {
204
218
  clearTimeout(this.interruptTimer);
@@ -237,18 +251,8 @@ function resolveExecMode(input) {
237
251
  if (input.fullAuto) {
238
252
  return { fullAuto: true, bypassApprovalsAndSandbox: false };
239
253
  }
240
- if (shouldTranslateLegacyApprovalMode(input)) {
241
- return { fullAuto: true, bypassApprovalsAndSandbox: false };
242
- }
243
254
  return { fullAuto: false, bypassApprovalsAndSandbox: false };
244
255
  }
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
- }
252
256
  function isIgnorableCodexLog(line) {
253
257
  return [
254
258
  'Reading additional input from stdin...',
@@ -1,2 +1,2 @@
1
- export declare function isDirectExecution(moduleUrl: string): boolean;
2
- export declare function runCli(moduleUrl: string, main: () => void | Promise<void>, onError: (error: unknown) => void): void;
1
+ export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
2
+ export type { CliMetadata, RunCliMetadataOptions, } from '@canonmsg/core';
package/dist/cli-entry.js CHANGED
@@ -1,26 +1 @@
1
- import { realpathSync } from 'node:fs';
2
- import { resolve } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
4
- export function isDirectExecution(moduleUrl) {
5
- const entry = process.argv[1];
6
- if (!entry)
7
- return false;
8
- const abs = resolve(entry);
9
- let real = abs;
10
- try {
11
- real = realpathSync(abs);
12
- }
13
- catch {
14
- // argv[1] may not exist on disk in rare harnesses; fall back to abs
15
- }
16
- return (pathToFileURL(real).href === moduleUrl ||
17
- pathToFileURL(abs).href === moduleUrl);
18
- }
19
- export function runCli(moduleUrl, main, onError) {
20
- if (!isDirectExecution(moduleUrl)) {
21
- return;
22
- }
23
- Promise.resolve()
24
- .then(() => main())
25
- .catch(onError);
26
- }
1
+ export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
@@ -0,0 +1,2 @@
1
+ import { type CodexCliVersionStatus } from './error-format.js';
2
+ export declare function detectCodexCliVersion(codexBin: string, timeoutMs?: number): CodexCliVersionStatus;
@@ -0,0 +1,22 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { parseCodexCliVersion, } from './error-format.js';
3
+ export function detectCodexCliVersion(codexBin, timeoutMs = 2_000) {
4
+ const result = spawnSync(codexBin, ['--version'], {
5
+ encoding: 'utf-8',
6
+ timeout: timeoutMs,
7
+ });
8
+ const raw = [result.stdout, result.stderr]
9
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
10
+ .join('\n')
11
+ .trim();
12
+ return {
13
+ codexBin,
14
+ raw: raw || null,
15
+ version: parseCodexCliVersion(raw),
16
+ error: result.error
17
+ ? result.error.message
18
+ : result.status === 0
19
+ ? null
20
+ : `codex --version exited with status ${result.status ?? 'unknown'}`,
21
+ };
22
+ }
@@ -0,0 +1,16 @@
1
+ export declare const MIN_CODEX_VERSION_FOR_GPT_5_5 = "0.125.0";
2
+ export interface CodexCliVersionStatus {
3
+ codexBin: string;
4
+ raw: string | null;
5
+ version: string | null;
6
+ error: string | null;
7
+ }
8
+ export declare function normalizeCodexDiagnosticText(value: string | null | undefined): string | null;
9
+ export declare function isRecoverableCodexThreadError(value: string | null | undefined): boolean;
10
+ export declare function isCodexUpgradeRequiredError(value: string | null | undefined): boolean;
11
+ export declare function compareSemverLike(a: string, b: string): number;
12
+ export declare function parseCodexCliVersion(raw: string | null | undefined): string | null;
13
+ export declare function isGpt55Model(model: string | null | undefined): boolean;
14
+ export declare function codexCliUpgradeMessage(model: string): string;
15
+ export declare function buildCodexModelGuardMessage(model: string | null | undefined, status: Pick<CodexCliVersionStatus, 'version'>): string | null;
16
+ export declare function formatCodexTurnFailure(errorText: string | null | undefined): string;
@@ -0,0 +1,106 @@
1
+ export const MIN_CODEX_VERSION_FOR_GPT_5_5 = '0.125.0';
2
+ function extractErrorMessage(value) {
3
+ if (!value || typeof value !== 'object')
4
+ return null;
5
+ const data = value;
6
+ const direct = typeof data.message === 'string' ? data.message.trim() : '';
7
+ if (direct)
8
+ return direct;
9
+ const error = data.error;
10
+ if (typeof error === 'string' && error.trim())
11
+ return error.trim();
12
+ if (error && typeof error === 'object') {
13
+ const nested = error;
14
+ const message = typeof nested.message === 'string' ? nested.message.trim() : '';
15
+ if (message)
16
+ return message;
17
+ const type = typeof nested.type === 'string' ? nested.type.trim() : '';
18
+ if (type)
19
+ return type;
20
+ }
21
+ return null;
22
+ }
23
+ function parseJsonMessage(text) {
24
+ try {
25
+ return extractErrorMessage(JSON.parse(text));
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function normalizeCodexDiagnosticText(value) {
32
+ if (!value)
33
+ return null;
34
+ const normalized = value.replace(/^error:\s*/i, '').trim();
35
+ if (!normalized)
36
+ return null;
37
+ const directJson = parseJsonMessage(normalized);
38
+ if (directJson)
39
+ return directJson;
40
+ for (const line of normalized.split(/\r?\n/)) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed.startsWith('{'))
43
+ continue;
44
+ const lineJson = parseJsonMessage(trimmed);
45
+ if (lineJson)
46
+ return lineJson;
47
+ }
48
+ return normalized;
49
+ }
50
+ export function isRecoverableCodexThreadError(value) {
51
+ const normalized = normalizeCodexDiagnosticText(value)?.toLowerCase() ?? '';
52
+ return /thread .*not found/.test(normalized)
53
+ || normalized.includes('unknown thread')
54
+ || normalized.includes('find_thread_path')
55
+ || normalized.includes('state db discrepancy');
56
+ }
57
+ export function isCodexUpgradeRequiredError(value) {
58
+ const normalized = normalizeCodexDiagnosticText(value)?.toLowerCase() ?? '';
59
+ return normalized.includes('requires a newer version of codex')
60
+ || (normalized.includes('upgrade') && normalized.includes('codex'));
61
+ }
62
+ export function compareSemverLike(a, b) {
63
+ const parse = (value) => value.split('.').map((part) => Number.parseInt(part, 10) || 0);
64
+ const left = parse(a);
65
+ const right = parse(b);
66
+ const length = Math.max(left.length, right.length, 3);
67
+ for (let index = 0; index < length; index += 1) {
68
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
69
+ if (diff !== 0)
70
+ return diff;
71
+ }
72
+ return 0;
73
+ }
74
+ export function parseCodexCliVersion(raw) {
75
+ if (!raw)
76
+ return null;
77
+ const match = raw.match(/\b(\d+\.\d+\.\d+)\b/);
78
+ return match?.[1] ?? null;
79
+ }
80
+ export function isGpt55Model(model) {
81
+ return typeof model === 'string' && /^gpt-5\.5(?:$|[-_:])/i.test(model.trim());
82
+ }
83
+ export function codexCliUpgradeMessage(model) {
84
+ return `The selected Codex model (${model}) requires a newer local Codex CLI. Update @openai/codex to ${MIN_CODEX_VERSION_FOR_GPT_5_5} or newer, then restart this Canon host.`;
85
+ }
86
+ export function buildCodexModelGuardMessage(model, status) {
87
+ const modelId = typeof model === 'string' ? model.trim() : '';
88
+ if (!isGpt55Model(modelId))
89
+ return null;
90
+ if (!status.version)
91
+ return null;
92
+ return compareSemverLike(status.version, MIN_CODEX_VERSION_FOR_GPT_5_5) < 0
93
+ ? codexCliUpgradeMessage(modelId)
94
+ : null;
95
+ }
96
+ export function formatCodexTurnFailure(errorText) {
97
+ const normalized = normalizeCodexDiagnosticText(errorText);
98
+ if (!normalized) {
99
+ return 'The Codex session stopped unexpectedly before sending a final reply.';
100
+ }
101
+ if (isCodexUpgradeRequiredError(normalized)) {
102
+ return codexCliUpgradeMessage('gpt-5.5');
103
+ }
104
+ const shortened = normalized.length > 280 ? `${normalized.slice(0, 277)}...` : normalized;
105
+ return `Codex failed before sending a final reply: ${shortened}`;
106
+ }
@@ -0,0 +1,4 @@
1
+ export interface LongLivedStream {
2
+ start(): Promise<void>;
3
+ }
4
+ export declare function startCodexStreamInBackground(stream: LongLivedStream, onError: (error: unknown) => void): void;
@@ -0,0 +1,3 @@
1
+ export function startCodexStreamInBackground(stream, onError) {
2
+ stream.start().catch(onError);
3
+ }
package/dist/host.js CHANGED
@@ -6,9 +6,38 @@ import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk
6
6
  import { buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
7
7
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
8
8
  import { CodexConversationAdapter, } from './adapter.js';
9
- import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
9
+ import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
10
10
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
11
+ import { detectCodexCliVersion } from './codex-cli-version.js';
12
+ import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
13
+ import { startCodexStreamInBackground } from './host-lifecycle.js';
11
14
  import { runCli } from './cli-entry.js';
15
+ const HELP = `canon-codex — run a local Codex agent host for Canon
16
+
17
+ USAGE
18
+ canon-codex [flags]
19
+
20
+ COMMON FLAGS
21
+ --cwd <path> Project directory to run Codex from
22
+ --workspace <path> Additional project to expose in Canon
23
+ --workspace-root <path> Discover projects under an approved root
24
+ --model <model> Default Codex model for new turns
25
+ --sandbox <mode> Codex sandbox mode
26
+ --full-auto Allow non-interactive write access
27
+ --codex-bin <path> Codex CLI binary to run
28
+ --config <key=value> Forward config to Codex CLI
29
+ --help, -h Show this help
30
+ --version, -V Show package version
31
+
32
+ AUTH
33
+ CANON_AGENT=<profile> canon-codex --cwd /path/to/project
34
+ CANON_API_KEY=agk_live_... canon-codex --cwd /path/to/project
35
+
36
+ EXAMPLES
37
+ canon-codex --cwd ~/dev/canon
38
+ canon-codex --cwd ~/dev/canon --workspace-root ~/dev --full-auto
39
+
40
+ Keep this terminal open while you want Canon to reach the agent.`;
12
41
  const MAX_SESSIONS = 12;
13
42
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
14
43
  const HEARTBEAT_MS = 30_000;
@@ -25,7 +54,7 @@ let workspaceOptions = [];
25
54
  let workspaceRoots = [];
26
55
  let workspaceRootMetadata = [];
27
56
  function buildCodexRuntimeDescriptor(input) {
28
- return buildFirstPartyCodingRuntimeDescriptor({
57
+ const descriptor = buildFirstPartyCodingRuntimeDescriptor({
29
58
  clientType: 'codex',
30
59
  models: input.models,
31
60
  workspaces: input.workspaces,
@@ -57,6 +86,27 @@ function buildCodexRuntimeDescriptor(input) {
57
86
  },
58
87
  ],
59
88
  });
89
+ if (input.models.length > 0) {
90
+ return descriptor;
91
+ }
92
+ return {
93
+ ...descriptor,
94
+ coreControls: descriptor.coreControls.filter((control) => control.id !== 'model'),
95
+ };
96
+ }
97
+ function modelOptionLabel(model) {
98
+ if (/^gpt-5\.5(?:$|[-_:])/i.test(model))
99
+ return 'GPT-5.5';
100
+ if (/^gpt-5\.4-mini(?:$|[-_:])/i.test(model))
101
+ return 'GPT-5.4 Mini';
102
+ if (/^gpt-5\.4(?:$|[-_:])/i.test(model))
103
+ return 'GPT-5.4';
104
+ return model;
105
+ }
106
+ function buildCodexModelOptions(model) {
107
+ return typeof model === 'string' && model.trim()
108
+ ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
109
+ : [];
60
110
  }
61
111
  function normalizeRuntimeTurnState(value) {
62
112
  const normalizedTurn = normalizeTurnState(value);
@@ -95,6 +145,48 @@ function resolveExecutionFallbackReason(environment) {
95
145
  ? null
96
146
  : environment.reason;
97
147
  }
148
+ function stringArg(args, key) {
149
+ const value = args[key];
150
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
151
+ }
152
+ function boolArg(args, key) {
153
+ return args[key] === true;
154
+ }
155
+ function resolveCodexEffectiveRuntimePolicy(input) {
156
+ const model = input.config?.model ?? stringArg(input.args, 'model');
157
+ const permissionMode = input.config?.permissionMode ?? input.permissionEnvelope.defaultPermissionMode;
158
+ if (permissionMode
159
+ && !input.permissionEnvelope.availablePermissionModes.some((option) => option.value === permissionMode)) {
160
+ throw new ExecutionEnvironmentError(`Permission mode "${permissionMode}" is not supported by this Codex host.`, 'This Canon host was started with stricter approval settings. Choose one of the advertised permission modes or restart the host with more permissive flags.');
161
+ }
162
+ const approvalOverride = mapCanonPermissionToCodex(permissionMode);
163
+ const defaultSandbox = (stringArg(input.args, 'sandbox') ?? null);
164
+ const defaultApprovalPolicy = (stringArg(input.args, 'ask-for-approval') ?? null);
165
+ const sandbox = approvalOverride ? approvalOverride.sandbox : defaultSandbox;
166
+ const approvalPolicy = approvalOverride ? null : defaultApprovalPolicy;
167
+ const fullAuto = approvalOverride ? approvalOverride.fullAuto : boolArg(input.args, 'full-auto');
168
+ const bypassApprovalsAndSandbox = approvalOverride
169
+ ? approvalOverride.bypassApprovalsAndSandbox
170
+ : boolArg(input.args, 'dangerously-bypass-approvals-and-sandbox');
171
+ const fingerprint = buildCodexThreadPolicyFingerprint({
172
+ baseCwd: input.environment.baseCwd,
173
+ executionMode: input.environment.mode,
174
+ permissionMode: permissionMode ?? null,
175
+ sandbox,
176
+ approvalPolicy,
177
+ fullAuto,
178
+ bypassApprovalsAndSandbox,
179
+ });
180
+ return {
181
+ ...(model ? { model } : {}),
182
+ ...(permissionMode ? { permissionMode } : {}),
183
+ sandbox,
184
+ approvalPolicy,
185
+ fullAuto,
186
+ bypassApprovalsAndSandbox,
187
+ fingerprint,
188
+ };
189
+ }
98
190
  function buildCanonPrompt(input) {
99
191
  return buildCanonHostPrompt({
100
192
  hostLabel: 'Codex',
@@ -112,14 +204,6 @@ function summarizeCommand(command) {
112
204
  const shortened = trimmed.length > 140 ? `${trimmed.slice(0, 137)}...` : trimmed;
113
205
  return `Running: ${shortened}`;
114
206
  }
115
- function formatTurnFailure(errorText) {
116
- if (!errorText) {
117
- return 'The Codex session stopped unexpectedly before sending a final reply.';
118
- }
119
- const normalized = errorText.replace(/^error:\s*/i, '').trim();
120
- const shortened = normalized.length > 280 ? `${normalized.slice(0, 277)}...` : normalized;
121
- return `Codex failed before sending a final reply: ${shortened}`;
122
- }
123
207
  function sleep(ms) {
124
208
  return new Promise((resolve) => setTimeout(resolve, ms));
125
209
  }
@@ -142,6 +226,11 @@ export async function main() {
142
226
  },
143
227
  strict: true,
144
228
  });
229
+ if (typeof args['ask-for-approval'] === 'string') {
230
+ console.error('[canon-codex] --ask-for-approval is no longer supported by Canon. Use --full-auto, --sandbox, or Canon permission modes instead.');
231
+ process.exitCode = 1;
232
+ return;
233
+ }
145
234
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
146
235
  const workspaceDiscovery = buildConfiguredWorkspaceOptionsWithRoots({
147
236
  primaryCwd: workingDir,
@@ -154,8 +243,13 @@ export async function main() {
154
243
  for (const warning of workspaceDiscovery.warnings) {
155
244
  console.error(`[canon-codex] ${warning}`);
156
245
  }
157
- if (typeof args['ask-for-approval'] === 'string') {
158
- console.error('[canon-codex] Note: newer Codex CLI releases do not accept --ask-for-approval for `codex exec`; Canon will translate compatible legacy usage when possible.');
246
+ const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
247
+ const codexCliStatus = detectCodexCliVersion(codexBin);
248
+ if (codexCliStatus.version) {
249
+ console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
250
+ }
251
+ else {
252
+ console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
159
253
  }
160
254
  const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
161
255
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
@@ -261,9 +355,19 @@ export async function main() {
261
355
  });
262
356
  }
263
357
  function writeState(session) {
358
+ const appliedAt = Date.now();
359
+ const controlState = {};
360
+ if (session.state.model !== undefined) {
361
+ controlState.model = { value: session.state.model, source: 'applied', appliedAt };
362
+ }
363
+ if (session.state.permissionMode !== undefined) {
364
+ controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
365
+ }
264
366
  runtimeState.writeSessionState(session.conversationId, {
265
367
  lastError: session.state.lastError,
266
368
  model: session.state.model,
369
+ permissionMode: session.state.permissionMode,
370
+ controlState,
267
371
  cwd: session.cwd,
268
372
  executionMode: session.environment.mode,
269
373
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
@@ -382,16 +486,17 @@ export async function main() {
382
486
  });
383
487
  try {
384
488
  const sessionCwd = environment.cwd;
385
- const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
386
- const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode);
387
- if (config?.permissionMode
388
- && !codexPermissionEnvelope.availablePermissionModes.some((option) => option.value === config.permissionMode)) {
389
- throw new ExecutionEnvironmentError(`Permission mode "${config.permissionMode}" is not supported by this Codex host.`, 'This Canon host was started with stricter approval settings. Choose one of the advertised permission modes or restart the host with more permissive flags.');
489
+ const policy = resolveCodexEffectiveRuntimePolicy({
490
+ args,
491
+ config,
492
+ permissionEnvelope: codexPermissionEnvelope,
493
+ environment,
494
+ });
495
+ const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
496
+ if (modelGuard) {
497
+ throw new ExecutionEnvironmentError(modelGuard, modelGuard);
390
498
  }
391
- const approvalOverride = mapCanonPermissionToCodex(config?.permissionMode);
392
- const defaultSandbox = (typeof args.sandbox === 'string' ? args.sandbox : null);
393
- const defaultFullAuto = Boolean(args['full-auto']);
394
- const defaultBypass = Boolean(args['dangerously-bypass-approvals-and-sandbox']);
499
+ const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
395
500
  const session = {
396
501
  conversationId,
397
502
  cwd: sessionCwd,
@@ -399,26 +504,24 @@ export async function main() {
399
504
  adapter: new CodexConversationAdapter({
400
505
  cwd: sessionCwd,
401
506
  threadId: storedThreadId,
402
- codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
403
- model: sessionModel ?? null,
404
- sandbox: approvalOverride ? approvalOverride.sandbox : defaultSandbox,
405
- approvalPolicy: (typeof args['ask-for-approval'] === 'string'
406
- ? args['ask-for-approval']
407
- : null),
507
+ codexBin,
508
+ model: policy.model ?? null,
509
+ sandbox: policy.sandbox,
510
+ approvalPolicy: policy.approvalPolicy,
408
511
  codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
409
512
  addDirs: args['add-dir'] ?? [],
410
513
  configOverrides: args.config ?? [],
411
- fullAuto: approvalOverride ? approvalOverride.fullAuto : defaultFullAuto,
412
- bypassApprovalsAndSandbox: approvalOverride
413
- ? approvalOverride.bypassApprovalsAndSandbox
414
- : defaultBypass,
514
+ fullAuto: policy.fullAuto,
515
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
415
516
  }),
416
517
  queue: [],
417
518
  running: false,
418
519
  state: {
419
- model: sessionModel,
520
+ model: policy.model,
521
+ permissionMode: policy.permissionMode,
420
522
  state: 'idle',
421
523
  },
524
+ policyFingerprint: policy.fingerprint,
422
525
  turnState: 'idle',
423
526
  currentTurnId: null,
424
527
  currentTurnOpenedAt: null,
@@ -428,7 +531,10 @@ export async function main() {
428
531
  closed: false,
429
532
  };
430
533
  sessions.set(conversationId, session);
431
- await baselineControlSignal(conversationId);
534
+ await Promise.all([
535
+ baselineControlSignal(conversationId),
536
+ baselineSessionControl(conversationId),
537
+ ]);
432
538
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
433
539
  writeState(session);
434
540
  writeTurn(session);
@@ -557,11 +663,15 @@ export async function main() {
557
663
  status: 'thinking',
558
664
  }).catch(() => { });
559
665
  try {
666
+ const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
667
+ if (modelGuard) {
668
+ throw new ExecutionEnvironmentError(modelGuard, modelGuard);
669
+ }
560
670
  const turnImagePaths = nextTurn.imagePaths ?? [];
561
- const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
671
+ const handleCodexEvent = (event) => {
562
672
  session.lastActivity = Date.now();
563
673
  if (event.type === 'thread.started') {
564
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode);
674
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
565
675
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
566
676
  return;
567
677
  }
@@ -589,13 +699,32 @@ export async function main() {
589
699
  if (event.type === 'turn.completed') {
590
700
  writeState(session);
591
701
  }
592
- }, (line) => {
702
+ };
703
+ const logCodexLine = (line) => {
593
704
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
594
- }, turnImagePaths);
705
+ };
706
+ const clearStoredThread = () => {
707
+ clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
708
+ session.adapter.clearThreadId();
709
+ };
710
+ const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths);
711
+ let result = await runTurnOnce();
712
+ if (!result.interrupted
713
+ && !result.finalMessage
714
+ && result.exitCode
715
+ && result.exitCode !== 0
716
+ && isRecoverableCodexThreadError(result.errorText)) {
717
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
718
+ clearStoredThread();
719
+ result = await runTurnOnce();
720
+ }
595
721
  if (result.threadId) {
596
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode);
722
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
597
723
  }
598
724
  if (!result.interrupted && result.finalMessage) {
725
+ if (isRecoverableCodexThreadError(result.errorText)) {
726
+ clearStoredThread();
727
+ }
599
728
  await client.sendMessage(session.conversationId, result.finalMessage, {
600
729
  metadata: {
601
730
  turnId: session.currentTurnId,
@@ -608,7 +737,7 @@ export async function main() {
608
737
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
609
738
  }
610
739
  else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
611
- const userVisibleError = formatTurnFailure(result.errorText);
740
+ const userVisibleError = formatCodexTurnFailure(result.errorText);
612
741
  session.state.lastError = userVisibleError;
613
742
  writeState(session);
614
743
  if (result.errorText) {
@@ -637,7 +766,9 @@ export async function main() {
637
766
  }
638
767
  }
639
768
  catch (error) {
640
- const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
769
+ const message = error instanceof ExecutionEnvironmentError
770
+ ? error.userMessage
771
+ : `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
641
772
  session.state.lastError = message;
642
773
  writeState(session);
643
774
  await client.sendMessage(session.conversationId, message, {
@@ -649,7 +780,7 @@ export async function main() {
649
780
  },
650
781
  }).catch(() => { });
651
782
  await handoffFinalMessage(session.conversationId);
652
- if (error instanceof Error && /invalid|not found|unknown thread/i.test(error.message)) {
783
+ if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
653
784
  clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
654
785
  }
655
786
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
@@ -678,6 +809,7 @@ export async function main() {
678
809
  ...EXECUTION_ENVIRONMENT_MODES,
679
810
  ];
680
811
  const codexPermissionEnvelope = deriveCodexPermissionEnvelope(args);
812
+ const codexModelOptions = buildCodexModelOptions(args.model);
681
813
  let runtimeDescriptor = {
682
814
  defaultWorkspaceId: workspaceOptions[0]?.id,
683
815
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
@@ -688,7 +820,7 @@ export async function main() {
688
820
  ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
689
821
  : {}),
690
822
  runtimeDescriptor: buildCodexRuntimeDescriptor({
691
- models: [],
823
+ models: codexModelOptions,
692
824
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
693
825
  workspaceRoots: workspaceRootMetadata,
694
826
  executionModes: hostAvailableExecutionModes,
@@ -707,6 +839,18 @@ export async function main() {
707
839
  lastSeenSignal.set(conversationId, timestamp);
708
840
  }
709
841
  }
842
+ async function baselineSessionControl(conversationId) {
843
+ if (lastSeenControl.has(conversationId))
844
+ return;
845
+ const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
846
+ if (!raw || typeof raw !== 'object')
847
+ return;
848
+ const timestamp = Number(raw.updatedAt ?? 0);
849
+ if (timestamp > 0) {
850
+ lastSeenControl.set(conversationId, timestamp);
851
+ }
852
+ }
853
+ let publishRuntimeDetailsInFlight = false;
710
854
  const publishRuntimeHeartbeat = async () => {
711
855
  heartbeatLocalRuntimeEntry(runtimeId, {
712
856
  agentId,
@@ -716,84 +860,99 @@ export async function main() {
716
860
  });
717
861
  if (!streamConnected)
718
862
  return;
719
- await refreshKnownConversationIds().catch((error) => {
720
- console.error('[canon-codex] Failed to refresh known conversations:', error);
721
- });
722
863
  await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
723
864
  console.error('[canon-codex] Failed to publish agent runtime:', error);
724
865
  });
725
- await publishHostSessionSnapshots({
726
- conversationIds: Array.from(knownConversationIds),
727
- agentId,
728
- clientType: 'codex',
729
- runtime: runtimeDescriptor,
730
- workspaceOptions,
731
- defaultCwd: workingDir,
732
- extraSessionConfigFields: ['permissionMode'],
733
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
734
- const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
735
- return [
736
- session.conversationId,
737
- {
738
- ...(session.state.model ? { model: session.state.model } : {}),
739
- ...(workspaceId ? { workspaceId } : {}),
740
- executionMode: session.environment.mode,
741
- executionBranch: session.environment.branch ?? null,
742
- },
743
- ];
744
- })),
745
- }).catch((error) => {
746
- console.error('[canon-codex] Failed to publish session snapshots:', error);
747
- });
748
- await Promise.all(Array.from(knownConversationIds).map(async (conversationId) => {
749
- const session = sessions.get(conversationId);
750
- const workspaceId = session
751
- ? resolveWorkspaceIdForBaseCwd(session.environment.baseCwd)
752
- : runtimeDescriptor.defaultWorkspaceId;
753
- const workspace = workspaceOptions.find((option) => option.id === workspaceId) ?? null;
754
- const descriptor = runtimeDescriptor.runtimeDescriptor;
755
- if (!descriptor)
756
- return;
757
- const payload = {
758
- descriptor,
759
- surfaceMode: 'host',
760
- statusItems: [
761
- {
762
- id: 'transport',
763
- label: 'Transport',
764
- value: 'exec --json',
765
- },
766
- {
767
- id: 'streaming',
768
- label: 'Live output',
769
- value: 'Thinking, tools, and completed-message previews',
770
- },
771
- {
772
- id: 'nativeActions',
773
- label: 'Native actions',
774
- value: 'Limited until app-server transport',
775
- tone: 'warning',
866
+ if (publishRuntimeDetailsInFlight)
867
+ return;
868
+ publishRuntimeDetailsInFlight = true;
869
+ try {
870
+ await refreshKnownConversationIds().catch((error) => {
871
+ console.error('[canon-codex] Failed to refresh known conversations:', error);
872
+ });
873
+ await publishHostSessionSnapshots({
874
+ conversationIds: Array.from(knownConversationIds),
875
+ agentId,
876
+ clientType: 'codex',
877
+ runtime: runtimeDescriptor,
878
+ workspaceOptions,
879
+ defaultCwd: workingDir,
880
+ extraSessionConfigFields: ['permissionMode'],
881
+ liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
882
+ const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
883
+ return [
884
+ session.conversationId,
885
+ {
886
+ ...(session.state.model ? { model: session.state.model } : {}),
887
+ ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
888
+ ...(workspaceId ? { workspaceId } : {}),
889
+ executionMode: session.environment.mode,
890
+ executionBranch: session.environment.branch ?? null,
891
+ },
892
+ ];
893
+ })),
894
+ }).catch((error) => {
895
+ console.error('[canon-codex] Failed to publish session snapshots:', error);
896
+ });
897
+ await Promise.all(Array.from(knownConversationIds).map(async (conversationId) => {
898
+ const session = sessions.get(conversationId);
899
+ const workspaceId = session
900
+ ? resolveWorkspaceIdForBaseCwd(session.environment.baseCwd)
901
+ : runtimeDescriptor.defaultWorkspaceId;
902
+ const workspace = workspaceOptions.find((option) => option.id === workspaceId) ?? null;
903
+ const descriptor = runtimeDescriptor.runtimeDescriptor;
904
+ if (!descriptor)
905
+ return;
906
+ const payload = {
907
+ descriptor,
908
+ surfaceMode: 'host',
909
+ statusItems: [
910
+ {
911
+ id: 'transport',
912
+ label: 'Transport',
913
+ value: 'exec --json',
914
+ },
915
+ {
916
+ id: 'streaming',
917
+ label: 'Live output',
918
+ value: 'Thinking, tools, and completed-message previews',
919
+ },
920
+ {
921
+ id: 'codex-cli',
922
+ label: 'Codex CLI',
923
+ value: codexCliStatus.version ?? (codexCliStatus.raw ?? 'Version unknown'),
924
+ tone: codexCliStatus.version ? 'default' : 'warning',
925
+ },
926
+ {
927
+ id: 'nativeActions',
928
+ label: 'Native actions',
929
+ value: 'Limited until app-server transport',
930
+ tone: 'warning',
931
+ },
932
+ ],
933
+ execution: {
934
+ resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
935
+ resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
936
+ workspaceRootId: workspace?.workspaceRootId ?? null,
937
+ workspaceRelativePath: workspace?.workspaceRelativePath ?? null,
938
+ executionMode: session?.environment.mode ?? null,
939
+ executionBranch: session?.environment.branch ?? null,
940
+ worktreePath: session?.environment.worktreePath ?? null,
941
+ fallbackReason: resolveExecutionFallbackReason(session?.environment),
776
942
  },
777
- ],
778
- execution: {
779
- resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
780
- resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
781
- workspaceRootId: workspace?.workspaceRootId ?? null,
782
- workspaceRelativePath: workspace?.workspaceRelativePath ?? null,
783
- executionMode: session?.environment.mode ?? null,
784
- executionBranch: session?.environment.branch ?? null,
785
- worktreePath: session?.environment.worktreePath ?? null,
786
- fallbackReason: resolveExecutionFallbackReason(session?.environment),
787
- },
788
- notes: [
789
- 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not token-by-token text deltas.',
790
- 'Codex review, compact/rollback, live plan/diff/reasoning updates, PTY command execution, plugin/app/MCP inventory, and structured approvals require the future app-server transport.',
791
- ],
792
- };
793
- await runtimeState.writeRuntimeInfo(conversationId, payload);
794
- })).catch((error) => {
795
- console.error('[canon-codex] Failed to publish runtime info:', error);
796
- });
943
+ notes: [
944
+ 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not token-by-token text deltas.',
945
+ 'Codex review, compact/rollback, live plan/diff/reasoning updates, PTY command execution, plugin/app/MCP inventory, and structured approvals require the future app-server transport.',
946
+ ],
947
+ };
948
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
949
+ })).catch((error) => {
950
+ console.error('[canon-codex] Failed to publish runtime info:', error);
951
+ });
952
+ }
953
+ finally {
954
+ publishRuntimeDetailsInFlight = false;
955
+ }
797
956
  };
798
957
  const stream = new CanonStream({
799
958
  apiKey,
@@ -843,7 +1002,7 @@ export async function main() {
843
1002
  ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
844
1003
  : {}),
845
1004
  runtimeDescriptor: buildCodexRuntimeDescriptor({
846
- models: [],
1005
+ models: codexModelOptions,
847
1006
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
848
1007
  workspaceRoots: workspaceRootMetadata,
849
1008
  executionModes: hostAvailableExecutionModes,
@@ -862,7 +1021,7 @@ export async function main() {
862
1021
  ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
863
1022
  : {}),
864
1023
  runtimeDescriptor: buildCodexRuntimeDescriptor({
865
- models: [],
1024
+ models: codexModelOptions,
866
1025
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
867
1026
  workspaceRoots: workspaceRootMetadata,
868
1027
  executionModes: hostAvailableExecutionModes,
@@ -932,7 +1091,7 @@ export async function main() {
932
1091
  catch (error) {
933
1092
  console.error('[canon-codex] Failed to load startup conversations:', error);
934
1093
  }
935
- await stream.start().catch((error) => {
1094
+ startCodexStreamInBackground(stream, (error) => {
936
1095
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
937
1096
  });
938
1097
  const pollControl = async () => {
@@ -948,6 +1107,14 @@ export async function main() {
948
1107
  const session = sessions.get(conversationId);
949
1108
  if (session && !session.closed) {
950
1109
  if (control.model && control.model !== session.state.model) {
1110
+ const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
1111
+ if (modelGuard) {
1112
+ session.state.lastError = modelGuard;
1113
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1114
+ writeState(session);
1115
+ await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
1116
+ continue;
1117
+ }
951
1118
  session.adapter.setModel(control.model);
952
1119
  session.state.model = control.model;
953
1120
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
@@ -960,6 +1127,7 @@ export async function main() {
960
1127
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
961
1128
  }
962
1129
  }
1130
+ await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
963
1131
  }
964
1132
  }
965
1133
  const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`);
@@ -980,14 +1148,14 @@ export async function main() {
980
1148
  continue;
981
1149
  }
982
1150
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
983
- if (session.running) {
984
- await session.adapter.interrupt();
985
- }
986
- session.turnState = 'interrupted';
987
1151
  if (signal.type === 'stop_and_drop') {
988
1152
  const droppedPrompts = session.queue.splice(0);
989
1153
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
990
1154
  }
1155
+ if (session.running) {
1156
+ await session.adapter.interrupt();
1157
+ }
1158
+ session.turnState = 'interrupted';
991
1159
  writeTurn(session);
992
1160
  clearStreaming(conversationId);
993
1161
  client.setTyping(conversationId, false).catch(() => { });
@@ -1042,7 +1210,11 @@ export async function main() {
1042
1210
  await new Promise(() => { });
1043
1211
  }
1044
1212
  runCli(import.meta.url, main, (error) => {
1045
- console.error('[canon-codex] Fatal error:', error);
1213
+ const message = error instanceof Error ? error.message : String(error);
1214
+ console.error(`[canon-codex] ${message}`);
1046
1215
  getActiveProfileLock()?.release();
1047
1216
  process.exit(1);
1217
+ }, {
1218
+ name: 'canon-codex',
1219
+ help: HELP,
1048
1220
  });
@@ -22,10 +22,6 @@ function codexOptionsThrough(mode) {
22
22
  const index = CODEX_PERMISSION_OPTIONS.findIndex((option) => option.value === mode);
23
23
  return index >= 0 ? CODEX_PERMISSION_OPTIONS.slice(0, index + 1) : [];
24
24
  }
25
- function isLegacyFullAuto(args) {
26
- return args['ask-for-approval'] === 'never'
27
- && (args.sandbox === 'workspace-write' || args.sandbox == null);
28
- }
29
25
  export function deriveCodexPermissionEnvelope(args) {
30
26
  if (args.sandbox === 'read-only') {
31
27
  return {
@@ -46,7 +42,7 @@ export function deriveCodexPermissionEnvelope(args) {
46
42
  availablePermissionModes: [...CODEX_PERMISSION_OPTIONS],
47
43
  };
48
44
  }
49
- if (args['full-auto'] || isLegacyFullAuto(args)) {
45
+ if (args['full-auto']) {
50
46
  return {
51
47
  defaultPermissionMode: 'full-auto',
52
48
  availablePermissionModes: codexOptionsThrough('full-auto'),
package/dist/register.js CHANGED
@@ -4,6 +4,27 @@ import { readFileSync } from 'node:fs';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, registerAndWaitForApproval, updatePendingRegistration, upsertAgentProfile, AGENTS_PATH, } from '@canonmsg/core';
6
6
  import { runCli } from './cli-entry.js';
7
+ const HELP = `canon-codex-register — register or reconnect a Codex agent in Canon
8
+
9
+ USAGE
10
+ canon-codex-register --name <name> --description <text> --phone <e164> [flags]
11
+
12
+ REQUIRED
13
+ --name <name> Agent display name shown in Canon
14
+ --description <text> Short profile description
15
+ --phone <e164> Owner phone number, for example +15551234567
16
+
17
+ FLAGS
18
+ --profile <name> Local profile name in ~/.canon/agents.json
19
+ --base-url <url> Canon API base URL override
20
+ --help, -h Show this help
21
+ --version, -V Show package version
22
+
23
+ EXAMPLES
24
+ canon-codex-register --name "My Codex" --description "Local coding agent" --phone "+15551234567"
25
+ canon-codex-register --name "Frontend" --description "React work" --phone "+15551234567" --profile frontend
26
+
27
+ After approval, start it with CANON_AGENT=<profile> canon-codex --cwd /path/to/project.`;
7
28
  export async function main() {
8
29
  setDefaultResultOrder('ipv4first');
9
30
  const { values } = parseArgs({
@@ -92,4 +113,7 @@ export async function main() {
92
113
  runCli(import.meta.url, main, (error) => {
93
114
  console.error('[canon-codex-register] Fatal error:', error);
94
115
  process.exit(1);
116
+ }, {
117
+ name: 'canon-codex-register',
118
+ help: HELP,
95
119
  });
@@ -1,4 +1,13 @@
1
1
  import { type ExecutionEnvironmentMode } from '@canonmsg/core';
2
- export declare function loadStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode): string | null;
3
- export declare function saveStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode): void;
2
+ export declare function buildCodexThreadPolicyFingerprint(input: {
3
+ baseCwd: string;
4
+ executionMode?: ExecutionEnvironmentMode;
5
+ permissionMode?: string | null;
6
+ sandbox?: string | null;
7
+ approvalPolicy?: string | null;
8
+ fullAuto?: boolean;
9
+ bypassApprovalsAndSandbox?: boolean;
10
+ }): string;
11
+ export declare function loadStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): string | null;
12
+ export declare function saveStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode, policyFingerprint?: string): void;
4
13
  export declare function clearStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd?: string, executionMode?: ExecutionEnvironmentMode): void;
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import { join } from 'node:path';
3
4
  import { CANON_DIR, clearRuntimeSessionState, loadRuntimeSessionState, saveRuntimeSessionState, } from '@canonmsg/core';
4
5
  const STORE_PATH = join(CANON_DIR, 'codex-sessions.json');
@@ -14,29 +15,60 @@ function saveStore(store) {
14
15
  mkdirSync(CANON_DIR, { recursive: true });
15
16
  writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
16
17
  }
17
- export function loadStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
18
+ export function buildCodexThreadPolicyFingerprint(input) {
19
+ return createHash('sha256').update(JSON.stringify({
20
+ version: 1,
21
+ baseCwd: input.baseCwd,
22
+ executionMode: input.executionMode ?? null,
23
+ permissionMode: input.permissionMode ?? null,
24
+ sandbox: input.sandbox ?? null,
25
+ approvalPolicy: input.approvalPolicy ?? null,
26
+ fullAuto: input.fullAuto === true,
27
+ bypassApprovalsAndSandbox: input.bypassApprovalsAndSandbox === true,
28
+ })).digest('hex').slice(0, 24);
29
+ }
30
+ export function loadStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode, policyFingerprint) {
18
31
  if (runtimeId) {
19
32
  const state = loadRuntimeSessionState(runtimeId, {
20
33
  conversationId,
21
34
  baseCwd,
22
35
  executionMode,
23
36
  });
24
- if (state?.threadId)
37
+ if (state?.threadId) {
38
+ if (policyFingerprint && state.codexPolicyFingerprint !== policyFingerprint) {
39
+ clearRuntimeSessionState(runtimeId, {
40
+ conversationId,
41
+ baseCwd,
42
+ executionMode,
43
+ });
44
+ return null;
45
+ }
25
46
  return state.threadId;
47
+ }
48
+ return null;
26
49
  }
27
50
  const store = loadStore();
28
51
  const record = store.agents[agentId]?.[conversationId];
29
52
  if (!record || record.cwd !== baseCwd)
30
53
  return null;
54
+ if (policyFingerprint && record.policyFingerprint !== policyFingerprint) {
55
+ delete store.agents[agentId][conversationId];
56
+ if (Object.keys(store.agents[agentId]).length === 0) {
57
+ delete store.agents[agentId];
58
+ }
59
+ saveStore(store);
60
+ return null;
61
+ }
31
62
  return record.threadId;
32
63
  }
33
- export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd, threadId, executionMode) {
64
+ export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd, threadId, executionMode, policyFingerprint) {
34
65
  if (runtimeId) {
35
66
  saveRuntimeSessionState(runtimeId, {
36
67
  conversationId,
37
68
  baseCwd,
38
69
  executionMode,
39
70
  threadId,
71
+ ...(policyFingerprint ? { codexPolicyFingerprint: policyFingerprint } : {}),
40
72
  });
41
73
  return;
42
74
  }
@@ -45,6 +77,7 @@ export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd,
45
77
  store.agents[agentId][conversationId] = {
46
78
  threadId,
47
79
  cwd: baseCwd,
80
+ ...(policyFingerprint ? { policyFingerprint } : {}),
48
81
  updatedAt: new Date().toISOString(),
49
82
  };
50
83
  saveStore(store);
package/dist/setup.js CHANGED
@@ -1,6 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { runCli } from './cli-entry.js';
4
+ const HELP = `canon-codex-setup — print Canon Codex setup guidance
5
+
6
+ USAGE
7
+ canon-codex-setup
8
+
9
+ FLAGS
10
+ --help, -h Show this help
11
+ --version, -V Show package version
12
+
13
+ This command does not register an agent. It checks for the Codex CLI and prints
14
+ the registration and host commands to run next.`;
4
15
  export function main() {
5
16
  console.log('Canon Codex Plugin Setup');
6
17
  console.log('========================\n');
@@ -38,4 +49,7 @@ export function main() {
38
49
  runCli(import.meta.url, main, (error) => {
39
50
  console.error('[canon-codex-setup] Fatal error:', error);
40
51
  process.exit(1);
52
+ }, {
53
+ name: 'canon-codex-setup',
54
+ help: HELP,
41
55
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@canonmsg/agent-sdk": "^1.1.0",
33
- "@canonmsg/core": "^0.15.0"
33
+ "@canonmsg/core": "^0.15.2"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"