@canonmsg/codex-plugin 0.9.3 → 0.9.5

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,6 +60,7 @@ 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>;
package/dist/adapter.js CHANGED
@@ -31,6 +31,9 @@ export class CodexConversationAdapter {
31
31
  getThreadId() {
32
32
  return this.threadId;
33
33
  }
34
+ clearThreadId() {
35
+ this.threadId = null;
36
+ }
34
37
  setModel(model) {
35
38
  this.model = model;
36
39
  }
@@ -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
@@ -3,12 +3,41 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
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, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
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);
@@ -112,14 +162,6 @@ function summarizeCommand(command) {
112
162
  const shortened = trimmed.length > 140 ? `${trimmed.slice(0, 137)}...` : trimmed;
113
163
  return `Running: ${shortened}`;
114
164
  }
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
165
  function sleep(ms) {
124
166
  return new Promise((resolve) => setTimeout(resolve, ms));
125
167
  }
@@ -157,6 +199,14 @@ export async function main() {
157
199
  if (typeof args['ask-for-approval'] === 'string') {
158
200
  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.');
159
201
  }
202
+ const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
203
+ const codexCliStatus = detectCodexCliVersion(codexBin);
204
+ if (codexCliStatus.version) {
205
+ console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
206
+ }
207
+ else {
208
+ console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
209
+ }
160
210
  const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
161
211
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
162
212
  const client = new CanonClient(apiKey, baseUrl);
@@ -264,6 +314,7 @@ export async function main() {
264
314
  runtimeState.writeSessionState(session.conversationId, {
265
315
  lastError: session.state.lastError,
266
316
  model: session.state.model,
317
+ permissionMode: session.state.permissionMode,
267
318
  cwd: session.cwd,
268
319
  executionMode: session.environment.mode,
269
320
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
@@ -295,6 +346,13 @@ export async function main() {
295
346
  return;
296
347
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
297
348
  }
349
+ async function markQueuedPromptsRejected(conversationId, prompts) {
350
+ await Promise.all(prompts.map((prompt) => {
351
+ if (!prompt.markAccepted || !prompt.sourceMessageId)
352
+ return Promise.resolve();
353
+ return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
354
+ }));
355
+ }
298
356
  function clearStreaming(conversationId) {
299
357
  runtimeState.clearStreaming(conversationId).catch(() => { });
300
358
  }
@@ -376,15 +434,37 @@ export async function main() {
376
434
  try {
377
435
  const sessionCwd = environment.cwd;
378
436
  const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
379
- const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode);
380
- if (config?.permissionMode
381
- && !codexPermissionEnvelope.availablePermissionModes.some((option) => option.value === config.permissionMode)) {
382
- 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.');
437
+ const modelGuard = buildCodexModelGuardMessage(sessionModel, codexCliStatus);
438
+ if (modelGuard) {
439
+ throw new ExecutionEnvironmentError(modelGuard, modelGuard);
440
+ }
441
+ const effectivePermissionMode = config?.permissionMode ?? codexPermissionEnvelope.defaultPermissionMode;
442
+ if (effectivePermissionMode
443
+ && !codexPermissionEnvelope.availablePermissionModes.some((option) => option.value === effectivePermissionMode)) {
444
+ throw new ExecutionEnvironmentError(`Permission mode "${effectivePermissionMode}" 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.');
383
445
  }
384
- const approvalOverride = mapCanonPermissionToCodex(config?.permissionMode);
446
+ const approvalOverride = mapCanonPermissionToCodex(effectivePermissionMode);
385
447
  const defaultSandbox = (typeof args.sandbox === 'string' ? args.sandbox : null);
386
448
  const defaultFullAuto = Boolean(args['full-auto']);
387
449
  const defaultBypass = Boolean(args['dangerously-bypass-approvals-and-sandbox']);
450
+ const legacyApprovalPolicy = (typeof args['ask-for-approval'] === 'string'
451
+ ? args['ask-for-approval']
452
+ : null);
453
+ const effectiveSandbox = approvalOverride ? approvalOverride.sandbox : defaultSandbox;
454
+ const effectiveFullAuto = approvalOverride ? approvalOverride.fullAuto : defaultFullAuto;
455
+ const effectiveBypass = approvalOverride
456
+ ? approvalOverride.bypassApprovalsAndSandbox
457
+ : defaultBypass;
458
+ const policyFingerprint = buildCodexThreadPolicyFingerprint({
459
+ baseCwd: environment.baseCwd,
460
+ executionMode: environment.mode,
461
+ permissionMode: effectivePermissionMode ?? null,
462
+ sandbox: effectiveSandbox,
463
+ approvalPolicy: approvalOverride ? null : legacyApprovalPolicy,
464
+ fullAuto: effectiveFullAuto,
465
+ bypassApprovalsAndSandbox: effectiveBypass,
466
+ });
467
+ const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policyFingerprint);
388
468
  const session = {
389
469
  conversationId,
390
470
  cwd: sessionCwd,
@@ -392,26 +472,24 @@ export async function main() {
392
472
  adapter: new CodexConversationAdapter({
393
473
  cwd: sessionCwd,
394
474
  threadId: storedThreadId,
395
- codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
475
+ codexBin,
396
476
  model: sessionModel ?? null,
397
- sandbox: approvalOverride ? approvalOverride.sandbox : defaultSandbox,
398
- approvalPolicy: (typeof args['ask-for-approval'] === 'string'
399
- ? args['ask-for-approval']
400
- : null),
477
+ sandbox: effectiveSandbox,
478
+ approvalPolicy: approvalOverride ? null : legacyApprovalPolicy,
401
479
  codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
402
480
  addDirs: args['add-dir'] ?? [],
403
481
  configOverrides: args.config ?? [],
404
- fullAuto: approvalOverride ? approvalOverride.fullAuto : defaultFullAuto,
405
- bypassApprovalsAndSandbox: approvalOverride
406
- ? approvalOverride.bypassApprovalsAndSandbox
407
- : defaultBypass,
482
+ fullAuto: effectiveFullAuto,
483
+ bypassApprovalsAndSandbox: effectiveBypass,
408
484
  }),
409
485
  queue: [],
410
486
  running: false,
411
487
  state: {
412
488
  model: sessionModel,
489
+ permissionMode: effectivePermissionMode,
413
490
  state: 'idle',
414
491
  },
492
+ policyFingerprint,
415
493
  turnState: 'idle',
416
494
  currentTurnId: null,
417
495
  currentTurnOpenedAt: null,
@@ -421,6 +499,10 @@ export async function main() {
421
499
  closed: false,
422
500
  };
423
501
  sessions.set(conversationId, session);
502
+ await Promise.all([
503
+ baselineControlSignal(conversationId),
504
+ baselineSessionControl(conversationId),
505
+ ]);
424
506
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
425
507
  writeState(session);
426
508
  writeTurn(session);
@@ -549,11 +631,15 @@ export async function main() {
549
631
  status: 'thinking',
550
632
  }).catch(() => { });
551
633
  try {
634
+ const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
635
+ if (modelGuard) {
636
+ throw new ExecutionEnvironmentError(modelGuard, modelGuard);
637
+ }
552
638
  const turnImagePaths = nextTurn.imagePaths ?? [];
553
- const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
639
+ const handleCodexEvent = (event) => {
554
640
  session.lastActivity = Date.now();
555
641
  if (event.type === 'thread.started') {
556
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode);
642
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
557
643
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
558
644
  return;
559
645
  }
@@ -581,13 +667,32 @@ export async function main() {
581
667
  if (event.type === 'turn.completed') {
582
668
  writeState(session);
583
669
  }
584
- }, (line) => {
670
+ };
671
+ const logCodexLine = (line) => {
585
672
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
586
- }, turnImagePaths);
673
+ };
674
+ const clearStoredThread = () => {
675
+ clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
676
+ session.adapter.clearThreadId();
677
+ };
678
+ const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths);
679
+ let result = await runTurnOnce();
680
+ if (!result.interrupted
681
+ && !result.finalMessage
682
+ && result.exitCode
683
+ && result.exitCode !== 0
684
+ && isRecoverableCodexThreadError(result.errorText)) {
685
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
686
+ clearStoredThread();
687
+ result = await runTurnOnce();
688
+ }
587
689
  if (result.threadId) {
588
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode);
690
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
589
691
  }
590
692
  if (!result.interrupted && result.finalMessage) {
693
+ if (isRecoverableCodexThreadError(result.errorText)) {
694
+ clearStoredThread();
695
+ }
591
696
  await client.sendMessage(session.conversationId, result.finalMessage, {
592
697
  metadata: {
593
698
  turnId: session.currentTurnId,
@@ -600,7 +705,7 @@ export async function main() {
600
705
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
601
706
  }
602
707
  else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
603
- const userVisibleError = formatTurnFailure(result.errorText);
708
+ const userVisibleError = formatCodexTurnFailure(result.errorText);
604
709
  session.state.lastError = userVisibleError;
605
710
  writeState(session);
606
711
  if (result.errorText) {
@@ -629,7 +734,9 @@ export async function main() {
629
734
  }
630
735
  }
631
736
  catch (error) {
632
- const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
737
+ const message = error instanceof ExecutionEnvironmentError
738
+ ? error.userMessage
739
+ : `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
633
740
  session.state.lastError = message;
634
741
  writeState(session);
635
742
  await client.sendMessage(session.conversationId, message, {
@@ -641,7 +748,7 @@ export async function main() {
641
748
  },
642
749
  }).catch(() => { });
643
750
  await handoffFinalMessage(session.conversationId);
644
- if (error instanceof Error && /invalid|not found|unknown thread/i.test(error.message)) {
751
+ if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
645
752
  clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
646
753
  }
647
754
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
@@ -663,11 +770,14 @@ export async function main() {
663
770
  }
664
771
  }
665
772
  let controlStopped = false;
773
+ const lastSeenControl = new Map();
774
+ const lastSeenSignal = new Map();
666
775
  let streamConnected = false;
667
776
  const hostAvailableExecutionModes = [
668
777
  ...EXECUTION_ENVIRONMENT_MODES,
669
778
  ];
670
779
  const codexPermissionEnvelope = deriveCodexPermissionEnvelope(args);
780
+ const codexModelOptions = buildCodexModelOptions(args.model);
671
781
  let runtimeDescriptor = {
672
782
  defaultWorkspaceId: workspaceOptions[0]?.id,
673
783
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
@@ -678,7 +788,7 @@ export async function main() {
678
788
  ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
679
789
  : {}),
680
790
  runtimeDescriptor: buildCodexRuntimeDescriptor({
681
- models: [],
791
+ models: codexModelOptions,
682
792
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
683
793
  workspaceRoots: workspaceRootMetadata,
684
794
  executionModes: hostAvailableExecutionModes,
@@ -686,6 +796,29 @@ export async function main() {
686
796
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
687
797
  }),
688
798
  };
799
+ async function baselineControlSignal(conversationId) {
800
+ if (lastSeenSignal.has(conversationId))
801
+ return;
802
+ const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`).catch(() => null);
803
+ if (!raw || typeof raw !== 'object')
804
+ return;
805
+ const timestamp = Number(raw.updatedAt ?? 0);
806
+ if (timestamp > 0) {
807
+ lastSeenSignal.set(conversationId, timestamp);
808
+ }
809
+ }
810
+ async function baselineSessionControl(conversationId) {
811
+ if (lastSeenControl.has(conversationId))
812
+ return;
813
+ const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
814
+ if (!raw || typeof raw !== 'object')
815
+ return;
816
+ const timestamp = Number(raw.updatedAt ?? 0);
817
+ if (timestamp > 0) {
818
+ lastSeenControl.set(conversationId, timestamp);
819
+ }
820
+ }
821
+ let publishRuntimeDetailsInFlight = false;
689
822
  const publishRuntimeHeartbeat = async () => {
690
823
  heartbeatLocalRuntimeEntry(runtimeId, {
691
824
  agentId,
@@ -695,84 +828,99 @@ export async function main() {
695
828
  });
696
829
  if (!streamConnected)
697
830
  return;
698
- await refreshKnownConversationIds().catch((error) => {
699
- console.error('[canon-codex] Failed to refresh known conversations:', error);
700
- });
701
831
  await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
702
832
  console.error('[canon-codex] Failed to publish agent runtime:', error);
703
833
  });
704
- await publishHostSessionSnapshots({
705
- conversationIds: Array.from(knownConversationIds),
706
- agentId,
707
- clientType: 'codex',
708
- runtime: runtimeDescriptor,
709
- workspaceOptions,
710
- defaultCwd: workingDir,
711
- extraSessionConfigFields: ['permissionMode'],
712
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
713
- const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
714
- return [
715
- session.conversationId,
716
- {
717
- ...(session.state.model ? { model: session.state.model } : {}),
718
- ...(workspaceId ? { workspaceId } : {}),
719
- executionMode: session.environment.mode,
720
- executionBranch: session.environment.branch ?? null,
721
- },
722
- ];
723
- })),
724
- }).catch((error) => {
725
- console.error('[canon-codex] Failed to publish session snapshots:', error);
726
- });
727
- await Promise.all(Array.from(knownConversationIds).map(async (conversationId) => {
728
- const session = sessions.get(conversationId);
729
- const workspaceId = session
730
- ? resolveWorkspaceIdForBaseCwd(session.environment.baseCwd)
731
- : runtimeDescriptor.defaultWorkspaceId;
732
- const workspace = workspaceOptions.find((option) => option.id === workspaceId) ?? null;
733
- const descriptor = runtimeDescriptor.runtimeDescriptor;
734
- if (!descriptor)
735
- return;
736
- const payload = {
737
- descriptor,
738
- surfaceMode: 'host',
739
- statusItems: [
740
- {
741
- id: 'transport',
742
- label: 'Transport',
743
- value: 'exec --json',
744
- },
745
- {
746
- id: 'streaming',
747
- label: 'Live output',
748
- value: 'Thinking, tools, and completed-message previews',
749
- },
750
- {
751
- id: 'nativeActions',
752
- label: 'Native actions',
753
- value: 'Limited until app-server transport',
754
- tone: 'warning',
834
+ if (publishRuntimeDetailsInFlight)
835
+ return;
836
+ publishRuntimeDetailsInFlight = true;
837
+ try {
838
+ await refreshKnownConversationIds().catch((error) => {
839
+ console.error('[canon-codex] Failed to refresh known conversations:', error);
840
+ });
841
+ await publishHostSessionSnapshots({
842
+ conversationIds: Array.from(knownConversationIds),
843
+ agentId,
844
+ clientType: 'codex',
845
+ runtime: runtimeDescriptor,
846
+ workspaceOptions,
847
+ defaultCwd: workingDir,
848
+ extraSessionConfigFields: ['permissionMode'],
849
+ liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
850
+ const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
851
+ return [
852
+ session.conversationId,
853
+ {
854
+ ...(session.state.model ? { model: session.state.model } : {}),
855
+ ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
856
+ ...(workspaceId ? { workspaceId } : {}),
857
+ executionMode: session.environment.mode,
858
+ executionBranch: session.environment.branch ?? null,
859
+ },
860
+ ];
861
+ })),
862
+ }).catch((error) => {
863
+ console.error('[canon-codex] Failed to publish session snapshots:', error);
864
+ });
865
+ await Promise.all(Array.from(knownConversationIds).map(async (conversationId) => {
866
+ const session = sessions.get(conversationId);
867
+ const workspaceId = session
868
+ ? resolveWorkspaceIdForBaseCwd(session.environment.baseCwd)
869
+ : runtimeDescriptor.defaultWorkspaceId;
870
+ const workspace = workspaceOptions.find((option) => option.id === workspaceId) ?? null;
871
+ const descriptor = runtimeDescriptor.runtimeDescriptor;
872
+ if (!descriptor)
873
+ return;
874
+ const payload = {
875
+ descriptor,
876
+ surfaceMode: 'host',
877
+ statusItems: [
878
+ {
879
+ id: 'transport',
880
+ label: 'Transport',
881
+ value: 'exec --json',
882
+ },
883
+ {
884
+ id: 'streaming',
885
+ label: 'Live output',
886
+ value: 'Thinking, tools, and completed-message previews',
887
+ },
888
+ {
889
+ id: 'codex-cli',
890
+ label: 'Codex CLI',
891
+ value: codexCliStatus.version ?? (codexCliStatus.raw ?? 'Version unknown'),
892
+ tone: codexCliStatus.version ? 'default' : 'warning',
893
+ },
894
+ {
895
+ id: 'nativeActions',
896
+ label: 'Native actions',
897
+ value: 'Limited until app-server transport',
898
+ tone: 'warning',
899
+ },
900
+ ],
901
+ execution: {
902
+ resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
903
+ resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
904
+ workspaceRootId: workspace?.workspaceRootId ?? null,
905
+ workspaceRelativePath: workspace?.workspaceRelativePath ?? null,
906
+ executionMode: session?.environment.mode ?? null,
907
+ executionBranch: session?.environment.branch ?? null,
908
+ worktreePath: session?.environment.worktreePath ?? null,
909
+ fallbackReason: resolveExecutionFallbackReason(session?.environment),
755
910
  },
756
- ],
757
- execution: {
758
- resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
759
- resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
760
- workspaceRootId: workspace?.workspaceRootId ?? null,
761
- workspaceRelativePath: workspace?.workspaceRelativePath ?? null,
762
- executionMode: session?.environment.mode ?? null,
763
- executionBranch: session?.environment.branch ?? null,
764
- worktreePath: session?.environment.worktreePath ?? null,
765
- fallbackReason: resolveExecutionFallbackReason(session?.environment),
766
- },
767
- notes: [
768
- '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.',
769
- '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.',
770
- ],
771
- };
772
- await runtimeState.writeRuntimeInfo(conversationId, payload);
773
- })).catch((error) => {
774
- console.error('[canon-codex] Failed to publish runtime info:', error);
775
- });
911
+ notes: [
912
+ '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.',
913
+ '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.',
914
+ ],
915
+ };
916
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
917
+ })).catch((error) => {
918
+ console.error('[canon-codex] Failed to publish runtime info:', error);
919
+ });
920
+ }
921
+ finally {
922
+ publishRuntimeDetailsInFlight = false;
923
+ }
776
924
  };
777
925
  const stream = new CanonStream({
778
926
  apiKey,
@@ -822,7 +970,7 @@ export async function main() {
822
970
  ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
823
971
  : {}),
824
972
  runtimeDescriptor: buildCodexRuntimeDescriptor({
825
- models: [],
973
+ models: codexModelOptions,
826
974
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
827
975
  workspaceRoots: workspaceRootMetadata,
828
976
  executionModes: hostAvailableExecutionModes,
@@ -841,7 +989,7 @@ export async function main() {
841
989
  ? { defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode }
842
990
  : {}),
843
991
  runtimeDescriptor: buildCodexRuntimeDescriptor({
844
- models: [],
992
+ models: codexModelOptions,
845
993
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
846
994
  workspaceRoots: workspaceRootMetadata,
847
995
  executionModes: hostAvailableExecutionModes,
@@ -911,11 +1059,9 @@ export async function main() {
911
1059
  catch (error) {
912
1060
  console.error('[canon-codex] Failed to load startup conversations:', error);
913
1061
  }
914
- await stream.start().catch((error) => {
1062
+ startCodexStreamInBackground(stream, (error) => {
915
1063
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
916
1064
  });
917
- const lastSeenControl = new Map();
918
- const lastSeenSignal = new Map();
919
1065
  const pollControl = async () => {
920
1066
  while (!controlStopped) {
921
1067
  for (const conversationId of [...sessions.keys()]) {
@@ -929,6 +1075,14 @@ export async function main() {
929
1075
  const session = sessions.get(conversationId);
930
1076
  if (session && !session.closed) {
931
1077
  if (control.model && control.model !== session.state.model) {
1078
+ const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
1079
+ if (modelGuard) {
1080
+ session.state.lastError = modelGuard;
1081
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1082
+ writeState(session);
1083
+ await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
1084
+ continue;
1085
+ }
932
1086
  session.adapter.setModel(control.model);
933
1087
  session.state.model = control.model;
934
1088
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
@@ -941,6 +1095,7 @@ export async function main() {
941
1095
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
942
1096
  }
943
1097
  }
1098
+ await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
944
1099
  }
945
1100
  }
946
1101
  const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`);
@@ -956,15 +1111,23 @@ export async function main() {
956
1111
  const session = sessions.get(conversationId);
957
1112
  if (!session || session.closed)
958
1113
  continue;
1114
+ if (!session.running && (signal.type !== 'stop_and_drop' || session.queue.length === 0)) {
1115
+ await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1116
+ continue;
1117
+ }
959
1118
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
960
- await session.adapter.interrupt();
961
- session.turnState = 'interrupted';
962
1119
  if (signal.type === 'stop_and_drop') {
963
- session.queue.length = 0;
1120
+ const droppedPrompts = session.queue.splice(0);
1121
+ await markQueuedPromptsRejected(conversationId, droppedPrompts);
1122
+ }
1123
+ if (session.running) {
1124
+ await session.adapter.interrupt();
964
1125
  }
1126
+ session.turnState = 'interrupted';
965
1127
  writeTurn(session);
966
1128
  clearStreaming(conversationId);
967
1129
  client.setTyping(conversationId, false).catch(() => { });
1130
+ await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
968
1131
  }
969
1132
  catch {
970
1133
  // Ignore transient RTDB failures.
@@ -1015,7 +1178,11 @@ export async function main() {
1015
1178
  await new Promise(() => { });
1016
1179
  }
1017
1180
  runCli(import.meta.url, main, (error) => {
1018
- console.error('[canon-codex] Fatal error:', error);
1181
+ const message = error instanceof Error ? error.message : String(error);
1182
+ console.error(`[canon-codex] ${message}`);
1019
1183
  getActiveProfileLock()?.release();
1020
1184
  process.exit(1);
1185
+ }, {
1186
+ name: 'canon-codex',
1187
+ help: HELP,
1021
1188
  });
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.3",
3
+ "version": "0.9.5",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,8 +29,8 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^1.0.0",
33
- "@canonmsg/core": "^0.15.0"
32
+ "@canonmsg/agent-sdk": "^1.1.0",
33
+ "@canonmsg/core": "^0.15.1"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"