@canonmsg/codex-plugin 0.18.11 → 0.19.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,22 +4,29 @@ 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, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
- import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
9
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, 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, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
10
- import { decideAutoReply, } from './inbound-policy.js';
7
+ import { getCodexImagePath, inferUploadMimeType, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
8
+ import { resolvePackagedBridgeBin } from './bridge-bin.js';
9
+ import { connectBridge } from '@canonmsg/framework';
10
+ import { ConnectionClosedError, JsonRpcError } from '@canonmsg/framework/protocol';
11
+ /** Send failed at the Canon/bridge boundary (turn itself completed). */
12
+ function isBridgeDeliveryError(error) {
13
+ return error instanceof JsonRpcError || error instanceof ConnectionClosedError;
14
+ }
15
+ import { STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, captureTurnArtifactSnapshot, collectMissedInboundMessages, collectTurnArtifacts, createBufferedNotificationSource, createConversationDirectory, createFinalDelivery, createHitlWaiters, createPortTypingSignals, createRuntimeSignalHandler, createRuntimeWriters, createSessionLifecycle, createTurnQueue, decideAutoReply, handoffFinalMessage, } from '@canonmsg/agent-host';
16
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildFirstPartyCodingRuntimeDescriptor, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, EXECUTION_ENVIRONMENT_MODES, DEFAULT_RUNTIME_CAPABILITIES, normalizeTurnMetadata, parseRuntimeCardV1, shouldTriggerAgentTurn, renderCanonHostInboundContent, renderCodingHostInboundPrompt, } from '@canonmsg/core/contract';
17
+ import { ExecutionEnvironmentError, buildConfiguredWorkspaceOptionsWithRoots, buildLocalRuntimeId, buildPublicWorkspaceOptions, buildPublicWorkspaceRoots, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, prepareConversationEnvironment, releaseConversationEnvironment, resolveCanonAgent, saveRuntimeSessionState, upsertLocalRuntimeEntry, } from '@canonmsg/core/local';
18
+ import { readHostSessionConfig, resolveHostWorkspaceCwd, } from '@canonmsg/core/host';
11
19
  import { CodexConversationAdapter, } from './adapter.js';
12
20
  import { CodexAppServerAdapter } from './app-server-adapter.js';
21
+ import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
13
22
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
14
23
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
15
24
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
16
25
  import { detectCodexCliVersion } from './codex-cli-version.js';
17
26
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
18
- import { startCodexStreamInBackground } from './host-lifecycle.js';
19
- import { createCodexControlPoller } from './control-channel.js';
27
+ import { attachCodexControlNotifications } from './control-channel.js';
20
28
  import { runCli } from './cli-entry.js';
21
- import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
22
- import { applyTextSegmentBlock, beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
29
+ import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, textSegmentBlockId, } from './turn-activity.js';
23
30
  const HELP = `canon-codex — run a local Codex agent host for Canon
24
31
 
25
32
  USAGE
@@ -71,10 +78,29 @@ export function buildCodexLiveSessionConfig(input) {
71
78
  executionBranch: input.executionBranch ?? null,
72
79
  };
73
80
  }
81
+ function buildCodexQueuedInput(input) {
82
+ return {
83
+ turnKey: randomUUID(),
84
+ prompt: input.prompt,
85
+ intent: input.intent ?? 'queue',
86
+ sourceMessageId: input.sourceMessageId ?? null,
87
+ markAccepted: Boolean(input.markAccepted),
88
+ imagePaths: input.imagePaths ?? [],
89
+ mediaAddDirs: input.mediaAddDirs ?? [],
90
+ planMode: Boolean(input.planMode),
91
+ artifactRoutingMode: input.artifactRoutingMode ?? 'disabled',
92
+ canUseCodexAppTools: Boolean(input.canUseCodexAppTools),
93
+ };
94
+ }
74
95
  const MAX_SESSIONS = 12;
75
96
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
97
+ /** Claude's streaming write throttle, adopted for both hosts (bridge plan Phase 2). */
98
+ const STREAMING_THROTTLE_MS = 250;
76
99
  const HEARTBEAT_MS = 30_000;
77
100
  const IDLE_CHECK_MS = 60_000;
101
+ /** Claude's bounded final-delivery retry machine, adopted for both hosts (bridge plan Phase 2). */
102
+ const FINAL_DELIVERY_RETRY_MS = 30_000;
103
+ const MAX_FINAL_DELIVERY_RETRIES = 1;
78
104
  const CODEX_RUNTIME_CAPABILITIES = {
79
105
  ...DEFAULT_RUNTIME_CAPABILITIES,
80
106
  supportsInterrupt: true,
@@ -82,6 +108,11 @@ const CODEX_RUNTIME_CAPABILITIES = {
82
108
  supportsQueue: true,
83
109
  supportsNonFinalPermanentMessages: false,
84
110
  };
111
+ // This host process resolves and locks exactly one agent profile. The lock
112
+ // handle returned by resolveCanonAgent is held here so the top-level runCli
113
+ // error handler (outside main's scope) can release it on a failed start —
114
+ // there is no core module-global profile-lock to fall back on.
115
+ let activeLockHandle = null;
85
116
  let workingDir = process.cwd();
86
117
  let workspaceOptions = [];
87
118
  let workspaceRoots = [];
@@ -242,16 +273,16 @@ function buildCodexModelOptions(model) {
242
273
  ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
243
274
  : [];
244
275
  }
245
- async function publishAgentRuntime(agentId, runtime) {
246
- await publishHostAgentRuntime(agentId, 'codex', runtime);
247
- }
248
- async function loadSessionConfig(conversationId, agentId) {
249
- return loadHostSessionConfig({
250
- conversationId,
251
- agentId,
252
- extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
276
+ async function publishAgentRuntime(port, runtime) {
277
+ await port.publishHostAgentRuntime({
278
+ clientType: 'codex',
279
+ runtime: runtime,
253
280
  });
254
281
  }
282
+ async function loadSessionConfig(port, conversationId) {
283
+ const raw = await port.getSessionConfig({ conversationId });
284
+ return readHostSessionConfig(raw, CODEX_SESSION_CONFIG_FIELDS);
285
+ }
255
286
  function resolveSessionExecutionMode(config) {
256
287
  if (config?.executionMode)
257
288
  return config.executionMode;
@@ -493,35 +524,46 @@ export async function main() {
493
524
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
494
525
  }
495
526
  console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
496
- const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
527
+ const { agentName: profileAgentName, profile, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
528
+ activeLockHandle = lockHandle ?? null;
497
529
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
498
- const client = new CanonClient(apiKey, baseUrl);
499
- const rtdb = initRTDBAuth(client);
500
- const typingSignals = createTypingStatusPublisher({
501
- setTyping: (conversationId, typing, status) => status
502
- ? client.setTyping(conversationId, typing, status)
503
- : client.setTyping(conversationId, typing),
530
+ // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
531
+ // The daemon owns the SSE stream, RTDB auth, control polling, dedup and
532
+ // reconnect; notification handlers register through a buffered source
533
+ // inside `configure` (replay-safe) and attach after the machinery exists.
534
+ const notifications = createBufferedNotificationSource([
535
+ 'ready',
536
+ 'connectionState',
537
+ 'onMessage',
538
+ 'onMessageDeleted',
539
+ 'onConversationUpdated',
540
+ 'onSessionControl',
541
+ 'onControlSignal',
542
+ 'onControlPrimitive',
543
+ 'onControlReply',
544
+ ]);
545
+ const bridge = await connectBridge({
546
+ ...(profile ? { profile } : {}),
547
+ mode: 'auto',
548
+ ...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
549
+ hello: {
550
+ clientType: 'codex',
551
+ wantFamilies: ['messages', 'runtime_turn'],
552
+ },
553
+ configure: (bridgeClient) => notifications.connect(bridgeClient),
504
554
  });
505
- let agentId;
506
- let ownerId = null;
507
- let ownerName = null;
508
- try {
509
- const ctx = await client.getAgentMe();
510
- agentId = ctx.agentId;
511
- ownerId = ctx.ownerId;
512
- ownerName = ctx.ownerName;
513
- console.error(`[canon-codex] Connected as ${ctx.displayName || agentId}`);
514
- }
515
- catch {
516
- if (profileAgentId) {
517
- agentId = profileAgentId;
518
- }
519
- else {
520
- const auth = await client.getAuthToken();
521
- agentId = auth.agentId;
522
- }
523
- console.error(`[canon-codex] Authenticated as ${agentId}`);
524
- }
555
+ // The BridgeClient's zod-derived result shapes mirror the core wire types
556
+ // structurally; the port pins the strong types the host machinery uses.
557
+ const port = bridge.client;
558
+ bridge.client.onProtocolError((error) => {
559
+ console.error(`[canon-codex] Bridge protocol error: ${error.message}`);
560
+ });
561
+ const typingSignals = createPortTypingSignals(port);
562
+ const agentId = bridge.hello.agentId;
563
+ const ownerId = bridge.hello.agentContext.ownerId ?? null;
564
+ const ownerName = bridge.hello.agentContext.ownerName ?? null;
565
+ console.error(`[canon-codex] Connected as ${bridge.hello.agentName || agentId} `
566
+ + `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
525
567
  const launchArgs = [...process.argv.slice(2)];
526
568
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
527
569
  launchArgs.push('--cwd', workingDir);
@@ -550,292 +592,81 @@ export async function main() {
550
592
  lastStartedAt: new Date().toISOString(),
551
593
  lastHeartbeatAt: new Date().toISOString(),
552
594
  });
553
- const runtimeState = createRuntimeStatePublisher({
595
+ // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
596
+ const directory = createConversationDirectory({
554
597
  agentId,
555
- clientType: 'codex',
556
- hostMode: true,
557
- });
558
- const sessions = new Map();
559
- const pendingSessionCreations = new Map();
560
- const conversationCache = new Map();
561
- const knownConversationIds = new Set();
562
- const promptedGroupContextConversationIds = new Set();
563
- const pendingMembershipChanges = new Map();
564
- let lastKnownConversationRefreshAt = 0;
565
- const { getConversationMeta } = createConversationMetadataLoader({
566
- client,
567
- conversationCache,
568
- });
598
+ ownerId,
599
+ ownerName,
600
+ refreshFloorMs: HEARTBEAT_MS,
601
+ }, { port });
602
+ const { conversationCache, knownConversationIds, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
569
603
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
570
604
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
571
605
  }
572
- async function refreshKnownConversationIds(force = false) {
573
- if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
574
- return;
575
- }
576
- const conversations = await client.getConversations();
577
- knownConversationIds.clear();
578
- for (const conversation of conversations) {
579
- knownConversationIds.add(conversation.id);
580
- conversationCache.set(conversation.id, conversation);
581
- }
582
- lastKnownConversationRefreshAt = Date.now();
583
- }
584
- function handleConversationUpdated(payload) {
585
- const rawMemberIds = payload.changes.memberIds;
586
- if (!Array.isArray(rawMemberIds))
587
- return;
588
- const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
589
- const cached = conversationCache.get(payload.conversationId);
590
- const membershipChange = payload.membershipChange
591
- ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
592
- if (cached) {
593
- conversationCache.set(payload.conversationId, {
594
- ...cached,
595
- memberIds,
596
- });
597
- }
598
- if (membershipChange) {
599
- pendingMembershipChanges.set(payload.conversationId, membershipChange);
600
- }
601
- if (!memberIds.includes(agentId)) {
602
- knownConversationIds.delete(payload.conversationId);
603
- conversationCache.delete(payload.conversationId);
604
- }
605
- }
606
- function getGroupContextMode(conversationId, conversation) {
607
- if (conversation?.type !== 'group')
608
- return undefined;
609
- if (pendingMembershipChanges.has(conversationId))
610
- return 'membership_change';
611
- if (!promptedGroupContextConversationIds.has(conversationId))
612
- return 'initial';
613
- return undefined;
614
- }
615
- function markGroupContextModeUsed(conversationId, mode) {
616
- if (!mode)
617
- return;
618
- promptedGroupContextConversationIds.add(conversationId);
619
- if (mode === 'membership_change') {
620
- pendingMembershipChanges.delete(conversationId);
621
- }
622
- }
623
- async function loadHydratedInboundContext(input) {
624
- const [conversation, page] = await Promise.all([
625
- getConversationMeta(input.conversationId),
626
- input.hydratedPage
627
- ? Promise.resolve(input.hydratedPage)
628
- : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
629
- ]);
630
- return buildHydratedInboundContext({
631
- agentId,
632
- conversationId: input.conversationId,
633
- conversation,
634
- page,
635
- activeSelfContextId: input.activeSelfContextId,
636
- selfContexts: input.selfContexts,
637
- provenance: input.provenance,
638
- message: input.message,
639
- senderName: input.senderName,
640
- isOwner: input.isOwner,
641
- ownerId,
642
- ownerName,
643
- membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
644
- groupContextMode: getGroupContextMode(input.conversationId, conversation),
645
- });
646
- }
647
- function writeState(session) {
648
- const appliedAt = Date.now();
649
- const controlState = {};
650
- if (session.state.model !== undefined) {
651
- controlState.model = { value: session.state.model, source: 'applied', appliedAt };
652
- }
653
- if (session.state.permissionMode !== undefined) {
654
- controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
655
- }
656
- if (session.state.effort !== undefined) {
657
- controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
658
- }
659
- runtimeState.writeSessionState(session.conversationId, {
660
- lastError: session.state.lastError,
661
- model: session.state.model,
662
- permissionMode: session.state.permissionMode,
663
- effort: session.state.effort,
664
- controlState,
665
- cwd: session.cwd,
666
- executionMode: session.environment.mode,
667
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
668
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
669
- ...(resolveExecutionFallbackReason(session.environment)
670
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
671
- : {}),
672
- hostMode: true,
673
- clientType: 'codex',
674
- isActive: true,
675
- ...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
676
- }).catch(() => { });
677
- }
678
- function writeTurn(session) {
679
- const isOpenTurn = session.turnState === 'thinking'
680
- || session.turnState === 'streaming'
681
- || session.turnState === 'tool'
682
- || session.turnState === 'waiting_input';
683
- runtimeState.writeTurnState(session.conversationId, {
684
- turnId: session.currentTurnId,
685
- state: session.turnState,
686
- queueDepth: session.queue.length,
687
- currentSpeakerId: agentId,
688
- lastAcceptedIntent: session.lastAcceptedIntent,
689
- capabilities: CODEX_RUNTIME_CAPABILITIES,
690
- ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
691
- ...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
692
- ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
693
- ? { completedAt: { '.sv': 'timestamp' } }
694
- : {}),
695
- }).catch(() => { });
696
- }
697
- function markTurnProgress(session) {
698
- session.currentTurnUpdatedAt = Date.now();
699
- }
700
606
  async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
701
607
  if (!markAccepted || !sourceMessageId)
702
608
  return;
703
- await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
704
- }
705
- async function markQueuedPromptsRejected(conversationId, prompts) {
706
- await Promise.all(prompts.map((prompt) => {
707
- if (!prompt.markAccepted || !prompt.sourceMessageId)
708
- return Promise.resolve();
709
- return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
710
- }));
711
- }
712
- function removeQueuedPrompt(conversationId, sourceMessageId) {
713
- const session = sessions.get(conversationId);
714
- if (!session || session.queue.length === 0)
715
- return;
716
- const before = session.queue.length;
717
- session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
718
- if (session.queue.length !== before) {
719
- writeTurn(session);
720
- }
721
- }
722
- function clearStreaming(conversationId) {
723
- runtimeState.clearStreaming(conversationId).catch(() => { });
724
- }
725
- function writeCodexStreaming(session, text, status) {
726
- if (text !== null) {
727
- session.turnLiveText = text;
728
- }
729
- runtimeState.writeStreaming(session.conversationId, {
730
- text: session.turnLiveText,
731
- status,
732
- messageId: session.currentTurnId ?? undefined,
733
- turnId: session.currentTurnId,
734
- blocks: session.turnBlocks,
609
+ await port.setDisposition({
610
+ conversationId,
611
+ messageId: sourceMessageId,
612
+ inboundDisposition: 'accepted_now',
735
613
  }).catch(() => { });
736
614
  }
737
- function upsertCodexTextSegment(session, event) {
738
- const next = applyTextSegmentBlock({
739
- turnLiveText: session.turnLiveText,
740
- turnBlocks: session.turnBlocks,
741
- }, {
742
- turnId: session.currentTurnId,
743
- itemId: event.itemId,
744
- text: event.text,
745
- });
746
- session.turnLiveText = next.turnLiveText;
747
- session.turnBlocks = next.turnBlocks;
748
- }
749
- function upsertTurnBlock(session, block) {
750
- const now = Date.now();
751
- const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
752
- const existing = index >= 0 ? session.turnBlocks[index] : null;
753
- const next = {
754
- ...(existing ?? {
755
- sequence: session.turnBlocks.length + 1,
756
- createdAt: now,
757
- }),
758
- ...block,
759
- turnId: session.currentTurnId ?? block.id,
760
- updatedAt: now,
761
- };
762
- session.turnBlocks = index >= 0
763
- ? [
764
- ...session.turnBlocks.slice(0, index),
765
- next,
766
- ...session.turnBlocks.slice(index + 1),
767
- ]
768
- : [...session.turnBlocks, next];
769
- }
770
- function completeTurnBlock(session, id, summary) {
771
- const existing = session.turnBlocks.find((block) => block.id === id);
772
- if (!existing)
773
- return;
774
- upsertTurnBlock(session, {
775
- id,
776
- kind: existing.kind,
777
- status: 'completed',
778
- title: existing.title,
779
- text: existing.text,
780
- summary: summary ?? existing.summary,
781
- });
782
- }
783
- function buildFinalTurnTrail(session) {
784
- return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
785
- ...block,
786
- turnId: session.currentTurnId ?? block.turnId,
787
- })));
788
- }
789
615
  function buildCodexMessageId(session, kind) {
790
616
  return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
791
617
  }
792
618
  function buildCodexRuntimeCardOutcomeMessageId(cardId, status) {
793
619
  return `codex-card-${cardId}-${status}`;
794
620
  }
795
- async function handoffFinalMessage(conversationId) {
796
- await sleep(FINAL_MESSAGE_HANDOFF_MS);
797
- clearStreaming(conversationId);
798
- typingSignals.clear(conversationId).catch(() => { });
799
- }
800
- function refreshVisibleWorkSignal(session) {
801
- if (!session.running || session.closed)
802
- return;
803
- if (session.turnState !== 'thinking' && session.turnState !== 'tool')
804
- return;
805
- typingSignals.start(session.conversationId, 'thinking').catch(() => { });
806
- }
807
- function startVisibleWorkSignal(session) {
808
- refreshVisibleWorkSignal(session);
809
- }
810
- function stopVisibleWorkSignal(session) {
811
- if (session.typingKeepaliveTimer) {
812
- clearInterval(session.typingKeepaliveTimer);
813
- session.typingKeepaliveTimer = null;
814
- }
815
- typingSignals.clear(session.conversationId).catch(() => { });
816
- }
817
- function closeSession(conversationId) {
818
- const session = sessions.get(conversationId);
819
- if (!session)
820
- return;
821
- session.closed = true;
822
- stopVisibleWorkSignal(session);
823
- if ('close' in session.adapter && typeof session.adapter.close === 'function') {
824
- session.adapter.close();
825
- }
826
- releaseConversationEnvironment(session.environment);
827
- clearStreaming(conversationId);
828
- runtimeState.clearSessionState(conversationId).catch(() => { });
829
- runtimeState.clearTurnState(conversationId).catch(() => { });
830
- typingSignals.clear(conversationId).catch(() => { });
831
- typingSignals.dispose(conversationId);
832
- sessions.delete(conversationId);
833
- }
621
+ // ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
622
+ const lifecycle = createSessionLifecycle({
623
+ maxSessions: MAX_SESSIONS,
624
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
625
+ heartbeatMs: HEARTBEAT_MS,
626
+ idleCheckMs: IDLE_CHECK_MS,
627
+ logPrefix: '[canon-codex]',
628
+ }, {
629
+ knownConversationIds,
630
+ createSession: (conversationId) => createSessionForConversation(conversationId),
631
+ isSessionRunning: (session) => session.running,
632
+ writeTurnOnHeartbeat: (session) => !session.running,
633
+ writeSessionState: (session) => session.writeState(),
634
+ writeSessionTurn: (session) => session.writeTurn(),
635
+ releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
636
+ // Codex resume state (thread ids) is persisted during turns, not on close.
637
+ persistResumeState: () => { },
638
+ disposeRuntime: (session) => {
639
+ if ('close' in session.adapter && typeof session.adapter.close === 'function') {
640
+ session.adapter.close();
641
+ }
642
+ },
643
+ interruptRuntime: (session) => session.adapter.interrupt(),
644
+ port,
645
+ publishRuntimeHeartbeat: () => publishRuntimeHeartbeat(),
646
+ onShutdown: async () => {
647
+ detachControlNotifications();
648
+ notifications.detach();
649
+ await port.clearAgentRuntime().catch(() => { });
650
+ for (const session of [...sessions.values()]) {
651
+ await session.adapter.interrupt().catch(() => { });
652
+ }
653
+ bridge.client.close();
654
+ },
655
+ onShutdownComplete: () => {
656
+ markLocalRuntimeStopped(runtimeId);
657
+ lockHandle?.release();
658
+ },
659
+ });
660
+ const { sessions, getOrCreateSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
661
+ /**
662
+ * Codex-specific new-session reset: clear the stored Codex thread and keep
663
+ * the Canon session alive (unlike claude, which closes and re-creates).
664
+ */
834
665
  async function resetRuntimeSession(session) {
835
666
  const conversationId = session.conversationId;
836
667
  session.resetRequested = true;
837
- const droppedPrompts = session.queue.splice(0);
838
- await markQueuedPromptsRejected(conversationId, droppedPrompts);
668
+ const droppedInputs = session.pendingInputs.splice(0);
669
+ await markSessionPendingInputsRejected(conversationId, droppedInputs);
839
670
  clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
840
671
  session.adapter.clearThreadId();
841
672
  session.activeSelfContextId = null;
@@ -849,43 +680,20 @@ export async function main() {
849
680
  session.currentTurnId = null;
850
681
  session.currentTurnOpenedAt = null;
851
682
  session.currentTurnUpdatedAt = null;
683
+ session.currentTurnCanUseCodexAppTools = false;
852
684
  session.lastAcceptedIntent = null;
853
685
  session.resetRequested = false;
854
686
  }
855
- stopVisibleWorkSignal(session);
856
- clearStreaming(conversationId);
687
+ session.stopVisibleWork();
688
+ session.clearStreaming().catch(() => { });
857
689
  typingSignals.clear(conversationId).catch(() => { });
858
- writeState(session);
859
- writeTurn(session);
860
- }
861
- function evictOldestIdle() {
862
- let oldest = null;
863
- for (const session of sessions.values()) {
864
- if (session.running)
865
- continue;
866
- if (!oldest || session.lastActivity < oldest.lastActivity)
867
- oldest = session;
868
- }
869
- if (oldest) {
870
- console.error(`[canon-codex] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
871
- closeSession(oldest.conversationId);
872
- }
690
+ session.writeState();
691
+ session.writeTurn();
873
692
  }
874
- async function getOrCreateSession(conversationId) {
875
- knownConversationIds.add(conversationId);
876
- const existing = sessions.get(conversationId);
877
- if (existing && !existing.closed) {
878
- existing.lastActivity = Date.now();
879
- return existing;
880
- }
881
- const pending = pendingSessionCreations.get(conversationId);
882
- if (pending)
883
- return pending;
884
- if (sessions.size >= MAX_SESSIONS) {
885
- evictOldestIdle();
886
- }
887
- const creation = (async () => {
888
- const config = await loadSessionConfig(conversationId, agentId);
693
+ /** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
694
+ function createSessionForConversation(conversationId) {
695
+ return (async () => {
696
+ const config = await loadSessionConfig(port, conversationId);
889
697
  const sessionExecutionMode = resolveSessionExecutionMode(config);
890
698
  const workspaceCwd = resolveWorkspaceCwd(config);
891
699
  const environment = prepareConversationEnvironment({
@@ -923,6 +731,7 @@ export async function main() {
923
731
  configOverrides: args.config ?? [],
924
732
  fullAuto: policy.fullAuto,
925
733
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
734
+ dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
926
735
  })
927
736
  : new CodexConversationAdapter({
928
737
  cwd: sessionCwd,
@@ -938,12 +747,110 @@ export async function main() {
938
747
  fullAuto: policy.fullAuto,
939
748
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
940
749
  });
941
- const session = {
750
+ // eslint-disable-next-line prefer-const -- session must be declared before the module closures but assigned after them
751
+ let session;
752
+ // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
753
+ // Snapshot streaming behind claude's 250ms min-interval write gate —
754
+ // deliberate behavior parity (bridge plan Phase 2): rapid runtime
755
+ // events coalesce into one trailing RTDB write; terminal states
756
+ // (waiting_input, clear) still bypass the gate.
757
+ const writers = createRuntimeWriters({
758
+ conversationId,
759
+ agentId,
760
+ capabilities: CODEX_RUNTIME_CAPABILITIES,
761
+ fallbackTurnId: `codex-${conversationId}`,
762
+ streamingMode: 'snapshot',
763
+ streamingThrottleMs: STREAMING_THROTTLE_MS,
764
+ requestedControls: {},
765
+ }, {
766
+ session: () => session,
767
+ port,
768
+ buildSessionStateExtras: (writerSession) => ({
769
+ ...(writerSession.state.lastError !== undefined
770
+ ? { lastError: writerSession.state.lastError }
771
+ : {}),
772
+ cwd: writerSession.cwd,
773
+ executionMode: writerSession.environment.mode,
774
+ ...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
775
+ ...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
776
+ ...(resolveExecutionFallbackReason(writerSession.environment)
777
+ ? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
778
+ : {}),
779
+ clientType: 'codex',
780
+ }),
781
+ formatControlError: (error) => error instanceof Error && error.message.trim()
782
+ ? error.message.trim()
783
+ : 'Codex did not apply that control.',
784
+ });
785
+ // ── HITL input waiter (generic skeleton from @canonmsg/agent-host) ──
786
+ const hitlWaiters = createHitlWaiters({
787
+ conversationId,
788
+ replyTimeoutMs: 30 * 60_000,
789
+ }, {
790
+ session: () => session,
791
+ port,
792
+ });
793
+ // ── Canon turn queue (generic skeleton from @canonmsg/agent-host) ──
794
+ const turnQueue = createTurnQueue({ conversationId }, {
795
+ session: () => session,
796
+ isSessionRunning: (queueSession) => queueSession.running,
797
+ markSessionRunning: (queueSession) => { queueSession.running = true; },
798
+ prepareInput: async () => { },
799
+ sendInput: (input) => { void executeTurn(input); },
800
+ interruptRuntime: (queueSession) => queueSession.adapter.interrupt(),
801
+ port,
802
+ writers: {
803
+ writeTurn: () => writers.writeTurn(),
804
+ stopVisibleWorkSignal: () => writers.stopVisibleWorkSignal(),
805
+ clearStreaming: () => writers.clearStreaming(),
806
+ },
807
+ });
808
+ // ── Final delivery (claude's bounded retry machine, adopted for codex) ──
809
+ const finalDelivery = createFinalDelivery({
810
+ conversationId,
811
+ logPrefix: '[canon-codex]',
812
+ delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
813
+ }, {
814
+ session: () => session,
815
+ port,
816
+ typingSignals,
817
+ writers: {
818
+ writeState: () => writers.writeState(),
819
+ writeTurn: () => writers.writeTurn(),
820
+ stopVisibleWorkSignal: () => writers.stopVisibleWorkSignal(),
821
+ clearStreaming: () => writers.clearStreaming(),
822
+ getFinalTurnTrail: () => writers.getFinalTurnTrail(),
823
+ replaceStreamingSnapshot: (text, status) => writers.streamingOutput.replaceSnapshot(text, status),
824
+ },
825
+ resetCompletedTurnState: (deliverySession) => {
826
+ deliverySession.running = false;
827
+ deliverySession.state.state = 'idle';
828
+ deliverySession.turnState = 'idle';
829
+ deliverySession.currentTurnId = null;
830
+ deliverySession.currentTurnOpenedAt = null;
831
+ deliverySession.currentTurnUpdatedAt = null;
832
+ deliverySession.currentTurnCanUseCodexAppTools = false;
833
+ deliverySession.lastAcceptedIntent = null;
834
+ deliverySession.resetRequested = false;
835
+ deliverySession.activeInput = null;
836
+ deliverySession.pendingFinalText = null;
837
+ deliverySession.pendingFinalDelivery = null;
838
+ },
839
+ shouldDeliverFinal: (turn, deliverySession) => Boolean(turn)
840
+ && !deliverySession.finalizedTurnKeys.has(turn.turnKey)
841
+ && !deliverySession.interruptedTurnKeys.has(turn.turnKey),
842
+ buildFinalMessageId: (turnKey) => `codex-final-${turnKey}`,
843
+ drainPendingInput: () => turnQueue.drainPendingInput(),
844
+ // Oversized finals chunk BRIDGE-side (its sendMessage runs core's
845
+ // retry+chunking) — the host-side chunked sender is deleted.
846
+ });
847
+ session = {
942
848
  conversationId,
943
849
  cwd: sessionCwd,
944
850
  environment,
945
851
  adapter,
946
- queue: [],
852
+ pendingInputs: [],
853
+ activeInput: null,
947
854
  running: false,
948
855
  state: buildCodexInitialSessionState({
949
856
  model: policy.model,
@@ -955,21 +862,353 @@ export async function main() {
955
862
  currentTurnId: null,
956
863
  currentTurnOpenedAt: null,
957
864
  currentTurnUpdatedAt: null,
865
+ currentTurnCanUseCodexAppTools: false,
958
866
  activeSelfContextId: null,
959
867
  lastAcceptedIntent: null,
868
+ interruptedTurnKeys: new Set(),
869
+ finalizedTurnKeys: new Set(),
870
+ pendingFinalText: null,
871
+ pendingFinalDelivery: null,
960
872
  resetRequested: false,
961
873
  lastActivity: Date.now(),
962
874
  typingKeepaliveTimer: null,
875
+ idleResetTimer: null,
876
+ finalDeliveryTimer: null,
963
877
  closed: false,
964
- turnLiveText: '',
965
- turnBlocks: [],
878
+ streamingText: '',
879
+ availableModels: [],
880
+ runtimeControlErrors: {},
881
+ pendingReply: null,
966
882
  turnCommandBlocks: createCommandBlockTracker(),
883
+ writeState: () => writers.writeState(),
884
+ writeTurn: () => writers.writeTurn(),
885
+ clearStreaming: () => writers.clearStreaming(),
886
+ startVisibleWork: () => writers.startVisibleWorkSignal(),
887
+ stopVisibleWork: () => writers.stopVisibleWorkSignal(),
888
+ stageTurnBlock: (block) => { writers.streamingOutput.stageBlock(block); },
889
+ setStreamingStatus: (status) => { writers.streamingOutput.setStatus(status).catch(() => { }); },
890
+ waitForRuntimeInputResponse: (input) => hitlWaiters.waitForRuntimeInputResponse(input),
891
+ enqueueInbound: (input) => turnQueue.enqueueCanonInput(input),
967
892
  };
893
+ const handoffFinal = () => handoffFinalMessage({
894
+ conversationId,
895
+ clearStreaming: () => writers.clearStreaming(),
896
+ typingSignals,
897
+ });
898
+ /** Drive the Codex runtime for one queued Canon turn (the adapter's input transport). */
899
+ async function executeTurn(turn) {
900
+ if (session.closed)
901
+ return;
902
+ if (session.activeInput) {
903
+ // Reentrancy guard: a concurrent start raced the in-flight turn —
904
+ // requeue at the front; the post-turn drain picks it up.
905
+ session.pendingInputs.unshift(turn);
906
+ return;
907
+ }
908
+ // A turn starting during the final-handoff window owns the session
909
+ // again — cancel the pending reset and drop any stale streamed state
910
+ // from the previous turn (claude's 'running' transition parity).
911
+ finalDelivery.clearIdleResetTimer();
912
+ session.pendingFinalText = null;
913
+ if (writers.streamingOutput.getBlocks().length > 0 || writers.streamingOutput.getText()) {
914
+ writers.clearStreaming().catch(() => { });
915
+ }
916
+ session.activeInput = turn;
917
+ session.running = true;
918
+ session.state.lastError = undefined;
919
+ session.state.state = 'running';
920
+ session.currentTurnId = turn.turnKey;
921
+ session.turnCommandBlocks = createCommandBlockTracker();
922
+ session.currentTurnOpenedAt = Date.now();
923
+ session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
924
+ session.currentTurnCanUseCodexAppTools = turn.canUseCodexAppTools;
925
+ session.lastAcceptedIntent = turn.intent;
926
+ session.turnState = 'thinking';
927
+ session.lastActivity = Date.now();
928
+ writers.writeState();
929
+ writers.writeTurn();
930
+ writers.startVisibleWorkSignal();
931
+ // Status-only seed: 'thinking' renders as a working filament row on the
932
+ // clients; text here would be bubbled as speech (v4 register rule).
933
+ writers.streamingOutput.startThinking('').catch(() => { });
934
+ let artifactBaseline = null;
935
+ let artifactsRouted = false;
936
+ const routeArtifactsOnce = async () => {
937
+ if (artifactsRouted)
938
+ return;
939
+ artifactsRouted = true;
940
+ if (turn.artifactRoutingMode === 'workspace-generated') {
941
+ await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
942
+ }
943
+ };
944
+ // True once the final-delivery machine owns the turn reset (handoff
945
+ // timer or bounded retry) — the finally block must not clobber it.
946
+ let turnSettledByFinalDelivery = false;
947
+ try {
948
+ const turnPrompt = turn.prompt;
949
+ if (turn.artifactRoutingMode === 'workspace-generated') {
950
+ artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
951
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
952
+ return null;
953
+ });
954
+ }
955
+ const modelGuardForTurn = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
956
+ if (modelGuardForTurn) {
957
+ throw new ExecutionEnvironmentError(modelGuardForTurn, modelGuardForTurn);
958
+ }
959
+ const turnImagePaths = turn.imagePaths;
960
+ const turnMediaAddDirs = turn.mediaAddDirs;
961
+ const handleCodexEvent = (event) => {
962
+ session.lastActivity = Date.now();
963
+ if (event.type === 'thread.started') {
964
+ if (session.resetRequested) {
965
+ return;
966
+ }
967
+ saveStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
968
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Thread ${event.threadId}`);
969
+ return;
970
+ }
971
+ if (event.type === 'skills.changed') {
972
+ void refreshCodexSkillInventory(true).then(() => publishRuntimeHeartbeat());
973
+ return;
974
+ }
975
+ if (event.type === 'message') {
976
+ session.turnState = 'streaming';
977
+ writers.writeTurn();
978
+ writers.stopVisibleWorkSignal();
979
+ writers.streamingOutput.replaceTextSegmentSnapshot(textSegmentBlockId(session.currentTurnId, event.itemId), event.text);
980
+ return;
981
+ }
982
+ if (event.type === 'plan.updated') {
983
+ session.turnState = 'streaming';
984
+ writers.writeTurn();
985
+ writers.stopVisibleWorkSignal();
986
+ writers.streamingOutput.stageBlock({
987
+ id: `plan:${session.currentTurnId}`,
988
+ kind: 'plan',
989
+ status: 'running',
990
+ title: 'Plan',
991
+ text: event.text,
992
+ });
993
+ writers.streamingOutput.replaceSnapshot(event.text, 'streaming').catch(() => { });
994
+ return;
995
+ }
996
+ if (event.type === 'waiting') {
997
+ session.turnState = 'waiting_input';
998
+ writers.writeTurn();
999
+ writers.stopVisibleWorkSignal();
1000
+ writers.streamingOutput.waitingInput().catch(() => { });
1001
+ return;
1002
+ }
1003
+ if (event.type === 'command.started') {
1004
+ session.turnState = 'tool';
1005
+ writers.writeTurn();
1006
+ writers.startVisibleWorkSignal();
1007
+ const blockId = beginCommandBlock(session.turnCommandBlocks, {
1008
+ turnId: session.currentTurnId,
1009
+ command: event.command,
1010
+ itemId: event.itemId,
1011
+ });
1012
+ writers.streamingOutput.stageBlock({
1013
+ id: blockId,
1014
+ kind: 'tool',
1015
+ status: 'running',
1016
+ title: summarizeCommand(event.command),
1017
+ summary: 'Command running',
1018
+ });
1019
+ writers.streamingOutput.setStatus('tool').catch(() => { });
1020
+ return;
1021
+ }
1022
+ if (event.type === 'command.completed') {
1023
+ const blockId = claimCommandBlock(session.turnCommandBlocks, {
1024
+ turnId: session.currentTurnId,
1025
+ command: event.command,
1026
+ itemId: event.itemId,
1027
+ });
1028
+ const existing = writers.streamingOutput.getBlocks().find((block) => block.id === blockId);
1029
+ if (existing) {
1030
+ writers.streamingOutput.stageBlock({
1031
+ id: blockId,
1032
+ kind: existing.kind,
1033
+ status: 'completed',
1034
+ summary: 'Command completed',
1035
+ });
1036
+ }
1037
+ if (session.turnState === 'tool') {
1038
+ session.turnState = 'thinking';
1039
+ writers.writeTurn();
1040
+ writers.startVisibleWorkSignal();
1041
+ writers.streamingOutput.setStatus('thinking').catch(() => { });
1042
+ }
1043
+ return;
1044
+ }
1045
+ if (event.type === 'turn.completed') {
1046
+ // Codex reports per-turn token usage but no context window, so the
1047
+ // meter publishes tokens only (input + cached covers the full
1048
+ // prompt context of the completed turn).
1049
+ const totalTokens = (event.usage?.input_tokens ?? 0)
1050
+ + (event.usage?.cached_input_tokens ?? 0)
1051
+ + (event.usage?.output_tokens ?? 0);
1052
+ if (totalTokens > 0) {
1053
+ session.state.contextUsage = { totalTokens };
1054
+ }
1055
+ writers.writeState();
1056
+ }
1057
+ };
1058
+ const logCodexLine = (line) => {
1059
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${line}`);
1060
+ };
1061
+ const clearStoredThread = () => {
1062
+ clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
1063
+ session.adapter.clearThreadId();
1064
+ };
1065
+ const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1066
+ planMode: turn.planMode,
1067
+ onServerRequest: (request) => handleCodexServerRequest(session, request),
1068
+ });
1069
+ let result = await runTurnOnce();
1070
+ if (!result.interrupted
1071
+ && !result.finalMessage
1072
+ && result.exitCode
1073
+ && result.exitCode !== 0
1074
+ && isRecoverableCodexThreadError(result.errorText)) {
1075
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1076
+ clearStoredThread();
1077
+ result = await runTurnOnce();
1078
+ }
1079
+ if (result.threadId && !session.resetRequested) {
1080
+ saveStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1081
+ }
1082
+ if (!result.interrupted && result.finalMessage && turn.planMode) {
1083
+ await routeArtifactsOnce();
1084
+ const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1085
+ responseUserId: ownerId ?? undefined,
1086
+ title: 'Codex Plan',
1087
+ body: result.finalMessage,
1088
+ });
1089
+ await port.sendMessage({
1090
+ conversationId,
1091
+ text: planApproval.text,
1092
+ messageId: buildCodexMessageId(session, 'plan'),
1093
+ metadata: {
1094
+ ...planApproval.metadata,
1095
+ turnId: session.currentTurnId,
1096
+ turnSemantics: 'control',
1097
+ replyBehavior: 'suppress_auto_reply',
1098
+ },
1099
+ });
1100
+ await handoffFinal();
1101
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Sent plan approval card`);
1102
+ }
1103
+ else if (!result.interrupted && result.finalMessage) {
1104
+ if (isRecoverableCodexThreadError(result.errorText)) {
1105
+ clearStoredThread();
1106
+ }
1107
+ await routeArtifactsOnce();
1108
+ // Claude's final-delivery machine (deliberate behavior parity):
1109
+ // failed sends are re-marked pending and retried on a bounded
1110
+ // timer instead of throwing into the generic failure path.
1111
+ const delivered = await finalDelivery.deliverFinalReply(result.finalMessage, turn);
1112
+ if (delivered) {
1113
+ session.activeInput = null;
1114
+ session.running = false;
1115
+ finalDelivery.scheduleFinalHandoffReset();
1116
+ }
1117
+ else {
1118
+ finalDelivery.scheduleFinalDeliveryRetry();
1119
+ }
1120
+ turnSettledByFinalDelivery = true;
1121
+ }
1122
+ else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
1123
+ await routeArtifactsOnce();
1124
+ const userVisibleError = formatCodexTurnFailure(result.errorText);
1125
+ session.state.lastError = userVisibleError;
1126
+ writers.writeState();
1127
+ if (result.errorText) {
1128
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
1129
+ }
1130
+ const turnTrail = writers.getFinalTurnTrail();
1131
+ await port.sendMessage({
1132
+ conversationId,
1133
+ text: userVisibleError,
1134
+ messageId: buildCodexMessageId(session, 'error'),
1135
+ ...(session.activeSelfContextId
1136
+ ? { selfContextId: session.activeSelfContextId }
1137
+ : {}),
1138
+ metadata: {
1139
+ turnId: session.currentTurnId,
1140
+ turnSemantics: 'turn_complete',
1141
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1142
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
1143
+ },
1144
+ });
1145
+ await handoffFinal();
1146
+ }
1147
+ else if (!result.interrupted) {
1148
+ await routeArtifactsOnce();
1149
+ await handoffFinal();
1150
+ }
1151
+ else if (result.interrupted) {
1152
+ session.turnState = 'interrupted';
1153
+ writers.writeTurn();
1154
+ writers.stopVisibleWorkSignal();
1155
+ writers.clearStreaming().catch(() => { });
1156
+ typingSignals.clear(conversationId).catch(() => { });
1157
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn interrupted`);
1158
+ }
1159
+ }
1160
+ catch (error) {
1161
+ const message = error instanceof ExecutionEnvironmentError
1162
+ ? error.userMessage
1163
+ : isBridgeDeliveryError(error)
1164
+ ? `The Codex host completed the turn, but Canon could not deliver the reply: ${error.message}`
1165
+ : `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
1166
+ session.state.lastError = message;
1167
+ writers.writeState();
1168
+ await routeArtifactsOnce();
1169
+ await port.sendMessage({
1170
+ conversationId,
1171
+ text: message,
1172
+ messageId: buildCodexMessageId(session, 'failure'),
1173
+ ...(session.activeSelfContextId
1174
+ ? { selfContextId: session.activeSelfContextId }
1175
+ : {}),
1176
+ metadata: {
1177
+ turnId: session.currentTurnId,
1178
+ turnSemantics: 'turn_complete',
1179
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1180
+ },
1181
+ }).catch(() => { });
1182
+ await handoffFinal();
1183
+ if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
1184
+ clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
1185
+ }
1186
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn failed:`, error);
1187
+ }
1188
+ finally {
1189
+ session.lastActivity = Date.now();
1190
+ if (!turnSettledByFinalDelivery) {
1191
+ writers.stopVisibleWorkSignal();
1192
+ session.running = false;
1193
+ session.state.state = 'idle';
1194
+ session.turnState = 'idle';
1195
+ session.currentTurnId = null;
1196
+ session.currentTurnOpenedAt = null;
1197
+ session.currentTurnUpdatedAt = null;
1198
+ session.currentTurnCanUseCodexAppTools = false;
1199
+ session.lastAcceptedIntent = null;
1200
+ session.resetRequested = false;
1201
+ session.activeInput = null;
1202
+ writers.writeState();
1203
+ writers.writeTurn();
1204
+ turnQueue.drainPendingInput();
1205
+ }
1206
+ }
1207
+ }
968
1208
  sessions.set(conversationId, session);
969
- await controlPoller.baseline([conversationId]);
970
1209
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
971
- writeState(session);
972
- writeTurn(session);
1210
+ session.writeState();
1211
+ session.writeTurn();
973
1212
  return session;
974
1213
  }
975
1214
  catch (error) {
@@ -977,113 +1216,57 @@ export async function main() {
977
1216
  throw error;
978
1217
  }
979
1218
  })();
980
- pendingSessionCreations.set(conversationId, creation);
981
- try {
982
- return await creation;
983
- }
984
- finally {
985
- pendingSessionCreations.delete(conversationId);
986
- }
987
- }
988
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled') {
989
- const nextPrompt = {
990
- prompt,
991
- intent,
992
- sourceMessageId,
993
- markAccepted,
994
- imagePaths,
995
- mediaAddDirs,
996
- planMode,
997
- artifactRoutingMode,
998
- };
999
- if (toFront) {
1000
- session.queue.unshift(nextPrompt);
1001
- }
1002
- else {
1003
- session.queue.push(nextPrompt);
1004
- }
1005
- session.lastActivity = Date.now();
1006
- writeTurn(session);
1007
- void runNextTurn(session);
1008
1219
  }
1009
1220
  function resolveArtifactRoutingMode(participantContext) {
1010
1221
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1011
1222
  ? 'workspace-generated'
1012
1223
  : 'disabled';
1013
1224
  }
1014
- async function waitForRuntimeInputResponse(input) {
1015
- while (Date.now() < input.expiresAt) {
1016
- const response = await client.consumeRuntimeInputResponse({
1017
- conversationId: input.conversationId,
1018
- inputId: input.inputId,
1019
- }).catch(() => null);
1020
- if (response?.status === 'submitted') {
1021
- return { status: 'submitted', value: response.value, answers: response.answers };
1022
- }
1023
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
1024
- return { status: response.status };
1025
- }
1026
- await sleep(1_000);
1027
- }
1028
- const response = await client.consumeRuntimeInputResponse({
1029
- conversationId: input.conversationId,
1030
- inputId: input.inputId,
1031
- }).catch(() => null);
1032
- if (response?.status === 'submitted') {
1033
- return { status: 'submitted', value: response.value, answers: response.answers };
1034
- }
1035
- return { status: 'timeout' };
1036
- }
1225
+ /**
1226
+ * Approval answers read the bridge's DURABLE record (the bridge owns the
1227
+ * single destructive consume loop + the approval_reply fast path); same 1s
1228
+ * cadence as the legacy consume poll.
1229
+ */
1037
1230
  async function waitForRuntimeApprovalResponse(input) {
1231
+ const read = async () => port.getHitlState({ requestId: input.approvalId }).catch(() => null);
1038
1232
  while (Date.now() < input.expiresAt) {
1039
- const response = await client.consumeRuntimeApprovalResponse({
1040
- conversationId: input.conversationId,
1041
- approvalId: input.approvalId,
1042
- }).catch(() => null);
1043
- if (response?.status === 'allow') {
1044
- return { decision: 'allow', sessionRule: response.sessionRule };
1233
+ const state = await read();
1234
+ if (state?.status === 'submitted') {
1235
+ const answer = (state.answer ?? {});
1236
+ return answer.decision === 'deny'
1237
+ ? { decision: 'deny' }
1238
+ : { decision: 'allow', sessionRule: answer.sessionRule };
1045
1239
  }
1046
- if (response?.status === 'deny' || response?.status === 'timeout') {
1240
+ if (state?.status === 'cancelled' || state?.status === 'timeout') {
1047
1241
  return { decision: 'deny' };
1048
1242
  }
1049
1243
  await sleep(1_000);
1050
1244
  }
1051
- await client.consumeRuntimeApprovalResponse({
1052
- conversationId: input.conversationId,
1053
- approvalId: input.approvalId,
1054
- }).catch(() => null);
1055
1245
  return { decision: 'deny' };
1056
1246
  }
1057
1247
  async function waitForRuntimeCardResponse(input) {
1058
- while (Date.now() < input.expiresAt) {
1059
- const response = await client.consumeRuntimeCardResponse({
1060
- conversationId: input.conversationId,
1061
- cardId: input.cardId,
1062
- }).catch(() => null);
1063
- if (response?.status === 'submitted') {
1248
+ const read = async () => port.getHitlState({ requestId: input.cardId }).catch(() => null);
1249
+ const map = (state) => {
1250
+ if (state?.status === 'submitted') {
1251
+ const answer = (state.answer ?? {});
1064
1252
  return {
1065
1253
  status: 'submitted',
1066
- ...(response.actionId ? { actionId: response.actionId } : {}),
1067
- ...(response.values ? { values: response.values } : {}),
1254
+ ...(answer.actionId ? { actionId: answer.actionId } : {}),
1255
+ ...(answer.values ? { values: answer.values } : {}),
1068
1256
  };
1069
1257
  }
1070
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
1071
- return { status: response.status };
1258
+ if (state?.status === 'cancelled' || state?.status === 'timeout') {
1259
+ return { status: state.status };
1072
1260
  }
1261
+ return null;
1262
+ };
1263
+ while (Date.now() < input.expiresAt) {
1264
+ const mapped = map(await read());
1265
+ if (mapped)
1266
+ return mapped;
1073
1267
  await sleep(1_000);
1074
1268
  }
1075
- const response = await client.consumeRuntimeCardResponse({
1076
- conversationId: input.conversationId,
1077
- cardId: input.cardId,
1078
- }).catch(() => null);
1079
- if (response?.status === 'submitted') {
1080
- return {
1081
- status: 'submitted',
1082
- ...(response.actionId ? { actionId: response.actionId } : {}),
1083
- ...(response.values ? { values: response.values } : {}),
1084
- };
1085
- }
1086
- return { status: 'timeout' };
1269
+ return map(await read()) ?? { status: 'timeout' };
1087
1270
  }
1088
1271
  function runtimeCardRequestPayload(method, params) {
1089
1272
  if (method !== 'item/runtimeCard/request'
@@ -1098,6 +1281,22 @@ export async function main() {
1098
1281
  const requestId = String(request.id);
1099
1282
  const params = request.params;
1100
1283
  const expiresAt = Date.now() + 30 * 60_000;
1284
+ if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1285
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1286
+ return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1287
+ }
1288
+ if (!session.currentTurnCanUseCodexAppTools) {
1289
+ return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1290
+ }
1291
+ return await handleCodexAppToolCall({
1292
+ adapter: session.adapter,
1293
+ currentThreadId: session.adapter.getThreadId(),
1294
+ currentCwd: session.cwd,
1295
+ workspaces: workspaceOptions,
1296
+ model: session.state.model ?? null,
1297
+ effort: session.state.effort ?? null,
1298
+ }, params);
1299
+ }
1101
1300
  const runtimeCardPayload = runtimeCardRequestPayload(request.method, params);
1102
1301
  if (runtimeCardPayload) {
1103
1302
  const card = parseRuntimeCardV1(runtimeCardPayload);
@@ -1111,14 +1310,15 @@ export async function main() {
1111
1310
  let requestCreated = false;
1112
1311
  let requestResolved = false;
1113
1312
  try {
1114
- await client.createRuntimeCardRequest({
1313
+ await port.requestCard({
1115
1314
  conversationId: session.conversationId,
1116
1315
  cardId,
1117
1316
  card: { ...card, cardId },
1118
- expiresAt,
1119
- // Omit responseUserId so the backend targets a reachable member (the
1120
- // owner if present, else the sole other member) — the owner is often
1121
- // not a member of agent-to-user DMs.
1317
+ expiresAt: new Date(expiresAt).toISOString(),
1318
+ // responseUserId: null → the backend targets a reachable member
1319
+ // (the owner if present, else the sole other member) — the owner is
1320
+ // often not a member of agent-to-user DMs.
1321
+ responseUserId: null,
1122
1322
  native: {
1123
1323
  runtime: 'codex',
1124
1324
  method: request.method,
@@ -1129,21 +1329,20 @@ export async function main() {
1129
1329
  threadId: readString(params, 'threadId') ?? '',
1130
1330
  },
1131
1331
  },
1132
- turnId: session.currentTurnId ?? undefined,
1332
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1133
1333
  });
1134
1334
  requestCreated = true;
1135
1335
  session.turnState = 'waiting_input';
1136
- markTurnProgress(session);
1137
- upsertTurnBlock(session, {
1336
+ session.stageTurnBlock({
1138
1337
  id: `card:${cardId}`,
1139
1338
  kind: 'input',
1140
1339
  status: 'pending',
1141
1340
  title: card.title,
1142
1341
  summary: card.template ?? 'runtime card',
1143
1342
  });
1144
- writeTurn(session);
1145
- stopVisibleWorkSignal(session);
1146
- writeCodexStreaming(session, null, 'waiting_input');
1343
+ session.writeTurn();
1344
+ session.stopVisibleWork();
1345
+ session.setStreamingStatus('waiting_input');
1147
1346
  const response = await waitForRuntimeCardResponse({
1148
1347
  conversationId: session.conversationId,
1149
1348
  cardId,
@@ -1151,7 +1350,9 @@ export async function main() {
1151
1350
  });
1152
1351
  requestResolved = true;
1153
1352
  const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1154
- await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1353
+ await port.sendMessage({
1354
+ conversationId: session.conversationId,
1355
+ text: outcome.text,
1155
1356
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1156
1357
  metadata: {
1157
1358
  ...outcome.metadata,
@@ -1160,25 +1361,27 @@ export async function main() {
1160
1361
  replyBehavior: 'suppress_auto_reply',
1161
1362
  },
1162
1363
  });
1163
- completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1364
+ session.stageTurnBlock({
1365
+ id: `card:${cardId}`,
1366
+ kind: 'input',
1367
+ status: 'completed',
1368
+ summary: `Card ${response.status}`,
1369
+ });
1164
1370
  if (session.turnState === 'waiting_input') {
1165
1371
  session.turnState = 'thinking';
1166
- markTurnProgress(session);
1167
- writeTurn(session);
1168
- startVisibleWorkSignal(session);
1169
- writeCodexStreaming(session, null, 'thinking');
1372
+ session.writeTurn();
1373
+ session.startVisibleWork();
1374
+ session.setStreamingStatus('thinking');
1170
1375
  }
1171
1376
  return response;
1172
1377
  }
1173
1378
  catch (error) {
1174
1379
  if (requestCreated && !requestResolved) {
1175
- await client.consumeRuntimeCardResponse({
1176
- conversationId: session.conversationId,
1177
- cardId,
1178
- cancel: true,
1179
- }).catch(() => null);
1380
+ await port.cancelHitl({ requestId: cardId }).catch(() => null);
1180
1381
  const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1181
- await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1382
+ await port.sendMessage({
1383
+ conversationId: session.conversationId,
1384
+ text: outcome.text,
1182
1385
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1183
1386
  metadata: {
1184
1387
  ...outcome.metadata,
@@ -1196,17 +1399,19 @@ export async function main() {
1196
1399
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1197
1400
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1198
1401
  const inputId = readString(params, 'itemId') ?? requestId;
1199
- await client.createRuntimeInputRequest({
1402
+ await port.requestInput({
1200
1403
  conversationId: session.conversationId,
1201
1404
  inputId,
1202
1405
  kind: 'clarify',
1203
- expiresAt,
1204
- responseUserId: ownerId ?? undefined,
1406
+ expiresAt: new Date(expiresAt).toISOString(),
1407
+ ...(ownerId ? { responseUserId: ownerId } : {}),
1205
1408
  title: 'Codex needs input',
1206
1409
  prompt: questions?.length
1207
1410
  ? 'Codex needs your input to continue.'
1208
1411
  : 'Codex needs input.',
1209
- ...(questions ? { questions } : {}),
1412
+ ...(questions
1413
+ ? { questions: questions }
1414
+ : { questions: [] }),
1210
1415
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
1211
1416
  native: {
1212
1417
  runtime: 'codex',
@@ -1218,13 +1423,9 @@ export async function main() {
1218
1423
  threadId: readString(params, 'threadId') ?? '',
1219
1424
  },
1220
1425
  },
1221
- turnId: session.currentTurnId ?? undefined,
1222
- });
1223
- const response = await waitForRuntimeInputResponse({
1224
- conversationId: session.conversationId,
1225
- inputId,
1226
- expiresAt,
1426
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1227
1427
  });
1428
+ const response = await session.waitForRuntimeInputResponse({ inputId, expiresAt });
1228
1429
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1229
1430
  }
1230
1431
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1233,25 +1434,29 @@ export async function main() {
1233
1434
  });
1234
1435
  if (mappedApproval) {
1235
1436
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1236
- await client.createRuntimeApprovalRequest({
1437
+ await port.requestApproval({
1237
1438
  conversationId: session.conversationId,
1238
1439
  approvalId,
1239
1440
  toolName: mappedApproval.toolName,
1240
- toolSummary: mappedApproval.toolSummary,
1441
+ detail: mappedApproval.toolSummary,
1241
1442
  category: mappedApproval.category,
1242
- risk: mappedApproval.risk,
1243
- riskLevel: mappedApproval.riskLevel,
1443
+ risk: mappedApproval.risk ?? 'normal',
1444
+ ...(mappedApproval.riskLevel ? { riskLevel: mappedApproval.riskLevel } : {}),
1244
1445
  native: {
1245
1446
  ...mappedApproval.native,
1246
1447
  requestId,
1247
1448
  method: request.method,
1248
1449
  },
1249
- details: mappedApproval.details,
1250
- ...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
1251
- responseUserId: ownerId ?? undefined,
1450
+ ...(mappedApproval.details
1451
+ ? { details: mappedApproval.details }
1452
+ : {}),
1453
+ ...(mappedApproval.diff
1454
+ ? { diff: mappedApproval.diff }
1455
+ : {}),
1456
+ ...(ownerId ? { responseUserId: ownerId } : {}),
1252
1457
  allowSessionRule: true,
1253
- expiresAt,
1254
- turnId: session.currentTurnId ?? undefined,
1458
+ expiresAt: new Date(expiresAt).toISOString(),
1459
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1255
1460
  });
1256
1461
  const response = await waitForRuntimeApprovalResponse({
1257
1462
  conversationId: session.conversationId,
@@ -1287,7 +1492,12 @@ export async function main() {
1287
1492
  : decision === 'reject'
1288
1493
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1289
1494
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1290
- enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
1495
+ session.enqueueInbound(buildCodexQueuedInput({
1496
+ prompt,
1497
+ sourceMessageId: input.message.id,
1498
+ planMode: decision !== 'approve',
1499
+ canUseCodexAppTools: input.isOwner,
1500
+ }));
1291
1501
  return;
1292
1502
  }
1293
1503
  let materialized = [];
@@ -1360,7 +1570,9 @@ export async function main() {
1360
1570
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1361
1571
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
1362
1572
  await markQueuedMessageAccepted(input.conversationId, input.message.id, shouldMarkAccepted);
1363
- await sendMessageWithRetryChunked(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1573
+ await port.sendMessage({
1574
+ conversationId: input.conversationId,
1575
+ text: `I couldn't start a coding session for this workspace: ${userMessage}`,
1364
1576
  messageId: `codex-start-failed-${input.message.id}`,
1365
1577
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1366
1578
  metadata: {
@@ -1383,19 +1595,36 @@ export async function main() {
1383
1595
  message: input.message,
1384
1596
  });
1385
1597
  if (session.running && deliveryIntent === 'interrupt') {
1386
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1387
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode);
1388
1598
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1389
- await session.adapter.interrupt().catch(() => { });
1390
- clearStreaming(input.conversationId);
1391
- typingSignals.clear(input.conversationId).catch(() => { });
1392
- return;
1393
1599
  }
1394
1600
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1395
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode);
1601
+ // The shared turn queue handles queue/interrupt/start semantics.
1602
+ session.enqueueInbound(buildCodexQueuedInput({
1603
+ prompt,
1604
+ intent: deliveryIntent,
1605
+ sourceMessageId: input.message.id,
1606
+ markAccepted: shouldMarkAccepted,
1607
+ imagePaths,
1608
+ mediaAddDirs,
1609
+ planMode: planCommand.planMode,
1610
+ artifactRoutingMode,
1611
+ canUseCodexAppTools: participantContext.isOwner,
1612
+ }));
1396
1613
  }
1397
- function sendTurnArtifactFile(session, file) {
1398
- return sendMediaFileMessage(client, session.conversationId, file.path, '', {
1614
+ async function sendTurnArtifactFile(session, file) {
1615
+ // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1616
+ // the upload; then send the permanent message with the attachment ref.
1617
+ const uploaded = await port.uploadMedia({
1618
+ path: file.path,
1619
+ mime: inferUploadMimeType(file.path),
1620
+ fileName: file.fileName,
1621
+ conversationId: session.conversationId,
1622
+ });
1623
+ return port.sendMessage({
1624
+ conversationId: session.conversationId,
1625
+ text: '',
1626
+ contentType: uploaded.attachment.kind,
1627
+ attachments: [uploaded.attachment],
1399
1628
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1400
1629
  metadata: {
1401
1630
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -1435,296 +1664,6 @@ export async function main() {
1435
1664
  console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1436
1665
  }
1437
1666
  }
1438
- async function runNextTurn(session) {
1439
- if (session.running || session.closed)
1440
- return;
1441
- const nextTurn = session.queue.shift();
1442
- if (!nextTurn)
1443
- return;
1444
- session.running = true;
1445
- session.state.lastError = undefined;
1446
- session.state.state = 'running';
1447
- session.currentTurnId = randomUUID();
1448
- session.turnLiveText = '';
1449
- session.turnBlocks = [];
1450
- session.turnCommandBlocks = createCommandBlockTracker();
1451
- session.currentTurnOpenedAt = Date.now();
1452
- session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1453
- session.lastAcceptedIntent = nextTurn.intent;
1454
- session.turnState = 'thinking';
1455
- session.lastActivity = Date.now();
1456
- await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
1457
- writeState(session);
1458
- writeTurn(session);
1459
- startVisibleWorkSignal(session);
1460
- // Status-only seed: 'thinking' renders as a working filament row on the
1461
- // clients; text here would be bubbled as speech (v4 register rule).
1462
- writeCodexStreaming(session, '', 'thinking');
1463
- let artifactBaseline = null;
1464
- let artifactsRouted = false;
1465
- const routeArtifactsOnce = async () => {
1466
- if (artifactsRouted)
1467
- return;
1468
- artifactsRouted = true;
1469
- if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1470
- await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
1471
- }
1472
- };
1473
- try {
1474
- const turnId = session.currentTurnId ?? randomUUID();
1475
- session.currentTurnId = turnId;
1476
- let turnPrompt = nextTurn.prompt;
1477
- if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1478
- artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
1479
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
1480
- return null;
1481
- });
1482
- }
1483
- const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
1484
- if (modelGuard) {
1485
- throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1486
- }
1487
- const turnImagePaths = nextTurn.imagePaths ?? [];
1488
- const turnMediaAddDirs = nextTurn.mediaAddDirs ?? [];
1489
- const handleCodexEvent = (event) => {
1490
- session.lastActivity = Date.now();
1491
- if (event.type === 'thread.started') {
1492
- if (session.resetRequested) {
1493
- return;
1494
- }
1495
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
1496
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
1497
- return;
1498
- }
1499
- if (event.type === 'skills.changed') {
1500
- void refreshCodexSkillInventory(true).then(() => publishRuntimeHeartbeat());
1501
- return;
1502
- }
1503
- if (event.type === 'message') {
1504
- session.turnState = 'streaming';
1505
- markTurnProgress(session);
1506
- writeTurn(session);
1507
- stopVisibleWorkSignal(session);
1508
- upsertCodexTextSegment(session, event);
1509
- writeCodexStreaming(session, null, 'streaming');
1510
- return;
1511
- }
1512
- if (event.type === 'plan.updated') {
1513
- session.turnState = 'streaming';
1514
- markTurnProgress(session);
1515
- writeTurn(session);
1516
- stopVisibleWorkSignal(session);
1517
- upsertTurnBlock(session, {
1518
- id: `plan:${session.currentTurnId}`,
1519
- kind: 'plan',
1520
- status: 'running',
1521
- title: 'Plan',
1522
- text: event.text,
1523
- });
1524
- writeCodexStreaming(session, event.text, 'streaming');
1525
- return;
1526
- }
1527
- if (event.type === 'waiting') {
1528
- session.turnState = 'waiting_input';
1529
- markTurnProgress(session);
1530
- writeTurn(session);
1531
- stopVisibleWorkSignal(session);
1532
- writeCodexStreaming(session, null, 'waiting_input');
1533
- return;
1534
- }
1535
- if (event.type === 'command.started') {
1536
- session.turnState = 'tool';
1537
- markTurnProgress(session);
1538
- writeTurn(session);
1539
- startVisibleWorkSignal(session);
1540
- const blockId = beginCommandBlock(session.turnCommandBlocks, {
1541
- turnId: session.currentTurnId,
1542
- command: event.command,
1543
- itemId: event.itemId,
1544
- });
1545
- upsertTurnBlock(session, {
1546
- id: blockId,
1547
- kind: 'tool',
1548
- status: 'running',
1549
- title: summarizeCommand(event.command),
1550
- summary: 'Command running',
1551
- });
1552
- writeCodexStreaming(session, null, 'tool');
1553
- return;
1554
- }
1555
- if (event.type === 'command.completed') {
1556
- const blockId = claimCommandBlock(session.turnCommandBlocks, {
1557
- turnId: session.currentTurnId,
1558
- command: event.command,
1559
- itemId: event.itemId,
1560
- });
1561
- completeTurnBlock(session, blockId, 'Command completed');
1562
- if (session.turnState === 'tool') {
1563
- session.turnState = 'thinking';
1564
- markTurnProgress(session);
1565
- writeTurn(session);
1566
- startVisibleWorkSignal(session);
1567
- writeCodexStreaming(session, null, 'thinking');
1568
- }
1569
- return;
1570
- }
1571
- if (event.type === 'turn.completed') {
1572
- // Codex reports per-turn token usage but no context window, so the
1573
- // meter publishes tokens only (input + cached covers the full
1574
- // prompt context of the completed turn).
1575
- const totalTokens = (event.usage?.input_tokens ?? 0)
1576
- + (event.usage?.cached_input_tokens ?? 0)
1577
- + (event.usage?.output_tokens ?? 0);
1578
- if (totalTokens > 0) {
1579
- session.state.contextUsage = { totalTokens };
1580
- }
1581
- writeState(session);
1582
- }
1583
- };
1584
- const logCodexLine = (line) => {
1585
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
1586
- };
1587
- const clearStoredThread = () => {
1588
- clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1589
- session.adapter.clearThreadId();
1590
- };
1591
- const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1592
- planMode: nextTurn.planMode,
1593
- onServerRequest: (request) => handleCodexServerRequest(session, request),
1594
- });
1595
- let result = await runTurnOnce();
1596
- if (!result.interrupted
1597
- && !result.finalMessage
1598
- && result.exitCode
1599
- && result.exitCode !== 0
1600
- && isRecoverableCodexThreadError(result.errorText)) {
1601
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1602
- clearStoredThread();
1603
- result = await runTurnOnce();
1604
- }
1605
- if (result.threadId && !session.resetRequested) {
1606
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1607
- }
1608
- if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1609
- await routeArtifactsOnce();
1610
- const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1611
- responseUserId: ownerId ?? undefined,
1612
- title: 'Codex Plan',
1613
- body: result.finalMessage,
1614
- });
1615
- await sendMessageWithRetry(client, session.conversationId, planApproval.text, {
1616
- messageId: buildCodexMessageId(session, 'plan'),
1617
- metadata: {
1618
- ...planApproval.metadata,
1619
- turnId: session.currentTurnId,
1620
- turnSemantics: 'control',
1621
- replyBehavior: 'suppress_auto_reply',
1622
- },
1623
- });
1624
- await handoffFinalMessage(session.conversationId);
1625
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1626
- }
1627
- else if (!result.interrupted && result.finalMessage) {
1628
- if (isRecoverableCodexThreadError(result.errorText)) {
1629
- clearStoredThread();
1630
- }
1631
- await routeArtifactsOnce();
1632
- const turnTrail = buildFinalTurnTrail(session);
1633
- await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
1634
- messageId: buildCodexMessageId(session, 'final'),
1635
- ...(session.activeSelfContextId
1636
- ? { selfContextId: session.activeSelfContextId }
1637
- : {}),
1638
- metadata: {
1639
- turnId: session.currentTurnId,
1640
- turnSemantics: 'turn_complete',
1641
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1642
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
1643
- },
1644
- });
1645
- await handoffFinalMessage(session.conversationId);
1646
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
1647
- }
1648
- else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
1649
- await routeArtifactsOnce();
1650
- const userVisibleError = formatCodexTurnFailure(result.errorText);
1651
- session.state.lastError = userVisibleError;
1652
- writeState(session);
1653
- if (result.errorText) {
1654
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
1655
- }
1656
- const turnTrail = buildFinalTurnTrail(session);
1657
- await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
1658
- messageId: buildCodexMessageId(session, 'error'),
1659
- ...(session.activeSelfContextId
1660
- ? { selfContextId: session.activeSelfContextId }
1661
- : {}),
1662
- metadata: {
1663
- turnId: session.currentTurnId,
1664
- turnSemantics: 'turn_complete',
1665
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1666
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
1667
- },
1668
- });
1669
- await handoffFinalMessage(session.conversationId);
1670
- }
1671
- else if (!result.interrupted) {
1672
- await routeArtifactsOnce();
1673
- await handoffFinalMessage(session.conversationId);
1674
- }
1675
- else if (result.interrupted) {
1676
- session.turnState = 'interrupted';
1677
- writeTurn(session);
1678
- stopVisibleWorkSignal(session);
1679
- clearStreaming(session.conversationId);
1680
- typingSignals.clear(session.conversationId).catch(() => { });
1681
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
1682
- }
1683
- }
1684
- catch (error) {
1685
- const message = error instanceof ExecutionEnvironmentError
1686
- ? error.userMessage
1687
- : error instanceof CanonApiError
1688
- ? `The Codex host completed the turn, but Canon could not deliver the reply: ${error.message}`
1689
- : `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
1690
- session.state.lastError = message;
1691
- writeState(session);
1692
- await routeArtifactsOnce();
1693
- await sendMessageWithRetryChunked(client, session.conversationId, message, {
1694
- messageId: buildCodexMessageId(session, 'failure'),
1695
- ...(session.activeSelfContextId
1696
- ? { selfContextId: session.activeSelfContextId }
1697
- : {}),
1698
- metadata: {
1699
- turnId: session.currentTurnId,
1700
- turnSemantics: 'turn_complete',
1701
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1702
- },
1703
- }).catch(() => { });
1704
- await handoffFinalMessage(session.conversationId);
1705
- if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
1706
- clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1707
- }
1708
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
1709
- }
1710
- finally {
1711
- stopVisibleWorkSignal(session);
1712
- session.running = false;
1713
- session.state.state = 'idle';
1714
- session.turnState = 'idle';
1715
- session.currentTurnId = null;
1716
- session.currentTurnOpenedAt = null;
1717
- session.currentTurnUpdatedAt = null;
1718
- session.lastAcceptedIntent = null;
1719
- session.resetRequested = false;
1720
- session.lastActivity = Date.now();
1721
- writeState(session);
1722
- writeTurn(session);
1723
- if (session.queue.length > 0) {
1724
- void runNextTurn(session);
1725
- }
1726
- }
1727
- }
1728
1667
  let streamConnected = false;
1729
1668
  const hostAvailableExecutionModes = [
1730
1669
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1793,7 +1732,7 @@ export async function main() {
1793
1732
  if (modelGuard) {
1794
1733
  session.state.lastError = modelGuard;
1795
1734
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1796
- writeState(session);
1735
+ session.writeState();
1797
1736
  // The poller consumes the node; skip effort handling for this pass,
1798
1737
  // matching the legacy loop.
1799
1738
  return;
@@ -1801,58 +1740,42 @@ export async function main() {
1801
1740
  session.adapter.setModel(control.model);
1802
1741
  session.state.model = control.model;
1803
1742
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1804
- writeState(session);
1743
+ session.writeState();
1805
1744
  }
1806
1745
  if (control.permissionMode) {
1807
1746
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1808
1747
  // Convergence contract: a consumed session control must always be
1809
1748
  // answered. Re-publish the currently applied state so clients settle on
1810
1749
  // the authoritative value instead of holding the composer until timeout.
1811
- writeState(session);
1750
+ session.writeState();
1812
1751
  }
1813
1752
  if (control.effort) {
1814
1753
  if (CODEX_EFFORT_VALUES.has(control.effort)) {
1815
1754
  session.adapter.setReasoningEffort(control.effort);
1816
1755
  session.state.effort = control.effort;
1817
1756
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
1818
- writeState(session);
1757
+ session.writeState();
1819
1758
  }
1820
1759
  else {
1821
1760
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
1822
1761
  // Same contract: ignored values still get an authoritative re-publish.
1823
- writeState(session);
1762
+ session.writeState();
1824
1763
  }
1825
1764
  }
1826
1765
  }
1827
- async function handleControlSignal(event) {
1828
- const { conversationId, type } = event;
1829
- const session = sessions.get(conversationId);
1830
- // No live session: dedupe already advanced, but the node stays in place,
1831
- // matching the legacy loop.
1832
- if (!session || session.closed)
1833
- return { consume: false };
1834
- if (type === 'new_session') {
1835
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
1836
- await resetRuntimeSession(session);
1837
- return;
1838
- }
1839
- if (!session.running && (type !== 'stop_and_drop' || session.queue.length === 0)) {
1840
- // Nothing to interrupt or drop — just consume the signal.
1841
- return;
1842
- }
1843
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
1844
- if (type === 'stop_and_drop') {
1845
- const droppedPrompts = session.queue.splice(0);
1846
- await markQueuedPromptsRejected(conversationId, droppedPrompts);
1847
- }
1848
- if (session.running) {
1849
- await session.adapter.interrupt();
1850
- }
1851
- session.turnState = 'interrupted';
1852
- writeTurn(session);
1853
- clearStreaming(conversationId);
1854
- typingSignals.clear(conversationId).catch(() => { });
1855
- }
1766
+ // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1767
+ const handleControlSignal = createRuntimeSignalHandler({
1768
+ agentId,
1769
+ logPrefix: '[canon-codex]',
1770
+ capabilities: CODEX_RUNTIME_CAPABILITIES,
1771
+ }, {
1772
+ sessions,
1773
+ port,
1774
+ isSessionRunning: (session) => session.running,
1775
+ interruptRuntime: (session) => session.adapter.interrupt(),
1776
+ resetSession: (session) => resetRuntimeSession(session),
1777
+ markSessionPendingInputsRejected,
1778
+ });
1856
1779
  async function handleControlPrimitive(event) {
1857
1780
  const { conversationId, value } = event;
1858
1781
  const primitiveId = typeof value.id === 'string' ? value.id : '';
@@ -1872,32 +1795,27 @@ export async function main() {
1872
1795
  try {
1873
1796
  await session.adapter.compactThread();
1874
1797
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
1875
- writeState(session);
1798
+ session.writeState();
1876
1799
  }
1877
1800
  catch (error) {
1878
1801
  const message = error instanceof Error ? error.message : String(error);
1879
1802
  session.state.lastError = `Could not compact Codex context: ${message}`;
1880
1803
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${session.state.lastError}`);
1881
- writeState(session);
1804
+ session.writeState();
1882
1805
  }
1883
1806
  }
1884
- const controlPoller = createCodexControlPoller({
1885
- rtdb,
1886
- agentId,
1887
- conversationIds: () => sessions.keys(),
1888
- hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
1889
- && (session.running || session.queue.length > 0 || session.turnState === 'waiting_input')),
1807
+ // Control events arrive as bridge notifications — the bridge owns the ONE
1808
+ // authoritative /control consumer for this identity (plan §2); the host's
1809
+ // adaptive poller (cadence, jitter, baselines) is deleted with it.
1810
+ const detachControlNotifications = attachCodexControlNotifications({
1811
+ source: notifications.source,
1890
1812
  onSessionControl: ({ conversationId, control }) => {
1891
1813
  applySessionControl(conversationId, control);
1892
1814
  },
1893
1815
  onSignal: handleControlSignal,
1894
1816
  onPrimitive: handleControlPrimitive,
1895
- onError: (error) => {
1896
- // The legacy loop ignored transient RTDB failures; keep read/consume
1897
- // errors quiet but surface handler failures.
1898
- if (error.scope !== 'handler')
1899
- return;
1900
- 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);
1817
+ onError: (error, kind, conversationId) => {
1818
+ console.error(`[canon-codex] [${(conversationId ?? 'unknown').slice(0, 8)}] Control ${kind} handler failed:`, error instanceof Error ? error.message : error);
1901
1819
  },
1902
1820
  });
1903
1821
  let publishRuntimeDetailsInFlight = false;
@@ -1910,7 +1828,7 @@ export async function main() {
1910
1828
  });
1911
1829
  if (!streamConnected)
1912
1830
  return;
1913
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1831
+ await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1914
1832
  console.error('[canon-codex] Failed to publish agent runtime:', error);
1915
1833
  });
1916
1834
  if (publishRuntimeDetailsInFlight)
@@ -1920,15 +1838,14 @@ export async function main() {
1920
1838
  await refreshKnownConversationIds().catch((error) => {
1921
1839
  console.error('[canon-codex] Failed to refresh known conversations:', error);
1922
1840
  });
1923
- await publishHostSessionSnapshots({
1841
+ await port.publishHostSessionSnapshots({
1924
1842
  conversationIds: Array.from(knownConversationIds),
1925
- agentId,
1926
1843
  clientType: 'codex',
1927
1844
  runtime: runtimeDescriptor,
1928
- workspaceOptions,
1845
+ workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1929
1846
  defaultCwd: workingDir,
1930
- extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
1931
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1847
+ extraSessionConfigFields: [...CODEX_SESSION_CONFIG_FIELDS],
1848
+ liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1932
1849
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1933
1850
  return [
1934
1851
  session.conversationId,
@@ -2010,7 +1927,10 @@ export async function main() {
2010
1927
  : 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not native plan questions or structured approvals.',
2011
1928
  ],
2012
1929
  };
2013
- await runtimeState.writeRuntimeInfo(conversationId, payload);
1930
+ await port.publishRuntimeStatus({
1931
+ conversationId,
1932
+ presentation: payload,
1933
+ });
2014
1934
  })).catch((error) => {
2015
1935
  console.error('[canon-codex] Failed to publish runtime info:', error);
2016
1936
  });
@@ -2019,66 +1939,91 @@ export async function main() {
2019
1939
  publishRuntimeDetailsInFlight = false;
2020
1940
  }
2021
1941
  };
2022
- const stream = new CanonStream({
2023
- apiKey,
2024
- agentId,
2025
- handler: {
2026
- onMessage: (payload) => {
2027
- const message = payload.message;
2028
- if (message.senderId === agentId)
2029
- return;
2030
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2031
- console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2032
- return;
2033
- }
2034
- void enqueueInboundMessage({
2035
- conversationId: payload.conversationId,
2036
- message,
2037
- senderName: message.senderName || message.senderId,
2038
- isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
2039
- behavior: payload.behavior,
2040
- activeSelfContextId: payload.activeSelfContextId,
2041
- selfContexts: payload.selfContexts,
2042
- provenance: payload.provenance,
2043
- turnDispatch: payload.turnDispatch,
2044
- });
2045
- if (message.id) {
2046
- saveRuntimeSessionState(runtimeId, {
2047
- conversationId: payload.conversationId,
2048
- baseCwd: workingDir,
2049
- lastInboundMessageId: message.id,
2050
- });
2051
- }
2052
- },
2053
- onMessageDeleted: (payload) => {
2054
- removeQueuedPrompt(payload.conversationId, payload.messageId);
2055
- },
2056
- onConversationUpdated: (payload) => {
2057
- handleConversationUpdated(payload);
2058
- },
2059
- onConnected: () => {
1942
+ // ── Inbound Canon events (bridge notifications) ──
1943
+ // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering.
1944
+ // HITL control-reply messages (approval_reply / plan_approval_reply) are
1945
+ // STRIPPED from onMessage bridge-side; plan replies arrive as
1946
+ // onControlReply and re-enter the plan flow below.
1947
+ const handleBridgeMessage = (payload) => {
1948
+ const message = payload.message;
1949
+ if (message.senderId === agentId)
1950
+ return;
1951
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
1952
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
1953
+ return;
1954
+ }
1955
+ void enqueueInboundMessage({
1956
+ conversationId: payload.conversationId,
1957
+ message,
1958
+ senderName: message.senderName || message.senderId,
1959
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
1960
+ behavior: payload.behavior,
1961
+ activeSelfContextId: payload.activeSelfContextId,
1962
+ selfContexts: payload.selfContexts,
1963
+ provenance: payload.provenance,
1964
+ turnDispatch: payload.turnDispatch,
1965
+ });
1966
+ if (message.id) {
1967
+ saveRuntimeSessionState(runtimeId, {
1968
+ conversationId: payload.conversationId,
1969
+ baseCwd: workingDir,
1970
+ lastInboundMessageId: message.id,
1971
+ });
1972
+ }
1973
+ };
1974
+ const attachInboundNotifications = () => notifications.attach({
1975
+ onMessage: (params) => {
1976
+ handleBridgeMessage(params);
1977
+ },
1978
+ onMessageDeleted: (params) => {
1979
+ const payload = params;
1980
+ removeQueuedInput(payload.conversationId, payload.messageId);
1981
+ },
1982
+ onConversationUpdated: (params) => {
1983
+ handleConversationUpdated(params);
1984
+ },
1985
+ onControlReply: (params) => {
1986
+ const payload = params;
1987
+ const message = payload.message;
1988
+ // Only the plan flow consumes stripped control replies host-side —
1989
+ // approval/card replies resolve against the bridge's HITL records.
1990
+ if (!isRecord(message.metadata) || message.metadata.type !== 'plan_approval_reply') {
1991
+ return;
1992
+ }
1993
+ void enqueueInboundMessage({
1994
+ conversationId: payload.conversationId,
1995
+ message,
1996
+ senderName: message.senderName || message.senderId,
1997
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
1998
+ });
1999
+ },
2000
+ ready: () => {
2001
+ streamConnected = true;
2002
+ void publishRuntimeHeartbeat();
2003
+ console.error('[canon-codex] Bridge ready');
2004
+ },
2005
+ connectionState: (params) => {
2006
+ const { upstream } = params;
2007
+ if (upstream === 'connected') {
2060
2008
  streamConnected = true;
2061
2009
  void publishRuntimeHeartbeat();
2062
- console.error('[canon-codex] SSE connected');
2063
- },
2064
- onDisconnected: () => {
2010
+ console.error('[canon-codex] Canon upstream connected');
2011
+ }
2012
+ else {
2065
2013
  streamConnected = false;
2066
- runtimeState.clearAgentRuntime().catch(() => { });
2067
- console.error('[canon-codex] SSE disconnected');
2068
- },
2069
- onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
2014
+ port.clearAgentRuntime().catch(() => { });
2015
+ console.error(`[canon-codex] Canon upstream ${upstream}`);
2016
+ }
2070
2017
  },
2071
2018
  });
2072
2019
  await refreshCodexSkillInventory();
2073
2020
  try {
2074
- const conversations = await client.getConversations();
2075
- lastKnownConversationRefreshAt = Date.now();
2021
+ const conversations = await directory.listConversations();
2022
+ directory.primeFromStartup(conversations);
2076
2023
  for (const conversation of conversations) {
2077
- knownConversationIds.add(conversation.id);
2078
- conversationCache.set(conversation.id, conversation);
2079
- clearStreaming(conversation.id);
2080
- runtimeState.clearSessionState(conversation.id).catch(() => { });
2081
- runtimeState.clearTurnState(conversation.id).catch(() => { });
2024
+ port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
2025
+ port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
2026
+ port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
2082
2027
  }
2083
2028
  for (const conversation of conversations) {
2084
2029
  const cursor = loadRuntimeSessionState(runtimeId, {
@@ -2086,7 +2031,12 @@ export async function main() {
2086
2031
  baseCwd: workingDir,
2087
2032
  })?.lastInboundMessageId;
2088
2033
  const recovery = await collectMissedInboundMessages({
2089
- fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
2034
+ fetchPage: (before) => port.getMessages({
2035
+ conversationId: conversation.id,
2036
+ limit: STARTUP_RECOVERY_PAGE_SIZE,
2037
+ ...(before ? { before } : {}),
2038
+ includeBehavior: true,
2039
+ }),
2090
2040
  cursor,
2091
2041
  agentId,
2092
2042
  });
@@ -2129,56 +2079,17 @@ export async function main() {
2129
2079
  catch (error) {
2130
2080
  console.error('[canon-codex] Failed to load startup conversations:', error);
2131
2081
  }
2132
- startCodexStreamInBackground(stream, (error) => {
2133
- console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2134
- });
2135
- controlPoller.start();
2136
- const heartbeat = setInterval(() => {
2137
- for (const session of sessions.values()) {
2138
- writeState(session);
2139
- if (!session.running) {
2140
- writeTurn(session);
2141
- }
2142
- }
2143
- void publishRuntimeHeartbeat();
2144
- }, HEARTBEAT_MS);
2145
- const idleCheck = setInterval(() => {
2146
- const now = Date.now();
2147
- for (const conversationId of [...sessions.keys()]) {
2148
- const session = sessions.get(conversationId);
2149
- if (!session || session.running)
2150
- continue;
2151
- if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
2152
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Idle timeout`);
2153
- closeSession(conversationId);
2154
- }
2155
- }
2156
- }, IDLE_CHECK_MS);
2157
- const shutdown = async () => {
2158
- console.error('[canon-codex] Shutting down...');
2159
- controlPoller.stop();
2160
- clearInterval(heartbeat);
2161
- clearInterval(idleCheck);
2162
- stream.stop();
2163
- await runtimeState.clearAgentRuntime().catch(() => { });
2164
- for (const session of [...sessions.values()]) {
2165
- await session.adapter.interrupt().catch(() => { });
2166
- closeSession(session.conversationId);
2167
- }
2168
- markLocalRuntimeStopped(runtimeId);
2169
- (lockHandle ?? getActiveProfileLock())?.release();
2170
- process.exit(0);
2171
- };
2172
- process.on('SIGINT', shutdown);
2173
- process.on('SIGTERM', shutdown);
2174
- process.on('SIGHUP', shutdown);
2082
+ attachInboundNotifications();
2083
+ // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2084
+ lifecycle.startTimers();
2085
+ lifecycle.installSignalHandlers();
2175
2086
  console.error('[canon-codex] Ready — sessions created on demand');
2176
2087
  await new Promise(() => { });
2177
2088
  }
2178
2089
  runCli(import.meta.url, main, (error) => {
2179
2090
  const message = error instanceof Error ? error.message : String(error);
2180
2091
  console.error(`[canon-codex] ${message}`);
2181
- getActiveProfileLock()?.release();
2092
+ activeLockHandle?.release();
2182
2093
  process.exit(1);
2183
2094
  }, {
2184
2095
  name: 'canon-codex',