@canonmsg/codex-plugin 0.14.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -4,8 +4,8 @@ import { randomUUID } from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
- import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
8
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, CanonApiError, sendMessageWithRetry, sendMessageWithRetryChunked, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
7
+ import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, sendMessageWithRetry, sendMessageWithRetryChunked, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
9
  import { decideAutoReply, } from './inbound-policy.js';
10
10
  import { CodexConversationAdapter, } from './adapter.js';
11
11
  import { CodexAppServerAdapter } from './app-server-adapter.js';
@@ -15,7 +15,10 @@ import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './per
15
15
  import { detectCodexCliVersion } from './codex-cli-version.js';
16
16
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
17
17
  import { startCodexStreamInBackground } from './host-lifecycle.js';
18
+ import { createCodexControlPoller } from './control-channel.js';
19
+ import { buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
18
20
  import { runCli } from './cli-entry.js';
21
+ import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
19
22
  import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
20
23
  const HELP = `canon-codex — run a local Codex agent host for Canon
21
24
 
@@ -54,9 +57,6 @@ const MAX_SESSIONS = 12;
54
57
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
55
58
  const HEARTBEAT_MS = 30_000;
56
59
  const IDLE_CHECK_MS = 60_000;
57
- const CONTROL_POLL_MS = 2_000;
58
- const IDLE_CONTROL_POLL_MS = 10_000;
59
- const CONTROL_POLL_JITTER_MS = 1_000;
60
60
  const CODEX_RUNTIME_CAPABILITIES = {
61
61
  ...DEFAULT_RUNTIME_CAPABILITIES,
62
62
  supportsInterrupt: true,
@@ -64,29 +64,20 @@ const CODEX_RUNTIME_CAPABILITIES = {
64
64
  supportsQueue: true,
65
65
  supportsNonFinalPermanentMessages: false,
66
66
  };
67
- function controlPollDelayMs(hasActiveWork) {
68
- const base = hasActiveWork ? CONTROL_POLL_MS : IDLE_CONTROL_POLL_MS;
69
- return base + Math.floor(Math.random() * CONTROL_POLL_JITTER_MS);
70
- }
71
67
  let workingDir = process.cwd();
72
68
  let workspaceOptions = [];
73
69
  let workspaceRoots = [];
74
70
  let workspaceRootMetadata = [];
71
+ /** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
72
+ const CODEX_EFFORT_OPTIONS = [
73
+ { value: 'minimal', label: 'Minimal' },
74
+ { value: 'low', label: 'Low' },
75
+ { value: 'medium', label: 'Medium' },
76
+ { value: 'high', label: 'High' },
77
+ ];
78
+ const CODEX_EFFORT_VALUES = new Set(CODEX_EFFORT_OPTIONS.map((option) => option.value));
75
79
  function buildCodexRuntimeDescriptor(input) {
76
80
  const commands = [
77
- ...(input.supportsPlanMode
78
- ? [{
79
- id: 'plan',
80
- label: 'Plan first',
81
- description: 'Ask Codex to plan before implementing. Text after /plan becomes the planning prompt.',
82
- aliases: ['plan'],
83
- category: 'plan',
84
- placements: ['composer_slash', 'command_palette'],
85
- availability: ['always'],
86
- trailingTextBehavior: 'send_as_prompt',
87
- dispatch: { kind: 'text_passthrough', template: '/plan {argument}' },
88
- }]
89
- : []),
90
81
  {
91
82
  id: 'runtime-status',
92
83
  label: 'Runtime status',
@@ -113,8 +104,36 @@ function buildCodexRuntimeDescriptor(input) {
113
104
  executionModes: input.executionModes,
114
105
  permissionModes: input.permissionModes,
115
106
  defaultPermissionMode: input.defaultPermissionMode,
107
+ permissionModeLabel: 'Execution policy',
108
+ modelLiveBehavior: 'next_turn',
109
+ effortOptions: [...CODEX_EFFORT_OPTIONS],
110
+ defaultEffort: 'medium',
111
+ effortLiveBehavior: 'next_turn',
116
112
  presentation: input.presentation,
117
113
  streamingTextMode: 'snapshot',
114
+ ...(input.supportsPlanMode
115
+ ? {
116
+ turnModes: [
117
+ {
118
+ id: 'normal',
119
+ label: 'Normal',
120
+ description: 'Let Codex answer or act normally.',
121
+ scope: 'next_turn',
122
+ default: true,
123
+ activation: { kind: 'message_metadata', value: 'normal' },
124
+ },
125
+ {
126
+ id: 'plan',
127
+ label: 'Plan',
128
+ description: 'Ask Codex to plan before implementing.',
129
+ scope: 'next_turn',
130
+ ownerOnly: true,
131
+ aliases: ['plan'],
132
+ activation: { kind: 'message_metadata', value: 'plan' },
133
+ },
134
+ ],
135
+ }
136
+ : {}),
118
137
  commands,
119
138
  ...(input.supportsRichCards
120
139
  ? {
@@ -162,7 +181,7 @@ async function loadSessionConfig(conversationId, agentId) {
162
181
  return loadHostSessionConfig({
163
182
  conversationId,
164
183
  agentId,
165
- extraStringFields: ['permissionMode'],
184
+ extraStringFields: ['permissionMode', 'effort'],
166
185
  });
167
186
  }
168
187
  function resolveSessionExecutionMode(config) {
@@ -237,6 +256,7 @@ function buildCanonPrompt(input) {
237
256
  activeSelfContextId: input.activeSelfContextId,
238
257
  provenance: input.provenance,
239
258
  replyContext: input.replyContext,
259
+ sessionContextLines: input.sessionContextLines,
240
260
  message: input.message,
241
261
  }));
242
262
  }
@@ -329,15 +349,21 @@ function mapCodexQuestions(value) {
329
349
  const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
330
350
  ? optionRecord.description.trim().slice(0, 300)
331
351
  : undefined;
332
- return [{ label, value: label, ...(description ? { description } : {}) }];
352
+ const preview = typeof optionRecord.preview === 'string' && optionRecord.preview.trim()
353
+ ? optionRecord.preview.trim().slice(0, 4000)
354
+ : undefined;
355
+ return [{ label, value: label, ...(description ? { description } : {}), ...(preview ? { preview } : {}) }];
333
356
  });
334
357
  return [{
335
358
  id,
336
359
  question,
337
360
  ...(header ? { header } : {}),
338
361
  ...(choices.length > 0 ? { choices } : {}),
339
- ...(choices.length > 0 && record.allowOther !== false ? { allowOther: true } : {}),
340
- ...(record.allowOther === true || record.isOther === true ? { allowOther: true } : {}),
362
+ // Shared automatic-"Other" convention (Mac-app parity): on for choice
363
+ // questions unless the agent explicitly opts out.
364
+ ...(resolveQuestionAllowOther(choices.length > 0, record.allowOther ?? record.isOther)
365
+ ? { allowOther: true }
366
+ : {}),
341
367
  ...(record.isSecret === true ? { isSecret: true } : {}),
342
368
  ...(record.multiSelect === true ? { multiSelect: true } : {}),
343
369
  }];
@@ -403,7 +429,7 @@ export async function main() {
403
429
  const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
404
430
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
405
431
  const client = new CanonClient(apiKey, baseUrl);
406
- initRTDBAuth(client);
432
+ const rtdb = initRTDBAuth(client);
407
433
  const typingSignals = createTypingStatusPublisher({
408
434
  setTyping: (conversationId, typing, status) => status
409
435
  ? client.setTyping(conversationId, typing, status)
@@ -575,6 +601,7 @@ export async function main() {
575
601
  hostMode: true,
576
602
  clientType: 'codex',
577
603
  isActive: true,
604
+ ...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
578
605
  }).catch(() => { });
579
606
  }
580
607
  function writeTurn(session) {
@@ -800,12 +827,16 @@ export async function main() {
800
827
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
801
828
  }
802
829
  const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
830
+ const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
831
+ ? config.effort
832
+ : null;
803
833
  const adapter = useAppServer
804
834
  ? new CodexAppServerAdapter({
805
835
  cwd: sessionCwd,
806
836
  threadId: storedThreadId,
807
837
  codexBin,
808
838
  model: policy.model ?? null,
839
+ reasoningEffort: initialEffort,
809
840
  sandbox: policy.sandbox,
810
841
  approvalPolicy: policy.approvalPolicy,
811
842
  addDirs: args['add-dir'] ?? [],
@@ -818,6 +849,7 @@ export async function main() {
818
849
  threadId: storedThreadId,
819
850
  codexBin,
820
851
  model: policy.model ?? null,
852
+ reasoningEffort: initialEffort,
821
853
  sandbox: policy.sandbox,
822
854
  approvalPolicy: policy.approvalPolicy,
823
855
  codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
@@ -854,10 +886,8 @@ export async function main() {
854
886
  turnCommandBlocks: createCommandBlockTracker(),
855
887
  };
856
888
  sessions.set(conversationId, session);
857
- await Promise.all([
858
- baselineControlSignal(conversationId),
859
- baselineSessionControl(conversationId),
860
- ]);
889
+ await controlPoller.baseline([conversationId]);
890
+ ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
861
891
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
862
892
  writeState(session);
863
893
  writeTurn(session);
@@ -1124,6 +1154,7 @@ export async function main() {
1124
1154
  method: request.method,
1125
1155
  },
1126
1156
  details: mappedApproval.details,
1157
+ ...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
1127
1158
  responseUserId: ownerId ?? undefined,
1128
1159
  allowSessionRule: true,
1129
1160
  expiresAt,
@@ -1148,6 +1179,10 @@ export async function main() {
1148
1179
  }
1149
1180
  async function enqueueInboundMessage(input) {
1150
1181
  knownConversationIds.add(input.conversationId);
1182
+ if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
1183
+ console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
1184
+ return;
1185
+ }
1151
1186
  if (isRecord(input.message.metadata)
1152
1187
  && input.message.metadata.type === 'plan_approval_reply'
1153
1188
  && typeof input.message.metadata.decision === 'string') {
@@ -1156,7 +1191,9 @@ export async function main() {
1156
1191
  const decision = input.message.metadata.decision;
1157
1192
  const prompt = decision === 'approve'
1158
1193
  ? 'The plan was approved. Implement the approved plan now.'
1159
- : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1194
+ : decision === 'reject'
1195
+ ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1196
+ : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1160
1197
  enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
1161
1198
  return;
1162
1199
  }
@@ -1173,8 +1210,12 @@ export async function main() {
1173
1210
  }
1174
1211
  }
1175
1212
  const renderedContent = renderInboundContent(input.message, materialized);
1213
+ const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1214
+ const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1176
1215
  const planCommand = useAppServer
1177
- ? parsePlanCommand(renderedContent)
1216
+ ? requestedPlanMode
1217
+ ? { planMode: true, content: renderedContent }
1218
+ : parsePlanCommand(renderedContent)
1178
1219
  : { planMode: false, content: renderedContent };
1179
1220
  const content = planCommand.content;
1180
1221
  const hydrated = await loadHydratedInboundContext({
@@ -1203,14 +1244,18 @@ export async function main() {
1203
1244
  .filter((path) => path !== null);
1204
1245
  const mediaAddDirs = uniqueStrings(promptMaterialized.map((attachment) => dirname(attachment.path)));
1205
1246
  const participantContext = hydrated.participantContext;
1206
- const autoReply = decideAutoReply(participantContext, behavior);
1247
+ const autoReply = input.turnDispatch?.kind === 'run_turn'
1248
+ ? {
1249
+ allow: true,
1250
+ reason: input.turnDispatch.reason || 'server dispatch allowed this turn',
1251
+ }
1252
+ : decideAutoReply(participantContext, behavior);
1207
1253
  if (!autoReply.allow) {
1208
1254
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1209
1255
  return;
1210
1256
  }
1211
1257
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
1212
1258
  markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
1213
- const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1214
1259
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
1215
1260
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
1216
1261
  let session;
@@ -1242,6 +1287,7 @@ export async function main() {
1242
1287
  activeSelfContextId,
1243
1288
  provenance: hydrated.provenance,
1244
1289
  replyContext,
1290
+ sessionContextLines: [buildOutboxContextLine(session.cwd)],
1245
1291
  message: input.message,
1246
1292
  });
1247
1293
  if (session.running && deliveryIntent === 'interrupt') {
@@ -1254,6 +1300,45 @@ export async function main() {
1254
1300
  }
1255
1301
  enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
1256
1302
  }
1303
+ /**
1304
+ * Turn-end media outbox flush (see ./outbox.ts for the convention). Never
1305
+ * throws: upload failures are logged and the files stay in place for a
1306
+ * later turn.
1307
+ */
1308
+ async function flushSessionOutbox(session) {
1309
+ const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
1310
+ try {
1311
+ const result = await flushOutbox({
1312
+ outboxDir: resolveOutboxDir(session.cwd),
1313
+ send: (file) => sendMediaFileMessage(client, session.conversationId, file.path, '', {
1314
+ ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1315
+ metadata: {
1316
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1317
+ // Media is permanent conversation content (promoted like a final
1318
+ // reply) but must never re-trigger other agents — the host's own
1319
+ // final text message stays the only turn-complete trigger.
1320
+ turnSemantics: 'turn_complete',
1321
+ replyBehavior: 'suppress_auto_reply',
1322
+ },
1323
+ }),
1324
+ });
1325
+ for (const entry of result.sent) {
1326
+ console.error(`${logPrefix} Sent outbox file ${entry.file.fileName} (${entry.messageId})`
1327
+ + (entry.removeFailed ? ' — removal failed; it may resend next turn' : ''));
1328
+ }
1329
+ for (const failure of result.failed) {
1330
+ console.error(`${logPrefix} Outbox upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
1331
+ }
1332
+ for (const skipped of result.skipped) {
1333
+ if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
1334
+ console.error(`${logPrefix} Outbox skipped ${skipped.fileName} (${skipped.reason}); left in place`);
1335
+ }
1336
+ }
1337
+ }
1338
+ catch (error) {
1339
+ console.error(`${logPrefix} Outbox flush failed:`, error instanceof Error ? error.message : error);
1340
+ }
1341
+ }
1257
1342
  async function runNextTurn(session) {
1258
1343
  if (session.running || session.closed)
1259
1344
  return;
@@ -1362,6 +1447,15 @@ export async function main() {
1362
1447
  return;
1363
1448
  }
1364
1449
  if (event.type === 'turn.completed') {
1450
+ // Codex reports per-turn token usage but no context window, so the
1451
+ // meter publishes tokens only (input + cached covers the full
1452
+ // prompt context of the completed turn).
1453
+ const totalTokens = (event.usage?.input_tokens ?? 0)
1454
+ + (event.usage?.cached_input_tokens ?? 0)
1455
+ + (event.usage?.output_tokens ?? 0);
1456
+ if (totalTokens > 0) {
1457
+ session.state.contextUsage = { totalTokens };
1458
+ }
1365
1459
  writeState(session);
1366
1460
  }
1367
1461
  };
@@ -1389,6 +1483,11 @@ export async function main() {
1389
1483
  if (result.threadId && !session.resetRequested) {
1390
1484
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1391
1485
  }
1486
+ // Turn-end outbox flush — media lands before the final text reply.
1487
+ // Interrupted turns keep their files for the next completed turn.
1488
+ if (!result.interrupted) {
1489
+ await flushSessionOutbox(session);
1490
+ }
1392
1491
  if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1393
1492
  const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1394
1493
  responseUserId: ownerId ?? undefined,
@@ -1504,9 +1603,6 @@ export async function main() {
1504
1603
  }
1505
1604
  }
1506
1605
  }
1507
- let controlStopped = false;
1508
- const lastSeenControl = new Map();
1509
- const lastSeenSignal = new Map();
1510
1606
  let streamConnected = false;
1511
1607
  const hostAvailableExecutionModes = [
1512
1608
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1540,28 +1636,87 @@ export async function main() {
1540
1636
  supportsRichCards: useAppServer,
1541
1637
  }),
1542
1638
  };
1543
- async function baselineControlSignal(conversationId) {
1544
- if (lastSeenSignal.has(conversationId))
1545
- return;
1546
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`).catch(() => null);
1547
- if (!raw || typeof raw !== 'object')
1639
+ function applySessionControl(conversationId, control) {
1640
+ const session = sessions.get(conversationId);
1641
+ if (!session || session.closed)
1548
1642
  return;
1549
- const timestamp = Number(raw.updatedAt ?? 0);
1550
- if (timestamp > 0) {
1551
- lastSeenSignal.set(conversationId, timestamp);
1643
+ if (control.model && control.model !== session.state.model) {
1644
+ const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
1645
+ if (modelGuard) {
1646
+ session.state.lastError = modelGuard;
1647
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1648
+ writeState(session);
1649
+ // The poller consumes the node; skip effort handling for this pass,
1650
+ // matching the legacy loop.
1651
+ return;
1652
+ }
1653
+ session.adapter.setModel(control.model);
1654
+ session.state.model = control.model;
1655
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1656
+ writeState(session);
1657
+ }
1658
+ if (control.permissionMode) {
1659
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1660
+ }
1661
+ if (control.effort) {
1662
+ if (CODEX_EFFORT_VALUES.has(control.effort)) {
1663
+ session.adapter.setReasoningEffort(control.effort);
1664
+ session.state.effort = control.effort;
1665
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
1666
+ writeState(session);
1667
+ }
1668
+ else {
1669
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
1670
+ }
1552
1671
  }
1553
1672
  }
1554
- async function baselineSessionControl(conversationId) {
1555
- if (lastSeenControl.has(conversationId))
1673
+ async function handleControlSignal(event) {
1674
+ const { conversationId, type } = event;
1675
+ const session = sessions.get(conversationId);
1676
+ // No live session: dedupe already advanced, but the node stays in place,
1677
+ // matching the legacy loop.
1678
+ if (!session || session.closed)
1679
+ return { consume: false };
1680
+ if (type === 'new_session') {
1681
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
1682
+ await resetRuntimeSession(session);
1556
1683
  return;
1557
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
1558
- if (!raw || typeof raw !== 'object')
1684
+ }
1685
+ if (!session.running && (type !== 'stop_and_drop' || session.queue.length === 0)) {
1686
+ // Nothing to interrupt or drop — just consume the signal.
1559
1687
  return;
1560
- const timestamp = Number(raw.updatedAt ?? 0);
1561
- if (timestamp > 0) {
1562
- lastSeenControl.set(conversationId, timestamp);
1563
1688
  }
1689
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
1690
+ if (type === 'stop_and_drop') {
1691
+ const droppedPrompts = session.queue.splice(0);
1692
+ await markQueuedPromptsRejected(conversationId, droppedPrompts);
1693
+ }
1694
+ if (session.running) {
1695
+ await session.adapter.interrupt();
1696
+ }
1697
+ session.turnState = 'interrupted';
1698
+ writeTurn(session);
1699
+ clearStreaming(conversationId);
1700
+ typingSignals.clear(conversationId).catch(() => { });
1564
1701
  }
1702
+ const controlPoller = createCodexControlPoller({
1703
+ rtdb,
1704
+ agentId,
1705
+ conversationIds: () => sessions.keys(),
1706
+ hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
1707
+ && (session.running || session.queue.length > 0 || session.turnState === 'waiting_input')),
1708
+ onSessionControl: ({ conversationId, control }) => {
1709
+ applySessionControl(conversationId, control);
1710
+ },
1711
+ onSignal: handleControlSignal,
1712
+ onError: (error) => {
1713
+ // The legacy loop ignored transient RTDB failures; keep read/consume
1714
+ // errors quiet but surface handler failures.
1715
+ if (error.scope !== 'handler')
1716
+ return;
1717
+ console.error(`[canon-codex] [${(error.conversationId ?? 'unknown').slice(0, 8)}] Control ${error.key ?? 'poll'} handler failed:`, error.error instanceof Error ? error.error.message : error.error);
1718
+ },
1719
+ });
1565
1720
  let publishRuntimeDetailsInFlight = false;
1566
1721
  const publishRuntimeHeartbeat = async () => {
1567
1722
  heartbeatLocalRuntimeEntry(runtimeId, {
@@ -1618,6 +1773,12 @@ export async function main() {
1618
1773
  const payload = {
1619
1774
  descriptor,
1620
1775
  surfaceMode: 'host',
1776
+ // The exec --json transport cannot block on approvals — without a
1777
+ // strip-level warning a user can believe they have an approval
1778
+ // gate they do not have.
1779
+ ...(useAppServer
1780
+ ? {}
1781
+ : { warning: "Approvals can't block on this Codex CLI — update Codex to enable the app-server transport and approval gates." }),
1621
1782
  statusItems: [
1622
1783
  {
1623
1784
  id: 'transport',
@@ -1643,6 +1804,11 @@ export async function main() {
1643
1804
  value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1644
1805
  ...(useAppServer ? {} : { tone: 'warning' }),
1645
1806
  },
1807
+ {
1808
+ id: 'mediaOut',
1809
+ label: 'Media out',
1810
+ value: 'Turn-end outbox',
1811
+ },
1646
1812
  ],
1647
1813
  execution: {
1648
1814
  resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
@@ -1677,6 +1843,10 @@ export async function main() {
1677
1843
  const message = payload.message;
1678
1844
  if (message.senderId === agentId)
1679
1845
  return;
1846
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
1847
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
1848
+ return;
1849
+ }
1680
1850
  void enqueueInboundMessage({
1681
1851
  conversationId: payload.conversationId,
1682
1852
  message,
@@ -1686,6 +1856,7 @@ export async function main() {
1686
1856
  activeSelfContextId: payload.activeSelfContextId,
1687
1857
  selfContexts: payload.selfContexts,
1688
1858
  provenance: payload.provenance,
1859
+ turnDispatch: payload.turnDispatch,
1689
1860
  });
1690
1861
  if (message.id) {
1691
1862
  saveRuntimeSessionState(runtimeId, {
@@ -1774,17 +1945,19 @@ export async function main() {
1774
1945
  conversationId: conversation.id,
1775
1946
  baseCwd: workingDir,
1776
1947
  })?.lastInboundMessageId;
1777
- const latestPage = await client.getMessagesPage(conversation.id, 25);
1778
- const inboundMessages = latestPage.messages
1779
- .filter((message) => message.senderId !== agentId)
1780
- .sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
1781
- const cursorIndex = cursor
1782
- ? inboundMessages.findIndex((message) => message.id === cursor)
1783
- : -1;
1784
- const messagesToRecover = cursorIndex >= 0
1785
- ? inboundMessages.slice(cursorIndex + 1)
1786
- : inboundMessages.slice(-1);
1787
- for (const latestMessage of messagesToRecover) {
1948
+ const recovery = await collectMissedInboundMessages({
1949
+ fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
1950
+ cursor,
1951
+ agentId,
1952
+ });
1953
+ const latestPage = recovery.newestPage;
1954
+ if (recovery.mode === 'truncated-window') {
1955
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
1956
+ }
1957
+ if (recovery.messages.length > 1) {
1958
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
1959
+ }
1960
+ for (const latestMessage of recovery.messages) {
1788
1961
  const triggerDecision = shouldTriggerAgentTurn({
1789
1962
  senderType: latestMessage.senderType ?? 'human',
1790
1963
  metadata: latestMessage.metadata,
@@ -1819,89 +1992,7 @@ export async function main() {
1819
1992
  startCodexStreamInBackground(stream, (error) => {
1820
1993
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
1821
1994
  });
1822
- const pollControl = async () => {
1823
- while (!controlStopped) {
1824
- const hadActiveWork = [...sessions.values()].some((session) => !session.closed
1825
- && (session.running || session.queue.length > 0 || session.turnState === 'waiting_input'));
1826
- for (const conversationId of [...sessions.keys()]) {
1827
- try {
1828
- const controlRaw = await rtdbRead(`/control/${conversationId}/${agentId}/session`);
1829
- if (controlRaw && typeof controlRaw === 'object') {
1830
- const control = controlRaw;
1831
- const timestamp = control.updatedAt ?? 0;
1832
- if (timestamp > (lastSeenControl.get(conversationId) ?? 0)) {
1833
- lastSeenControl.set(conversationId, timestamp);
1834
- const session = sessions.get(conversationId);
1835
- if (session && !session.closed) {
1836
- if (control.model && control.model !== session.state.model) {
1837
- const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
1838
- if (modelGuard) {
1839
- session.state.lastError = modelGuard;
1840
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1841
- writeState(session);
1842
- await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
1843
- continue;
1844
- }
1845
- session.adapter.setModel(control.model);
1846
- session.state.model = control.model;
1847
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1848
- writeState(session);
1849
- }
1850
- if (control.permissionMode) {
1851
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1852
- }
1853
- if (control.effort) {
1854
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
1855
- }
1856
- }
1857
- await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
1858
- }
1859
- }
1860
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`);
1861
- if (!raw || typeof raw !== 'object')
1862
- continue;
1863
- const signal = raw;
1864
- const timestamp = signal.updatedAt ?? 0;
1865
- if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop' && signal.type !== 'new_session')
1866
- || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
1867
- continue;
1868
- }
1869
- lastSeenSignal.set(conversationId, timestamp);
1870
- const session = sessions.get(conversationId);
1871
- if (!session || session.closed)
1872
- continue;
1873
- if (signal.type === 'new_session') {
1874
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
1875
- await resetRuntimeSession(session);
1876
- await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1877
- continue;
1878
- }
1879
- if (!session.running && (signal.type !== 'stop_and_drop' || session.queue.length === 0)) {
1880
- await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1881
- continue;
1882
- }
1883
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
1884
- if (signal.type === 'stop_and_drop') {
1885
- const droppedPrompts = session.queue.splice(0);
1886
- await markQueuedPromptsRejected(conversationId, droppedPrompts);
1887
- }
1888
- if (session.running) {
1889
- await session.adapter.interrupt();
1890
- }
1891
- session.turnState = 'interrupted';
1892
- writeTurn(session);
1893
- clearStreaming(conversationId);
1894
- typingSignals.clear(conversationId).catch(() => { });
1895
- await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1896
- }
1897
- catch {
1898
- // Ignore transient RTDB failures.
1899
- }
1900
- }
1901
- await new Promise((resolve) => setTimeout(resolve, controlPollDelayMs(hadActiveWork)));
1902
- }
1903
- };
1904
- void pollControl();
1995
+ controlPoller.start();
1905
1996
  const heartbeat = setInterval(() => {
1906
1997
  for (const session of sessions.values()) {
1907
1998
  writeState(session);
@@ -1925,7 +2016,7 @@ export async function main() {
1925
2016
  }, IDLE_CHECK_MS);
1926
2017
  const shutdown = async () => {
1927
2018
  console.error('[canon-codex] Shutting down...');
1928
- controlStopped = true;
2019
+ controlPoller.stop();
1929
2020
  clearInterval(heartbeat);
1930
2021
  clearInterval(idleCheck);
1931
2022
  stream.stop();
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { main as hostMain } from './host.js';
2
2
  export { main as registerMain } from './register.js';
3
3
  export { main as setupMain } from './setup.js';
4
- export { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
4
+ export { extractCodexApprovalDiff, mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
5
5
  export type { CanonCodexApprovalRequest, CodexAppServerApprovalMethod, CodexApprovalDecision, CodexNativeApprovalRequest, } from './app-server-approval.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { main as hostMain } from './host.js';
2
2
  export { main as registerMain } from './register.js';
3
3
  export { main as setupMain } from './setup.js';
4
- export { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
4
+ export { extractCodexApprovalDiff, mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';