@canonmsg/codex-plugin 0.32.1 → 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
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
8
  import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, } from '@canonmsg/coding-agent-host';
9
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CANON_DIR, runtimePlanDescriptor, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, sendMessageWithRetry, isPendingCanonOperation, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, hydrateCanonReplyContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CANON_DIR, runtimePlanDescriptor, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, sendMessageWithRetry, isPendingCanonOperation, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
10
  import { validateCard } from '@canonmsg/rich-cards';
11
11
  import { CodexConversationAdapter, } from './adapter.js';
12
12
  import { CodexAppServerAdapter, } from './app-server-adapter.js';
@@ -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,
@@ -987,7 +991,7 @@ export async function main() {
987
991
  ? Promise.resolve(input.hydratedPage)
988
992
  : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
989
993
  ]);
990
- return buildHydratedInboundContext({
994
+ const hydrated = buildHydratedInboundContext({
991
995
  agentId,
992
996
  conversationId: input.conversationId,
993
997
  conversation,
@@ -1004,6 +1008,15 @@ export async function main() {
1004
1008
  membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
1005
1009
  groupContextMode: getGroupContextMode(input.conversationId, conversation),
1006
1010
  });
1011
+ return {
1012
+ ...hydrated,
1013
+ replyContext: await hydrateCanonReplyContext({
1014
+ client,
1015
+ conversationId: input.conversationId,
1016
+ message: input.message,
1017
+ messages: page?.messages,
1018
+ }),
1019
+ };
1007
1020
  }
1008
1021
  function writeState(session) {
1009
1022
  runtimeState.writeSessionState(session.conversationId, {
@@ -1058,7 +1071,10 @@ export async function main() {
1058
1071
  }
1059
1072
  }
1060
1073
  async function markQueuedPromptsRejected(conversationId, prompts) {
1061
- 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
+ }
1062
1078
  if (!prompt.markAccepted || !prompt.sourceMessageId)
1063
1079
  return Promise.resolve();
1064
1080
  return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
@@ -1251,6 +1267,13 @@ export async function main() {
1251
1267
  if (!session)
1252
1268
  return;
1253
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
+ }
1254
1277
  session.currentTurnAbortController?.abort(new Error('Codex session closed'));
1255
1278
  session.currentTurnAbortController = null;
1256
1279
  stopVisibleWorkSignal(session);
@@ -1276,7 +1299,7 @@ export async function main() {
1276
1299
  session.activeSelfContextId = null;
1277
1300
  session.state.lastError = undefined;
1278
1301
  if (session.running) {
1279
- session.currentTurnAbortController?.abort(new Error('Codex session reset'));
1302
+ session.currentTurnAbortController?.abort(new CodexTurnCanceledError('Codex session reset'));
1280
1303
  await session.adapter.interrupt();
1281
1304
  session.turnState = 'interrupted';
1282
1305
  }
@@ -1407,6 +1430,10 @@ export async function main() {
1407
1430
  fullAuto: policy.fullAuto,
1408
1431
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
1409
1432
  dynamicTools: codexDynamicTools,
1433
+ onCapabilitiesChanged: () => {
1434
+ invalidateCodexCatalog();
1435
+ runtimeHeartbeat.refresh();
1436
+ },
1410
1437
  })
1411
1438
  : new CodexConversationAdapter({
1412
1439
  cwd: sessionCwd,
@@ -1562,6 +1589,12 @@ export async function main() {
1562
1589
  return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
1563
1590
  }
1564
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;
1565
1598
  const requestId = String(request.id);
1566
1599
  const params = request.params;
1567
1600
  const expiresAt = Date.now() + 30 * 60_000;
@@ -1622,12 +1655,14 @@ export async function main() {
1622
1655
  ...(responseRouting.responseUserId
1623
1656
  ? { responseUserId: responseRouting.responseUserId }
1624
1657
  : {}),
1625
- turnId: session.currentTurnId ?? undefined,
1658
+ turnId: requestTurnId ?? undefined,
1626
1659
  }, {
1627
1660
  requestId: inputId,
1628
1661
  expiresAt,
1629
- signal: session.currentTurnAbortController?.signal,
1662
+ signal: requestSignal,
1630
1663
  onCreated: () => {
1664
+ if (requestSignal.aborted)
1665
+ return;
1631
1666
  session.turnState = 'waiting_input';
1632
1667
  markTurnProgress(session);
1633
1668
  stopVisibleWorkSignal(session);
@@ -1635,6 +1670,7 @@ export async function main() {
1635
1670
  writeCodexStreaming(session, null, 'waiting_input');
1636
1671
  },
1637
1672
  });
1673
+ requestSignal.throwIfAborted();
1638
1674
  resumeTurnFromWaiting(session);
1639
1675
  return successfulCodexAppToolResult(response);
1640
1676
  }
@@ -1654,7 +1690,7 @@ export async function main() {
1654
1690
  card: { ...card, cardId },
1655
1691
  cardId,
1656
1692
  expiresAt,
1657
- turnId: session.currentTurnId ?? undefined,
1693
+ turnId: requestTurnId ?? undefined,
1658
1694
  });
1659
1695
  return successfulCodexAppToolResult({
1660
1696
  status: 'sent',
@@ -1669,15 +1705,17 @@ export async function main() {
1669
1705
  const responseRouting = buildCodexTurnResponseRouting({ requestingUserId, ownerId });
1670
1706
  const response = await runtimeRequests.request('card', session.conversationId, {
1671
1707
  card,
1672
- turnId: session.currentTurnId ?? undefined,
1708
+ turnId: requestTurnId ?? undefined,
1673
1709
  ...(responseRouting.responseUserId
1674
1710
  ? { responseUserId: responseRouting.responseUserId }
1675
1711
  : {}),
1676
1712
  }, {
1677
1713
  requestId: cardId,
1678
1714
  expiresAt,
1679
- signal: session.currentTurnAbortController?.signal,
1715
+ signal: requestSignal,
1680
1716
  onCreated: () => {
1717
+ if (requestSignal.aborted)
1718
+ return;
1681
1719
  session.turnState = 'waiting_input';
1682
1720
  markTurnProgress(session);
1683
1721
  upsertTurnBlock(session, {
@@ -1692,6 +1730,7 @@ export async function main() {
1692
1730
  writeCodexStreaming(session, null, 'waiting_input');
1693
1731
  },
1694
1732
  });
1733
+ requestSignal.throwIfAborted();
1695
1734
  completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1696
1735
  resumeTurnFromWaiting(session);
1697
1736
  return successfulCodexAppToolResult(response);
@@ -1739,21 +1778,23 @@ export async function main() {
1739
1778
  runtime: 'codex',
1740
1779
  method: request.method,
1741
1780
  requestId,
1742
- turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
1781
+ turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
1743
1782
  handles: {
1744
1783
  itemId: readString(params, 'itemId') ?? '',
1745
1784
  threadId: readString(params, 'threadId') ?? '',
1746
1785
  },
1747
1786
  },
1748
- turnId: session.currentTurnId ?? undefined,
1787
+ turnId: requestTurnId ?? undefined,
1749
1788
  ...(responseRouting.responseUserId
1750
1789
  ? { responseUserId: responseRouting.responseUserId }
1751
1790
  : {}),
1752
1791
  }, {
1753
1792
  requestId: cardId,
1754
1793
  expiresAt,
1755
- signal: session.currentTurnAbortController?.signal,
1794
+ signal: requestSignal,
1756
1795
  onCreated: () => {
1796
+ if (requestSignal.aborted)
1797
+ return;
1757
1798
  requestCreated = true;
1758
1799
  session.turnState = 'waiting_input';
1759
1800
  markTurnProgress(session);
@@ -1769,6 +1810,7 @@ export async function main() {
1769
1810
  writeCodexStreaming(session, null, 'waiting_input');
1770
1811
  },
1771
1812
  });
1813
+ requestSignal.throwIfAborted();
1772
1814
  const response = cardResult.status === 'submitted'
1773
1815
  ? {
1774
1816
  status: 'submitted',
@@ -1788,11 +1830,12 @@ export async function main() {
1788
1830
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1789
1831
  metadata: {
1790
1832
  ...outcome.metadata,
1791
- turnId: session.currentTurnId ?? undefined,
1833
+ turnId: requestTurnId ?? undefined,
1792
1834
  turnSemantics: 'control',
1793
1835
  replyBehavior: 'suppress_auto_reply',
1794
1836
  },
1795
1837
  });
1838
+ requestSignal.throwIfAborted();
1796
1839
  completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1797
1840
  resumeTurnFromWaiting(session);
1798
1841
  return response;
@@ -1809,7 +1852,7 @@ export async function main() {
1809
1852
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1810
1853
  metadata: {
1811
1854
  ...outcome.metadata,
1812
- turnId: session.currentTurnId ?? undefined,
1855
+ turnId: requestTurnId ?? undefined,
1813
1856
  turnSemantics: 'control',
1814
1857
  replyBehavior: 'suppress_auto_reply',
1815
1858
  },
@@ -1844,18 +1887,19 @@ export async function main() {
1844
1887
  runtime: 'codex',
1845
1888
  method: request.method,
1846
1889
  requestId,
1847
- turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
1890
+ turnId: readString(params, 'turnId') ?? requestTurnId ?? undefined,
1848
1891
  handles: {
1849
1892
  itemId: readString(params, 'itemId') ?? '',
1850
1893
  threadId: readString(params, 'threadId') ?? '',
1851
1894
  },
1852
1895
  },
1853
- turnId: session.currentTurnId ?? undefined,
1896
+ turnId: requestTurnId ?? undefined,
1854
1897
  }, {
1855
1898
  requestId: inputId,
1856
1899
  expiresAt,
1857
- signal: session.currentTurnAbortController?.signal,
1900
+ signal: requestSignal,
1858
1901
  });
1902
+ requestSignal.throwIfAborted();
1859
1903
  resumeTurnFromWaiting(session);
1860
1904
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1861
1905
  }
@@ -1869,7 +1913,7 @@ export async function main() {
1869
1913
  const response = await runtimeRequests.request('approval', session.conversationId, {
1870
1914
  ...mappedApproval,
1871
1915
  native: { ...mappedApproval.native, requestId, method: request.method },
1872
- turnId: session.currentTurnId ?? undefined,
1916
+ turnId: requestTurnId ?? undefined,
1873
1917
  ...(responseRouting.responseUserId
1874
1918
  ? { responseUserId: responseRouting.responseUserId }
1875
1919
  : {}),
@@ -1877,8 +1921,9 @@ export async function main() {
1877
1921
  }, {
1878
1922
  requestId: approvalId,
1879
1923
  expiresAt,
1880
- signal: session.currentTurnAbortController?.signal,
1924
+ signal: requestSignal,
1881
1925
  });
1926
+ requestSignal.throwIfAborted();
1882
1927
  resumeTurnFromWaiting(session);
1883
1928
  if (request.method === 'item/permissions/requestApproval') {
1884
1929
  return response.decision === 'allow'
@@ -1890,7 +1935,7 @@ export async function main() {
1890
1935
  ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
1891
1936
  });
1892
1937
  }
1893
- return {};
1938
+ throw new Error(`Unsupported Codex server request: ${request.method}`);
1894
1939
  }
1895
1940
  async function enqueueInboundMessage(input) {
1896
1941
  knownConversationIds.add(input.conversationId);
@@ -2049,7 +2094,7 @@ export async function main() {
2049
2094
  replyAuthority: input.replyAuthority ?? null,
2050
2095
  });
2051
2096
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
2052
- 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'));
2053
2098
  await session.adapter.interrupt().catch(() => { });
2054
2099
  clearStreaming(input.conversationId);
2055
2100
  typingSignals.clear(input.conversationId).catch(() => { });
@@ -2107,6 +2152,8 @@ export async function main() {
2107
2152
  }
2108
2153
  const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
2109
2154
  let journaledInput = false;
2155
+ let inputDeferred = false;
2156
+ let preserveUnclaimedInput = false;
2110
2157
  let completedOutput;
2111
2158
  let nativeCompleted = false;
2112
2159
  session.state.lastError = undefined;
@@ -2208,6 +2255,10 @@ export async function main() {
2208
2255
  void refreshCodexSkillInventory(true).then(() => runtimeHeartbeat.refresh());
2209
2256
  return;
2210
2257
  }
2258
+ if (event.type === 'request.resolved') {
2259
+ resumeTurnFromWaiting(session);
2260
+ return;
2261
+ }
2211
2262
  if (event.type === 'settings.updated') {
2212
2263
  if (event.model)
2213
2264
  session.state.model = event.model;
@@ -2500,6 +2551,33 @@ export async function main() {
2500
2551
  await deliverCompletedOutput(completedOutput);
2501
2552
  }
2502
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
+ }
2503
2581
  if (nativeCompleted) {
2504
2582
  session.state.lastError = completedOutput
2505
2583
  ? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
@@ -2550,7 +2628,8 @@ export async function main() {
2550
2628
  activeCompletedOutputs.delete(completedOutput.turnId);
2551
2629
  session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
2552
2630
  session.currentTurnAbortController = null;
2553
- recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2631
+ if (!preserveUnclaimedInput)
2632
+ recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
2554
2633
  stopVisibleWorkSignal(session);
2555
2634
  session.running = false;
2556
2635
  session.state.state = 'idle';
@@ -2566,7 +2645,7 @@ export async function main() {
2566
2645
  session.lastActivity = Date.now();
2567
2646
  writeState(session);
2568
2647
  writeTurn(session);
2569
- if (session.queue.length > 0) {
2648
+ if (!inputDeferred && session.queue.length > 0) {
2570
2649
  void runNextTurn(session);
2571
2650
  }
2572
2651
  }
@@ -2649,43 +2728,63 @@ export async function main() {
2649
2728
  }),
2650
2729
  });
2651
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
+ }
2652
2743
  async function refreshCodexSkillInventory(forceReload = false) {
2653
2744
  if (!useAppServer)
2654
- return;
2745
+ return true;
2746
+ const revision = codexCatalogRevision;
2655
2747
  const probe = new CodexAppServerAdapter({
2656
2748
  cwd: workingDir,
2657
2749
  codexBin,
2658
2750
  model: typeof args.model === 'string' ? args.model : null,
2659
2751
  configOverrides: args.config ?? [],
2660
2752
  });
2753
+ let succeeded = true;
2754
+ let models = [];
2755
+ let skills = [];
2661
2756
  try {
2662
- const discoveredModels = await probe.listModels();
2663
- if (discoveredModels.length > 0) {
2664
- codexModels = discoveredModels;
2665
- codexModelOptions = buildCodexModelOptions(codexModels, args.model);
2666
- codexEffortOptions = buildCodexEffortOptions(codexModels);
2667
- codexDefaultModel = resolveCodexDefaultModel(codexModels, args.model);
2668
- codexDefaultEffort = resolveCodexEffortForModel({
2669
- models: codexModels,
2670
- model: codexDefaultModel,
2671
- requestedEffort: configuredCodexEffort,
2672
- }).value;
2673
- }
2757
+ models = await probe.listModels();
2674
2758
  }
2675
2759
  catch (error) {
2760
+ succeeded = false;
2676
2761
  console.error('[canon-codex] Failed to load Codex models:', error instanceof Error ? error.message : error);
2677
2762
  }
2678
2763
  try {
2679
- codexSkills = await probe.listSkills({ forceReload });
2764
+ skills = await probe.listSkills({ forceReload });
2680
2765
  }
2681
2766
  catch (error) {
2682
- codexSkills = [];
2767
+ succeeded = false;
2683
2768
  console.error('[canon-codex] Failed to load Codex skills:', error instanceof Error ? error.message : error);
2684
2769
  }
2685
2770
  finally {
2686
- runtimeDescriptor = buildCurrentRuntimeDescriptor();
2687
2771
  probe.close();
2688
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;
2689
2788
  }
2690
2789
  async function handleControlSignal(event) {
2691
2790
  const { conversationId, type } = event;
@@ -2704,13 +2803,15 @@ export async function main() {
2704
2803
  return;
2705
2804
  }
2706
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
+ }
2707
2809
  if (type === 'stop_and_drop') {
2708
2810
  const droppedPrompts = session.queue.splice(0);
2709
2811
  await markQueuedPromptsRejected(conversationId, droppedPrompts);
2710
2812
  settleRejectedPromptCheckpoints(conversationId, droppedPrompts);
2711
2813
  }
2712
2814
  if (session.running) {
2713
- session.currentTurnAbortController?.abort(new Error(`Codex turn interrupted by ${type}`));
2714
2815
  await session.adapter.interrupt();
2715
2816
  }
2716
2817
  session.turnState = 'interrupted';
@@ -2771,6 +2872,15 @@ export async function main() {
2771
2872
  });
2772
2873
  };
2773
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
+ }
2774
2884
  await refreshKnownConversationIds().catch((error) => {
2775
2885
  console.error('[canon-codex] Failed to refresh known conversations:', error);
2776
2886
  });
@@ -2787,6 +2897,11 @@ export async function main() {
2787
2897
  if (signal.aborted)
2788
2898
  return;
2789
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;
2790
2905
  const descriptor = runtimeDescriptor.runtimeDescriptor;
2791
2906
  if (!descriptor)
2792
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.1",
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,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.0",
35
+ "@canonmsg/agent-sdk": "^11.0.1",
35
36
  "@canonmsg/agent-tools": "^0.11.0",
36
37
  "@canonmsg/coding-agent-host": "^0.9.0",
37
- "@canonmsg/core": "^13.0.3",
38
+ "@canonmsg/core": "^13.0.4",
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
+ }