@canonmsg/codex-plugin 0.32.2 → 0.32.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/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Connect the local Codex CLI to [Canon](https://canonmail.com/agents) so a Canon user can message your coding agent from the app.
4
4
 
5
+ The audited stable app-server baseline is **Codex CLI 0.155.1**. Install it with `npm install -g @openai/codex@0.155.1`. Canon keeps using the native CLI installed on the host; it does not replace your CLI automatically. Release checks run `npm run smoke:protocol --workspace @canonmsg/codex-plugin` against this version using a temporary, unauthenticated home without starting a model turn.
6
+
7
+ Managed app-server sessions withdraw Canon questions and approvals when Codex resolves them or their turn ends. Model choices include reported input modalities. Runtime details show observed provider authentication, usage-limit availability and tool reconnect needs without exposing account identifiers, tool credentials or raw provider errors.
8
+
5
9
  The plugin uses the local user's existing Codex authentication by default. That means Canon follows whatever plan or login mode the user has configured in Codex itself, instead of asking for a separate Canon-side OpenAI credential.
6
10
 
7
11
  ## Quick start
package/dist/adapter.d.ts CHANGED
@@ -38,11 +38,15 @@ export type CodexEvent = {
38
38
  effort: string | null;
39
39
  } | {
40
40
  type: 'skills.changed';
41
+ } | {
42
+ type: 'request.resolved';
41
43
  };
42
44
  export interface CodexServerRequest {
43
45
  id: string | number;
44
46
  method: string;
45
47
  params: Record<string, unknown>;
48
+ /** Aborted when the native request is resolved or its owning turn ends. */
49
+ signal?: AbortSignal;
46
50
  }
47
51
  export interface CodexRunTurnOptions {
48
52
  /** Original human text, before Canon adds conversation/reply context. */
@@ -18,6 +18,7 @@ export interface CodexModelMetadata {
18
18
  displayName: string;
19
19
  description?: string;
20
20
  supportedReasoningEfforts: CodexReasoningEffortMetadata[];
21
+ inputModalities?: string[];
21
22
  defaultReasoningEffort?: string;
22
23
  isDefault: boolean;
23
24
  }
@@ -32,6 +33,7 @@ export declare class CodexAppServerAdapter {
32
33
  private readonly fullAuto;
33
34
  private readonly bypassApprovalsAndSandbox;
34
35
  private readonly dynamicTools;
36
+ private readonly onCapabilitiesChanged?;
35
37
  private child;
36
38
  private threadId;
37
39
  private loadedThreadId;
@@ -41,6 +43,7 @@ export declare class CodexAppServerAdapter {
41
43
  private currentTurnId;
42
44
  private requestSeq;
43
45
  private pending;
46
+ private serverRequests;
44
47
  private currentOnEvent;
45
48
  private currentOnLog;
46
49
  private currentRequestHandler;
@@ -51,6 +54,7 @@ export declare class CodexAppServerAdapter {
51
54
  private interrupted;
52
55
  private initialized;
53
56
  private skillsCache;
57
+ private readonly runtimeStatus;
54
58
  private messageTextByItem;
55
59
  private planText;
56
60
  constructor(opts: {
@@ -65,6 +69,7 @@ export declare class CodexAppServerAdapter {
65
69
  fullAuto?: boolean;
66
70
  bypassApprovalsAndSandbox?: boolean;
67
71
  dynamicTools?: readonly JsonRecord[];
72
+ onCapabilitiesChanged?: () => void;
68
73
  });
69
74
  getThreadId(): string | null;
70
75
  getResolvedModel(): string | null;
@@ -72,6 +77,8 @@ export declare class CodexAppServerAdapter {
72
77
  model?: string;
73
78
  effort?: string;
74
79
  }>;
80
+ getRuntimeFacts(): import("@canonmsg/core").CanonRuntimeFact[];
81
+ refreshRuntimeFacts(): Promise<void>;
75
82
  getResolvedReasoningEffort(): string | null;
76
83
  clearThreadId(): void;
77
84
  setModel(model: string | null): void;
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { normalizePlanStepStatus, renderPlanSteps } from '@canonmsg/coding-agent-host';
4
+ import { CodexRuntimeStatus } from './runtime-status.js';
4
5
  const DEFAULT_DISCOVERY_REQUEST_TIMEOUT_MS = 5_000;
5
6
  export class CodexAppServerAdapter {
6
7
  cwd;
@@ -13,6 +14,7 @@ export class CodexAppServerAdapter {
13
14
  fullAuto;
14
15
  bypassApprovalsAndSandbox;
15
16
  dynamicTools;
17
+ onCapabilitiesChanged;
16
18
  child = null;
17
19
  threadId;
18
20
  loadedThreadId = null;
@@ -23,6 +25,7 @@ export class CodexAppServerAdapter {
23
25
  currentTurnId = null;
24
26
  requestSeq = 1;
25
27
  pending = new Map();
28
+ serverRequests = new Map();
26
29
  currentOnEvent = null;
27
30
  currentOnLog = null;
28
31
  currentRequestHandler = null;
@@ -33,6 +36,7 @@ export class CodexAppServerAdapter {
33
36
  interrupted = false;
34
37
  initialized = false;
35
38
  skillsCache = null;
39
+ runtimeStatus = new CodexRuntimeStatus();
36
40
  messageTextByItem = new Map();
37
41
  planText = '';
38
42
  constructor(opts) {
@@ -48,6 +52,7 @@ export class CodexAppServerAdapter {
48
52
  this.fullAuto = opts.fullAuto ?? false;
49
53
  this.bypassApprovalsAndSandbox = opts.bypassApprovalsAndSandbox ?? false;
50
54
  this.dynamicTools = opts.dynamicTools ?? [];
55
+ this.onCapabilitiesChanged = opts.onCapabilitiesChanged;
51
56
  }
52
57
  getThreadId() {
53
58
  return this.threadId;
@@ -58,10 +63,27 @@ export class CodexAppServerAdapter {
58
63
  getObservedSettings() {
59
64
  return { ...this.observedSettings };
60
65
  }
66
+ getRuntimeFacts() {
67
+ return this.runtimeStatus.facts();
68
+ }
69
+ async refreshRuntimeFacts() {
70
+ if (!this.child || !this.initialized || !this.runtimeStatus.hasQuota())
71
+ return;
72
+ const revision = this.runtimeStatus.revision;
73
+ try {
74
+ const snapshot = await this.sendRequest('account/rateLimits/read', {}, 2_000);
75
+ if (revision === this.runtimeStatus.revision && isRecord(snapshot))
76
+ this.runtimeStatus.replaceQuota(snapshot);
77
+ }
78
+ catch {
79
+ // Unavailable snapshots do not mean zero usage. Retain reported facts.
80
+ }
81
+ }
61
82
  getResolvedReasoningEffort() {
62
83
  return this.resolvedReasoningEffort ?? this.reasoningEffort;
63
84
  }
64
85
  clearThreadId() {
86
+ this.runtimeStatus.reset();
65
87
  this.observedSettings = {};
66
88
  this.threadId = null;
67
89
  this.loadedThreadId = null;
@@ -119,6 +141,7 @@ export class CodexAppServerAdapter {
119
141
  }
120
142
  close() {
121
143
  this.observedSettings = {};
144
+ this.runtimeStatus.reset();
122
145
  this.child?.kill('SIGTERM');
123
146
  this.child = null;
124
147
  this.initialized = false;
@@ -352,6 +375,7 @@ export class CodexAppServerAdapter {
352
375
  this.observedSettings = {};
353
376
  const message = `Codex app-server exited${code === null ? '' : ` with code ${code}`}`;
354
377
  this.initialized = false;
378
+ this.runtimeStatus.reset();
355
379
  this.child = null;
356
380
  for (const pending of this.pending.values()) {
357
381
  pending.reject(new Error(message));
@@ -369,6 +393,7 @@ export class CodexAppServerAdapter {
369
393
  clientInfo: { name: 'canon-codex', version: '0.0.0' },
370
394
  capabilities: { experimentalApi: true, supportsServerRequests: true },
371
395
  });
396
+ this.write({ method: 'initialized' });
372
397
  this.initialized = true;
373
398
  }
374
399
  handleLine(line) {
@@ -398,6 +423,7 @@ export class CodexAppServerAdapter {
398
423
  }
399
424
  }
400
425
  async handleServerRequest(request) {
426
+ const controller = new AbortController();
401
427
  try {
402
428
  const params = isRecord(request.params) ? request.params : {};
403
429
  // Detached child threads may use the ordinary coding-thread bridge, but
@@ -414,16 +440,24 @@ export class CodexAppServerAdapter {
414
440
  });
415
441
  return;
416
442
  }
443
+ this.serverRequests.set(request.id, {
444
+ controller,
445
+ threadId: readString(params, 'threadId') ?? this.threadId,
446
+ });
417
447
  const result = this.currentRequestHandler
418
448
  ? await this.currentRequestHandler({
419
449
  id: request.id,
420
450
  method: request.method,
421
451
  params,
452
+ signal: controller.signal,
422
453
  })
423
454
  : defaultServerRequestResult(request.method);
424
- this.write({ id: request.id, result });
455
+ if (!controller.signal.aborted)
456
+ this.write({ id: request.id, result });
425
457
  }
426
458
  catch (error) {
459
+ if (controller.signal.aborted)
460
+ return;
427
461
  this.write({
428
462
  id: request.id,
429
463
  error: {
@@ -432,11 +466,42 @@ export class CodexAppServerAdapter {
432
466
  },
433
467
  });
434
468
  }
469
+ finally {
470
+ if (this.serverRequests.get(request.id)?.controller === controller) {
471
+ this.serverRequests.delete(request.id);
472
+ }
473
+ }
435
474
  }
436
475
  handleNotification(method, params) {
476
+ if (method === 'account/updated') {
477
+ this.observedSettings = {};
478
+ this.skillsCache = null;
479
+ this.onCapabilitiesChanged?.();
480
+ }
481
+ // Account events are process-wide. Tool readiness can be scoped to a thread.
482
+ if (this.isCurrentThreadNotification(params) && this.runtimeStatus.observe(method, params))
483
+ return;
484
+ if (method === 'serverRequest/resolved') {
485
+ const id = params.requestId;
486
+ if (typeof id !== 'number' && typeof id !== 'string')
487
+ return;
488
+ const request = this.serverRequests.get(id);
489
+ const threadId = readString(params, 'threadId');
490
+ if (request && (!threadId || threadId === request.threadId)) {
491
+ this.serverRequests.delete(id);
492
+ request.controller.abort(new Error('Codex resolved the native request'));
493
+ if (this.serverRequests.size === 0 && this.isRunning() && this.isCurrentThreadNotification(params)) {
494
+ this.currentOnEvent?.({ type: 'request.resolved' });
495
+ }
496
+ }
497
+ return;
498
+ }
437
499
  if (method === 'skills/changed') {
438
500
  this.skillsCache = null;
439
- this.currentOnEvent?.({ type: 'skills.changed' });
501
+ if (this.onCapabilitiesChanged)
502
+ this.onCapabilitiesChanged();
503
+ else
504
+ this.currentOnEvent?.({ type: 'skills.changed' });
440
505
  return;
441
506
  }
442
507
  if (!this.isCurrentThreadNotification(params))
@@ -596,6 +661,10 @@ export class CodexAppServerAdapter {
596
661
  this.clearActiveTurn();
597
662
  }
598
663
  clearActiveTurn() {
664
+ for (const { controller } of this.serverRequests.values()) {
665
+ controller.abort(new Error('Codex turn ended'));
666
+ }
667
+ this.serverRequests.clear();
599
668
  this.currentTurnId = null;
600
669
  this.currentOnEvent = null;
601
670
  this.currentOnLog = null;
@@ -768,9 +837,13 @@ function parseModelListResponse(result) {
768
837
  })
769
838
  : [];
770
839
  const defaultReasoningEffort = readString(entry, 'defaultReasoningEffort');
840
+ const inputModalities = Array.isArray(entry.inputModalities)
841
+ ? [...new Set(entry.inputModalities.filter((value) => typeof value === 'string' && /^[a-z][a-z_-]{0,31}$/.test(value)))]
842
+ : [];
771
843
  models.push({
772
844
  id,
773
845
  model,
846
+ ...(inputModalities.length ? { inputModalities } : {}),
774
847
  displayName,
775
848
  ...(description ? { description } : {}),
776
849
  supportedReasoningEfforts: efforts,
@@ -839,9 +912,11 @@ function renderPlan(params) {
839
912
  })), { explanation });
840
913
  }
841
914
  function defaultServerRequestResult(method) {
915
+ if (method === 'item/permissions/requestApproval')
916
+ return { permissions: {}, scope: 'turn' };
842
917
  if (method.includes('requestApproval'))
843
918
  return { decision: 'decline' };
844
919
  if (method === 'item/tool/requestUserInput')
845
920
  return { answers: {} };
846
- return {};
921
+ throw new Error(`Unsupported Codex server request: ${method}`);
847
922
  }
package/dist/host.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { type TurnArtifactRoutingDecision, type TurnArtifactRoutingMode, type RecoveryCheckpointTracker } from '@canonmsg/coding-agent-host';
3
3
  import { type CanonRuntimeCommandDescriptor, type CanonRuntimeDescriptor, type CanonRuntimeFact, type CanonRuntimePresentationPolicy, type ExecutionEnvironmentMode, type PreparedExecutionEnvironment, type WorkspaceOption, type CanonWorkspaceRootMetadata, type DeliveryIntent, type RuntimeStreamingPayload, type TurnLifecycleState, type TurnOutputBlock, type TurnVerbosity, type TurnVerbosityConfig } from '@canonmsg/core';
4
+ import type { EndpointInboundHandoff } from '@canonmsg/core';
4
5
  import type { AgentReplyAuthorityV1 } from '@canonmsg/backend-contracts';
5
6
  import { CodexConversationAdapter, type CodexSandboxMode } from './adapter.js';
6
7
  import { CodexAppServerAdapter, type CodexSkillMetadata } from './app-server-adapter.js';
@@ -36,6 +37,7 @@ interface Session {
36
37
  planMode?: boolean;
37
38
  intent: DeliveryIntent;
38
39
  sourceMessageId?: string | null;
40
+ inboundHandoff?: EndpointInboundHandoff;
39
41
  markAccepted?: boolean;
40
42
  imagePaths?: string[];
41
43
  mediaAddDirs?: string[];
@@ -62,6 +64,7 @@ interface Session {
62
64
  currentReplyAuthority: AgentReplyAuthorityV1 | null;
63
65
  /** Cancels Canon interactions created by the currently running Codex turn. */
64
66
  currentTurnAbortController: AbortController | null;
67
+ currentInboundHandoff?: EndpointInboundHandoff;
65
68
  /**
66
69
  * The verbosity the RUNNING turn was opened with. Promoted off the queue
67
70
  * entry at `runNextTurn` and never re-read mid-turn, so the live writer, the
package/dist/host.js CHANGED
@@ -84,6 +84,7 @@ export function buildCodexSessionFacts(session) {
84
84
  id: 'runtime', label: 'Runtime', value: appServer ? 'Codex app-server' : 'Codex CLI', group: 'runtime',
85
85
  }];
86
86
  if (appServer) {
87
+ facts.push(...adapter.getRuntimeFacts());
87
88
  const observed = adapter.getObservedSettings();
88
89
  if (observed.model)
89
90
  facts.push({ id: 'model', label: 'Model', value: observed.model, group: 'model' });
@@ -105,6 +106,9 @@ const CODEX_UNDELIVERABLE_FINAL_WORDING = {
105
106
  lead: 'The Codex host completed the turn, but Canon could not deliver the reply',
106
107
  };
107
108
  const IDLE_CHECK_MS = 60_000;
109
+ /** Explicit user controls cancel work; lifecycle shutdown must preserve it. */
110
+ class CodexTurnCanceledError extends Error {
111
+ }
108
112
  const PLAN_REVIEW_TIMEOUT_MS = 10 * 60_000;
109
113
  const CODEX_RUNTIME_CAPABILITIES = {
110
114
  ...DEFAULT_RUNTIME_CAPABILITIES,
@@ -1067,7 +1071,8 @@ export async function main() {
1067
1071
  }
1068
1072
  }
1069
1073
  async function markQueuedPromptsRejected(conversationId, prompts) {
1070
- await Promise.all(prompts.map((prompt) => {
1074
+ await Promise.all(prompts.map(async (prompt) => {
1075
+ await prompt.inboundHandoff?.cancel('canceled-before-execution');
1071
1076
  if (!prompt.markAccepted || !prompt.sourceMessageId)
1072
1077
  return Promise.resolve();
1073
1078
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
@@ -1075,10 +1080,12 @@ export async function main() {
1075
1080
  }
1076
1081
  function settleRejectedPromptCheckpoints(conversationId, prompts) {
1077
1082
  const checkpoints = recoveryCheckpointsFor(conversationId);
1078
- for (const prompt of prompts)
1079
- checkpoints.settle(prompt.sourceMessageId);
1083
+ for (const prompt of prompts) {
1084
+ if (!prompt.inboundHandoff || prompt.inboundHandoff.phase === 'settled')
1085
+ checkpoints.settle(prompt.sourceMessageId);
1086
+ }
1080
1087
  }
1081
- function removeQueuedPrompt(conversationId, sourceMessageId) {
1088
+ async function removeQueuedPrompt(conversationId, sourceMessageId) {
1082
1089
  const session = sessions.get(conversationId);
1083
1090
  if (!session || session.queue.length === 0)
1084
1091
  return;
@@ -1086,6 +1093,7 @@ export async function main() {
1086
1093
  if (removed.length === 0)
1087
1094
  return;
1088
1095
  session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
1096
+ await Promise.all(removed.map((prompt) => prompt.inboundHandoff?.cancel('message-deleted')));
1089
1097
  settleRejectedPromptCheckpoints(conversationId, removed);
1090
1098
  writeTurn(session);
1091
1099
  }
@@ -1260,6 +1268,11 @@ export async function main() {
1260
1268
  if (!session)
1261
1269
  return;
1262
1270
  session.closed = true;
1271
+ // Closing a native session relinquishes only unsubmitted Canon work.
1272
+ for (const prompt of session.queue) {
1273
+ void prompt.inboundHandoff?.release('session-closed').catch(console.error);
1274
+ }
1275
+ void session.currentInboundHandoff?.release('session-closed').catch(console.error);
1263
1276
  session.currentTurnAbortController?.abort(new Error('Codex session closed'));
1264
1277
  session.currentTurnAbortController = null;
1265
1278
  stopVisibleWorkSignal(session);
@@ -1277,6 +1290,7 @@ export async function main() {
1277
1290
  async function resetRuntimeSession(session) {
1278
1291
  const conversationId = session.conversationId;
1279
1292
  session.resetRequested = true;
1293
+ void session.currentInboundHandoff?.cancel('canceled-before-execution').catch(console.error);
1280
1294
  const droppedPrompts = session.queue.splice(0);
1281
1295
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
1282
1296
  settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
@@ -1285,7 +1299,7 @@ export async function main() {
1285
1299
  session.activeSelfContextId = null;
1286
1300
  session.state.lastError = undefined;
1287
1301
  if (session.running) {
1288
- session.currentTurnAbortController?.abort(new Error('Codex session reset'));
1302
+ session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex session reset'));
1289
1303
  await session.adapter.interrupt();
1290
1304
  session.turnState = 'interrupted';
1291
1305
  }
@@ -1416,6 +1430,10 @@ export async function main() {
1416
1430
  fullAuto: policy.fullAuto,
1417
1431
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1418
1432
  dynamicTools: codexDynamicTools,
1433
+ onCapabilitiesChanged: () => {
1434
+ invalidateCodexCatalog();
1435
+ runtimeHeartbeat.refresh();
1436
+ },
1419
1437
  })
1420
1438
  : new CodexConversationAdapter({
1421
1439
  cwd: sessionCwd,
@@ -1498,6 +1516,7 @@ export async function main() {
1498
1516
  const nextPrompt = {
1499
1517
  prompt,
1500
1518
  skillInvocationText: turn.skillInvocationText,
1519
+ inboundHandoff: turn.inboundHandoff,
1501
1520
  intent,
1502
1521
  sourceMessageId,
1503
1522
  markAccepted,
@@ -1571,6 +1590,12 @@ export async function main() {
1571
1590
  return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
1572
1591
  }
1573
1592
  async function handleCodexServerRequest(session, request, requestingUserId, sourceMessageId) {
1593
+ const requestSignal = AbortSignal.any([
1594
+ ...(request.signal ? [request.signal] : []),
1595
+ ...(session.currentTurnAbortController ? [session.currentTurnAbortController.signal] : []),
1596
+ ]);
1597
+ requestSignal.throwIfAborted();
1598
+ const requestTurnId = session.currentTurnId;
1574
1599
  const requestId = String(request.id);
1575
1600
  const params = request.params;
1576
1601
  const expiresAt = Date.now() + 30 * 60_000;
@@ -1631,12 +1656,14 @@ export async function main() {
1631
1656
  ...(responseRouting.responseUserId
1632
1657
  ? { responseUserId: responseRouting.responseUserId }
1633
1658
  : {}),
1634
- turnId: session.currentTurnId ?? undefined,
1659
+ turnId: requestTurnId ?? undefined,
1635
1660
  }, {
1636
1661
  requestId: inputId,
1637
1662
  expiresAt,
1638
- signal: session.currentTurnAbortController?.signal,
1663
+ signal: requestSignal,
1639
1664
  onCreated: () => {
1665
+ if (requestSignal.aborted)
1666
+ return;
1640
1667
  session.turnState = 'waiting_input';
1641
1668
  markTurnProgress(session);
1642
1669
  stopVisibleWorkSignal(session);
@@ -1644,6 +1671,7 @@ export async function main() {
1644
1671
  writeCodexStreaming(session, null, 'waiting_input');
1645
1672
  },
1646
1673
  });
1674
+ requestSignal.throwIfAborted();
1647
1675
  resumeTurnFromWaiting(session);
1648
1676
  return successfulCodexAppToolResult(response);
1649
1677
  }
@@ -1663,7 +1691,7 @@ export async function main() {
1663
1691
  card: { ...card, cardId },
1664
1692
  cardId,
1665
1693
  expiresAt,
1666
- turnId: session.currentTurnId ?? undefined,
1694
+ turnId: requestTurnId ?? undefined,
1667
1695
  });
1668
1696
  return successfulCodexAppToolResult({
1669
1697
  status: 'sent',
@@ -1678,15 +1706,17 @@ export async function main() {
1678
1706
  const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1679
1707
  const response = await runtimeRequests.request('card', session.conversationId, {
1680
1708
  card,
1681
- turnId: session.currentTurnId ?? undefined,
1709
+ turnId: requestTurnId ?? undefined,
1682
1710
  ...(responseRouting.responseUserId
1683
1711
  ? { responseUserId: responseRouting.responseUserId }
1684
1712
  : {}),
1685
1713
  }, {
1686
1714
  requestId: cardId,
1687
1715
  expiresAt,
1688
- signal: session.currentTurnAbortController?.signal,
1716
+ signal: requestSignal,
1689
1717
  onCreated: () => {
1718
+ if (requestSignal.aborted)
1719
+ return;
1690
1720
  session.turnState = 'waiting_input';
1691
1721
  markTurnProgress(session);
1692
1722
  upsertTurnBlock(session, {
@@ -1701,6 +1731,7 @@ export async function main() {
1701
1731
  writeCodexStreaming(session, null, 'waiting_input');
1702
1732
  },
1703
1733
  });
1734
+ requestSignal.throwIfAborted();
1704
1735
  completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1705
1736
  resumeTurnFromWaiting(session);
1706
1737
  return successfulCodexAppToolResult(response);
@@ -1748,21 +1779,23 @@ export async function main() {
1748
1779
  runtime: 'codex',
1749
1780
  method: request.method,
1750
1781
  requestId,
1751
- turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
1782
+ turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
1752
1783
  handles: {
1753
1784
  itemId: readString(params, 'itemId') ?? '',
1754
1785
  threadId: readString(params, 'threadId') ?? '',
1755
1786
  },
1756
1787
  },
1757
- turnId: session.currentTurnId ?? undefined,
1788
+ turnId: requestTurnId ?? undefined,
1758
1789
  ...(responseRouting.responseUserId
1759
1790
  ? { responseUserId: responseRouting.responseUserId }
1760
1791
  : {}),
1761
1792
  }, {
1762
1793
  requestId: cardId,
1763
1794
  expiresAt,
1764
- signal: session.currentTurnAbortController?.signal,
1795
+ signal: requestSignal,
1765
1796
  onCreated: () => {
1797
+ if (requestSignal.aborted)
1798
+ return;
1766
1799
  requestCreated = true;
1767
1800
  session.turnState = 'waiting_input';
1768
1801
  markTurnProgress(session);
@@ -1778,6 +1811,7 @@ export async function main() {
1778
1811
  writeCodexStreaming(session, null, 'waiting_input');
1779
1812
  },
1780
1813
  });
1814
+ requestSignal.throwIfAborted();
1781
1815
  const response = cardResult.status === 'submitted'
1782
1816
  ? {
1783
1817
  status: 'submitted',
@@ -1797,11 +1831,12 @@ export async function main() {
1797
1831
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1798
1832
  metadata: {
1799
1833
  ...outcome.metadata,
1800
- turnId: session.currentTurnId ?? undefined,
1834
+ turnId: requestTurnId ?? undefined,
1801
1835
  turnSemantics: 'control',
1802
1836
  replyBehavior: 'suppress_auto_reply',
1803
1837
  },
1804
1838
  });
1839
+ requestSignal.throwIfAborted();
1805
1840
  completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1806
1841
  resumeTurnFromWaiting(session);
1807
1842
  return response;
@@ -1818,7 +1853,7 @@ export async function main() {
1818
1853
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1819
1854
  metadata: {
1820
1855
  ...outcome.metadata,
1821
- turnId: session.currentTurnId ?? undefined,
1856
+ turnId: requestTurnId ?? undefined,
1822
1857
  turnSemantics: 'control',
1823
1858
  replyBehavior: 'suppress_auto_reply',
1824
1859
  },
@@ -1853,18 +1888,19 @@ export async function main() {
1853
1888
  runtime: 'codex',
1854
1889
  method: request.method,
1855
1890
  requestId,
1856
- turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
1891
+ turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
1857
1892
  handles: {
1858
1893
  itemId: readString(params, 'itemId') ?? '',
1859
1894
  threadId: readString(params, 'threadId') ?? '',
1860
1895
  },
1861
1896
  },
1862
- turnId: session.currentTurnId ?? undefined,
1897
+ turnId: requestTurnId ?? undefined,
1863
1898
  }, {
1864
1899
  requestId: inputId,
1865
1900
  expiresAt,
1866
- signal: session.currentTurnAbortController?.signal,
1901
+ signal: requestSignal,
1867
1902
  });
1903
+ requestSignal.throwIfAborted();
1868
1904
  resumeTurnFromWaiting(session);
1869
1905
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1870
1906
  }
@@ -1878,7 +1914,7 @@ export async function main() {
1878
1914
  const response = await runtimeRequests.request('approval', session.conversationId, {
1879
1915
  ...mappedApproval,
1880
1916
  native: { ...mappedApproval.native, requestId, method: request.method },
1881
- turnId: session.currentTurnId ?? undefined,
1917
+ turnId: requestTurnId ?? undefined,
1882
1918
  ...(responseRouting.responseUserId
1883
1919
  ? { responseUserId: responseRouting.responseUserId }
1884
1920
  : {}),
@@ -1886,8 +1922,9 @@ export async function main() {
1886
1922
  }, {
1887
1923
  requestId: approvalId,
1888
1924
  expiresAt,
1889
- signal: session.currentTurnAbortController?.signal,
1925
+ signal: requestSignal,
1890
1926
  });
1927
+ requestSignal.throwIfAborted();
1891
1928
  resumeTurnFromWaiting(session);
1892
1929
  if (request.method === 'item/permissions/requestApproval') {
1893
1930
  return response.decision === 'allow'
@@ -1899,14 +1936,15 @@ export async function main() {
1899
1936
  ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
1900
1937
  });
1901
1938
  }
1902
- return {};
1939
+ throw new Error(`Unsupported Codex server request: ${request.method}`);
1903
1940
  }
1904
1941
  async function enqueueInboundMessage(input) {
1905
1942
  knownConversationIds.add(input.conversationId);
1906
1943
  if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1907
1944
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1908
- recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1909
- await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1945
+ if (await input.inboundHandoff.settle('not-dispatched')) {
1946
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1947
+ }
1910
1948
  return;
1911
1949
  }
1912
1950
  if (input.message.metadata?.type === 'plan_approval_reply') {
@@ -1914,8 +1952,9 @@ export async function main() {
1914
1952
  // A service agent never advertises or enters plan mode. Consume stale
1915
1953
  // replies left by an older coding descriptor instead of turning them
1916
1954
  // into hidden plan/implementation prompts after an upgrade or restart.
1917
- recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1918
- await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1955
+ if (await input.inboundHandoff.settle('not-dispatched')) {
1956
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1957
+ }
1919
1958
  return;
1920
1959
  }
1921
1960
  const planId = readString(input.message.metadata, 'planId');
@@ -1923,8 +1962,9 @@ export async function main() {
1923
1962
  senderId: input.message.senderId,
1924
1963
  metadata: input.message.metadata,
1925
1964
  })) {
1926
- recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1927
- await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
1965
+ if (await input.inboundHandoff.settle('not-dispatched')) {
1966
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
1967
+ }
1928
1968
  return;
1929
1969
  }
1930
1970
  const session = await getOrCreateSession(input.conversationId);
@@ -1938,6 +1978,7 @@ export async function main() {
1938
1978
  enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', {
1939
1979
  canUseCodexAppTools: input.isOwner || serviceAgentMode,
1940
1980
  requestingUserId: getCodexRequestingUserId(input.message),
1981
+ inboundHandoff: input.inboundHandoff,
1941
1982
  });
1942
1983
  return;
1943
1984
  }
@@ -1998,8 +2039,9 @@ export async function main() {
1998
2039
  : decideAutoReply(participantContext, behavior);
1999
2040
  if (!autoReply.allow) {
2000
2041
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2001
- recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
2002
- await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
2042
+ if (await input.inboundHandoff.settle('not-dispatched')) {
2043
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
2044
+ }
2003
2045
  return;
2004
2046
  }
2005
2047
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
@@ -2034,8 +2076,9 @@ export async function main() {
2034
2076
  },
2035
2077
  ...(input.replyAuthority ? { replyAuthority: input.replyAuthority } : {}),
2036
2078
  }).catch(() => { });
2037
- recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
2038
- await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
2079
+ if (await input.inboundHandoff.settle('not-dispatched')) {
2080
+ recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
2081
+ }
2039
2082
  return;
2040
2083
  }
2041
2084
  session.activeSelfContextId = activeSelfContextId;
@@ -2054,11 +2097,13 @@ export async function main() {
2054
2097
  if (session.running && deliveryIntent === 'interrupt') {
2055
2098
  enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, {
2056
2099
  ...resolveCodexTurnModes(participantContext, input.message),
2100
+ inboundHandoff: input.inboundHandoff,
2057
2101
  skillInvocationText: input.message.senderType === 'human' ? input.message.text ?? undefined : undefined,
2058
2102
  replyAuthority: input.replyAuthority ?? null,
2059
2103
  });
2060
2104
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
2061
- session.currentTurnAbortController?.abort(new Error('Codex turn interrupted by a newer message'));
2105
+ void session.currentInboundHandoff?.cancel('canceled-before-execution').catch(console.error);
2106
+ session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex turn interrupted by a newer message'));
2062
2107
  await session.adapter.interrupt().catch(() => { });
2063
2108
  clearStreaming(input.conversationId);
2064
2109
  typingSignals.clear(input.conversationId).catch(() => { });
@@ -2066,6 +2111,7 @@ export async function main() {
2066
2111
  }
2067
2112
  enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, {
2068
2113
  ...resolveCodexTurnModes(participantContext, input.message),
2114
+ inboundHandoff: input.inboundHandoff,
2069
2115
  skillInvocationText: input.message.senderType === 'human' ? input.message.text ?? undefined : undefined,
2070
2116
  replyAuthority: input.replyAuthority ?? null,
2071
2117
  });
@@ -2115,7 +2161,9 @@ export async function main() {
2115
2161
  return;
2116
2162
  }
2117
2163
  const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
2118
- let journaledInput = false;
2164
+ session.currentInboundHandoff = nextTurn.inboundHandoff;
2165
+ let inputDeferred = false;
2166
+ let preserveUnclaimedInput = false;
2119
2167
  let completedOutput;
2120
2168
  let nativeCompleted = false;
2121
2169
  session.state.lastError = undefined;
@@ -2217,6 +2265,10 @@ export async function main() {
2217
2265
  void refreshCodexSkillInventory(true).then(() => runtimeHeartbeat.refresh());
2218
2266
  return;
2219
2267
  }
2268
+ if (event.type === 'request.resolved') {
2269
+ resumeTurnFromWaiting(session);
2270
+ return;
2271
+ }
2220
2272
  if (event.type === 'settings.updated') {
2221
2273
  if (event.model)
2222
2274
  session.state.model = event.model;
@@ -2338,10 +2390,33 @@ export async function main() {
2338
2390
  // Canon-origin queues must retain a durable execution owner, including
2339
2391
  // legacy queues restored before endpoint migration. Native continuations
2340
2392
  // without a Canon source message remain a separate operator path.
2341
- if (inboundId && !await endpoint.claimInbound(inboundId))
2393
+ const handoff = nextTurn.inboundHandoff ?? (inboundId ? await endpoint.acquireInbound(inboundId) : undefined);
2394
+ session.currentInboundHandoff = handoff ?? undefined;
2395
+ if (inboundId && !handoff) {
2396
+ preserveUnclaimedInput = true;
2342
2397
  return;
2343
- journaledInput = !!inboundId;
2344
- let result = await runTurnOnce();
2398
+ }
2399
+ if (handoff && (session.resetRequested || session.currentTurnAbortController?.signal.reason instanceof CodexTurnCanceledError)) {
2400
+ await handoff.cancel('canceled-before-execution');
2401
+ }
2402
+ else if (handoff && (session.closed || session.currentTurnAbortController?.signal.aborted)) {
2403
+ await handoff.release('session-closed');
2404
+ }
2405
+ const started = handoff
2406
+ ? await endpoint.startInbound(handoff, runTurnOnce)
2407
+ : { status: 'submitted', value: await runTurnOnce() };
2408
+ if (started.status !== 'submitted') {
2409
+ if (started.status === 'deferred' && !session.closed && !session.resetRequested
2410
+ && !session.currentTurnAbortController?.signal.aborted) {
2411
+ nextTurn.inboundHandoff = handoff ?? undefined;
2412
+ session.queue.unshift(nextTurn);
2413
+ inputDeferred = true;
2414
+ }
2415
+ preserveUnclaimedInput = inputDeferred || handoff?.phase === 'released';
2416
+ clearStreaming(session.conversationId);
2417
+ return;
2418
+ }
2419
+ let result = started.value;
2345
2420
  if (!result.interrupted
2346
2421
  && !result.finalMessage
2347
2422
  && result.exitCode
@@ -2385,7 +2460,7 @@ export async function main() {
2385
2460
  : { kind: 'none', reason: result.interrupted ? 'interrupted' : session.currentTurnSilenced ? 'silent' : 'empty' };
2386
2461
  const record = {
2387
2462
  version: 1, turnId: session.currentTurnId, conversationId: session.conversationId,
2388
- sourceMessageId: journaledInput ? nextTurn.sourceMessageId : null,
2463
+ sourceMessageId: inboundId ? nextTurn.sourceMessageId : null,
2389
2464
  nativeThreadId: result.threadId, createdAt: new Date().toISOString(), output,
2390
2465
  audience: conversationCache.has(session.conversationId) ? {
2391
2466
  memberIds: [...conversationCache.get(session.conversationId).memberIds].sort(),
@@ -2509,6 +2584,14 @@ export async function main() {
2509
2584
  await deliverCompletedOutput(completedOutput);
2510
2585
  }
2511
2586
  catch (error) {
2587
+ if (!nativeCompleted && inboundId && session.currentInboundHandoff?.phase !== 'submitted') {
2588
+ // A store/preflight failure proves no native callback ran. The shared
2589
+ // inbox can reoffer it; a generic failure reply is not completion proof.
2590
+ preserveUnclaimedInput = session.currentInboundHandoff?.phase !== 'settled';
2591
+ clearStreaming(session.conversationId);
2592
+ console.error('[canon-codex] Native input awaits handoff recovery:', error);
2593
+ return;
2594
+ }
2512
2595
  if (nativeCompleted) {
2513
2596
  session.state.lastError = completedOutput
2514
2597
  ? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
@@ -2557,9 +2640,28 @@ export async function main() {
2557
2640
  finally {
2558
2641
  if (completedOutput)
2559
2642
  activeCompletedOutputs.delete(completedOutput.turnId);
2643
+ const handoff = session.currentInboundHandoff;
2644
+ if (!inputDeferred && handoff && handoff.phase !== 'submitted' && handoff.phase !== 'settled') {
2645
+ preserveUnclaimedInput = true;
2646
+ try {
2647
+ await handoff.release('native-preflight');
2648
+ }
2649
+ catch (error) {
2650
+ // Keep the existing queue/heartbeat as the local retry owner if the
2651
+ // durable release itself fails. Never silently discard the handle.
2652
+ if (!session.closed && !session.currentTurnAbortController?.signal.aborted) {
2653
+ nextTurn.inboundHandoff = handoff;
2654
+ session.queue.unshift(nextTurn);
2655
+ inputDeferred = true;
2656
+ }
2657
+ console.error('[canon-codex] Failed to release unsubmitted input:', error);
2658
+ }
2659
+ }
2560
2660
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2561
2661
  session.currentTurnAbortController = null;
2562
- recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2662
+ session.currentInboundHandoff = undefined;
2663
+ if (!preserveUnclaimedInput)
2664
+ recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2563
2665
  stopVisibleWorkSignal(session);
2564
2666
  session.running = false;
2565
2667
  session.state.state = 'idle';
@@ -2575,35 +2677,11 @@ export async function main() {
2575
2677
  session.lastActivity = Date.now();
2576
2678
  writeState(session);
2577
2679
  writeTurn(session);
2578
- if (session.queue.length > 0) {
2680
+ if (!inputDeferred && session.queue.length > 0) {
2579
2681
  void runNextTurn(session);
2580
2682
  }
2581
2683
  }
2582
2684
  }
2583
- const acceptedInboundMessageIds = new Set();
2584
- const inFlightInboundMessageIds = new Set();
2585
- function claimInboundMessageId(messageId) {
2586
- if (!messageId)
2587
- return true;
2588
- if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
2589
- return false;
2590
- inFlightInboundMessageIds.add(messageId);
2591
- return true;
2592
- }
2593
- function settleInboundMessageId(messageId, accepted) {
2594
- if (!messageId)
2595
- return;
2596
- inFlightInboundMessageIds.delete(messageId);
2597
- if (!accepted)
2598
- return;
2599
- acceptedInboundMessageIds.add(messageId);
2600
- while (acceptedInboundMessageIds.size > 2_048) {
2601
- const oldest = acceptedInboundMessageIds.values().next().value;
2602
- if (!oldest)
2603
- break;
2604
- acceptedInboundMessageIds.delete(oldest);
2605
- }
2606
- }
2607
2685
  const hostAvailableExecutionModes = serviceAgentMode
2608
2686
  ? ['locked']
2609
2687
  : [...EXECUTION_ENVIRONMENT_MODES];
@@ -2658,43 +2736,63 @@ export async function main() {
2658
2736
  }),
2659
2737
  });
2660
2738
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
2739
+ let codexCatalogRevision = 0;
2740
+ let refreshedCodexCatalogRevision = -1;
2741
+ function invalidateCodexCatalog() {
2742
+ codexCatalogRevision += 1;
2743
+ codexModels = [];
2744
+ codexSkills = [];
2745
+ codexModelOptions = buildCodexModelOptions([], args.model);
2746
+ codexEffortOptions = [];
2747
+ codexDefaultModel = resolveCodexDefaultModel([], args.model);
2748
+ codexDefaultEffort = null;
2749
+ runtimeDescriptor = buildCurrentRuntimeDescriptor();
2750
+ }
2661
2751
  async function refreshCodexSkillInventory(forceReload = false) {
2662
2752
  if (!useAppServer)
2663
- return;
2753
+ return true;
2754
+ const revision = codexCatalogRevision;
2664
2755
  const probe = new CodexAppServerAdapter({
2665
2756
  cwd: workingDir,
2666
2757
  codexBin,
2667
2758
  model: typeof args.model === 'string' ? args.model : null,
2668
2759
  configOverrides: args.config ?? [],
2669
2760
  });
2761
+ let succeeded = true;
2762
+ let models = [];
2763
+ let skills = [];
2670
2764
  try {
2671
- const discoveredModels = await probe.listModels();
2672
- if (discoveredModels.length > 0) {
2673
- codexModels = discoveredModels;
2674
- codexModelOptions = buildCodexModelOptions(codexModels, args.model);
2675
- codexEffortOptions = buildCodexEffortOptions(codexModels);
2676
- codexDefaultModel = resolveCodexDefaultModel(codexModels, args.model);
2677
- codexDefaultEffort = resolveCodexEffortForModel({
2678
- models: codexModels,
2679
- model: codexDefaultModel,
2680
- requestedEffort: configuredCodexEffort,
2681
- }).value;
2682
- }
2765
+ models = await probe.listModels();
2683
2766
  }
2684
2767
  catch (error) {
2768
+ succeeded = false;
2685
2769
  console.error('[canon-codex] Failed to load Codex models:', error instanceof Error ? error.message : error);
2686
2770
  }
2687
2771
  try {
2688
- codexSkills = await probe.listSkills({ forceReload });
2772
+ skills = await probe.listSkills({ forceReload });
2689
2773
  }
2690
2774
  catch (error) {
2691
- codexSkills = [];
2775
+ succeeded = false;
2692
2776
  console.error('[canon-codex] Failed to load Codex skills:', error instanceof Error ? error.message : error);
2693
2777
  }
2694
2778
  finally {
2695
- runtimeDescriptor = buildCurrentRuntimeDescriptor();
2696
2779
  probe.close();
2697
2780
  }
2781
+ // An account change during discovery invalidates that probe's observations.
2782
+ if (revision !== codexCatalogRevision)
2783
+ return false;
2784
+ codexModels = models;
2785
+ codexSkills = skills;
2786
+ codexModelOptions = buildCodexModelOptions(models, args.model);
2787
+ codexEffortOptions = models.length ? buildCodexEffortOptions(models) : [];
2788
+ codexDefaultModel = resolveCodexDefaultModel(models, args.model);
2789
+ codexDefaultEffort = models.length ? resolveCodexEffortForModel({
2790
+ models, model: codexDefaultModel, requestedEffort: configuredCodexEffort,
2791
+ }).value : null;
2792
+ runtimeDescriptor = buildCurrentRuntimeDescriptor();
2793
+ if (succeeded)
2794
+ refreshedCodexCatalogRevision = revision;
2795
+ return succeeded;
2698
2796
  }
2699
2797
  async function handleControlSignal(event) {
2700
2798
  const { conversationId, type } = event;
@@ -2713,13 +2811,16 @@ export async function main() {
2713
2811
  return;
2714
2812
  }
2715
2813
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
2814
+ if (session.running) {
2815
+ void session.currentInboundHandoff?.cancel('canceled-before-execution').catch(console.error);
2816
+ session.currentTurnAbortController?.abort(new CodexTurnCanceledError(`Codex turn interrupted by ${type}`));
2817
+ }
2716
2818
  if (type === 'stop_and_drop') {
2717
2819
  const droppedPrompts = session.queue.splice(0);
2718
2820
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
2719
2821
  settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
2720
2822
  }
2721
2823
  if (session.running) {
2722
- session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
2723
2824
  await session.adapter.interrupt();
2724
2825
  }
2725
2826
  session.turnState = 'interrupted';
@@ -2780,6 +2881,15 @@ export async function main() {
2780
2881
  });
2781
2882
  };
2782
2883
  const refreshRuntimeDetails = async (signal) => {
2884
+ // Reuse the heartbeat's serialized detail lane; no second polling loop.
2885
+ if (refreshedCodexCatalogRevision !== codexCatalogRevision) {
2886
+ const refreshed = await refreshCodexSkillInventory(true);
2887
+ if (signal.aborted)
2888
+ return;
2889
+ // Failures retry on the existing heartbeat, not an immediate refresh loop.
2890
+ if (refreshed)
2891
+ runtimeHeartbeat.refresh();
2892
+ }
2783
2893
  await refreshKnownConversationIds().catch((error) => {
2784
2894
  console.error('[canon-codex] Failed to refresh known conversations:', error);
2785
2895
  });
@@ -2796,6 +2906,11 @@ export async function main() {
2796
2906
  if (signal.aborted)
2797
2907
  return;
2798
2908
  const results = await Promise.allSettled(Array.from(knownConversationIds).map(async (conversationId) => {
2909
+ const adapter = sessions.get(conversationId)?.adapter;
2910
+ if (adapter instanceof CodexAppServerAdapter)
2911
+ await adapter.refreshRuntimeFacts();
2912
+ if (signal.aborted)
2913
+ return;
2799
2914
  const descriptor = runtimeDescriptor.runtimeDescriptor;
2800
2915
  if (!descriptor)
2801
2916
  return;
@@ -2839,17 +2954,19 @@ export async function main() {
2839
2954
  await endpoint.setInboundState(event.id, 'settled', 'own-message');
2840
2955
  return;
2841
2956
  }
2842
- if (!claimInboundMessageId(message.id))
2957
+ const handoff = await endpoint.acquireInbound(event.id);
2958
+ if (!handoff)
2843
2959
  return;
2844
2960
  recoveryCheckpointsFor(payload.conversationId).track(message.id);
2845
2961
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2846
2962
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2847
- recoveryCheckpointsFor(payload.conversationId).settle(message.id);
2848
- settleInboundMessageId(message.id, true);
2849
- await endpoint.setInboundState(`message:${payload.conversationId}:${message.id}`, 'settled', 'observe-only');
2963
+ if (await handoff.settle('observe-only')) {
2964
+ recoveryCheckpointsFor(payload.conversationId).settle(message.id);
2965
+ }
2850
2966
  return;
2851
2967
  }
2852
2968
  await enqueueInboundMessage({
2969
+ inboundHandoff: handoff,
2853
2970
  conversationId: payload.conversationId,
2854
2971
  message,
2855
2972
  senderName: message.senderName || message.senderId,
@@ -2860,8 +2977,8 @@ export async function main() {
2860
2977
  provenance: payload.provenance,
2861
2978
  turnDispatch: payload.turnDispatch,
2862
2979
  replyAuthority: payload.replyAuthority,
2863
- }).then(() => settleInboundMessageId(message.id, true), (error) => {
2864
- settleInboundMessageId(message.id, false);
2980
+ }).catch(async (error) => {
2981
+ await handoff.release('queue-failed');
2865
2982
  console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
2866
2983
  throw error;
2867
2984
  });
@@ -2877,7 +2994,7 @@ export async function main() {
2877
2994
  data: payload });
2878
2995
  },
2879
2996
  onMessageDeleted: (payload) => {
2880
- removeQueuedPrompt(payload.conversationId, payload.messageId);
2997
+ void removeQueuedPrompt(payload.conversationId, payload.messageId).catch(console.error);
2881
2998
  },
2882
2999
  onConversationUpdated: (payload) => {
2883
3000
  handleConversationUpdated(payload);
@@ -2957,6 +3074,7 @@ export async function main() {
2957
3074
  clearInterval(heartbeat);
2958
3075
  clearInterval(idleCheck);
2959
3076
  for (const session of sessions.values()) {
3077
+ void session.currentInboundHandoff?.release('host-shutdown').catch(console.error);
2960
3078
  session.currentTurnAbortController?.abort(new Error('Codex host shutting down'));
2961
3079
  }
2962
3080
  runtimeRequests.dispose();
@@ -26,11 +26,15 @@ export function buildCodexModelOptions(models, configuredModel) {
26
26
  }
27
27
  return Number(right.isDefault) - Number(left.isDefault);
28
28
  });
29
- const options = ordered.map((model) => ({
30
- value: model.model,
31
- label: model.displayName,
32
- ...(model.description ? { description: model.description } : {}),
33
- }));
29
+ const options = ordered.map((model) => {
30
+ const description = [model.description, model.inputModalities?.length
31
+ ? `Inputs: ${model.inputModalities.join(', ')}.` : undefined].filter(Boolean).join(' ');
32
+ return {
33
+ value: model.model,
34
+ label: model.displayName,
35
+ ...(description ? { description } : {}),
36
+ };
37
+ });
34
38
  if (configured && !options.some((option) => option.value === configured)) {
35
39
  options.push({ value: configured, label: configured });
36
40
  }
@@ -0,0 +1,16 @@
1
+ import type { CanonRuntimeFact } from '@canonmsg/core';
2
+ type RecordValue = Record<string, unknown>;
3
+ /** Retain only operational availability, never account details or raw provider errors. */
4
+ export declare class CodexRuntimeStatus {
5
+ private authenticated;
6
+ private limits;
7
+ private tools;
8
+ revision: number;
9
+ hasQuota(): boolean;
10
+ replaceQuota(snapshot: RecordValue): void;
11
+ private mergeQuota;
12
+ reset(): void;
13
+ observe(method: string, params: RecordValue): boolean;
14
+ facts(): CanonRuntimeFact[];
15
+ }
16
+ export {};
@@ -0,0 +1,93 @@
1
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
2
+ /** Retain only operational availability, never account details or raw provider errors. */
3
+ export class CodexRuntimeStatus {
4
+ authenticated = null;
5
+ limits = new Map();
6
+ tools = new Map();
7
+ revision = 0;
8
+ hasQuota() { return this.limits.size > 0; }
9
+ replaceQuota(snapshot) {
10
+ this.limits.clear();
11
+ const buckets = record(snapshot.rateLimitsByLimitId);
12
+ for (const limit of buckets ? Object.values(buckets) : [snapshot.rateLimits])
13
+ this.mergeQuota(limit);
14
+ }
15
+ mergeQuota(value) {
16
+ const limit = record(value);
17
+ if (!limit)
18
+ return;
19
+ const id = typeof limit.limitId === 'string' ? limit.limitId : 'default';
20
+ const state = { ...this.limits.get(id) };
21
+ for (const key of ['primary', 'secondary']) {
22
+ const used = record(limit[key])?.usedPercent;
23
+ if (typeof used === 'number' && Number.isFinite(used) && used >= 0)
24
+ state[key] = used;
25
+ }
26
+ if (typeof limit.spendControlReached === 'boolean')
27
+ state.spend = limit.spendControlReached;
28
+ if (typeof limit.rateLimitReachedType === 'string' && limit.rateLimitReachedType)
29
+ state.reason = limit.rateLimitReachedType;
30
+ if (Object.keys(state).length)
31
+ this.limits.set(id, state);
32
+ }
33
+ reset() {
34
+ this.revision += 1;
35
+ this.authenticated = null;
36
+ this.limits.clear();
37
+ this.tools.clear();
38
+ }
39
+ observe(method, params) {
40
+ if (method === 'account/updated') {
41
+ this.reset();
42
+ if (typeof params.authMode === 'string' && params.authMode)
43
+ this.authenticated = true;
44
+ else if (params.authMode === null)
45
+ this.authenticated = false;
46
+ return true;
47
+ }
48
+ if (method === 'account/rateLimits/updated') {
49
+ this.revision += 1;
50
+ this.mergeQuota(params.rateLimits);
51
+ return true;
52
+ }
53
+ if (method === 'mcpServer/startupStatus/updated') {
54
+ if (typeof params.name !== 'string' || !params.name)
55
+ return false;
56
+ if (params.status === 'failed') {
57
+ this.tools.set(params.name, params.failureReason === 'reauthenticationRequired' ? 'auth_needed' : 'failed');
58
+ }
59
+ else if (params.status === 'ready' || params.status === 'starting') {
60
+ this.tools.set(params.name, params.status);
61
+ }
62
+ else {
63
+ this.tools.delete(params.name);
64
+ }
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ facts() {
70
+ const facts = [];
71
+ if (this.authenticated !== null)
72
+ facts.push({
73
+ id: 'provider-auth', label: 'Provider authentication', group: 'account',
74
+ value: this.authenticated ? 'Authenticated' : 'No account reported',
75
+ tone: this.authenticated ? 'good' : 'neutral',
76
+ });
77
+ const limited = [...this.limits.values()].some(limit => (limit.primary ?? 0) >= 100 || (limit.secondary ?? 0) >= 100 || limit.spend || Boolean(limit.reason));
78
+ if (this.limits.size)
79
+ facts.push({
80
+ id: 'provider-limits', label: 'Provider limits', group: 'limits',
81
+ value: limited ? 'Usage limit reached' : 'Within reported usage limits',
82
+ tone: limited ? 'warning' : 'good',
83
+ });
84
+ const states = [...this.tools.values()];
85
+ if (states.includes('auth_needed') || states.includes('failed'))
86
+ facts.push({
87
+ id: 'provider-tools', label: 'Tool connections', group: 'connection',
88
+ value: states.includes('auth_needed') ? 'A tool connection needs sign-in' : 'A tool connection failed',
89
+ tone: 'warning',
90
+ });
91
+ return facts;
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.32.2",
3
+ "version": "0.32.5",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,13 +28,14 @@
28
28
  "smoke": "node scripts/smoke-test.mjs",
29
29
  "smoke:attach": "node scripts/smoke-attach.mjs",
30
30
  "test": "vitest run",
31
- "prepack": "npm run build"
31
+ "prepack": "npm run build",
32
+ "smoke:protocol": "node scripts/smoke-protocol.mjs"
32
33
  },
33
34
  "dependencies": {
34
- "@canonmsg/agent-sdk": "^11.0.1",
35
+ "@canonmsg/agent-sdk": "^11.0.2",
35
36
  "@canonmsg/agent-tools": "^0.11.0",
36
- "@canonmsg/coding-agent-host": "^0.9.0",
37
- "@canonmsg/core": "^13.0.4",
37
+ "@canonmsg/coding-agent-host": "^0.10.0",
38
+ "@canonmsg/core": "^13.0.5",
38
39
  "@canonmsg/rich-cards": "^0.10.6",
39
40
  "ws": "^8.21.3"
40
41
  },
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // Uses a temporary native home, no credentials, and never starts a model turn.
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { CodexAppServerAdapter } from '../dist/app-server-adapter.js';
8
+ import { detectCodexCliVersion } from '../dist/codex-cli-version.js';
9
+
10
+ const codexBin = process.env.CODEX_BIN || 'codex';
11
+ const expected = '0.155.1';
12
+ const version = detectCodexCliVersion(codexBin);
13
+ assert.equal(version.version, expected, `This compatibility smoke targets Codex ${expected}`);
14
+ const home = await mkdtemp(join(tmpdir(), 'canon-codex-protocol-'));
15
+ const originalHome = process.env.CODEX_HOME;
16
+ process.env.CODEX_HOME = home;
17
+ const adapter = new CodexAppServerAdapter({ cwd: home, codexBin });
18
+ const timeout = setTimeout(() => { adapter.close(); console.error('Codex protocol smoke timed out'); process.exitCode = 1; }, 20_000);
19
+ try {
20
+ const models = await adapter.listModels();
21
+ assert.ok(models.length > 0, 'Native model discovery returned no models');
22
+ assert.ok(models.every(model => model.model && Array.isArray(model.supportedReasoningEfforts)));
23
+ await adapter.listSkills();
24
+ const account = await adapter.requestAppServer('account/read', { refreshToken: false });
25
+ assert.equal(account.account, null, 'Smoke must use an unauthenticated temporary home');
26
+ console.log(JSON.stringify({ codex: version.version, protocol: 'app-server', initialization: 'passed', modelDiscovery: 'passed', skillDiscovery: 'passed', authenticated: false, modelTurnsStarted: 0 }));
27
+ } finally {
28
+ clearTimeout(timeout);
29
+ adapter.close();
30
+ if (originalHome === undefined) delete process.env.CODEX_HOME;
31
+ else process.env.CODEX_HOME = originalHome;
32
+ await rm(home, { recursive: true, force: true });
33
+ }