@canonmsg/codex-plugin 0.18.12 → 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,10 +4,18 @@ 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';
13
21
  import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
@@ -16,11 +24,9 @@ import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThrea
16
24
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
17
25
  import { detectCodexCliVersion } from './codex-cli-version.js';
18
26
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
19
- import { startCodexStreamInBackground } from './host-lifecycle.js';
20
- import { createCodexControlPoller } from './control-channel.js';
27
+ import { attachCodexControlNotifications } from './control-channel.js';
21
28
  import { runCli } from './cli-entry.js';
22
- import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
23
- import { applyTextSegmentBlock, beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
29
+ import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, textSegmentBlockId, } from './turn-activity.js';
24
30
  const HELP = `canon-codex — run a local Codex agent host for Canon
25
31
 
26
32
  USAGE
@@ -72,10 +78,29 @@ export function buildCodexLiveSessionConfig(input) {
72
78
  executionBranch: input.executionBranch ?? null,
73
79
  };
74
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
+ }
75
95
  const MAX_SESSIONS = 12;
76
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;
77
99
  const HEARTBEAT_MS = 30_000;
78
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;
79
104
  const CODEX_RUNTIME_CAPABILITIES = {
80
105
  ...DEFAULT_RUNTIME_CAPABILITIES,
81
106
  supportsInterrupt: true,
@@ -83,6 +108,11 @@ const CODEX_RUNTIME_CAPABILITIES = {
83
108
  supportsQueue: true,
84
109
  supportsNonFinalPermanentMessages: false,
85
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;
86
116
  let workingDir = process.cwd();
87
117
  let workspaceOptions = [];
88
118
  let workspaceRoots = [];
@@ -243,16 +273,16 @@ function buildCodexModelOptions(model) {
243
273
  ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
244
274
  : [];
245
275
  }
246
- async function publishAgentRuntime(agentId, runtime) {
247
- await publishHostAgentRuntime(agentId, 'codex', runtime);
248
- }
249
- async function loadSessionConfig(conversationId, agentId) {
250
- return loadHostSessionConfig({
251
- conversationId,
252
- agentId,
253
- extraStringFields: CODEX_SESSION_CONFIG_FIELDS,
276
+ async function publishAgentRuntime(port, runtime) {
277
+ await port.publishHostAgentRuntime({
278
+ clientType: 'codex',
279
+ runtime: runtime,
254
280
  });
255
281
  }
282
+ async function loadSessionConfig(port, conversationId) {
283
+ const raw = await port.getSessionConfig({ conversationId });
284
+ return readHostSessionConfig(raw, CODEX_SESSION_CONFIG_FIELDS);
285
+ }
256
286
  function resolveSessionExecutionMode(config) {
257
287
  if (config?.executionMode)
258
288
  return config.executionMode;
@@ -494,35 +524,46 @@ export async function main() {
494
524
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
495
525
  }
496
526
  console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
497
- 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;
498
529
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
499
- const client = new CanonClient(apiKey, baseUrl);
500
- const rtdb = initRTDBAuth(client);
501
- const typingSignals = createTypingStatusPublisher({
502
- setTyping: (conversationId, typing, status) => status
503
- ? client.setTyping(conversationId, typing, status)
504
- : 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),
505
554
  });
506
- let agentId;
507
- let ownerId = null;
508
- let ownerName = null;
509
- try {
510
- const ctx = await client.getAgentMe();
511
- agentId = ctx.agentId;
512
- ownerId = ctx.ownerId;
513
- ownerName = ctx.ownerName;
514
- console.error(`[canon-codex] Connected as ${ctx.displayName || agentId}`);
515
- }
516
- catch {
517
- if (profileAgentId) {
518
- agentId = profileAgentId;
519
- }
520
- else {
521
- const auth = await client.getAuthToken();
522
- agentId = auth.agentId;
523
- }
524
- console.error(`[canon-codex] Authenticated as ${agentId}`);
525
- }
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})`);
526
567
  const launchArgs = [...process.argv.slice(2)];
527
568
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
528
569
  launchArgs.push('--cwd', workingDir);
@@ -551,292 +592,81 @@ export async function main() {
551
592
  lastStartedAt: new Date().toISOString(),
552
593
  lastHeartbeatAt: new Date().toISOString(),
553
594
  });
554
- const runtimeState = createRuntimeStatePublisher({
595
+ // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
596
+ const directory = createConversationDirectory({
555
597
  agentId,
556
- clientType: 'codex',
557
- hostMode: true,
558
- });
559
- const sessions = new Map();
560
- const pendingSessionCreations = new Map();
561
- const conversationCache = new Map();
562
- const knownConversationIds = new Set();
563
- const promptedGroupContextConversationIds = new Set();
564
- const pendingMembershipChanges = new Map();
565
- let lastKnownConversationRefreshAt = 0;
566
- const { getConversationMeta } = createConversationMetadataLoader({
567
- client,
568
- conversationCache,
569
- });
598
+ ownerId,
599
+ ownerName,
600
+ refreshFloorMs: HEARTBEAT_MS,
601
+ }, { port });
602
+ const { conversationCache, knownConversationIds, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
570
603
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
571
604
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
572
605
  }
573
- async function refreshKnownConversationIds(force = false) {
574
- if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
575
- return;
576
- }
577
- const conversations = await client.getConversations();
578
- knownConversationIds.clear();
579
- for (const conversation of conversations) {
580
- knownConversationIds.add(conversation.id);
581
- conversationCache.set(conversation.id, conversation);
582
- }
583
- lastKnownConversationRefreshAt = Date.now();
584
- }
585
- function handleConversationUpdated(payload) {
586
- const rawMemberIds = payload.changes.memberIds;
587
- if (!Array.isArray(rawMemberIds))
588
- return;
589
- const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
590
- const cached = conversationCache.get(payload.conversationId);
591
- const membershipChange = payload.membershipChange
592
- ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
593
- if (cached) {
594
- conversationCache.set(payload.conversationId, {
595
- ...cached,
596
- memberIds,
597
- });
598
- }
599
- if (membershipChange) {
600
- pendingMembershipChanges.set(payload.conversationId, membershipChange);
601
- }
602
- if (!memberIds.includes(agentId)) {
603
- knownConversationIds.delete(payload.conversationId);
604
- conversationCache.delete(payload.conversationId);
605
- }
606
- }
607
- function getGroupContextMode(conversationId, conversation) {
608
- if (conversation?.type !== 'group')
609
- return undefined;
610
- if (pendingMembershipChanges.has(conversationId))
611
- return 'membership_change';
612
- if (!promptedGroupContextConversationIds.has(conversationId))
613
- return 'initial';
614
- return undefined;
615
- }
616
- function markGroupContextModeUsed(conversationId, mode) {
617
- if (!mode)
618
- return;
619
- promptedGroupContextConversationIds.add(conversationId);
620
- if (mode === 'membership_change') {
621
- pendingMembershipChanges.delete(conversationId);
622
- }
623
- }
624
- async function loadHydratedInboundContext(input) {
625
- const [conversation, page] = await Promise.all([
626
- getConversationMeta(input.conversationId),
627
- input.hydratedPage
628
- ? Promise.resolve(input.hydratedPage)
629
- : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
630
- ]);
631
- return buildHydratedInboundContext({
632
- agentId,
633
- conversationId: input.conversationId,
634
- conversation,
635
- page,
636
- activeSelfContextId: input.activeSelfContextId,
637
- selfContexts: input.selfContexts,
638
- provenance: input.provenance,
639
- message: input.message,
640
- senderName: input.senderName,
641
- isOwner: input.isOwner,
642
- ownerId,
643
- ownerName,
644
- membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
645
- groupContextMode: getGroupContextMode(input.conversationId, conversation),
646
- });
647
- }
648
- function writeState(session) {
649
- const appliedAt = Date.now();
650
- const controlState = {};
651
- if (session.state.model !== undefined) {
652
- controlState.model = { value: session.state.model, source: 'applied', appliedAt };
653
- }
654
- if (session.state.permissionMode !== undefined) {
655
- controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
656
- }
657
- if (session.state.effort !== undefined) {
658
- controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
659
- }
660
- runtimeState.writeSessionState(session.conversationId, {
661
- lastError: session.state.lastError,
662
- model: session.state.model,
663
- permissionMode: session.state.permissionMode,
664
- effort: session.state.effort,
665
- controlState,
666
- cwd: session.cwd,
667
- executionMode: session.environment.mode,
668
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
669
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
670
- ...(resolveExecutionFallbackReason(session.environment)
671
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
672
- : {}),
673
- hostMode: true,
674
- clientType: 'codex',
675
- isActive: true,
676
- ...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
677
- }).catch(() => { });
678
- }
679
- function writeTurn(session) {
680
- const isOpenTurn = session.turnState === 'thinking'
681
- || session.turnState === 'streaming'
682
- || session.turnState === 'tool'
683
- || session.turnState === 'waiting_input';
684
- runtimeState.writeTurnState(session.conversationId, {
685
- turnId: session.currentTurnId,
686
- state: session.turnState,
687
- queueDepth: session.queue.length,
688
- currentSpeakerId: agentId,
689
- lastAcceptedIntent: session.lastAcceptedIntent,
690
- capabilities: CODEX_RUNTIME_CAPABILITIES,
691
- ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
692
- ...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
693
- ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
694
- ? { completedAt: { '.sv': 'timestamp' } }
695
- : {}),
696
- }).catch(() => { });
697
- }
698
- function markTurnProgress(session) {
699
- session.currentTurnUpdatedAt = Date.now();
700
- }
701
606
  async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
702
607
  if (!markAccepted || !sourceMessageId)
703
608
  return;
704
- await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
705
- }
706
- async function markQueuedPromptsRejected(conversationId, prompts) {
707
- await Promise.all(prompts.map((prompt) => {
708
- if (!prompt.markAccepted || !prompt.sourceMessageId)
709
- return Promise.resolve();
710
- return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
711
- }));
712
- }
713
- function removeQueuedPrompt(conversationId, sourceMessageId) {
714
- const session = sessions.get(conversationId);
715
- if (!session || session.queue.length === 0)
716
- return;
717
- const before = session.queue.length;
718
- session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
719
- if (session.queue.length !== before) {
720
- writeTurn(session);
721
- }
722
- }
723
- function clearStreaming(conversationId) {
724
- runtimeState.clearStreaming(conversationId).catch(() => { });
725
- }
726
- function writeCodexStreaming(session, text, status) {
727
- if (text !== null) {
728
- session.turnLiveText = text;
729
- }
730
- runtimeState.writeStreaming(session.conversationId, {
731
- text: session.turnLiveText,
732
- status,
733
- messageId: session.currentTurnId ?? undefined,
734
- turnId: session.currentTurnId,
735
- blocks: session.turnBlocks,
609
+ await port.setDisposition({
610
+ conversationId,
611
+ messageId: sourceMessageId,
612
+ inboundDisposition: 'accepted_now',
736
613
  }).catch(() => { });
737
614
  }
738
- function upsertCodexTextSegment(session, event) {
739
- const next = applyTextSegmentBlock({
740
- turnLiveText: session.turnLiveText,
741
- turnBlocks: session.turnBlocks,
742
- }, {
743
- turnId: session.currentTurnId,
744
- itemId: event.itemId,
745
- text: event.text,
746
- });
747
- session.turnLiveText = next.turnLiveText;
748
- session.turnBlocks = next.turnBlocks;
749
- }
750
- function upsertTurnBlock(session, block) {
751
- const now = Date.now();
752
- const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
753
- const existing = index >= 0 ? session.turnBlocks[index] : null;
754
- const next = {
755
- ...(existing ?? {
756
- sequence: session.turnBlocks.length + 1,
757
- createdAt: now,
758
- }),
759
- ...block,
760
- turnId: session.currentTurnId ?? block.id,
761
- updatedAt: now,
762
- };
763
- session.turnBlocks = index >= 0
764
- ? [
765
- ...session.turnBlocks.slice(0, index),
766
- next,
767
- ...session.turnBlocks.slice(index + 1),
768
- ]
769
- : [...session.turnBlocks, next];
770
- }
771
- function completeTurnBlock(session, id, summary) {
772
- const existing = session.turnBlocks.find((block) => block.id === id);
773
- if (!existing)
774
- return;
775
- upsertTurnBlock(session, {
776
- id,
777
- kind: existing.kind,
778
- status: 'completed',
779
- title: existing.title,
780
- text: existing.text,
781
- summary: summary ?? existing.summary,
782
- });
783
- }
784
- function buildFinalTurnTrail(session) {
785
- return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
786
- ...block,
787
- turnId: session.currentTurnId ?? block.turnId,
788
- })));
789
- }
790
615
  function buildCodexMessageId(session, kind) {
791
616
  return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
792
617
  }
793
618
  function buildCodexRuntimeCardOutcomeMessageId(cardId, status) {
794
619
  return `codex-card-${cardId}-${status}`;
795
620
  }
796
- async function handoffFinalMessage(conversationId) {
797
- await sleep(FINAL_MESSAGE_HANDOFF_MS);
798
- clearStreaming(conversationId);
799
- typingSignals.clear(conversationId).catch(() => { });
800
- }
801
- function refreshVisibleWorkSignal(session) {
802
- if (!session.running || session.closed)
803
- return;
804
- if (session.turnState !== 'thinking' && session.turnState !== 'tool')
805
- return;
806
- typingSignals.start(session.conversationId, 'thinking').catch(() => { });
807
- }
808
- function startVisibleWorkSignal(session) {
809
- refreshVisibleWorkSignal(session);
810
- }
811
- function stopVisibleWorkSignal(session) {
812
- if (session.typingKeepaliveTimer) {
813
- clearInterval(session.typingKeepaliveTimer);
814
- session.typingKeepaliveTimer = null;
815
- }
816
- typingSignals.clear(session.conversationId).catch(() => { });
817
- }
818
- function closeSession(conversationId) {
819
- const session = sessions.get(conversationId);
820
- if (!session)
821
- return;
822
- session.closed = true;
823
- stopVisibleWorkSignal(session);
824
- if ('close' in session.adapter && typeof session.adapter.close === 'function') {
825
- session.adapter.close();
826
- }
827
- releaseConversationEnvironment(session.environment);
828
- clearStreaming(conversationId);
829
- runtimeState.clearSessionState(conversationId).catch(() => { });
830
- runtimeState.clearTurnState(conversationId).catch(() => { });
831
- typingSignals.clear(conversationId).catch(() => { });
832
- typingSignals.dispose(conversationId);
833
- sessions.delete(conversationId);
834
- }
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
+ */
835
665
  async function resetRuntimeSession(session) {
836
666
  const conversationId = session.conversationId;
837
667
  session.resetRequested = true;
838
- const droppedPrompts = session.queue.splice(0);
839
- await markQueuedPromptsRejected(conversationId, droppedPrompts);
668
+ const droppedInputs = session.pendingInputs.splice(0);
669
+ await markSessionPendingInputsRejected(conversationId, droppedInputs);
840
670
  clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
841
671
  session.adapter.clearThreadId();
842
672
  session.activeSelfContextId = null;
@@ -854,40 +684,16 @@ export async function main() {
854
684
  session.lastAcceptedIntent = null;
855
685
  session.resetRequested = false;
856
686
  }
857
- stopVisibleWorkSignal(session);
858
- clearStreaming(conversationId);
687
+ session.stopVisibleWork();
688
+ session.clearStreaming().catch(() => { });
859
689
  typingSignals.clear(conversationId).catch(() => { });
860
- writeState(session);
861
- writeTurn(session);
862
- }
863
- function evictOldestIdle() {
864
- let oldest = null;
865
- for (const session of sessions.values()) {
866
- if (session.running)
867
- continue;
868
- if (!oldest || session.lastActivity < oldest.lastActivity)
869
- oldest = session;
870
- }
871
- if (oldest) {
872
- console.error(`[canon-codex] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
873
- closeSession(oldest.conversationId);
874
- }
690
+ session.writeState();
691
+ session.writeTurn();
875
692
  }
876
- async function getOrCreateSession(conversationId) {
877
- knownConversationIds.add(conversationId);
878
- const existing = sessions.get(conversationId);
879
- if (existing && !existing.closed) {
880
- existing.lastActivity = Date.now();
881
- return existing;
882
- }
883
- const pending = pendingSessionCreations.get(conversationId);
884
- if (pending)
885
- return pending;
886
- if (sessions.size >= MAX_SESSIONS) {
887
- evictOldestIdle();
888
- }
889
- const creation = (async () => {
890
- 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);
891
697
  const sessionExecutionMode = resolveSessionExecutionMode(config);
892
698
  const workspaceCwd = resolveWorkspaceCwd(config);
893
699
  const environment = prepareConversationEnvironment({
@@ -941,12 +747,110 @@ export async function main() {
941
747
  fullAuto: policy.fullAuto,
942
748
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
943
749
  });
944
- 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 = {
945
848
  conversationId,
946
849
  cwd: sessionCwd,
947
850
  environment,
948
851
  adapter,
949
- queue: [],
852
+ pendingInputs: [],
853
+ activeInput: null,
950
854
  running: false,
951
855
  state: buildCodexInitialSessionState({
952
856
  model: policy.model,
@@ -961,19 +865,350 @@ export async function main() {
961
865
  currentTurnCanUseCodexAppTools: false,
962
866
  activeSelfContextId: null,
963
867
  lastAcceptedIntent: null,
868
+ interruptedTurnKeys: new Set(),
869
+ finalizedTurnKeys: new Set(),
870
+ pendingFinalText: null,
871
+ pendingFinalDelivery: null,
964
872
  resetRequested: false,
965
873
  lastActivity: Date.now(),
966
874
  typingKeepaliveTimer: null,
875
+ idleResetTimer: null,
876
+ finalDeliveryTimer: null,
967
877
  closed: false,
968
- turnLiveText: '',
969
- turnBlocks: [],
878
+ streamingText: '',
879
+ availableModels: [],
880
+ runtimeControlErrors: {},
881
+ pendingReply: null,
970
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),
971
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
+ }
972
1208
  sessions.set(conversationId, session);
973
- await controlPoller.baseline([conversationId]);
974
1209
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
975
- writeState(session);
976
- writeTurn(session);
1210
+ session.writeState();
1211
+ session.writeTurn();
977
1212
  return session;
978
1213
  }
979
1214
  catch (error) {
@@ -981,114 +1216,57 @@ export async function main() {
981
1216
  throw error;
982
1217
  }
983
1218
  })();
984
- pendingSessionCreations.set(conversationId, creation);
985
- try {
986
- return await creation;
987
- }
988
- finally {
989
- pendingSessionCreations.delete(conversationId);
990
- }
991
- }
992
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false) {
993
- const nextPrompt = {
994
- prompt,
995
- intent,
996
- sourceMessageId,
997
- markAccepted,
998
- imagePaths,
999
- mediaAddDirs,
1000
- planMode,
1001
- artifactRoutingMode,
1002
- canUseCodexAppTools,
1003
- };
1004
- if (toFront) {
1005
- session.queue.unshift(nextPrompt);
1006
- }
1007
- else {
1008
- session.queue.push(nextPrompt);
1009
- }
1010
- session.lastActivity = Date.now();
1011
- writeTurn(session);
1012
- void runNextTurn(session);
1013
1219
  }
1014
1220
  function resolveArtifactRoutingMode(participantContext) {
1015
1221
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1016
1222
  ? 'workspace-generated'
1017
1223
  : 'disabled';
1018
1224
  }
1019
- async function waitForRuntimeInputResponse(input) {
1020
- while (Date.now() < input.expiresAt) {
1021
- const response = await client.consumeRuntimeInputResponse({
1022
- conversationId: input.conversationId,
1023
- inputId: input.inputId,
1024
- }).catch(() => null);
1025
- if (response?.status === 'submitted') {
1026
- return { status: 'submitted', value: response.value, answers: response.answers };
1027
- }
1028
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
1029
- return { status: response.status };
1030
- }
1031
- await sleep(1_000);
1032
- }
1033
- const response = await client.consumeRuntimeInputResponse({
1034
- conversationId: input.conversationId,
1035
- inputId: input.inputId,
1036
- }).catch(() => null);
1037
- if (response?.status === 'submitted') {
1038
- return { status: 'submitted', value: response.value, answers: response.answers };
1039
- }
1040
- return { status: 'timeout' };
1041
- }
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
+ */
1042
1230
  async function waitForRuntimeApprovalResponse(input) {
1231
+ const read = async () => port.getHitlState({ requestId: input.approvalId }).catch(() => null);
1043
1232
  while (Date.now() < input.expiresAt) {
1044
- const response = await client.consumeRuntimeApprovalResponse({
1045
- conversationId: input.conversationId,
1046
- approvalId: input.approvalId,
1047
- }).catch(() => null);
1048
- if (response?.status === 'allow') {
1049
- 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 };
1050
1239
  }
1051
- if (response?.status === 'deny' || response?.status === 'timeout') {
1240
+ if (state?.status === 'cancelled' || state?.status === 'timeout') {
1052
1241
  return { decision: 'deny' };
1053
1242
  }
1054
1243
  await sleep(1_000);
1055
1244
  }
1056
- await client.consumeRuntimeApprovalResponse({
1057
- conversationId: input.conversationId,
1058
- approvalId: input.approvalId,
1059
- }).catch(() => null);
1060
1245
  return { decision: 'deny' };
1061
1246
  }
1062
1247
  async function waitForRuntimeCardResponse(input) {
1063
- while (Date.now() < input.expiresAt) {
1064
- const response = await client.consumeRuntimeCardResponse({
1065
- conversationId: input.conversationId,
1066
- cardId: input.cardId,
1067
- }).catch(() => null);
1068
- 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 ?? {});
1069
1252
  return {
1070
1253
  status: 'submitted',
1071
- ...(response.actionId ? { actionId: response.actionId } : {}),
1072
- ...(response.values ? { values: response.values } : {}),
1254
+ ...(answer.actionId ? { actionId: answer.actionId } : {}),
1255
+ ...(answer.values ? { values: answer.values } : {}),
1073
1256
  };
1074
1257
  }
1075
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
1076
- return { status: response.status };
1258
+ if (state?.status === 'cancelled' || state?.status === 'timeout') {
1259
+ return { status: state.status };
1077
1260
  }
1261
+ return null;
1262
+ };
1263
+ while (Date.now() < input.expiresAt) {
1264
+ const mapped = map(await read());
1265
+ if (mapped)
1266
+ return mapped;
1078
1267
  await sleep(1_000);
1079
1268
  }
1080
- const response = await client.consumeRuntimeCardResponse({
1081
- conversationId: input.conversationId,
1082
- cardId: input.cardId,
1083
- }).catch(() => null);
1084
- if (response?.status === 'submitted') {
1085
- return {
1086
- status: 'submitted',
1087
- ...(response.actionId ? { actionId: response.actionId } : {}),
1088
- ...(response.values ? { values: response.values } : {}),
1089
- };
1090
- }
1091
- return { status: 'timeout' };
1269
+ return map(await read()) ?? { status: 'timeout' };
1092
1270
  }
1093
1271
  function runtimeCardRequestPayload(method, params) {
1094
1272
  if (method !== 'item/runtimeCard/request'
@@ -1132,14 +1310,15 @@ export async function main() {
1132
1310
  let requestCreated = false;
1133
1311
  let requestResolved = false;
1134
1312
  try {
1135
- await client.createRuntimeCardRequest({
1313
+ await port.requestCard({
1136
1314
  conversationId: session.conversationId,
1137
1315
  cardId,
1138
1316
  card: { ...card, cardId },
1139
- expiresAt,
1140
- // Omit responseUserId so the backend targets a reachable member (the
1141
- // owner if present, else the sole other member) — the owner is often
1142
- // 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,
1143
1322
  native: {
1144
1323
  runtime: 'codex',
1145
1324
  method: request.method,
@@ -1150,21 +1329,20 @@ export async function main() {
1150
1329
  threadId: readString(params, 'threadId') ?? '',
1151
1330
  },
1152
1331
  },
1153
- turnId: session.currentTurnId ?? undefined,
1332
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1154
1333
  });
1155
1334
  requestCreated = true;
1156
1335
  session.turnState = 'waiting_input';
1157
- markTurnProgress(session);
1158
- upsertTurnBlock(session, {
1336
+ session.stageTurnBlock({
1159
1337
  id: `card:${cardId}`,
1160
1338
  kind: 'input',
1161
1339
  status: 'pending',
1162
1340
  title: card.title,
1163
1341
  summary: card.template ?? 'runtime card',
1164
1342
  });
1165
- writeTurn(session);
1166
- stopVisibleWorkSignal(session);
1167
- writeCodexStreaming(session, null, 'waiting_input');
1343
+ session.writeTurn();
1344
+ session.stopVisibleWork();
1345
+ session.setStreamingStatus('waiting_input');
1168
1346
  const response = await waitForRuntimeCardResponse({
1169
1347
  conversationId: session.conversationId,
1170
1348
  cardId,
@@ -1172,7 +1350,9 @@ export async function main() {
1172
1350
  });
1173
1351
  requestResolved = true;
1174
1352
  const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1175
- await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1353
+ await port.sendMessage({
1354
+ conversationId: session.conversationId,
1355
+ text: outcome.text,
1176
1356
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1177
1357
  metadata: {
1178
1358
  ...outcome.metadata,
@@ -1181,25 +1361,27 @@ export async function main() {
1181
1361
  replyBehavior: 'suppress_auto_reply',
1182
1362
  },
1183
1363
  });
1184
- 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
+ });
1185
1370
  if (session.turnState === 'waiting_input') {
1186
1371
  session.turnState = 'thinking';
1187
- markTurnProgress(session);
1188
- writeTurn(session);
1189
- startVisibleWorkSignal(session);
1190
- writeCodexStreaming(session, null, 'thinking');
1372
+ session.writeTurn();
1373
+ session.startVisibleWork();
1374
+ session.setStreamingStatus('thinking');
1191
1375
  }
1192
1376
  return response;
1193
1377
  }
1194
1378
  catch (error) {
1195
1379
  if (requestCreated && !requestResolved) {
1196
- await client.consumeRuntimeCardResponse({
1197
- conversationId: session.conversationId,
1198
- cardId,
1199
- cancel: true,
1200
- }).catch(() => null);
1380
+ await port.cancelHitl({ requestId: cardId }).catch(() => null);
1201
1381
  const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1202
- await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1382
+ await port.sendMessage({
1383
+ conversationId: session.conversationId,
1384
+ text: outcome.text,
1203
1385
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1204
1386
  metadata: {
1205
1387
  ...outcome.metadata,
@@ -1217,17 +1399,19 @@ export async function main() {
1217
1399
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1218
1400
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1219
1401
  const inputId = readString(params, 'itemId') ?? requestId;
1220
- await client.createRuntimeInputRequest({
1402
+ await port.requestInput({
1221
1403
  conversationId: session.conversationId,
1222
1404
  inputId,
1223
1405
  kind: 'clarify',
1224
- expiresAt,
1225
- responseUserId: ownerId ?? undefined,
1406
+ expiresAt: new Date(expiresAt).toISOString(),
1407
+ ...(ownerId ? { responseUserId: ownerId } : {}),
1226
1408
  title: 'Codex needs input',
1227
1409
  prompt: questions?.length
1228
1410
  ? 'Codex needs your input to continue.'
1229
1411
  : 'Codex needs input.',
1230
- ...(questions ? { questions } : {}),
1412
+ ...(questions
1413
+ ? { questions: questions }
1414
+ : { questions: [] }),
1231
1415
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
1232
1416
  native: {
1233
1417
  runtime: 'codex',
@@ -1239,13 +1423,9 @@ export async function main() {
1239
1423
  threadId: readString(params, 'threadId') ?? '',
1240
1424
  },
1241
1425
  },
1242
- turnId: session.currentTurnId ?? undefined,
1243
- });
1244
- const response = await waitForRuntimeInputResponse({
1245
- conversationId: session.conversationId,
1246
- inputId,
1247
- expiresAt,
1426
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1248
1427
  });
1428
+ const response = await session.waitForRuntimeInputResponse({ inputId, expiresAt });
1249
1429
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1250
1430
  }
1251
1431
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1254,25 +1434,29 @@ export async function main() {
1254
1434
  });
1255
1435
  if (mappedApproval) {
1256
1436
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1257
- await client.createRuntimeApprovalRequest({
1437
+ await port.requestApproval({
1258
1438
  conversationId: session.conversationId,
1259
1439
  approvalId,
1260
1440
  toolName: mappedApproval.toolName,
1261
- toolSummary: mappedApproval.toolSummary,
1441
+ detail: mappedApproval.toolSummary,
1262
1442
  category: mappedApproval.category,
1263
- risk: mappedApproval.risk,
1264
- riskLevel: mappedApproval.riskLevel,
1443
+ risk: mappedApproval.risk ?? 'normal',
1444
+ ...(mappedApproval.riskLevel ? { riskLevel: mappedApproval.riskLevel } : {}),
1265
1445
  native: {
1266
1446
  ...mappedApproval.native,
1267
1447
  requestId,
1268
1448
  method: request.method,
1269
1449
  },
1270
- details: mappedApproval.details,
1271
- ...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
1272
- responseUserId: ownerId ?? undefined,
1450
+ ...(mappedApproval.details
1451
+ ? { details: mappedApproval.details }
1452
+ : {}),
1453
+ ...(mappedApproval.diff
1454
+ ? { diff: mappedApproval.diff }
1455
+ : {}),
1456
+ ...(ownerId ? { responseUserId: ownerId } : {}),
1273
1457
  allowSessionRule: true,
1274
- expiresAt,
1275
- turnId: session.currentTurnId ?? undefined,
1458
+ expiresAt: new Date(expiresAt).toISOString(),
1459
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1276
1460
  });
1277
1461
  const response = await waitForRuntimeApprovalResponse({
1278
1462
  conversationId: session.conversationId,
@@ -1308,7 +1492,12 @@ export async function main() {
1308
1492
  : decision === 'reject'
1309
1493
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1310
1494
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1311
- enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner);
1495
+ session.enqueueInbound(buildCodexQueuedInput({
1496
+ prompt,
1497
+ sourceMessageId: input.message.id,
1498
+ planMode: decision !== 'approve',
1499
+ canUseCodexAppTools: input.isOwner,
1500
+ }));
1312
1501
  return;
1313
1502
  }
1314
1503
  let materialized = [];
@@ -1381,7 +1570,9 @@ export async function main() {
1381
1570
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1382
1571
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
1383
1572
  await markQueuedMessageAccepted(input.conversationId, input.message.id, shouldMarkAccepted);
1384
- 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}`,
1385
1576
  messageId: `codex-start-failed-${input.message.id}`,
1386
1577
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1387
1578
  metadata: {
@@ -1404,19 +1595,36 @@ export async function main() {
1404
1595
  message: input.message,
1405
1596
  });
1406
1597
  if (session.running && deliveryIntent === 'interrupt') {
1407
- const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1408
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1409
1598
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1410
- await session.adapter.interrupt().catch(() => { });
1411
- clearStreaming(input.conversationId);
1412
- typingSignals.clear(input.conversationId).catch(() => { });
1413
- return;
1414
1599
  }
1415
1600
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1416
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
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
+ }));
1417
1613
  }
1418
- function sendTurnArtifactFile(session, file) {
1419
- 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],
1420
1628
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1421
1629
  metadata: {
1422
1630
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -1456,298 +1664,6 @@ export async function main() {
1456
1664
  console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1457
1665
  }
1458
1666
  }
1459
- async function runNextTurn(session) {
1460
- if (session.running || session.closed)
1461
- return;
1462
- const nextTurn = session.queue.shift();
1463
- if (!nextTurn)
1464
- return;
1465
- session.running = true;
1466
- session.state.lastError = undefined;
1467
- session.state.state = 'running';
1468
- session.currentTurnId = randomUUID();
1469
- session.turnLiveText = '';
1470
- session.turnBlocks = [];
1471
- session.turnCommandBlocks = createCommandBlockTracker();
1472
- session.currentTurnOpenedAt = Date.now();
1473
- session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1474
- session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
1475
- session.lastAcceptedIntent = nextTurn.intent;
1476
- session.turnState = 'thinking';
1477
- session.lastActivity = Date.now();
1478
- await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
1479
- writeState(session);
1480
- writeTurn(session);
1481
- startVisibleWorkSignal(session);
1482
- // Status-only seed: 'thinking' renders as a working filament row on the
1483
- // clients; text here would be bubbled as speech (v4 register rule).
1484
- writeCodexStreaming(session, '', 'thinking');
1485
- let artifactBaseline = null;
1486
- let artifactsRouted = false;
1487
- const routeArtifactsOnce = async () => {
1488
- if (artifactsRouted)
1489
- return;
1490
- artifactsRouted = true;
1491
- if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1492
- await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
1493
- }
1494
- };
1495
- try {
1496
- const turnId = session.currentTurnId ?? randomUUID();
1497
- session.currentTurnId = turnId;
1498
- let turnPrompt = nextTurn.prompt;
1499
- if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1500
- artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
1501
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
1502
- return null;
1503
- });
1504
- }
1505
- const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
1506
- if (modelGuard) {
1507
- throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1508
- }
1509
- const turnImagePaths = nextTurn.imagePaths ?? [];
1510
- const turnMediaAddDirs = nextTurn.mediaAddDirs ?? [];
1511
- const handleCodexEvent = (event) => {
1512
- session.lastActivity = Date.now();
1513
- if (event.type === 'thread.started') {
1514
- if (session.resetRequested) {
1515
- return;
1516
- }
1517
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
1518
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
1519
- return;
1520
- }
1521
- if (event.type === 'skills.changed') {
1522
- void refreshCodexSkillInventory(true).then(() => publishRuntimeHeartbeat());
1523
- return;
1524
- }
1525
- if (event.type === 'message') {
1526
- session.turnState = 'streaming';
1527
- markTurnProgress(session);
1528
- writeTurn(session);
1529
- stopVisibleWorkSignal(session);
1530
- upsertCodexTextSegment(session, event);
1531
- writeCodexStreaming(session, null, 'streaming');
1532
- return;
1533
- }
1534
- if (event.type === 'plan.updated') {
1535
- session.turnState = 'streaming';
1536
- markTurnProgress(session);
1537
- writeTurn(session);
1538
- stopVisibleWorkSignal(session);
1539
- upsertTurnBlock(session, {
1540
- id: `plan:${session.currentTurnId}`,
1541
- kind: 'plan',
1542
- status: 'running',
1543
- title: 'Plan',
1544
- text: event.text,
1545
- });
1546
- writeCodexStreaming(session, event.text, 'streaming');
1547
- return;
1548
- }
1549
- if (event.type === 'waiting') {
1550
- session.turnState = 'waiting_input';
1551
- markTurnProgress(session);
1552
- writeTurn(session);
1553
- stopVisibleWorkSignal(session);
1554
- writeCodexStreaming(session, null, 'waiting_input');
1555
- return;
1556
- }
1557
- if (event.type === 'command.started') {
1558
- session.turnState = 'tool';
1559
- markTurnProgress(session);
1560
- writeTurn(session);
1561
- startVisibleWorkSignal(session);
1562
- const blockId = beginCommandBlock(session.turnCommandBlocks, {
1563
- turnId: session.currentTurnId,
1564
- command: event.command,
1565
- itemId: event.itemId,
1566
- });
1567
- upsertTurnBlock(session, {
1568
- id: blockId,
1569
- kind: 'tool',
1570
- status: 'running',
1571
- title: summarizeCommand(event.command),
1572
- summary: 'Command running',
1573
- });
1574
- writeCodexStreaming(session, null, 'tool');
1575
- return;
1576
- }
1577
- if (event.type === 'command.completed') {
1578
- const blockId = claimCommandBlock(session.turnCommandBlocks, {
1579
- turnId: session.currentTurnId,
1580
- command: event.command,
1581
- itemId: event.itemId,
1582
- });
1583
- completeTurnBlock(session, blockId, 'Command completed');
1584
- if (session.turnState === 'tool') {
1585
- session.turnState = 'thinking';
1586
- markTurnProgress(session);
1587
- writeTurn(session);
1588
- startVisibleWorkSignal(session);
1589
- writeCodexStreaming(session, null, 'thinking');
1590
- }
1591
- return;
1592
- }
1593
- if (event.type === 'turn.completed') {
1594
- // Codex reports per-turn token usage but no context window, so the
1595
- // meter publishes tokens only (input + cached covers the full
1596
- // prompt context of the completed turn).
1597
- const totalTokens = (event.usage?.input_tokens ?? 0)
1598
- + (event.usage?.cached_input_tokens ?? 0)
1599
- + (event.usage?.output_tokens ?? 0);
1600
- if (totalTokens > 0) {
1601
- session.state.contextUsage = { totalTokens };
1602
- }
1603
- writeState(session);
1604
- }
1605
- };
1606
- const logCodexLine = (line) => {
1607
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
1608
- };
1609
- const clearStoredThread = () => {
1610
- clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1611
- session.adapter.clearThreadId();
1612
- };
1613
- const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1614
- planMode: nextTurn.planMode,
1615
- onServerRequest: (request) => handleCodexServerRequest(session, request),
1616
- });
1617
- let result = await runTurnOnce();
1618
- if (!result.interrupted
1619
- && !result.finalMessage
1620
- && result.exitCode
1621
- && result.exitCode !== 0
1622
- && isRecoverableCodexThreadError(result.errorText)) {
1623
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1624
- clearStoredThread();
1625
- result = await runTurnOnce();
1626
- }
1627
- if (result.threadId && !session.resetRequested) {
1628
- saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1629
- }
1630
- if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1631
- await routeArtifactsOnce();
1632
- const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1633
- responseUserId: ownerId ?? undefined,
1634
- title: 'Codex Plan',
1635
- body: result.finalMessage,
1636
- });
1637
- await sendMessageWithRetry(client, session.conversationId, planApproval.text, {
1638
- messageId: buildCodexMessageId(session, 'plan'),
1639
- metadata: {
1640
- ...planApproval.metadata,
1641
- turnId: session.currentTurnId,
1642
- turnSemantics: 'control',
1643
- replyBehavior: 'suppress_auto_reply',
1644
- },
1645
- });
1646
- await handoffFinalMessage(session.conversationId);
1647
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1648
- }
1649
- else if (!result.interrupted && result.finalMessage) {
1650
- if (isRecoverableCodexThreadError(result.errorText)) {
1651
- clearStoredThread();
1652
- }
1653
- await routeArtifactsOnce();
1654
- const turnTrail = buildFinalTurnTrail(session);
1655
- await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
1656
- messageId: buildCodexMessageId(session, 'final'),
1657
- ...(session.activeSelfContextId
1658
- ? { selfContextId: session.activeSelfContextId }
1659
- : {}),
1660
- metadata: {
1661
- turnId: session.currentTurnId,
1662
- turnSemantics: 'turn_complete',
1663
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1664
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
1665
- },
1666
- });
1667
- await handoffFinalMessage(session.conversationId);
1668
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
1669
- }
1670
- else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
1671
- await routeArtifactsOnce();
1672
- const userVisibleError = formatCodexTurnFailure(result.errorText);
1673
- session.state.lastError = userVisibleError;
1674
- writeState(session);
1675
- if (result.errorText) {
1676
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
1677
- }
1678
- const turnTrail = buildFinalTurnTrail(session);
1679
- await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
1680
- messageId: buildCodexMessageId(session, 'error'),
1681
- ...(session.activeSelfContextId
1682
- ? { selfContextId: session.activeSelfContextId }
1683
- : {}),
1684
- metadata: {
1685
- turnId: session.currentTurnId,
1686
- turnSemantics: 'turn_complete',
1687
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1688
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
1689
- },
1690
- });
1691
- await handoffFinalMessage(session.conversationId);
1692
- }
1693
- else if (!result.interrupted) {
1694
- await routeArtifactsOnce();
1695
- await handoffFinalMessage(session.conversationId);
1696
- }
1697
- else if (result.interrupted) {
1698
- session.turnState = 'interrupted';
1699
- writeTurn(session);
1700
- stopVisibleWorkSignal(session);
1701
- clearStreaming(session.conversationId);
1702
- typingSignals.clear(session.conversationId).catch(() => { });
1703
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
1704
- }
1705
- }
1706
- catch (error) {
1707
- const message = error instanceof ExecutionEnvironmentError
1708
- ? error.userMessage
1709
- : error instanceof CanonApiError
1710
- ? `The Codex host completed the turn, but Canon could not deliver the reply: ${error.message}`
1711
- : `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
1712
- session.state.lastError = message;
1713
- writeState(session);
1714
- await routeArtifactsOnce();
1715
- await sendMessageWithRetryChunked(client, session.conversationId, message, {
1716
- messageId: buildCodexMessageId(session, 'failure'),
1717
- ...(session.activeSelfContextId
1718
- ? { selfContextId: session.activeSelfContextId }
1719
- : {}),
1720
- metadata: {
1721
- turnId: session.currentTurnId,
1722
- turnSemantics: 'turn_complete',
1723
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1724
- },
1725
- }).catch(() => { });
1726
- await handoffFinalMessage(session.conversationId);
1727
- if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
1728
- clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1729
- }
1730
- console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
1731
- }
1732
- finally {
1733
- stopVisibleWorkSignal(session);
1734
- session.running = false;
1735
- session.state.state = 'idle';
1736
- session.turnState = 'idle';
1737
- session.currentTurnId = null;
1738
- session.currentTurnOpenedAt = null;
1739
- session.currentTurnUpdatedAt = null;
1740
- session.currentTurnCanUseCodexAppTools = false;
1741
- session.lastAcceptedIntent = null;
1742
- session.resetRequested = false;
1743
- session.lastActivity = Date.now();
1744
- writeState(session);
1745
- writeTurn(session);
1746
- if (session.queue.length > 0) {
1747
- void runNextTurn(session);
1748
- }
1749
- }
1750
- }
1751
1667
  let streamConnected = false;
1752
1668
  const hostAvailableExecutionModes = [
1753
1669
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1816,7 +1732,7 @@ export async function main() {
1816
1732
  if (modelGuard) {
1817
1733
  session.state.lastError = modelGuard;
1818
1734
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1819
- writeState(session);
1735
+ session.writeState();
1820
1736
  // The poller consumes the node; skip effort handling for this pass,
1821
1737
  // matching the legacy loop.
1822
1738
  return;
@@ -1824,58 +1740,42 @@ export async function main() {
1824
1740
  session.adapter.setModel(control.model);
1825
1741
  session.state.model = control.model;
1826
1742
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1827
- writeState(session);
1743
+ session.writeState();
1828
1744
  }
1829
1745
  if (control.permissionMode) {
1830
1746
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1831
1747
  // Convergence contract: a consumed session control must always be
1832
1748
  // answered. Re-publish the currently applied state so clients settle on
1833
1749
  // the authoritative value instead of holding the composer until timeout.
1834
- writeState(session);
1750
+ session.writeState();
1835
1751
  }
1836
1752
  if (control.effort) {
1837
1753
  if (CODEX_EFFORT_VALUES.has(control.effort)) {
1838
1754
  session.adapter.setReasoningEffort(control.effort);
1839
1755
  session.state.effort = control.effort;
1840
1756
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
1841
- writeState(session);
1757
+ session.writeState();
1842
1758
  }
1843
1759
  else {
1844
1760
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
1845
1761
  // Same contract: ignored values still get an authoritative re-publish.
1846
- writeState(session);
1762
+ session.writeState();
1847
1763
  }
1848
1764
  }
1849
1765
  }
1850
- async function handleControlSignal(event) {
1851
- const { conversationId, type } = event;
1852
- const session = sessions.get(conversationId);
1853
- // No live session: dedupe already advanced, but the node stays in place,
1854
- // matching the legacy loop.
1855
- if (!session || session.closed)
1856
- return { consume: false };
1857
- if (type === 'new_session') {
1858
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
1859
- await resetRuntimeSession(session);
1860
- return;
1861
- }
1862
- if (!session.running && (type !== 'stop_and_drop' || session.queue.length === 0)) {
1863
- // Nothing to interrupt or drop — just consume the signal.
1864
- return;
1865
- }
1866
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
1867
- if (type === 'stop_and_drop') {
1868
- const droppedPrompts = session.queue.splice(0);
1869
- await markQueuedPromptsRejected(conversationId, droppedPrompts);
1870
- }
1871
- if (session.running) {
1872
- await session.adapter.interrupt();
1873
- }
1874
- session.turnState = 'interrupted';
1875
- writeTurn(session);
1876
- clearStreaming(conversationId);
1877
- typingSignals.clear(conversationId).catch(() => { });
1878
- }
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
+ });
1879
1779
  async function handleControlPrimitive(event) {
1880
1780
  const { conversationId, value } = event;
1881
1781
  const primitiveId = typeof value.id === 'string' ? value.id : '';
@@ -1895,32 +1795,27 @@ export async function main() {
1895
1795
  try {
1896
1796
  await session.adapter.compactThread();
1897
1797
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
1898
- writeState(session);
1798
+ session.writeState();
1899
1799
  }
1900
1800
  catch (error) {
1901
1801
  const message = error instanceof Error ? error.message : String(error);
1902
1802
  session.state.lastError = `Could not compact Codex context: ${message}`;
1903
1803
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${session.state.lastError}`);
1904
- writeState(session);
1804
+ session.writeState();
1905
1805
  }
1906
1806
  }
1907
- const controlPoller = createCodexControlPoller({
1908
- rtdb,
1909
- agentId,
1910
- conversationIds: () => sessions.keys(),
1911
- hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
1912
- && (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,
1913
1812
  onSessionControl: ({ conversationId, control }) => {
1914
1813
  applySessionControl(conversationId, control);
1915
1814
  },
1916
1815
  onSignal: handleControlSignal,
1917
1816
  onPrimitive: handleControlPrimitive,
1918
- onError: (error) => {
1919
- // The legacy loop ignored transient RTDB failures; keep read/consume
1920
- // errors quiet but surface handler failures.
1921
- if (error.scope !== 'handler')
1922
- return;
1923
- 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);
1924
1819
  },
1925
1820
  });
1926
1821
  let publishRuntimeDetailsInFlight = false;
@@ -1933,7 +1828,7 @@ export async function main() {
1933
1828
  });
1934
1829
  if (!streamConnected)
1935
1830
  return;
1936
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1831
+ await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1937
1832
  console.error('[canon-codex] Failed to publish agent runtime:', error);
1938
1833
  });
1939
1834
  if (publishRuntimeDetailsInFlight)
@@ -1943,15 +1838,14 @@ export async function main() {
1943
1838
  await refreshKnownConversationIds().catch((error) => {
1944
1839
  console.error('[canon-codex] Failed to refresh known conversations:', error);
1945
1840
  });
1946
- await publishHostSessionSnapshots({
1841
+ await port.publishHostSessionSnapshots({
1947
1842
  conversationIds: Array.from(knownConversationIds),
1948
- agentId,
1949
1843
  clientType: 'codex',
1950
1844
  runtime: runtimeDescriptor,
1951
- workspaceOptions,
1845
+ workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1952
1846
  defaultCwd: workingDir,
1953
- extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
1954
- 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) => {
1955
1849
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1956
1850
  return [
1957
1851
  session.conversationId,
@@ -2033,7 +1927,10 @@ export async function main() {
2033
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.',
2034
1928
  ],
2035
1929
  };
2036
- await runtimeState.writeRuntimeInfo(conversationId, payload);
1930
+ await port.publishRuntimeStatus({
1931
+ conversationId,
1932
+ presentation: payload,
1933
+ });
2037
1934
  })).catch((error) => {
2038
1935
  console.error('[canon-codex] Failed to publish runtime info:', error);
2039
1936
  });
@@ -2042,66 +1939,91 @@ export async function main() {
2042
1939
  publishRuntimeDetailsInFlight = false;
2043
1940
  }
2044
1941
  };
2045
- const stream = new CanonStream({
2046
- apiKey,
2047
- agentId,
2048
- handler: {
2049
- onMessage: (payload) => {
2050
- const message = payload.message;
2051
- if (message.senderId === agentId)
2052
- return;
2053
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2054
- console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2055
- return;
2056
- }
2057
- void enqueueInboundMessage({
2058
- conversationId: payload.conversationId,
2059
- message,
2060
- senderName: message.senderName || message.senderId,
2061
- isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
2062
- behavior: payload.behavior,
2063
- activeSelfContextId: payload.activeSelfContextId,
2064
- selfContexts: payload.selfContexts,
2065
- provenance: payload.provenance,
2066
- turnDispatch: payload.turnDispatch,
2067
- });
2068
- if (message.id) {
2069
- saveRuntimeSessionState(runtimeId, {
2070
- conversationId: payload.conversationId,
2071
- baseCwd: workingDir,
2072
- lastInboundMessageId: message.id,
2073
- });
2074
- }
2075
- },
2076
- onMessageDeleted: (payload) => {
2077
- removeQueuedPrompt(payload.conversationId, payload.messageId);
2078
- },
2079
- onConversationUpdated: (payload) => {
2080
- handleConversationUpdated(payload);
2081
- },
2082
- 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') {
2083
2008
  streamConnected = true;
2084
2009
  void publishRuntimeHeartbeat();
2085
- console.error('[canon-codex] SSE connected');
2086
- },
2087
- onDisconnected: () => {
2010
+ console.error('[canon-codex] Canon upstream connected');
2011
+ }
2012
+ else {
2088
2013
  streamConnected = false;
2089
- runtimeState.clearAgentRuntime().catch(() => { });
2090
- console.error('[canon-codex] SSE disconnected');
2091
- },
2092
- onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
2014
+ port.clearAgentRuntime().catch(() => { });
2015
+ console.error(`[canon-codex] Canon upstream ${upstream}`);
2016
+ }
2093
2017
  },
2094
2018
  });
2095
2019
  await refreshCodexSkillInventory();
2096
2020
  try {
2097
- const conversations = await client.getConversations();
2098
- lastKnownConversationRefreshAt = Date.now();
2021
+ const conversations = await directory.listConversations();
2022
+ directory.primeFromStartup(conversations);
2099
2023
  for (const conversation of conversations) {
2100
- knownConversationIds.add(conversation.id);
2101
- conversationCache.set(conversation.id, conversation);
2102
- clearStreaming(conversation.id);
2103
- runtimeState.clearSessionState(conversation.id).catch(() => { });
2104
- 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(() => { });
2105
2027
  }
2106
2028
  for (const conversation of conversations) {
2107
2029
  const cursor = loadRuntimeSessionState(runtimeId, {
@@ -2109,7 +2031,12 @@ export async function main() {
2109
2031
  baseCwd: workingDir,
2110
2032
  })?.lastInboundMessageId;
2111
2033
  const recovery = await collectMissedInboundMessages({
2112
- 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
+ }),
2113
2040
  cursor,
2114
2041
  agentId,
2115
2042
  });
@@ -2152,56 +2079,17 @@ export async function main() {
2152
2079
  catch (error) {
2153
2080
  console.error('[canon-codex] Failed to load startup conversations:', error);
2154
2081
  }
2155
- startCodexStreamInBackground(stream, (error) => {
2156
- console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2157
- });
2158
- controlPoller.start();
2159
- const heartbeat = setInterval(() => {
2160
- for (const session of sessions.values()) {
2161
- writeState(session);
2162
- if (!session.running) {
2163
- writeTurn(session);
2164
- }
2165
- }
2166
- void publishRuntimeHeartbeat();
2167
- }, HEARTBEAT_MS);
2168
- const idleCheck = setInterval(() => {
2169
- const now = Date.now();
2170
- for (const conversationId of [...sessions.keys()]) {
2171
- const session = sessions.get(conversationId);
2172
- if (!session || session.running)
2173
- continue;
2174
- if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
2175
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Idle timeout`);
2176
- closeSession(conversationId);
2177
- }
2178
- }
2179
- }, IDLE_CHECK_MS);
2180
- const shutdown = async () => {
2181
- console.error('[canon-codex] Shutting down...');
2182
- controlPoller.stop();
2183
- clearInterval(heartbeat);
2184
- clearInterval(idleCheck);
2185
- stream.stop();
2186
- await runtimeState.clearAgentRuntime().catch(() => { });
2187
- for (const session of [...sessions.values()]) {
2188
- await session.adapter.interrupt().catch(() => { });
2189
- closeSession(session.conversationId);
2190
- }
2191
- markLocalRuntimeStopped(runtimeId);
2192
- (lockHandle ?? getActiveProfileLock())?.release();
2193
- process.exit(0);
2194
- };
2195
- process.on('SIGINT', shutdown);
2196
- process.on('SIGTERM', shutdown);
2197
- process.on('SIGHUP', shutdown);
2082
+ attachInboundNotifications();
2083
+ // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2084
+ lifecycle.startTimers();
2085
+ lifecycle.installSignalHandlers();
2198
2086
  console.error('[canon-codex] Ready — sessions created on demand');
2199
2087
  await new Promise(() => { });
2200
2088
  }
2201
2089
  runCli(import.meta.url, main, (error) => {
2202
2090
  const message = error instanceof Error ? error.message : String(error);
2203
2091
  console.error(`[canon-codex] ${message}`);
2204
- getActiveProfileLock()?.release();
2092
+ activeLockHandle?.release();
2205
2093
  process.exit(1);
2206
2094
  }, {
2207
2095
  name: 'canon-codex',