@canonmsg/codex-plugin 0.32.2 → 0.32.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/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
@@ -36,6 +36,8 @@ interface Session {
36
36
  planMode?: boolean;
37
37
  intent: DeliveryIntent;
38
38
  sourceMessageId?: string | null;
39
+ /** A recovery gate rejected this input before any native execution. */
40
+ recoveryDeferred?: boolean;
39
41
  markAccepted?: boolean;
40
42
  imagePaths?: string[];
41
43
  mediaAddDirs?: string[];
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,10 @@ 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
+ if (prompt.recoveryDeferred && prompt.sourceMessageId) {
1076
+ await endpoint.setInboundState(`message:${conversationId}:${prompt.sourceMessageId}`, 'settled', 'canceled-before-execution');
1077
+ }
1071
1078
  if (!prompt.markAccepted || !prompt.sourceMessageId)
1072
1079
  return Promise.resolve();
1073
1080
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
@@ -1260,6 +1267,13 @@ export async function main() {
1260
1267
  if (!session)
1261
1268
  return;
1262
1269
  session.closed = true;
1270
+ // The local queue is going away, but never-started deferred input still
1271
+ // belongs to the durable inbox. Release only that queue's dedupe ownership
1272
+ // so an eligible reoffer can reconstruct it after eviction or removal.
1273
+ for (const prompt of session.queue) {
1274
+ if (prompt.recoveryDeferred && prompt.sourceMessageId)
1275
+ acceptedInboundMessageIds.delete(prompt.sourceMessageId);
1276
+ }
1263
1277
  session.currentTurnAbortController?.abort(new Error('Codex session closed'));
1264
1278
  session.currentTurnAbortController = null;
1265
1279
  stopVisibleWorkSignal(session);
@@ -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,
@@ -1571,6 +1589,12 @@ export async function main() {
1571
1589
  return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
1572
1590
  }
1573
1591
  async function handleCodexServerRequest(session, request, requestingUserId, sourceMessageId) {
1592
+ const requestSignal = AbortSignal.any([
1593
+ ...(request.signal ? [request.signal] : []),
1594
+ ...(session.currentTurnAbortController ? [session.currentTurnAbortController.signal] : []),
1595
+ ]);
1596
+ requestSignal.throwIfAborted();
1597
+ const requestTurnId = session.currentTurnId;
1574
1598
  const requestId = String(request.id);
1575
1599
  const params = request.params;
1576
1600
  const expiresAt = Date.now() + 30 * 60_000;
@@ -1631,12 +1655,14 @@ export async function main() {
1631
1655
  ...(responseRouting.responseUserId
1632
1656
  ? { responseUserId: responseRouting.responseUserId }
1633
1657
  : {}),
1634
- turnId: session.currentTurnId ?? undefined,
1658
+ turnId: requestTurnId ?? undefined,
1635
1659
  }, {
1636
1660
  requestId: inputId,
1637
1661
  expiresAt,
1638
- signal: session.currentTurnAbortController?.signal,
1662
+ signal: requestSignal,
1639
1663
  onCreated: () => {
1664
+ if (requestSignal.aborted)
1665
+ return;
1640
1666
  session.turnState = 'waiting_input';
1641
1667
  markTurnProgress(session);
1642
1668
  stopVisibleWorkSignal(session);
@@ -1644,6 +1670,7 @@ export async function main() {
1644
1670
  writeCodexStreaming(session, null, 'waiting_input');
1645
1671
  },
1646
1672
  });
1673
+ requestSignal.throwIfAborted();
1647
1674
  resumeTurnFromWaiting(session);
1648
1675
  return successfulCodexAppToolResult(response);
1649
1676
  }
@@ -1663,7 +1690,7 @@ export async function main() {
1663
1690
  card: { ...card, cardId },
1664
1691
  cardId,
1665
1692
  expiresAt,
1666
- turnId: session.currentTurnId ?? undefined,
1693
+ turnId: requestTurnId ?? undefined,
1667
1694
  });
1668
1695
  return successfulCodexAppToolResult({
1669
1696
  status: 'sent',
@@ -1678,15 +1705,17 @@ export async function main() {
1678
1705
  const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1679
1706
  const response = await runtimeRequests.request('card', session.conversationId, {
1680
1707
  card,
1681
- turnId: session.currentTurnId ?? undefined,
1708
+ turnId: requestTurnId ?? undefined,
1682
1709
  ...(responseRouting.responseUserId
1683
1710
  ? { responseUserId: responseRouting.responseUserId }
1684
1711
  : {}),
1685
1712
  }, {
1686
1713
  requestId: cardId,
1687
1714
  expiresAt,
1688
- signal: session.currentTurnAbortController?.signal,
1715
+ signal: requestSignal,
1689
1716
  onCreated: () => {
1717
+ if (requestSignal.aborted)
1718
+ return;
1690
1719
  session.turnState = 'waiting_input';
1691
1720
  markTurnProgress(session);
1692
1721
  upsertTurnBlock(session, {
@@ -1701,6 +1730,7 @@ export async function main() {
1701
1730
  writeCodexStreaming(session, null, 'waiting_input');
1702
1731
  },
1703
1732
  });
1733
+ requestSignal.throwIfAborted();
1704
1734
  completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1705
1735
  resumeTurnFromWaiting(session);
1706
1736
  return successfulCodexAppToolResult(response);
@@ -1748,21 +1778,23 @@ export async function main() {
1748
1778
  runtime: 'codex',
1749
1779
  method: request.method,
1750
1780
  requestId,
1751
- turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
1781
+ turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
1752
1782
  handles: {
1753
1783
  itemId: readString(params, 'itemId') ?? '',
1754
1784
  threadId: readString(params, 'threadId') ?? '',
1755
1785
  },
1756
1786
  },
1757
- turnId: session.currentTurnId ?? undefined,
1787
+ turnId: requestTurnId ?? undefined,
1758
1788
  ...(responseRouting.responseUserId
1759
1789
  ? { responseUserId: responseRouting.responseUserId }
1760
1790
  : {}),
1761
1791
  }, {
1762
1792
  requestId: cardId,
1763
1793
  expiresAt,
1764
- signal: session.currentTurnAbortController?.signal,
1794
+ signal: requestSignal,
1765
1795
  onCreated: () => {
1796
+ if (requestSignal.aborted)
1797
+ return;
1766
1798
  requestCreated = true;
1767
1799
  session.turnState = 'waiting_input';
1768
1800
  markTurnProgress(session);
@@ -1778,6 +1810,7 @@ export async function main() {
1778
1810
  writeCodexStreaming(session, null, 'waiting_input');
1779
1811
  },
1780
1812
  });
1813
+ requestSignal.throwIfAborted();
1781
1814
  const response = cardResult.status === 'submitted'
1782
1815
  ? {
1783
1816
  status: 'submitted',
@@ -1797,11 +1830,12 @@ export async function main() {
1797
1830
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1798
1831
  metadata: {
1799
1832
  ...outcome.metadata,
1800
- turnId: session.currentTurnId ?? undefined,
1833
+ turnId: requestTurnId ?? undefined,
1801
1834
  turnSemantics: 'control',
1802
1835
  replyBehavior: 'suppress_auto_reply',
1803
1836
  },
1804
1837
  });
1838
+ requestSignal.throwIfAborted();
1805
1839
  completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1806
1840
  resumeTurnFromWaiting(session);
1807
1841
  return response;
@@ -1818,7 +1852,7 @@ export async function main() {
1818
1852
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1819
1853
  metadata: {
1820
1854
  ...outcome.metadata,
1821
- turnId: session.currentTurnId ?? undefined,
1855
+ turnId: requestTurnId ?? undefined,
1822
1856
  turnSemantics: 'control',
1823
1857
  replyBehavior: 'suppress_auto_reply',
1824
1858
  },
@@ -1853,18 +1887,19 @@ export async function main() {
1853
1887
  runtime: 'codex',
1854
1888
  method: request.method,
1855
1889
  requestId,
1856
- turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
1890
+ turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
1857
1891
  handles: {
1858
1892
  itemId: readString(params, 'itemId') ?? '',
1859
1893
  threadId: readString(params, 'threadId') ?? '',
1860
1894
  },
1861
1895
  },
1862
- turnId: session.currentTurnId ?? undefined,
1896
+ turnId: requestTurnId ?? undefined,
1863
1897
  }, {
1864
1898
  requestId: inputId,
1865
1899
  expiresAt,
1866
- signal: session.currentTurnAbortController?.signal,
1900
+ signal: requestSignal,
1867
1901
  });
1902
+ requestSignal.throwIfAborted();
1868
1903
  resumeTurnFromWaiting(session);
1869
1904
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1870
1905
  }
@@ -1878,7 +1913,7 @@ export async function main() {
1878
1913
  const response = await runtimeRequests.request('approval', session.conversationId, {
1879
1914
  ...mappedApproval,
1880
1915
  native: { ...mappedApproval.native, requestId, method: request.method },
1881
- turnId: session.currentTurnId ?? undefined,
1916
+ turnId: requestTurnId ?? undefined,
1882
1917
  ...(responseRouting.responseUserId
1883
1918
  ? { responseUserId: responseRouting.responseUserId }
1884
1919
  : {}),
@@ -1886,8 +1921,9 @@ export async function main() {
1886
1921
  }, {
1887
1922
  requestId: approvalId,
1888
1923
  expiresAt,
1889
- signal: session.currentTurnAbortController?.signal,
1924
+ signal: requestSignal,
1890
1925
  });
1926
+ requestSignal.throwIfAborted();
1891
1927
  resumeTurnFromWaiting(session);
1892
1928
  if (request.method === 'item/permissions/requestApproval') {
1893
1929
  return response.decision === 'allow'
@@ -1899,7 +1935,7 @@ export async function main() {
1899
1935
  ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
1900
1936
  });
1901
1937
  }
1902
- return {};
1938
+ throw new Error(`Unsupported Codex server request: ${request.method}`);
1903
1939
  }
1904
1940
  async function enqueueInboundMessage(input) {
1905
1941
  knownConversationIds.add(input.conversationId);
@@ -2058,7 +2094,7 @@ export async function main() {
2058
2094
  replyAuthority: input.replyAuthority ?? null,
2059
2095
  });
2060
2096
  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'));
2097
+ session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex turn interrupted by a newer message'));
2062
2098
  await session.adapter.interrupt().catch(() => { });
2063
2099
  clearStreaming(input.conversationId);
2064
2100
  typingSignals.clear(input.conversationId).catch(() => { });
@@ -2116,6 +2152,8 @@ export async function main() {
2116
2152
  }
2117
2153
  const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
2118
2154
  let journaledInput = false;
2155
+ let inputDeferred = false;
2156
+ let preserveUnclaimedInput = false;
2119
2157
  let completedOutput;
2120
2158
  let nativeCompleted = false;
2121
2159
  session.state.lastError = undefined;
@@ -2217,6 +2255,10 @@ export async function main() {
2217
2255
  void refreshCodexSkillInventory(true).then(() => runtimeHeartbeat.refresh());
2218
2256
  return;
2219
2257
  }
2258
+ if (event.type === 'request.resolved') {
2259
+ resumeTurnFromWaiting(session);
2260
+ return;
2261
+ }
2220
2262
  if (event.type === 'settings.updated') {
2221
2263
  if (event.model)
2222
2264
  session.state.model = event.model;
@@ -2509,6 +2551,33 @@ export async function main() {
2509
2551
  await deliverCompletedOutput(completedOutput);
2510
2552
  }
2511
2553
  catch (error) {
2554
+ if (!journaledInput && inboundId && error instanceof Error
2555
+ && 'code' in error && error.code === 'SYNC_NOT_READY') {
2556
+ // Recovery can be invalidated after the inbox offers an input. No
2557
+ // native work has started: keep this same queued turn for the host
2558
+ // heartbeat instead of publishing a failure or suppressing its retry
2559
+ // through the accepted-message dedupe cache.
2560
+ if (session.resetRequested || session.currentTurnAbortController?.signal.reason instanceof CodexTurnCanceledError) {
2561
+ await endpoint.setInboundState(inboundId, 'settled', 'canceled-before-execution');
2562
+ }
2563
+ else {
2564
+ preserveUnclaimedInput = true;
2565
+ if (!session.closed && !session.currentTurnAbortController?.signal.aborted) {
2566
+ nextTurn.recoveryDeferred = true;
2567
+ session.queue.unshift(nextTurn);
2568
+ inputDeferred = true;
2569
+ }
2570
+ else {
2571
+ // A closed/shutting-down session cannot retain the local queue.
2572
+ // Leave its durable offer available to the next live session.
2573
+ acceptedInboundMessageIds.delete(nextTurn.sourceMessageId);
2574
+ }
2575
+ }
2576
+ // The attempt may already have published its empty thinking row.
2577
+ // With no native output there is nothing to hand off or salvage.
2578
+ clearStreaming(session.conversationId);
2579
+ return;
2580
+ }
2512
2581
  if (nativeCompleted) {
2513
2582
  session.state.lastError = completedOutput
2514
2583
  ? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
@@ -2559,7 +2628,8 @@ export async function main() {
2559
2628
  activeCompletedOutputs.delete(completedOutput.turnId);
2560
2629
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2561
2630
  session.currentTurnAbortController = null;
2562
- recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2631
+ if (!preserveUnclaimedInput)
2632
+ recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2563
2633
  stopVisibleWorkSignal(session);
2564
2634
  session.running = false;
2565
2635
  session.state.state = 'idle';
@@ -2575,7 +2645,7 @@ export async function main() {
2575
2645
  session.lastActivity = Date.now();
2576
2646
  writeState(session);
2577
2647
  writeTurn(session);
2578
- if (session.queue.length > 0) {
2648
+ if (!inputDeferred && session.queue.length > 0) {
2579
2649
  void runNextTurn(session);
2580
2650
  }
2581
2651
  }
@@ -2658,43 +2728,63 @@ export async function main() {
2658
2728
  }),
2659
2729
  });
2660
2730
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
2731
+ let codexCatalogRevision = 0;
2732
+ let refreshedCodexCatalogRevision = -1;
2733
+ function invalidateCodexCatalog() {
2734
+ codexCatalogRevision += 1;
2735
+ codexModels = [];
2736
+ codexSkills = [];
2737
+ codexModelOptions = buildCodexModelOptions([], args.model);
2738
+ codexEffortOptions = [];
2739
+ codexDefaultModel = resolveCodexDefaultModel([], args.model);
2740
+ codexDefaultEffort = null;
2741
+ runtimeDescriptor = buildCurrentRuntimeDescriptor();
2742
+ }
2661
2743
  async function refreshCodexSkillInventory(forceReload = false) {
2662
2744
  if (!useAppServer)
2663
- return;
2745
+ return true;
2746
+ const revision = codexCatalogRevision;
2664
2747
  const probe = new CodexAppServerAdapter({
2665
2748
  cwd: workingDir,
2666
2749
  codexBin,
2667
2750
  model: typeof args.model === 'string' ? args.model : null,
2668
2751
  configOverrides: args.config ?? [],
2669
2752
  });
2753
+ let succeeded = true;
2754
+ let models = [];
2755
+ let skills = [];
2670
2756
  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
- }
2757
+ models = await probe.listModels();
2683
2758
  }
2684
2759
  catch (error) {
2760
+ succeeded = false;
2685
2761
  console.error('[canon-codex] Failed to load Codex models:', error instanceof Error ? error.message : error);
2686
2762
  }
2687
2763
  try {
2688
- codexSkills = await probe.listSkills({ forceReload });
2764
+ skills = await probe.listSkills({ forceReload });
2689
2765
  }
2690
2766
  catch (error) {
2691
- codexSkills = [];
2767
+ succeeded = false;
2692
2768
  console.error('[canon-codex] Failed to load Codex skills:', error instanceof Error ? error.message : error);
2693
2769
  }
2694
2770
  finally {
2695
- runtimeDescriptor = buildCurrentRuntimeDescriptor();
2696
2771
  probe.close();
2697
2772
  }
2773
+ // An account change during discovery invalidates that probe's observations.
2774
+ if (revision !== codexCatalogRevision)
2775
+ return false;
2776
+ codexModels = models;
2777
+ codexSkills = skills;
2778
+ codexModelOptions = buildCodexModelOptions(models, args.model);
2779
+ codexEffortOptions = models.length ? buildCodexEffortOptions(models) : [];
2780
+ codexDefaultModel = resolveCodexDefaultModel(models, args.model);
2781
+ codexDefaultEffort = models.length ? resolveCodexEffortForModel({
2782
+ models, model: codexDefaultModel, requestedEffort: configuredCodexEffort,
2783
+ }).value : null;
2784
+ runtimeDescriptor = buildCurrentRuntimeDescriptor();
2785
+ if (succeeded)
2786
+ refreshedCodexCatalogRevision = revision;
2787
+ return succeeded;
2698
2788
  }
2699
2789
  async function handleControlSignal(event) {
2700
2790
  const { conversationId, type } = event;
@@ -2713,13 +2803,15 @@ export async function main() {
2713
2803
  return;
2714
2804
  }
2715
2805
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
2806
+ if (session.running) {
2807
+ session.currentTurnAbortController?.abort(new CodexTurnCanceledError(`Codex turn interrupted by ${type}`));
2808
+ }
2716
2809
  if (type === 'stop_and_drop') {
2717
2810
  const droppedPrompts = session.queue.splice(0);
2718
2811
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
2719
2812
  settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
2720
2813
  }
2721
2814
  if (session.running) {
2722
- session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
2723
2815
  await session.adapter.interrupt();
2724
2816
  }
2725
2817
  session.turnState = 'interrupted';
@@ -2780,6 +2872,15 @@ export async function main() {
2780
2872
  });
2781
2873
  };
2782
2874
  const refreshRuntimeDetails = async (signal) => {
2875
+ // Reuse the heartbeat's serialized detail lane; no second polling loop.
2876
+ if (refreshedCodexCatalogRevision !== codexCatalogRevision) {
2877
+ const refreshed = await refreshCodexSkillInventory(true);
2878
+ if (signal.aborted)
2879
+ return;
2880
+ // Failures retry on the existing heartbeat, not an immediate refresh loop.
2881
+ if (refreshed)
2882
+ runtimeHeartbeat.refresh();
2883
+ }
2783
2884
  await refreshKnownConversationIds().catch((error) => {
2784
2885
  console.error('[canon-codex] Failed to refresh known conversations:', error);
2785
2886
  });
@@ -2796,6 +2897,11 @@ export async function main() {
2796
2897
  if (signal.aborted)
2797
2898
  return;
2798
2899
  const results = await Promise.allSettled(Array.from(knownConversationIds).map(async (conversationId) => {
2900
+ const adapter = sessions.get(conversationId)?.adapter;
2901
+ if (adapter instanceof CodexAppServerAdapter)
2902
+ await adapter.refreshRuntimeFacts();
2903
+ if (signal.aborted)
2904
+ return;
2799
2905
  const descriptor = runtimeDescriptor.runtimeDescriptor;
2800
2906
  if (!descriptor)
2801
2907
  return;
@@ -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.3",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,8 @@
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
35
  "@canonmsg/agent-sdk": "^11.0.1",
@@ -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
+ }