@canonmsg/codex-plugin 0.22.1 → 0.22.3

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
@@ -32,6 +32,10 @@ export type CodexEvent = {
32
32
  cached_input_tokens?: number;
33
33
  output_tokens?: number;
34
34
  };
35
+ } | {
36
+ type: 'settings.updated';
37
+ model: string | null;
38
+ effort: string | null;
35
39
  } | {
36
40
  type: 'skills.changed';
37
41
  };
@@ -8,6 +8,19 @@ export interface CodexSkillMetadata {
8
8
  shortDescription?: string;
9
9
  displayName?: string;
10
10
  }
11
+ export interface CodexReasoningEffortMetadata {
12
+ value: string;
13
+ description?: string;
14
+ }
15
+ export interface CodexModelMetadata {
16
+ id: string;
17
+ model: string;
18
+ displayName: string;
19
+ description?: string;
20
+ supportedReasoningEfforts: CodexReasoningEffortMetadata[];
21
+ defaultReasoningEffort?: string;
22
+ isDefault: boolean;
23
+ }
11
24
  export declare class CodexAppServerAdapter {
12
25
  private readonly cwd;
13
26
  private readonly codexBin;
@@ -24,6 +37,7 @@ export declare class CodexAppServerAdapter {
24
37
  private threadId;
25
38
  private loadedThreadId;
26
39
  private resolvedModel;
40
+ private resolvedReasoningEffort;
27
41
  private currentTurnId;
28
42
  private requestSeq;
29
43
  private pending;
@@ -54,6 +68,8 @@ export declare class CodexAppServerAdapter {
54
68
  dynamicTools?: readonly JsonRecord[];
55
69
  });
56
70
  getThreadId(): string | null;
71
+ getResolvedModel(): string | null;
72
+ getResolvedReasoningEffort(): string | null;
57
73
  clearThreadId(): void;
58
74
  setModel(model: string | null): void;
59
75
  /**
@@ -78,7 +94,11 @@ export declare class CodexAppServerAdapter {
78
94
  private buildWritableRoots;
79
95
  listSkills(options?: {
80
96
  forceReload?: boolean;
97
+ requestTimeoutMs?: number;
81
98
  }): Promise<CodexSkillMetadata[]>;
99
+ listModels(options?: {
100
+ requestTimeoutMs?: number;
101
+ }): Promise<CodexModelMetadata[]>;
82
102
  private buildTurnInput;
83
103
  private ensureStarted;
84
104
  private handleLine;
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
+ const DEFAULT_DISCOVERY_REQUEST_TIMEOUT_MS = 5_000;
3
4
  export class CodexAppServerAdapter {
4
5
  cwd;
5
6
  codexBin;
@@ -16,6 +17,7 @@ export class CodexAppServerAdapter {
16
17
  threadId;
17
18
  loadedThreadId = null;
18
19
  resolvedModel = null;
20
+ resolvedReasoningEffort;
19
21
  currentTurnId = null;
20
22
  requestSeq = 1;
21
23
  pending = new Map();
@@ -37,6 +39,7 @@ export class CodexAppServerAdapter {
37
39
  this.codexBin = opts.codexBin ?? 'codex';
38
40
  this.model = opts.model ?? null;
39
41
  this.reasoningEffort = opts.reasoningEffort ?? null;
42
+ this.resolvedReasoningEffort = this.reasoningEffort;
40
43
  this.sandbox = opts.sandbox ?? null;
41
44
  this.legacyApprovalPolicy = opts.approvalPolicy ?? null;
42
45
  this.addDirs = opts.addDirs ?? [];
@@ -48,13 +51,21 @@ export class CodexAppServerAdapter {
48
51
  getThreadId() {
49
52
  return this.threadId;
50
53
  }
54
+ getResolvedModel() {
55
+ return this.resolvedModel ?? this.model;
56
+ }
57
+ getResolvedReasoningEffort() {
58
+ return this.resolvedReasoningEffort ?? this.reasoningEffort;
59
+ }
51
60
  clearThreadId() {
52
61
  this.threadId = null;
53
62
  this.loadedThreadId = null;
54
63
  this.resolvedModel = null;
64
+ this.resolvedReasoningEffort = this.reasoningEffort;
55
65
  }
56
66
  setModel(model) {
57
67
  this.model = model;
68
+ this.resolvedModel = model;
58
69
  }
59
70
  /**
60
71
  * Sets GPT reasoning effort, applied via model_reasoning_effort. The config
@@ -66,9 +77,10 @@ export class CodexAppServerAdapter {
66
77
  */
67
78
  setReasoningEffort(effort) {
68
79
  const next = effort && effort.trim() ? effort.trim() : null;
69
- if (next === this.reasoningEffort)
80
+ if (next === this.reasoningEffort && next === this.resolvedReasoningEffort)
70
81
  return;
71
82
  this.reasoningEffort = next;
83
+ this.resolvedReasoningEffort = next;
72
84
  if (this.threadId && this.loadedThreadId === this.threadId) {
73
85
  this.loadedThreadId = null;
74
86
  }
@@ -200,7 +212,7 @@ export class CodexAppServerAdapter {
200
212
  continue;
201
213
  config[key] = parseConfigValue(raw.slice(separator + 1));
202
214
  }
203
- if (this.reasoningEffort && config.model_reasoning_effort === undefined) {
215
+ if (this.reasoningEffort) {
204
216
  config.model_reasoning_effort = this.reasoningEffort;
205
217
  }
206
218
  return Object.keys(config).length > 0 ? { config } : {};
@@ -249,11 +261,38 @@ export class CodexAppServerAdapter {
249
261
  const result = await this.sendRequest('skills/list', {
250
262
  cwds: [this.cwd],
251
263
  ...(options.forceReload ? { forceReload: true } : {}),
252
- });
264
+ }, options.requestTimeoutMs ?? DEFAULT_DISCOVERY_REQUEST_TIMEOUT_MS);
253
265
  const parsed = parseSkillsListResponse(result);
254
266
  this.skillsCache = parsed;
255
267
  return parsed;
256
268
  }
269
+ async listModels(options = {}) {
270
+ await this.ensureStarted();
271
+ const models = [];
272
+ const seen = new Set();
273
+ const seenCursors = new Set();
274
+ let cursor = null;
275
+ do {
276
+ const result = await this.sendRequest('model/list', {
277
+ limit: 100,
278
+ includeHidden: false,
279
+ ...(cursor ? { cursor } : {}),
280
+ }, options.requestTimeoutMs ?? DEFAULT_DISCOVERY_REQUEST_TIMEOUT_MS);
281
+ for (const model of parseModelListResponse(result)) {
282
+ if (seen.has(model.model))
283
+ continue;
284
+ seen.add(model.model);
285
+ models.push(model);
286
+ }
287
+ const nextCursor = readString(result, 'nextCursor') ?? null;
288
+ if (nextCursor && seenCursors.has(nextCursor))
289
+ break;
290
+ if (nextCursor)
291
+ seenCursors.add(nextCursor);
292
+ cursor = nextCursor;
293
+ } while (cursor);
294
+ return models;
295
+ }
257
296
  async buildTurnInput(prompt, imagePaths) {
258
297
  const skillPrompt = parseSkillSlashPrompt(prompt);
259
298
  const input = [];
@@ -373,6 +412,25 @@ export class CodexAppServerAdapter {
373
412
  }
374
413
  if (!this.isCurrentThreadNotification(params))
375
414
  return;
415
+ if (method === 'thread/settings/updated') {
416
+ const settings = isRecord(params.threadSettings) ? params.threadSettings : {};
417
+ const model = readString(settings, 'model') ?? null;
418
+ const effort = readString(settings, 'effort') ?? null;
419
+ this.resolvedModel = model ?? this.resolvedModel;
420
+ this.resolvedReasoningEffort = effort;
421
+ this.currentOnEvent?.({ type: 'settings.updated', model, effort });
422
+ return;
423
+ }
424
+ if (method === 'model/rerouted') {
425
+ const model = readString(params, 'toModel') ?? null;
426
+ this.resolvedModel = model ?? this.resolvedModel;
427
+ this.currentOnEvent?.({
428
+ type: 'settings.updated',
429
+ model,
430
+ effort: this.getResolvedReasoningEffort(),
431
+ });
432
+ return;
433
+ }
376
434
  if (method === 'turn/started') {
377
435
  this.currentTurnId = readString(params.turn, 'id') ?? this.currentTurnId;
378
436
  this.currentOnEvent?.({ type: 'turn.started' });
@@ -518,15 +576,39 @@ export class CodexAppServerAdapter {
518
576
  this.messageTextByItem.clear();
519
577
  this.planText = '';
520
578
  }
521
- sendRequest(method, params) {
579
+ sendRequest(method, params, timeoutMs) {
522
580
  const id = this.requestSeq++;
523
581
  return new Promise((resolve, reject) => {
524
- this.pending.set(id, { resolve, reject });
582
+ let timer = null;
583
+ const clearTimer = () => {
584
+ if (!timer)
585
+ return;
586
+ clearTimeout(timer);
587
+ timer = null;
588
+ };
589
+ this.pending.set(id, {
590
+ resolve: (value) => {
591
+ clearTimer();
592
+ resolve(value);
593
+ },
594
+ reject: (error) => {
595
+ clearTimer();
596
+ reject(error);
597
+ },
598
+ });
599
+ if (timeoutMs !== undefined && timeoutMs > 0) {
600
+ timer = setTimeout(() => {
601
+ if (!this.pending.delete(id))
602
+ return;
603
+ reject(new Error(`Codex app-server ${method} timed out after ${timeoutMs}ms`));
604
+ }, timeoutMs);
605
+ }
525
606
  try {
526
607
  this.write({ id, method, params });
527
608
  }
528
609
  catch (error) {
529
610
  this.pending.delete(id);
611
+ clearTimer();
530
612
  reject(error);
531
613
  }
532
614
  });
@@ -619,6 +701,45 @@ function parseSkillsListResponse(result) {
619
701
  }
620
702
  return skills;
621
703
  }
704
+ function parseModelListResponse(result) {
705
+ const entries = Array.isArray(result.data) ? result.data : [];
706
+ const models = [];
707
+ for (const entry of entries) {
708
+ if (!isRecord(entry) || entry.hidden === true)
709
+ continue;
710
+ const id = readString(entry, 'id');
711
+ const model = readString(entry, 'model') ?? id;
712
+ if (!id || !model)
713
+ continue;
714
+ const displayName = readString(entry, 'displayName') ?? model;
715
+ const description = readString(entry, 'description');
716
+ const efforts = Array.isArray(entry.supportedReasoningEfforts)
717
+ ? entry.supportedReasoningEfforts.flatMap((rawEffort) => {
718
+ if (!isRecord(rawEffort))
719
+ return [];
720
+ const value = readString(rawEffort, 'reasoningEffort');
721
+ if (!value)
722
+ return [];
723
+ const effortDescription = readString(rawEffort, 'description');
724
+ return [{
725
+ value,
726
+ ...(effortDescription ? { description: effortDescription } : {}),
727
+ }];
728
+ })
729
+ : [];
730
+ const defaultReasoningEffort = readString(entry, 'defaultReasoningEffort');
731
+ models.push({
732
+ id,
733
+ model,
734
+ displayName,
735
+ ...(description ? { description } : {}),
736
+ supportedReasoningEfforts: efforts,
737
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
738
+ isDefault: entry.isDefault === true,
739
+ });
740
+ }
741
+ return models;
742
+ }
622
743
  function stringifyPreview(value) {
623
744
  if (typeof value === 'string')
624
745
  return value;
package/dist/host.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type WorkspaceOption, type CanonWorkspaceRootMetadata } from '@canonmsg/core';
3
3
  import { type CodexSkillMetadata } from './app-server-adapter.js';
4
+ import { type CodexControlOption } from './model-catalog.js';
4
5
  interface HostSessionState {
5
6
  lastError?: string;
6
7
  model?: string;
@@ -32,30 +33,14 @@ export declare function buildCodexLiveSessionConfig(input: {
32
33
  permissionMode?: string | undefined;
33
34
  model?: string | undefined;
34
35
  };
35
- /** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
36
- export declare const CODEX_EFFORT_OPTIONS: readonly [{
37
- readonly value: "minimal";
38
- readonly label: "Minimal";
39
- }, {
40
- readonly value: "low";
41
- readonly label: "Low";
42
- }, {
43
- readonly value: "medium";
44
- readonly label: "Medium";
45
- }, {
46
- readonly value: "high";
47
- readonly label: "High";
48
- }, {
49
- readonly value: "xhigh";
50
- readonly label: "Extra high";
51
- }];
36
+ /** Conservative fallback used only when native app-server discovery is unavailable. */
37
+ export declare const CODEX_EFFORT_OPTIONS: readonly CodexControlOption[];
52
38
  export declare const CODEX_SESSION_CONFIG_FIELDS: readonly ["permissionMode", "effort"];
53
39
  export declare function buildCodexSkillCommands(skills: ReadonlyArray<CodexSkillMetadata>): CanonRuntimeCommandDescriptor[];
54
40
  export declare function buildCodexRuntimeDescriptor(input: {
55
- models: Array<{
56
- value: string;
57
- label: string;
58
- }>;
41
+ models: CodexControlOption[];
42
+ effortOptions?: CodexControlOption[];
43
+ defaultEffort?: string | null;
59
44
  workspaces: WorkspaceOption[];
60
45
  workspaceRoots?: CanonWorkspaceRootMetadata[];
61
46
  executionModes: ExecutionEnvironmentMode[];
package/dist/host.js CHANGED
@@ -9,7 +9,7 @@ import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/co
9
9
  import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, RuntimeRequestManager, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, loadRuntimeSessionState, sendMessageWithRetry, sendMessageWithRetryChunked, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { decideAutoReply, } from './inbound-policy.js';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
- import { CodexAppServerAdapter } from './app-server-adapter.js';
12
+ import { CodexAppServerAdapter, } from './app-server-adapter.js';
13
13
  import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
14
14
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
15
15
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -21,6 +21,7 @@ import { createCodexControlPoller } from './control-channel.js';
21
21
  import { runCli } from './cli-entry.js';
22
22
  import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
23
23
  import { applyTextSegmentBlock, beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
24
+ import { FALLBACK_CODEX_EFFORT_OPTIONS, buildCodexEffortOptions, buildCodexModelOptions, readCodexConfiguredEffort, resolveCodexDefaultModel, resolveCodexEffortForModel, } from './model-catalog.js';
24
25
  const HELP = `canon-codex — run a local Codex agent host for Canon
25
26
 
26
27
  USAGE
@@ -76,6 +77,7 @@ const MAX_SESSIONS = 12;
76
77
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
77
78
  const HEARTBEAT_MS = 30_000;
78
79
  const IDLE_CHECK_MS = 60_000;
80
+ const PLAN_REVIEW_TIMEOUT_MS = 10 * 60_000;
79
81
  const CODEX_RUNTIME_CAPABILITIES = {
80
82
  ...DEFAULT_RUNTIME_CAPABILITIES,
81
83
  supportsInterrupt: true,
@@ -87,15 +89,8 @@ let workingDir = process.cwd();
87
89
  let workspaceOptions = [];
88
90
  let workspaceRoots = [];
89
91
  let workspaceRootMetadata = [];
90
- /** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
91
- export const CODEX_EFFORT_OPTIONS = [
92
- { value: 'minimal', label: 'Minimal' },
93
- { value: 'low', label: 'Low' },
94
- { value: 'medium', label: 'Medium' },
95
- { value: 'high', label: 'High' },
96
- { value: 'xhigh', label: 'Extra high' },
97
- ];
98
- const CODEX_EFFORT_VALUES = new Set(CODEX_EFFORT_OPTIONS.map((option) => option.value));
92
+ /** Conservative fallback used only when native app-server discovery is unavailable. */
93
+ export const CODEX_EFFORT_OPTIONS = FALLBACK_CODEX_EFFORT_OPTIONS;
99
94
  export const CODEX_SESSION_CONFIG_FIELDS = ['permissionMode', 'effort'];
100
95
  const MAX_CODEX_SKILL_COMMAND_CHOICES = 50;
101
96
  export function buildCodexSkillCommands(skills) {
@@ -175,8 +170,8 @@ export function buildCodexRuntimeDescriptor(input) {
175
170
  defaultPermissionMode: input.defaultPermissionMode,
176
171
  permissionModeLabel: 'Execution policy',
177
172
  modelLiveBehavior: 'next_turn',
178
- effortOptions: [...CODEX_EFFORT_OPTIONS],
179
- defaultEffort: 'medium',
173
+ effortOptions: input.effortOptions ?? [...CODEX_EFFORT_OPTIONS],
174
+ defaultEffort: input.defaultEffort ?? 'medium',
180
175
  effortLiveBehavior: 'next_turn',
181
176
  presentation: input.presentation,
182
177
  streamingTextMode: 'snapshot',
@@ -241,20 +236,6 @@ export function buildCodexTurnResponseRouting(input) {
241
236
  export function getCodexRequestingUserId(message) {
242
237
  return message.senderType === 'human' ? message.senderId : null;
243
238
  }
244
- function modelOptionLabel(model) {
245
- if (/^gpt-5\.5(?:$|[-_:])/i.test(model))
246
- return 'GPT-5.5';
247
- if (/^gpt-5\.4-mini(?:$|[-_:])/i.test(model))
248
- return 'GPT-5.4 Mini';
249
- if (/^gpt-5\.4(?:$|[-_:])/i.test(model))
250
- return 'GPT-5.4';
251
- return model;
252
- }
253
- function buildCodexModelOptions(model) {
254
- return typeof model === 'string' && model.trim()
255
- ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
256
- : [];
257
- }
258
239
  async function publishAgentRuntime(agentId, runtime) {
259
240
  await publishHostAgentRuntime(agentId, 'codex', runtime);
260
241
  }
@@ -1018,9 +999,13 @@ export async function main() {
1018
999
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1019
1000
  }
1020
1001
  const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
1021
- const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
1022
- ? config.effort
1023
- : null;
1002
+ const effectiveModel = policy.model ?? codexDefaultModel;
1003
+ const initialEffortResolution = resolveCodexEffortForModel({
1004
+ models: codexModels,
1005
+ model: effectiveModel,
1006
+ requestedEffort: config?.effort ?? configuredCodexEffort,
1007
+ });
1008
+ const initialEffort = initialEffortResolution.value;
1024
1009
  const adapter = useAppServer
1025
1010
  ? new CodexAppServerAdapter({
1026
1011
  cwd: sessionCwd,
@@ -1058,7 +1043,7 @@ export async function main() {
1058
1043
  queue: [],
1059
1044
  running: false,
1060
1045
  state: buildCodexInitialSessionState({
1061
- model: policy.model,
1046
+ model: effectiveModel ?? undefined,
1062
1047
  permissionMode: policy.permissionMode,
1063
1048
  effort: initialEffort,
1064
1049
  }),
@@ -1123,6 +1108,19 @@ export async function main() {
1123
1108
  writeTurn(session);
1124
1109
  void runNextTurn(session);
1125
1110
  }
1111
+ function enqueueCodexPlanReviewResult(session, result, responseUserId) {
1112
+ if (result.status === 'cancelled' || result.status === 'timeout') {
1113
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Plan review ${result.status}`);
1114
+ return;
1115
+ }
1116
+ const feedback = result.feedback?.trim();
1117
+ const prompt = result.status === 'approve'
1118
+ ? 'The plan was approved. Implement the approved plan now.'
1119
+ : result.status === 'reject'
1120
+ ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1121
+ : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1122
+ enqueuePrompt(session, prompt, 'queue', false, result.receiptId ?? null, false, [], [], result.status !== 'approve', 'disabled', false, responseUserId);
1123
+ }
1126
1124
  function resolveArtifactRoutingMode(participantContext) {
1127
1125
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1128
1126
  ? 'workspace-generated'
@@ -1331,6 +1329,14 @@ export async function main() {
1331
1329
  if (isRecord(input.message.metadata)
1332
1330
  && input.message.metadata.type === 'plan_approval_reply'
1333
1331
  && typeof input.message.metadata.decision === 'string') {
1332
+ const planId = readString(input.message.metadata, 'planId');
1333
+ if (planId && runtimeRequests.handleMessage(input.conversationId, {
1334
+ senderId: input.message.senderId,
1335
+ metadata: input.message.metadata,
1336
+ })) {
1337
+ persistInboundRecoveryCursorWhenIdle(input.conversationId, input.message.id);
1338
+ return;
1339
+ }
1334
1340
  const session = await getOrCreateSession(input.conversationId);
1335
1341
  const feedback = readString(input.message.metadata, 'feedback');
1336
1342
  const decision = input.message.metadata.decision;
@@ -1555,6 +1561,13 @@ export async function main() {
1555
1561
  void refreshCodexSkillInventory(true).then(() => publishRuntimeHeartbeat());
1556
1562
  return;
1557
1563
  }
1564
+ if (event.type === 'settings.updated') {
1565
+ if (event.model)
1566
+ session.state.model = event.model;
1567
+ session.state.effort = event.effort ?? undefined;
1568
+ writeState(session);
1569
+ return;
1570
+ }
1558
1571
  if (event.type === 'message') {
1559
1572
  session.turnState = 'streaming';
1560
1573
  markTurnProgress(session);
@@ -1657,6 +1670,17 @@ export async function main() {
1657
1670
  clearStoredThread();
1658
1671
  result = await runTurnOnce();
1659
1672
  }
1673
+ if (session.adapter instanceof CodexAppServerAdapter) {
1674
+ const resolvedModel = session.adapter.getResolvedModel();
1675
+ const resolvedEffort = session.adapter.getResolvedReasoningEffort();
1676
+ if ((resolvedModel && resolvedModel !== session.state.model)
1677
+ || resolvedEffort !== (session.state.effort ?? null)) {
1678
+ if (resolvedModel)
1679
+ session.state.model = resolvedModel;
1680
+ session.state.effort = resolvedEffort ?? undefined;
1681
+ writeState(session);
1682
+ }
1683
+ }
1660
1684
  if (result.threadId && !session.resetRequested) {
1661
1685
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1662
1686
  }
@@ -1666,20 +1690,43 @@ export async function main() {
1666
1690
  requestingUserId: nextTurn.requestingUserId,
1667
1691
  ownerId,
1668
1692
  });
1669
- // Route plan-CREATE through the unified spine (`/runtime-plan/request`):
1670
- // seeds the server-owned pending node/attention/state and authors the
1671
- // `plan_approval` card. Resolution is UNCHANGED — Codex still re-queues a
1672
- // fresh turn off the server-authored `plan_approval_reply`.
1673
- await client.createRuntimePlanRequest({
1674
- conversationId: session.conversationId,
1675
- planId: session.currentTurnId ?? randomUUID(),
1693
+ const planId = session.currentTurnId ?? randomUUID();
1694
+ let planCreated = false;
1695
+ let resolvePlanCreated;
1696
+ let rejectPlanCreated;
1697
+ const planCreatedPromise = new Promise((resolve, reject) => {
1698
+ resolvePlanCreated = resolve;
1699
+ rejectPlanCreated = reject;
1700
+ });
1701
+ const planReview = runtimeRequests.request('plan', session.conversationId, {
1676
1702
  title: 'Codex Plan',
1677
1703
  body: result.finalMessage,
1678
1704
  ...(responseRouting.responseUserId
1679
1705
  ? { responseUserId: responseRouting.responseUserId }
1680
1706
  : {}),
1681
1707
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1708
+ }, {
1709
+ requestId: planId,
1710
+ expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS,
1711
+ responderPolicy: 'infer',
1712
+ onCreated: () => {
1713
+ planCreated = true;
1714
+ session.turnState = 'waiting_input';
1715
+ writeTurn(session);
1716
+ resolvePlanCreated();
1717
+ },
1718
+ });
1719
+ void planReview.then((planResult) => {
1720
+ resolvePlanCreated();
1721
+ enqueueCodexPlanReviewResult(session, planResult, responseRouting.responseUserId ?? null);
1722
+ }).catch((error) => {
1723
+ if (!planCreated) {
1724
+ rejectPlanCreated(error);
1725
+ return;
1726
+ }
1727
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Plan review failed:`, error instanceof Error ? error.message : error);
1682
1728
  });
1729
+ await planCreatedPromise;
1683
1730
  await handoffFinalMessage(session.conversationId);
1684
1731
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1685
1732
  }
@@ -1822,7 +1869,16 @@ export async function main() {
1822
1869
  ...EXECUTION_ENVIRONMENT_MODES,
1823
1870
  ];
1824
1871
  const codexPermissionEnvelope = deriveCodexPermissionEnvelope(args);
1825
- const codexModelOptions = buildCodexModelOptions(args.model);
1872
+ const configuredCodexEffort = readCodexConfiguredEffort(args.config ?? []);
1873
+ let codexModels = [];
1874
+ let codexModelOptions = buildCodexModelOptions(codexModels, args.model);
1875
+ let codexEffortOptions = buildCodexEffortOptions(codexModels);
1876
+ let codexDefaultModel = resolveCodexDefaultModel(codexModels, args.model);
1877
+ let codexDefaultEffort = resolveCodexEffortForModel({
1878
+ models: codexModels,
1879
+ model: codexDefaultModel,
1880
+ requestedEffort: configuredCodexEffort,
1881
+ }).value;
1826
1882
  const runtimePresentation = buildRuntimePresentationPolicy({
1827
1883
  base: DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION,
1828
1884
  preset: parseRuntimeVisibilityPreset(args['runtime-visibility']),
@@ -1832,7 +1888,7 @@ export async function main() {
1832
1888
  let codexSkills = [];
1833
1889
  const buildCurrentRuntimeDescriptor = () => ({
1834
1890
  defaultWorkspaceId: workspaceOptions[0]?.id,
1835
- ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
1891
+ ...(codexDefaultModel ? { defaultModel: codexDefaultModel } : {}),
1836
1892
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
1837
1893
  availableExecutionModes: hostAvailableExecutionModes,
1838
1894
  availablePermissionModes: [...codexPermissionEnvelope.availablePermissionModes],
@@ -1841,6 +1897,8 @@ export async function main() {
1841
1897
  : {}),
1842
1898
  runtimeDescriptor: buildCodexRuntimeDescriptor({
1843
1899
  models: codexModelOptions,
1900
+ effortOptions: codexEffortOptions,
1901
+ defaultEffort: codexDefaultEffort,
1844
1902
  workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1845
1903
  workspaceRoots: workspaceRootMetadata,
1846
1904
  executionModes: hostAvailableExecutionModes,
@@ -1863,16 +1921,32 @@ export async function main() {
1863
1921
  model: typeof args.model === 'string' ? args.model : null,
1864
1922
  configOverrides: args.config ?? [],
1865
1923
  });
1924
+ try {
1925
+ const discoveredModels = await probe.listModels();
1926
+ if (discoveredModels.length > 0) {
1927
+ codexModels = discoveredModels;
1928
+ codexModelOptions = buildCodexModelOptions(codexModels, args.model);
1929
+ codexEffortOptions = buildCodexEffortOptions(codexModels);
1930
+ codexDefaultModel = resolveCodexDefaultModel(codexModels, args.model);
1931
+ codexDefaultEffort = resolveCodexEffortForModel({
1932
+ models: codexModels,
1933
+ model: codexDefaultModel,
1934
+ requestedEffort: configuredCodexEffort,
1935
+ }).value;
1936
+ }
1937
+ }
1938
+ catch (error) {
1939
+ console.error('[canon-codex] Failed to load Codex models:', error instanceof Error ? error.message : error);
1940
+ }
1866
1941
  try {
1867
1942
  codexSkills = await probe.listSkills({ forceReload });
1868
- runtimeDescriptor = buildCurrentRuntimeDescriptor();
1869
1943
  }
1870
1944
  catch (error) {
1871
1945
  codexSkills = [];
1872
- runtimeDescriptor = buildCurrentRuntimeDescriptor();
1873
1946
  console.error('[canon-codex] Failed to load Codex skills:', error instanceof Error ? error.message : error);
1874
1947
  }
1875
1948
  finally {
1949
+ runtimeDescriptor = buildCurrentRuntimeDescriptor();
1876
1950
  probe.close();
1877
1951
  }
1878
1952
  }
@@ -1880,7 +1954,13 @@ export async function main() {
1880
1954
  const session = sessions.get(conversationId);
1881
1955
  if (!session || session.closed)
1882
1956
  return;
1957
+ let modelChanged = false;
1883
1958
  if (control.model && control.model !== session.state.model) {
1959
+ if (codexModelOptions.length > 0 && !codexModelOptions.some((option) => option.value === control.model)) {
1960
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring model outside the discovered Codex catalog (${control.model})`);
1961
+ writeState(session);
1962
+ return;
1963
+ }
1884
1964
  const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
1885
1965
  if (modelGuard) {
1886
1966
  session.state.lastError = modelGuard;
@@ -1892,29 +1972,36 @@ export async function main() {
1892
1972
  }
1893
1973
  session.adapter.setModel(control.model);
1894
1974
  session.state.model = control.model;
1975
+ modelChanged = true;
1895
1976
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1896
- writeState(session);
1897
1977
  }
1898
1978
  if (control.permissionMode) {
1899
1979
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1900
1980
  // Convergence contract: a consumed session control must always be
1901
1981
  // answered. Re-publish the currently applied state so clients settle on
1902
1982
  // the authoritative value instead of holding the composer until timeout.
1903
- writeState(session);
1904
1983
  }
1905
- if (control.effort) {
1906
- if (CODEX_EFFORT_VALUES.has(control.effort)) {
1907
- session.adapter.setReasoningEffort(control.effort);
1908
- session.state.effort = control.effort;
1984
+ if (control.effort || modelChanged) {
1985
+ const effortResolution = resolveCodexEffortForModel({
1986
+ models: codexModels,
1987
+ model: session.state.model,
1988
+ requestedEffort: control.effort ?? session.state.effort,
1989
+ });
1990
+ if (effortResolution.value !== session.state.effort) {
1991
+ session.adapter.setReasoningEffort(effortResolution.value);
1992
+ session.state.effort = effortResolution.value ?? undefined;
1993
+ }
1994
+ if (control.effort && !effortResolution.accepted) {
1995
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${control.effort} is unsupported by ${session.state.model ?? 'the active model'}; reset to ${effortResolution.value ?? 'the model default'}`);
1996
+ }
1997
+ else if (control.effort) {
1909
1998
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
1910
- writeState(session);
1911
1999
  }
1912
- else {
1913
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
1914
- // Same contract: ignored values still get an authoritative re-publish.
1915
- writeState(session);
2000
+ else if (modelChanged && effortResolution.value) {
2001
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort for ${session.state.model} -> ${effortResolution.value}`);
1916
2002
  }
1917
2003
  }
2004
+ writeState(session);
1918
2005
  }
1919
2006
  async function handleControlSignal(event) {
1920
2007
  const { conversationId, type } = event;
@@ -0,0 +1,19 @@
1
+ import type { CodexModelMetadata } from './app-server-adapter.js';
2
+ export interface CodexControlOption {
3
+ value: string;
4
+ label: string;
5
+ description?: string;
6
+ }
7
+ export declare const FALLBACK_CODEX_EFFORT_OPTIONS: ReadonlyArray<CodexControlOption>;
8
+ export declare function buildCodexModelOptions(models: ReadonlyArray<CodexModelMetadata>, configuredModel?: unknown): CodexControlOption[];
9
+ export declare function buildCodexEffortOptions(models: ReadonlyArray<CodexModelMetadata>): CodexControlOption[];
10
+ export declare function resolveCodexDefaultModel(models: ReadonlyArray<CodexModelMetadata>, configuredModel?: unknown): string | null;
11
+ export declare function resolveCodexEffortForModel(input: {
12
+ models: ReadonlyArray<CodexModelMetadata>;
13
+ model: string | null | undefined;
14
+ requestedEffort: string | null | undefined;
15
+ }): {
16
+ value: string | null;
17
+ accepted: boolean;
18
+ };
19
+ export declare function readCodexConfiguredEffort(configOverrides: ReadonlyArray<string>): string | null;
@@ -0,0 +1,137 @@
1
+ const EFFORT_LABELS = {
2
+ minimal: 'Minimal',
3
+ low: 'Low',
4
+ medium: 'Medium',
5
+ high: 'High',
6
+ xhigh: 'Extra high',
7
+ max: 'Max',
8
+ ultra: 'Ultra',
9
+ };
10
+ const EFFORT_ORDER = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
11
+ export const FALLBACK_CODEX_EFFORT_OPTIONS = [
12
+ { value: 'minimal', label: 'Minimal' },
13
+ { value: 'low', label: 'Low' },
14
+ { value: 'medium', label: 'Medium' },
15
+ { value: 'high', label: 'High' },
16
+ { value: 'xhigh', label: 'Extra high' },
17
+ ];
18
+ export function buildCodexModelOptions(models, configuredModel) {
19
+ const configured = typeof configuredModel === 'string' ? configuredModel.trim() : '';
20
+ const ordered = [...models].sort((left, right) => {
21
+ if (configured) {
22
+ if (left.model === configured)
23
+ return -1;
24
+ if (right.model === configured)
25
+ return 1;
26
+ }
27
+ return Number(right.isDefault) - Number(left.isDefault);
28
+ });
29
+ const options = ordered.map((model) => ({
30
+ value: model.model,
31
+ label: model.displayName,
32
+ ...(model.description ? { description: model.description } : {}),
33
+ }));
34
+ if (configured && !options.some((option) => option.value === configured)) {
35
+ options.push({ value: configured, label: configured });
36
+ }
37
+ return options;
38
+ }
39
+ export function buildCodexEffortOptions(models) {
40
+ if (models.length === 0)
41
+ return [...FALLBACK_CODEX_EFFORT_OPTIONS];
42
+ const values = new Set();
43
+ const descriptions = new Map();
44
+ for (const model of models) {
45
+ for (const effort of model.supportedReasoningEfforts) {
46
+ values.add(effort.value);
47
+ if (effort.description && !descriptions.has(effort.value)) {
48
+ descriptions.set(effort.value, effort.description);
49
+ }
50
+ }
51
+ }
52
+ return [...values]
53
+ .sort((left, right) => {
54
+ const leftIndex = EFFORT_ORDER.indexOf(left);
55
+ const rightIndex = EFFORT_ORDER.indexOf(right);
56
+ if (leftIndex === -1 && rightIndex === -1)
57
+ return left.localeCompare(right);
58
+ if (leftIndex === -1)
59
+ return 1;
60
+ if (rightIndex === -1)
61
+ return -1;
62
+ return leftIndex - rightIndex;
63
+ })
64
+ .map((value) => {
65
+ const description = buildEffortDescription(value, descriptions.get(value), models);
66
+ return {
67
+ value,
68
+ label: EFFORT_LABELS[value] ?? humanizeEffort(value),
69
+ ...(description ? { description } : {}),
70
+ };
71
+ });
72
+ }
73
+ export function resolveCodexDefaultModel(models, configuredModel) {
74
+ const configured = typeof configuredModel === 'string' ? configuredModel.trim() : '';
75
+ if (configured)
76
+ return configured;
77
+ return models.find((model) => model.isDefault)?.model ?? models[0]?.model ?? null;
78
+ }
79
+ export function resolveCodexEffortForModel(input) {
80
+ const requested = input.requestedEffort?.trim() || null;
81
+ const model = input.models.find((candidate) => candidate.model === input.model);
82
+ if (!model) {
83
+ const fallbackValues = new Set([
84
+ ...FALLBACK_CODEX_EFFORT_OPTIONS.map((option) => option.value),
85
+ ...input.models.flatMap((candidate) => (candidate.supportedReasoningEfforts.map((effort) => effort.value))),
86
+ ]);
87
+ if (!requested)
88
+ return { value: 'medium', accepted: true };
89
+ return fallbackValues.has(requested)
90
+ ? { value: requested, accepted: true }
91
+ : { value: 'medium', accepted: false };
92
+ }
93
+ const supported = new Set(model.supportedReasoningEfforts.map((effort) => effort.value));
94
+ const fallback = supported.has(model.defaultReasoningEffort ?? '')
95
+ ? model.defaultReasoningEffort ?? null
96
+ : model.supportedReasoningEfforts[0]?.value ?? null;
97
+ if (!requested)
98
+ return { value: fallback, accepted: true };
99
+ return supported.has(requested)
100
+ ? { value: requested, accepted: true }
101
+ : { value: fallback, accepted: false };
102
+ }
103
+ export function readCodexConfiguredEffort(configOverrides) {
104
+ let configured = null;
105
+ for (const raw of configOverrides) {
106
+ const separator = raw.indexOf('=');
107
+ if (separator <= 0 || raw.slice(0, separator).trim() !== 'model_reasoning_effort')
108
+ continue;
109
+ const value = raw.slice(separator + 1).trim();
110
+ if (!value)
111
+ continue;
112
+ configured = unquote(value);
113
+ }
114
+ return configured;
115
+ }
116
+ function humanizeEffort(value) {
117
+ return value
118
+ .replace(/[_-]+/g, ' ')
119
+ .replace(/\b\w/g, (character) => character.toUpperCase());
120
+ }
121
+ function buildEffortDescription(effort, nativeDescription, models) {
122
+ const supportedBy = models.filter((model) => (model.supportedReasoningEfforts.some((candidate) => candidate.value === effort)));
123
+ const compatibility = supportedBy.length > 0 && supportedBy.length < models.length
124
+ ? `Supported by ${supportedBy.map((model) => model.displayName).join(', ')}.`
125
+ : '';
126
+ return [nativeDescription, compatibility].filter(Boolean).join(' ') || undefined;
127
+ }
128
+ function unquote(value) {
129
+ if (value.length >= 2) {
130
+ const first = value[0];
131
+ const last = value[value.length - 1];
132
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
133
+ return value.slice(1, -1);
134
+ }
135
+ }
136
+ return value;
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.22.1",
3
+ "version": "0.22.3",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "@canonmsg/agent-sdk": "^5.1.1",
33
33
  "@canonmsg/coding-agent-host": "^0.2.2",
34
- "@canonmsg/core": "^4.2.2"
34
+ "@canonmsg/core": "^4.2.3"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.0.0"