@canonmsg/codex-plugin 0.14.1 → 0.18.1

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,14 +64,18 @@ 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
81
  {
@@ -100,6 +104,11 @@ function buildCodexRuntimeDescriptor(input) {
100
104
  executionModes: input.executionModes,
101
105
  permissionModes: input.permissionModes,
102
106
  defaultPermissionMode: input.defaultPermissionMode,
107
+ permissionModeLabel: 'Execution policy',
108
+ modelLiveBehavior: 'next_turn',
109
+ effortOptions: [...CODEX_EFFORT_OPTIONS],
110
+ defaultEffort: 'medium',
111
+ effortLiveBehavior: 'next_turn',
103
112
  presentation: input.presentation,
104
113
  streamingTextMode: 'snapshot',
105
114
  ...(input.supportsPlanMode
@@ -172,7 +181,7 @@ async function loadSessionConfig(conversationId, agentId) {
172
181
  return loadHostSessionConfig({
173
182
  conversationId,
174
183
  agentId,
175
- extraStringFields: ['permissionMode'],
184
+ extraStringFields: ['permissionMode', 'effort'],
176
185
  });
177
186
  }
178
187
  function resolveSessionExecutionMode(config) {
@@ -247,6 +256,7 @@ function buildCanonPrompt(input) {
247
256
  activeSelfContextId: input.activeSelfContextId,
248
257
  provenance: input.provenance,
249
258
  replyContext: input.replyContext,
259
+ sessionContextLines: input.sessionContextLines,
250
260
  message: input.message,
251
261
  }));
252
262
  }
@@ -339,15 +349,21 @@ function mapCodexQuestions(value) {
339
349
  const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
340
350
  ? optionRecord.description.trim().slice(0, 300)
341
351
  : undefined;
342
- 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 } : {}) }];
343
356
  });
344
357
  return [{
345
358
  id,
346
359
  question,
347
360
  ...(header ? { header } : {}),
348
361
  ...(choices.length > 0 ? { choices } : {}),
349
- ...(choices.length > 0 && record.allowOther !== false ? { allowOther: true } : {}),
350
- ...(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
+ : {}),
351
367
  ...(record.isSecret === true ? { isSecret: true } : {}),
352
368
  ...(record.multiSelect === true ? { multiSelect: true } : {}),
353
369
  }];
@@ -413,7 +429,7 @@ export async function main() {
413
429
  const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
414
430
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
415
431
  const client = new CanonClient(apiKey, baseUrl);
416
- initRTDBAuth(client);
432
+ const rtdb = initRTDBAuth(client);
417
433
  const typingSignals = createTypingStatusPublisher({
418
434
  setTyping: (conversationId, typing, status) => status
419
435
  ? client.setTyping(conversationId, typing, status)
@@ -585,6 +601,7 @@ export async function main() {
585
601
  hostMode: true,
586
602
  clientType: 'codex',
587
603
  isActive: true,
604
+ ...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
588
605
  }).catch(() => { });
589
606
  }
590
607
  function writeTurn(session) {
@@ -810,12 +827,16 @@ export async function main() {
810
827
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
811
828
  }
812
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;
813
833
  const adapter = useAppServer
814
834
  ? new CodexAppServerAdapter({
815
835
  cwd: sessionCwd,
816
836
  threadId: storedThreadId,
817
837
  codexBin,
818
838
  model: policy.model ?? null,
839
+ reasoningEffort: initialEffort,
819
840
  sandbox: policy.sandbox,
820
841
  approvalPolicy: policy.approvalPolicy,
821
842
  addDirs: args['add-dir'] ?? [],
@@ -828,6 +849,7 @@ export async function main() {
828
849
  threadId: storedThreadId,
829
850
  codexBin,
830
851
  model: policy.model ?? null,
852
+ reasoningEffort: initialEffort,
831
853
  sandbox: policy.sandbox,
832
854
  approvalPolicy: policy.approvalPolicy,
833
855
  codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
@@ -864,10 +886,8 @@ export async function main() {
864
886
  turnCommandBlocks: createCommandBlockTracker(),
865
887
  };
866
888
  sessions.set(conversationId, session);
867
- await Promise.all([
868
- baselineControlSignal(conversationId),
869
- baselineSessionControl(conversationId),
870
- ]);
889
+ await controlPoller.baseline([conversationId]);
890
+ ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
871
891
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
872
892
  writeState(session);
873
893
  writeTurn(session);
@@ -1134,6 +1154,7 @@ export async function main() {
1134
1154
  method: request.method,
1135
1155
  },
1136
1156
  details: mappedApproval.details,
1157
+ ...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
1137
1158
  responseUserId: ownerId ?? undefined,
1138
1159
  allowSessionRule: true,
1139
1160
  expiresAt,
@@ -1170,7 +1191,9 @@ export async function main() {
1170
1191
  const decision = input.message.metadata.decision;
1171
1192
  const prompt = decision === 'approve'
1172
1193
  ? 'The plan was approved. Implement the approved plan now.'
1173
- : `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}` : ''}`;
1174
1197
  enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
1175
1198
  return;
1176
1199
  }
@@ -1264,6 +1287,7 @@ export async function main() {
1264
1287
  activeSelfContextId,
1265
1288
  provenance: hydrated.provenance,
1266
1289
  replyContext,
1290
+ sessionContextLines: [buildOutboxContextLine(session.cwd)],
1267
1291
  message: input.message,
1268
1292
  });
1269
1293
  if (session.running && deliveryIntent === 'interrupt') {
@@ -1276,6 +1300,45 @@ export async function main() {
1276
1300
  }
1277
1301
  enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
1278
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
+ }
1279
1342
  async function runNextTurn(session) {
1280
1343
  if (session.running || session.closed)
1281
1344
  return;
@@ -1384,6 +1447,15 @@ export async function main() {
1384
1447
  return;
1385
1448
  }
1386
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
+ }
1387
1459
  writeState(session);
1388
1460
  }
1389
1461
  };
@@ -1411,6 +1483,11 @@ export async function main() {
1411
1483
  if (result.threadId && !session.resetRequested) {
1412
1484
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1413
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
+ }
1414
1491
  if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1415
1492
  const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1416
1493
  responseUserId: ownerId ?? undefined,
@@ -1526,9 +1603,6 @@ export async function main() {
1526
1603
  }
1527
1604
  }
1528
1605
  }
1529
- let controlStopped = false;
1530
- const lastSeenControl = new Map();
1531
- const lastSeenSignal = new Map();
1532
1606
  let streamConnected = false;
1533
1607
  const hostAvailableExecutionModes = [
1534
1608
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1562,28 +1636,87 @@ export async function main() {
1562
1636
  supportsRichCards: useAppServer,
1563
1637
  }),
1564
1638
  };
1565
- async function baselineControlSignal(conversationId) {
1566
- if (lastSeenSignal.has(conversationId))
1567
- return;
1568
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`).catch(() => null);
1569
- if (!raw || typeof raw !== 'object')
1639
+ function applySessionControl(conversationId, control) {
1640
+ const session = sessions.get(conversationId);
1641
+ if (!session || session.closed)
1570
1642
  return;
1571
- const timestamp = Number(raw.updatedAt ?? 0);
1572
- if (timestamp > 0) {
1573
- 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
+ }
1574
1671
  }
1575
1672
  }
1576
- async function baselineSessionControl(conversationId) {
1577
- 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);
1578
1683
  return;
1579
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
1580
- 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.
1581
1687
  return;
1582
- const timestamp = Number(raw.updatedAt ?? 0);
1583
- if (timestamp > 0) {
1584
- lastSeenControl.set(conversationId, timestamp);
1585
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(() => { });
1586
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
+ });
1587
1720
  let publishRuntimeDetailsInFlight = false;
1588
1721
  const publishRuntimeHeartbeat = async () => {
1589
1722
  heartbeatLocalRuntimeEntry(runtimeId, {
@@ -1640,6 +1773,12 @@ export async function main() {
1640
1773
  const payload = {
1641
1774
  descriptor,
1642
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." }),
1643
1782
  statusItems: [
1644
1783
  {
1645
1784
  id: 'transport',
@@ -1665,6 +1804,11 @@ export async function main() {
1665
1804
  value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1666
1805
  ...(useAppServer ? {} : { tone: 'warning' }),
1667
1806
  },
1807
+ {
1808
+ id: 'mediaOut',
1809
+ label: 'Media out',
1810
+ value: 'Turn-end outbox',
1811
+ },
1668
1812
  ],
1669
1813
  execution: {
1670
1814
  resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
@@ -1801,17 +1945,19 @@ export async function main() {
1801
1945
  conversationId: conversation.id,
1802
1946
  baseCwd: workingDir,
1803
1947
  })?.lastInboundMessageId;
1804
- const latestPage = await client.getMessagesPage(conversation.id, 25);
1805
- const inboundMessages = latestPage.messages
1806
- .filter((message) => message.senderId !== agentId)
1807
- .sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
1808
- const cursorIndex = cursor
1809
- ? inboundMessages.findIndex((message) => message.id === cursor)
1810
- : -1;
1811
- const messagesToRecover = cursorIndex >= 0
1812
- ? inboundMessages.slice(cursorIndex + 1)
1813
- : inboundMessages.slice(-1);
1814
- 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) {
1815
1961
  const triggerDecision = shouldTriggerAgentTurn({
1816
1962
  senderType: latestMessage.senderType ?? 'human',
1817
1963
  metadata: latestMessage.metadata,
@@ -1846,89 +1992,7 @@ export async function main() {
1846
1992
  startCodexStreamInBackground(stream, (error) => {
1847
1993
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
1848
1994
  });
1849
- const pollControl = async () => {
1850
- while (!controlStopped) {
1851
- const hadActiveWork = [...sessions.values()].some((session) => !session.closed
1852
- && (session.running || session.queue.length > 0 || session.turnState === 'waiting_input'));
1853
- for (const conversationId of [...sessions.keys()]) {
1854
- try {
1855
- const controlRaw = await rtdbRead(`/control/${conversationId}/${agentId}/session`);
1856
- if (controlRaw && typeof controlRaw === 'object') {
1857
- const control = controlRaw;
1858
- const timestamp = control.updatedAt ?? 0;
1859
- if (timestamp > (lastSeenControl.get(conversationId) ?? 0)) {
1860
- lastSeenControl.set(conversationId, timestamp);
1861
- const session = sessions.get(conversationId);
1862
- if (session && !session.closed) {
1863
- if (control.model && control.model !== session.state.model) {
1864
- const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
1865
- if (modelGuard) {
1866
- session.state.lastError = modelGuard;
1867
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1868
- writeState(session);
1869
- await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
1870
- continue;
1871
- }
1872
- session.adapter.setModel(control.model);
1873
- session.state.model = control.model;
1874
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1875
- writeState(session);
1876
- }
1877
- if (control.permissionMode) {
1878
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1879
- }
1880
- if (control.effort) {
1881
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
1882
- }
1883
- }
1884
- await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
1885
- }
1886
- }
1887
- const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`);
1888
- if (!raw || typeof raw !== 'object')
1889
- continue;
1890
- const signal = raw;
1891
- const timestamp = signal.updatedAt ?? 0;
1892
- if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop' && signal.type !== 'new_session')
1893
- || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
1894
- continue;
1895
- }
1896
- lastSeenSignal.set(conversationId, timestamp);
1897
- const session = sessions.get(conversationId);
1898
- if (!session || session.closed)
1899
- continue;
1900
- if (signal.type === 'new_session') {
1901
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
1902
- await resetRuntimeSession(session);
1903
- await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1904
- continue;
1905
- }
1906
- if (!session.running && (signal.type !== 'stop_and_drop' || session.queue.length === 0)) {
1907
- await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1908
- continue;
1909
- }
1910
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
1911
- if (signal.type === 'stop_and_drop') {
1912
- const droppedPrompts = session.queue.splice(0);
1913
- await markQueuedPromptsRejected(conversationId, droppedPrompts);
1914
- }
1915
- if (session.running) {
1916
- await session.adapter.interrupt();
1917
- }
1918
- session.turnState = 'interrupted';
1919
- writeTurn(session);
1920
- clearStreaming(conversationId);
1921
- typingSignals.clear(conversationId).catch(() => { });
1922
- await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1923
- }
1924
- catch {
1925
- // Ignore transient RTDB failures.
1926
- }
1927
- }
1928
- await new Promise((resolve) => setTimeout(resolve, controlPollDelayMs(hadActiveWork)));
1929
- }
1930
- };
1931
- void pollControl();
1995
+ controlPoller.start();
1932
1996
  const heartbeat = setInterval(() => {
1933
1997
  for (const session of sessions.values()) {
1934
1998
  writeState(session);
@@ -1952,7 +2016,7 @@ export async function main() {
1952
2016
  }, IDLE_CHECK_MS);
1953
2017
  const shutdown = async () => {
1954
2018
  console.error('[canon-codex] Shutting down...');
1955
- controlStopped = true;
2019
+ controlPoller.stop();
1956
2020
  clearInterval(heartbeat);
1957
2021
  clearInterval(idleCheck);
1958
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';
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Turn-end media outbox for Canon coding hosts.
3
+ *
4
+ * The host advertises a per-conversation outbox directory inside the session
5
+ * working directory (`<cwd>/.canon/outbox/`). When the runtime wants a file
6
+ * (screenshot, plot, artifact) delivered to the Canon conversation it writes
7
+ * the file there — an explicit channel, never inferred from reply prose. At
8
+ * turn end the host scans the outbox, uploads each regular file as a Canon
9
+ * media attachment, and removes files that were delivered. Failed uploads
10
+ * stay in place for a later turn; subdirectories, symlinks, and dotfiles are
11
+ * ignored.
12
+ *
13
+ * This module is intentionally identical in packages/claude-code-plugin and
14
+ * packages/codex-plugin — keep both copies in sync (future consolidation
15
+ * candidate).
16
+ */
17
+ export declare const OUTBOX_MAX_FILES_PER_TURN = 8;
18
+ export declare const OUTBOX_MAX_FILE_BYTES: number;
19
+ export interface OutboxFile {
20
+ path: string;
21
+ fileName: string;
22
+ sizeBytes: number;
23
+ }
24
+ export type OutboxSkipReason = 'not-regular-file' | 'hidden' | 'too-large' | 'file-cap';
25
+ export interface OutboxScanResult {
26
+ /** Regular files eligible for upload this turn, ordered by file name. */
27
+ files: OutboxFile[];
28
+ skipped: Array<{
29
+ fileName: string;
30
+ reason: OutboxSkipReason;
31
+ }>;
32
+ }
33
+ export interface OutboxFlushResult {
34
+ sent: Array<{
35
+ file: OutboxFile;
36
+ messageId: string;
37
+ removeFailed?: true;
38
+ }>;
39
+ failed: Array<{
40
+ file: OutboxFile;
41
+ error: string;
42
+ }>;
43
+ skipped: OutboxScanResult['skipped'];
44
+ }
45
+ export declare function resolveOutboxDir(sessionCwd: string): string;
46
+ /**
47
+ * Create the outbox directory for a session and drop a `.gitignore` into the
48
+ * host-managed `.canon/` dir (only when absent) so outbox state never shows
49
+ * up as untracked dirt inside project checkouts or conversation worktrees.
50
+ */
51
+ export declare function ensureOutboxDir(sessionCwd: string): Promise<string>;
52
+ /**
53
+ * The one terse paragraph injected into the runtime's Canon context so the
54
+ * agent knows the outbox exists. Hosts may append their own extra sentence
55
+ * (e.g. an immediate-send tool) but must not paraphrase the convention.
56
+ */
57
+ export declare function buildOutboxContextLine(sessionCwd: string): string;
58
+ /**
59
+ * Discover the outbox files eligible for upload this turn. A missing outbox
60
+ * directory is an empty result. Entries are ordered by file name so multi-file
61
+ * turns deliver deterministically; everything past the per-turn cap (or over
62
+ * the size cap) is left in place and reported as skipped.
63
+ */
64
+ export declare function scanOutbox(outboxDir: string, options?: {
65
+ maxFiles?: number;
66
+ maxFileBytes?: number;
67
+ }): Promise<OutboxScanResult>;
68
+ /**
69
+ * Upload-and-consume pass over the outbox. Each eligible file is handed to
70
+ * `send`; on success the file is removed (consumed), on failure it is left in
71
+ * place for a later turn. A failed removal after a successful send is still
72
+ * reported as sent (flagged `removeFailed`) so callers can warn about a
73
+ * potential duplicate next turn instead of re-reporting a delivery failure.
74
+ */
75
+ export declare function flushOutbox(input: {
76
+ outboxDir: string;
77
+ send: (file: OutboxFile) => Promise<{
78
+ messageId: string;
79
+ }>;
80
+ maxFiles?: number;
81
+ maxFileBytes?: number;
82
+ remove?: (path: string) => Promise<void>;
83
+ }): Promise<OutboxFlushResult>;