@canonmsg/codex-plugin 0.18.12 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -4,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, hasSpeechSegmentText, 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,372 @@ 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
+ if (hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
994
+ // Speech is already flowing: publish the staged plan block
995
+ // only. Replacing the whole snapshot with plan text would be
996
+ // undone by the next agentMessage snapshot and make the
997
+ // smoothed bubble snap back and forth.
998
+ writers.streamingOutput.flush().catch(() => { });
999
+ }
1000
+ else {
1001
+ writers.streamingOutput.replaceSnapshot(event.text, 'streaming').catch(() => { });
1002
+ }
1003
+ return;
1004
+ }
1005
+ if (event.type === 'waiting') {
1006
+ session.turnState = 'waiting_input';
1007
+ writers.writeTurn();
1008
+ writers.stopVisibleWorkSignal();
1009
+ writers.streamingOutput.waitingInput().catch(() => { });
1010
+ return;
1011
+ }
1012
+ if (event.type === 'command.started') {
1013
+ const blockId = beginCommandBlock(session.turnCommandBlocks, {
1014
+ turnId: session.currentTurnId,
1015
+ command: event.command,
1016
+ itemId: event.itemId,
1017
+ });
1018
+ writers.streamingOutput.stageBlock({
1019
+ id: blockId,
1020
+ kind: 'tool',
1021
+ status: 'running',
1022
+ title: summarizeCommand(event.command),
1023
+ summary: 'Command running',
1024
+ });
1025
+ if (hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
1026
+ // Speech is already flowing: the staged tool block narrates
1027
+ // the command in the margin. Publish it without flapping the
1028
+ // bubble status away from 'streaming'.
1029
+ writers.streamingOutput.flush().catch(() => { });
1030
+ return;
1031
+ }
1032
+ session.turnState = 'tool';
1033
+ writers.writeTurn();
1034
+ writers.startVisibleWorkSignal();
1035
+ writers.streamingOutput.setStatus('tool').catch(() => { });
1036
+ return;
1037
+ }
1038
+ if (event.type === 'command.completed') {
1039
+ const blockId = claimCommandBlock(session.turnCommandBlocks, {
1040
+ turnId: session.currentTurnId,
1041
+ command: event.command,
1042
+ itemId: event.itemId,
1043
+ });
1044
+ const existing = writers.streamingOutput.getBlocks().find((block) => block.id === blockId);
1045
+ if (existing) {
1046
+ writers.streamingOutput.stageBlock({
1047
+ id: blockId,
1048
+ kind: existing.kind,
1049
+ status: 'completed',
1050
+ summary: 'Command completed',
1051
+ });
1052
+ }
1053
+ if (session.turnState === 'tool'
1054
+ && !hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
1055
+ session.turnState = 'thinking';
1056
+ writers.writeTurn();
1057
+ writers.startVisibleWorkSignal();
1058
+ writers.streamingOutput.setStatus('thinking').catch(() => { });
1059
+ }
1060
+ else {
1061
+ // Speech is flowing (or the turn never left streaming):
1062
+ // publish the staged completion without a status flap.
1063
+ writers.streamingOutput.flush().catch(() => { });
1064
+ }
1065
+ return;
1066
+ }
1067
+ if (event.type === 'turn.completed') {
1068
+ // Codex reports per-turn token usage but no context window, so the
1069
+ // meter publishes tokens only (input + cached covers the full
1070
+ // prompt context of the completed turn).
1071
+ const totalTokens = (event.usage?.input_tokens ?? 0)
1072
+ + (event.usage?.cached_input_tokens ?? 0)
1073
+ + (event.usage?.output_tokens ?? 0);
1074
+ if (totalTokens > 0) {
1075
+ session.state.contextUsage = { totalTokens };
1076
+ }
1077
+ writers.writeState();
1078
+ }
1079
+ };
1080
+ const logCodexLine = (line) => {
1081
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${line}`);
1082
+ };
1083
+ const clearStoredThread = () => {
1084
+ clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
1085
+ session.adapter.clearThreadId();
1086
+ };
1087
+ const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1088
+ planMode: turn.planMode,
1089
+ onServerRequest: (request) => handleCodexServerRequest(session, request),
1090
+ });
1091
+ let result = await runTurnOnce();
1092
+ if (!result.interrupted
1093
+ && !result.finalMessage
1094
+ && result.exitCode
1095
+ && result.exitCode !== 0
1096
+ && isRecoverableCodexThreadError(result.errorText)) {
1097
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1098
+ clearStoredThread();
1099
+ result = await runTurnOnce();
1100
+ }
1101
+ if (result.threadId && !session.resetRequested) {
1102
+ saveStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1103
+ }
1104
+ if (!result.interrupted && result.finalMessage && turn.planMode) {
1105
+ await routeArtifactsOnce();
1106
+ const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1107
+ responseUserId: ownerId ?? undefined,
1108
+ title: 'Codex Plan',
1109
+ body: result.finalMessage,
1110
+ });
1111
+ await port.sendMessage({
1112
+ conversationId,
1113
+ text: planApproval.text,
1114
+ messageId: buildCodexMessageId(session, 'plan'),
1115
+ metadata: {
1116
+ ...planApproval.metadata,
1117
+ turnId: session.currentTurnId,
1118
+ turnSemantics: 'control',
1119
+ replyBehavior: 'suppress_auto_reply',
1120
+ },
1121
+ });
1122
+ await handoffFinal();
1123
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Sent plan approval card`);
1124
+ }
1125
+ else if (!result.interrupted && result.finalMessage) {
1126
+ if (isRecoverableCodexThreadError(result.errorText)) {
1127
+ clearStoredThread();
1128
+ }
1129
+ await routeArtifactsOnce();
1130
+ // Claude's final-delivery machine (deliberate behavior parity):
1131
+ // failed sends are re-marked pending and retried on a bounded
1132
+ // timer instead of throwing into the generic failure path.
1133
+ const delivered = await finalDelivery.deliverFinalReply(result.finalMessage, turn);
1134
+ if (delivered) {
1135
+ session.activeInput = null;
1136
+ session.running = false;
1137
+ finalDelivery.scheduleFinalHandoffReset();
1138
+ }
1139
+ else {
1140
+ finalDelivery.scheduleFinalDeliveryRetry();
1141
+ }
1142
+ turnSettledByFinalDelivery = true;
1143
+ }
1144
+ else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
1145
+ await routeArtifactsOnce();
1146
+ const userVisibleError = formatCodexTurnFailure(result.errorText);
1147
+ session.state.lastError = userVisibleError;
1148
+ writers.writeState();
1149
+ if (result.errorText) {
1150
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
1151
+ }
1152
+ const turnTrail = writers.getFinalTurnTrail();
1153
+ await port.sendMessage({
1154
+ conversationId,
1155
+ text: userVisibleError,
1156
+ messageId: buildCodexMessageId(session, 'error'),
1157
+ ...(session.activeSelfContextId
1158
+ ? { selfContextId: session.activeSelfContextId }
1159
+ : {}),
1160
+ metadata: {
1161
+ turnId: session.currentTurnId,
1162
+ turnSemantics: 'turn_complete',
1163
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1164
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
1165
+ },
1166
+ });
1167
+ await handoffFinal();
1168
+ }
1169
+ else if (!result.interrupted) {
1170
+ await routeArtifactsOnce();
1171
+ await handoffFinal();
1172
+ }
1173
+ else if (result.interrupted) {
1174
+ session.turnState = 'interrupted';
1175
+ writers.writeTurn();
1176
+ writers.stopVisibleWorkSignal();
1177
+ writers.clearStreaming().catch(() => { });
1178
+ typingSignals.clear(conversationId).catch(() => { });
1179
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn interrupted`);
1180
+ }
1181
+ }
1182
+ catch (error) {
1183
+ const message = error instanceof ExecutionEnvironmentError
1184
+ ? error.userMessage
1185
+ : isBridgeDeliveryError(error)
1186
+ ? `The Codex host completed the turn, but Canon could not deliver the reply: ${error.message}`
1187
+ : `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
1188
+ session.state.lastError = message;
1189
+ writers.writeState();
1190
+ await routeArtifactsOnce();
1191
+ await port.sendMessage({
1192
+ conversationId,
1193
+ text: message,
1194
+ messageId: buildCodexMessageId(session, 'failure'),
1195
+ ...(session.activeSelfContextId
1196
+ ? { selfContextId: session.activeSelfContextId }
1197
+ : {}),
1198
+ metadata: {
1199
+ turnId: session.currentTurnId,
1200
+ turnSemantics: 'turn_complete',
1201
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1202
+ },
1203
+ }).catch(() => { });
1204
+ await handoffFinal();
1205
+ if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
1206
+ clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
1207
+ }
1208
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn failed:`, error);
1209
+ }
1210
+ finally {
1211
+ session.lastActivity = Date.now();
1212
+ if (!turnSettledByFinalDelivery) {
1213
+ writers.stopVisibleWorkSignal();
1214
+ session.running = false;
1215
+ session.state.state = 'idle';
1216
+ session.turnState = 'idle';
1217
+ session.currentTurnId = null;
1218
+ session.currentTurnOpenedAt = null;
1219
+ session.currentTurnUpdatedAt = null;
1220
+ session.currentTurnCanUseCodexAppTools = false;
1221
+ session.lastAcceptedIntent = null;
1222
+ session.resetRequested = false;
1223
+ session.activeInput = null;
1224
+ writers.writeState();
1225
+ writers.writeTurn();
1226
+ turnQueue.drainPendingInput();
1227
+ }
1228
+ }
1229
+ }
972
1230
  sessions.set(conversationId, session);
973
- await controlPoller.baseline([conversationId]);
974
1231
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
975
- writeState(session);
976
- writeTurn(session);
1232
+ session.writeState();
1233
+ session.writeTurn();
977
1234
  return session;
978
1235
  }
979
1236
  catch (error) {
@@ -981,114 +1238,57 @@ export async function main() {
981
1238
  throw error;
982
1239
  }
983
1240
  })();
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
1241
  }
1014
1242
  function resolveArtifactRoutingMode(participantContext) {
1015
1243
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1016
1244
  ? 'workspace-generated'
1017
1245
  : 'disabled';
1018
1246
  }
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
- }
1247
+ /**
1248
+ * Approval answers read the bridge's DURABLE record (the bridge owns the
1249
+ * single destructive consume loop + the approval_reply fast path); same 1s
1250
+ * cadence as the legacy consume poll.
1251
+ */
1042
1252
  async function waitForRuntimeApprovalResponse(input) {
1253
+ const read = async () => port.getHitlState({ requestId: input.approvalId }).catch(() => null);
1043
1254
  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 };
1255
+ const state = await read();
1256
+ if (state?.status === 'submitted') {
1257
+ const answer = (state.answer ?? {});
1258
+ return answer.decision === 'deny'
1259
+ ? { decision: 'deny' }
1260
+ : { decision: 'allow', sessionRule: answer.sessionRule };
1050
1261
  }
1051
- if (response?.status === 'deny' || response?.status === 'timeout') {
1262
+ if (state?.status === 'cancelled' || state?.status === 'timeout') {
1052
1263
  return { decision: 'deny' };
1053
1264
  }
1054
1265
  await sleep(1_000);
1055
1266
  }
1056
- await client.consumeRuntimeApprovalResponse({
1057
- conversationId: input.conversationId,
1058
- approvalId: input.approvalId,
1059
- }).catch(() => null);
1060
1267
  return { decision: 'deny' };
1061
1268
  }
1062
1269
  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') {
1270
+ const read = async () => port.getHitlState({ requestId: input.cardId }).catch(() => null);
1271
+ const map = (state) => {
1272
+ if (state?.status === 'submitted') {
1273
+ const answer = (state.answer ?? {});
1069
1274
  return {
1070
1275
  status: 'submitted',
1071
- ...(response.actionId ? { actionId: response.actionId } : {}),
1072
- ...(response.values ? { values: response.values } : {}),
1276
+ ...(answer.actionId ? { actionId: answer.actionId } : {}),
1277
+ ...(answer.values ? { values: answer.values } : {}),
1073
1278
  };
1074
1279
  }
1075
- if (response?.status === 'cancelled' || response?.status === 'timeout') {
1076
- return { status: response.status };
1280
+ if (state?.status === 'cancelled' || state?.status === 'timeout') {
1281
+ return { status: state.status };
1077
1282
  }
1283
+ return null;
1284
+ };
1285
+ while (Date.now() < input.expiresAt) {
1286
+ const mapped = map(await read());
1287
+ if (mapped)
1288
+ return mapped;
1078
1289
  await sleep(1_000);
1079
1290
  }
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' };
1291
+ return map(await read()) ?? { status: 'timeout' };
1092
1292
  }
1093
1293
  function runtimeCardRequestPayload(method, params) {
1094
1294
  if (method !== 'item/runtimeCard/request'
@@ -1132,14 +1332,15 @@ export async function main() {
1132
1332
  let requestCreated = false;
1133
1333
  let requestResolved = false;
1134
1334
  try {
1135
- await client.createRuntimeCardRequest({
1335
+ await port.requestCard({
1136
1336
  conversationId: session.conversationId,
1137
1337
  cardId,
1138
1338
  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.
1339
+ expiresAt: new Date(expiresAt).toISOString(),
1340
+ // responseUserId: null → the backend targets a reachable member
1341
+ // (the owner if present, else the sole other member) — the owner is
1342
+ // often not a member of agent-to-user DMs.
1343
+ responseUserId: null,
1143
1344
  native: {
1144
1345
  runtime: 'codex',
1145
1346
  method: request.method,
@@ -1150,21 +1351,20 @@ export async function main() {
1150
1351
  threadId: readString(params, 'threadId') ?? '',
1151
1352
  },
1152
1353
  },
1153
- turnId: session.currentTurnId ?? undefined,
1354
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1154
1355
  });
1155
1356
  requestCreated = true;
1156
1357
  session.turnState = 'waiting_input';
1157
- markTurnProgress(session);
1158
- upsertTurnBlock(session, {
1358
+ session.stageTurnBlock({
1159
1359
  id: `card:${cardId}`,
1160
1360
  kind: 'input',
1161
1361
  status: 'pending',
1162
1362
  title: card.title,
1163
1363
  summary: card.template ?? 'runtime card',
1164
1364
  });
1165
- writeTurn(session);
1166
- stopVisibleWorkSignal(session);
1167
- writeCodexStreaming(session, null, 'waiting_input');
1365
+ session.writeTurn();
1366
+ session.stopVisibleWork();
1367
+ session.setStreamingStatus('waiting_input');
1168
1368
  const response = await waitForRuntimeCardResponse({
1169
1369
  conversationId: session.conversationId,
1170
1370
  cardId,
@@ -1172,7 +1372,9 @@ export async function main() {
1172
1372
  });
1173
1373
  requestResolved = true;
1174
1374
  const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1175
- await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1375
+ await port.sendMessage({
1376
+ conversationId: session.conversationId,
1377
+ text: outcome.text,
1176
1378
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1177
1379
  metadata: {
1178
1380
  ...outcome.metadata,
@@ -1181,25 +1383,27 @@ export async function main() {
1181
1383
  replyBehavior: 'suppress_auto_reply',
1182
1384
  },
1183
1385
  });
1184
- completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1386
+ session.stageTurnBlock({
1387
+ id: `card:${cardId}`,
1388
+ kind: 'input',
1389
+ status: 'completed',
1390
+ summary: `Card ${response.status}`,
1391
+ });
1185
1392
  if (session.turnState === 'waiting_input') {
1186
1393
  session.turnState = 'thinking';
1187
- markTurnProgress(session);
1188
- writeTurn(session);
1189
- startVisibleWorkSignal(session);
1190
- writeCodexStreaming(session, null, 'thinking');
1394
+ session.writeTurn();
1395
+ session.startVisibleWork();
1396
+ session.setStreamingStatus('thinking');
1191
1397
  }
1192
1398
  return response;
1193
1399
  }
1194
1400
  catch (error) {
1195
1401
  if (requestCreated && !requestResolved) {
1196
- await client.consumeRuntimeCardResponse({
1197
- conversationId: session.conversationId,
1198
- cardId,
1199
- cancel: true,
1200
- }).catch(() => null);
1402
+ await port.cancelHitl({ requestId: cardId }).catch(() => null);
1201
1403
  const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1202
- await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1404
+ await port.sendMessage({
1405
+ conversationId: session.conversationId,
1406
+ text: outcome.text,
1203
1407
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1204
1408
  metadata: {
1205
1409
  ...outcome.metadata,
@@ -1217,17 +1421,19 @@ export async function main() {
1217
1421
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1218
1422
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1219
1423
  const inputId = readString(params, 'itemId') ?? requestId;
1220
- await client.createRuntimeInputRequest({
1424
+ await port.requestInput({
1221
1425
  conversationId: session.conversationId,
1222
1426
  inputId,
1223
1427
  kind: 'clarify',
1224
- expiresAt,
1225
- responseUserId: ownerId ?? undefined,
1428
+ expiresAt: new Date(expiresAt).toISOString(),
1429
+ ...(ownerId ? { responseUserId: ownerId } : {}),
1226
1430
  title: 'Codex needs input',
1227
1431
  prompt: questions?.length
1228
1432
  ? 'Codex needs your input to continue.'
1229
1433
  : 'Codex needs input.',
1230
- ...(questions ? { questions } : {}),
1434
+ ...(questions
1435
+ ? { questions: questions }
1436
+ : { questions: [] }),
1231
1437
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
1232
1438
  native: {
1233
1439
  runtime: 'codex',
@@ -1239,13 +1445,9 @@ export async function main() {
1239
1445
  threadId: readString(params, 'threadId') ?? '',
1240
1446
  },
1241
1447
  },
1242
- turnId: session.currentTurnId ?? undefined,
1243
- });
1244
- const response = await waitForRuntimeInputResponse({
1245
- conversationId: session.conversationId,
1246
- inputId,
1247
- expiresAt,
1448
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1248
1449
  });
1450
+ const response = await session.waitForRuntimeInputResponse({ inputId, expiresAt });
1249
1451
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1250
1452
  }
1251
1453
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1254,25 +1456,29 @@ export async function main() {
1254
1456
  });
1255
1457
  if (mappedApproval) {
1256
1458
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1257
- await client.createRuntimeApprovalRequest({
1459
+ await port.requestApproval({
1258
1460
  conversationId: session.conversationId,
1259
1461
  approvalId,
1260
1462
  toolName: mappedApproval.toolName,
1261
- toolSummary: mappedApproval.toolSummary,
1463
+ detail: mappedApproval.toolSummary,
1262
1464
  category: mappedApproval.category,
1263
- risk: mappedApproval.risk,
1264
- riskLevel: mappedApproval.riskLevel,
1465
+ risk: mappedApproval.risk ?? 'normal',
1466
+ ...(mappedApproval.riskLevel ? { riskLevel: mappedApproval.riskLevel } : {}),
1265
1467
  native: {
1266
1468
  ...mappedApproval.native,
1267
1469
  requestId,
1268
1470
  method: request.method,
1269
1471
  },
1270
- details: mappedApproval.details,
1271
- ...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
1272
- responseUserId: ownerId ?? undefined,
1472
+ ...(mappedApproval.details
1473
+ ? { details: mappedApproval.details }
1474
+ : {}),
1475
+ ...(mappedApproval.diff
1476
+ ? { diff: mappedApproval.diff }
1477
+ : {}),
1478
+ ...(ownerId ? { responseUserId: ownerId } : {}),
1273
1479
  allowSessionRule: true,
1274
- expiresAt,
1275
- turnId: session.currentTurnId ?? undefined,
1480
+ expiresAt: new Date(expiresAt).toISOString(),
1481
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1276
1482
  });
1277
1483
  const response = await waitForRuntimeApprovalResponse({
1278
1484
  conversationId: session.conversationId,
@@ -1308,7 +1514,12 @@ export async function main() {
1308
1514
  : decision === 'reject'
1309
1515
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1310
1516
  : `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);
1517
+ session.enqueueInbound(buildCodexQueuedInput({
1518
+ prompt,
1519
+ sourceMessageId: input.message.id,
1520
+ planMode: decision !== 'approve',
1521
+ canUseCodexAppTools: input.isOwner,
1522
+ }));
1312
1523
  return;
1313
1524
  }
1314
1525
  let materialized = [];
@@ -1381,7 +1592,9 @@ export async function main() {
1381
1592
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1382
1593
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
1383
1594
  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}`, {
1595
+ await port.sendMessage({
1596
+ conversationId: input.conversationId,
1597
+ text: `I couldn't start a coding session for this workspace: ${userMessage}`,
1385
1598
  messageId: `codex-start-failed-${input.message.id}`,
1386
1599
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1387
1600
  metadata: {
@@ -1404,19 +1617,36 @@ export async function main() {
1404
1617
  message: input.message,
1405
1618
  });
1406
1619
  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
1620
  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
1621
  }
1415
1622
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1416
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1623
+ // The shared turn queue handles queue/interrupt/start semantics.
1624
+ session.enqueueInbound(buildCodexQueuedInput({
1625
+ prompt,
1626
+ intent: deliveryIntent,
1627
+ sourceMessageId: input.message.id,
1628
+ markAccepted: shouldMarkAccepted,
1629
+ imagePaths,
1630
+ mediaAddDirs,
1631
+ planMode: planCommand.planMode,
1632
+ artifactRoutingMode,
1633
+ canUseCodexAppTools: participantContext.isOwner,
1634
+ }));
1417
1635
  }
1418
- function sendTurnArtifactFile(session, file) {
1419
- return sendMediaFileMessage(client, session.conversationId, file.path, '', {
1636
+ async function sendTurnArtifactFile(session, file) {
1637
+ // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1638
+ // the upload; then send the permanent message with the attachment ref.
1639
+ const uploaded = await port.uploadMedia({
1640
+ path: file.path,
1641
+ mime: inferUploadMimeType(file.path),
1642
+ fileName: file.fileName,
1643
+ conversationId: session.conversationId,
1644
+ });
1645
+ return port.sendMessage({
1646
+ conversationId: session.conversationId,
1647
+ text: '',
1648
+ contentType: uploaded.attachment.kind,
1649
+ attachments: [uploaded.attachment],
1420
1650
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1421
1651
  metadata: {
1422
1652
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -1456,298 +1686,6 @@ export async function main() {
1456
1686
  console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1457
1687
  }
1458
1688
  }
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
1689
  let streamConnected = false;
1752
1690
  const hostAvailableExecutionModes = [
1753
1691
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1816,7 +1754,7 @@ export async function main() {
1816
1754
  if (modelGuard) {
1817
1755
  session.state.lastError = modelGuard;
1818
1756
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1819
- writeState(session);
1757
+ session.writeState();
1820
1758
  // The poller consumes the node; skip effort handling for this pass,
1821
1759
  // matching the legacy loop.
1822
1760
  return;
@@ -1824,58 +1762,42 @@ export async function main() {
1824
1762
  session.adapter.setModel(control.model);
1825
1763
  session.state.model = control.model;
1826
1764
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1827
- writeState(session);
1765
+ session.writeState();
1828
1766
  }
1829
1767
  if (control.permissionMode) {
1830
1768
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1831
1769
  // Convergence contract: a consumed session control must always be
1832
1770
  // answered. Re-publish the currently applied state so clients settle on
1833
1771
  // the authoritative value instead of holding the composer until timeout.
1834
- writeState(session);
1772
+ session.writeState();
1835
1773
  }
1836
1774
  if (control.effort) {
1837
1775
  if (CODEX_EFFORT_VALUES.has(control.effort)) {
1838
1776
  session.adapter.setReasoningEffort(control.effort);
1839
1777
  session.state.effort = control.effort;
1840
1778
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
1841
- writeState(session);
1779
+ session.writeState();
1842
1780
  }
1843
1781
  else {
1844
1782
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
1845
1783
  // Same contract: ignored values still get an authoritative re-publish.
1846
- writeState(session);
1784
+ session.writeState();
1847
1785
  }
1848
1786
  }
1849
1787
  }
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
- }
1788
+ // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1789
+ const handleControlSignal = createRuntimeSignalHandler({
1790
+ agentId,
1791
+ logPrefix: '[canon-codex]',
1792
+ capabilities: CODEX_RUNTIME_CAPABILITIES,
1793
+ }, {
1794
+ sessions,
1795
+ port,
1796
+ isSessionRunning: (session) => session.running,
1797
+ interruptRuntime: (session) => session.adapter.interrupt(),
1798
+ resetSession: (session) => resetRuntimeSession(session),
1799
+ markSessionPendingInputsRejected,
1800
+ });
1879
1801
  async function handleControlPrimitive(event) {
1880
1802
  const { conversationId, value } = event;
1881
1803
  const primitiveId = typeof value.id === 'string' ? value.id : '';
@@ -1895,32 +1817,27 @@ export async function main() {
1895
1817
  try {
1896
1818
  await session.adapter.compactThread();
1897
1819
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
1898
- writeState(session);
1820
+ session.writeState();
1899
1821
  }
1900
1822
  catch (error) {
1901
1823
  const message = error instanceof Error ? error.message : String(error);
1902
1824
  session.state.lastError = `Could not compact Codex context: ${message}`;
1903
1825
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${session.state.lastError}`);
1904
- writeState(session);
1826
+ session.writeState();
1905
1827
  }
1906
1828
  }
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')),
1829
+ // Control events arrive as bridge notifications — the bridge owns the ONE
1830
+ // authoritative /control consumer for this identity (plan §2); the host's
1831
+ // adaptive poller (cadence, jitter, baselines) is deleted with it.
1832
+ const detachControlNotifications = attachCodexControlNotifications({
1833
+ source: notifications.source,
1913
1834
  onSessionControl: ({ conversationId, control }) => {
1914
1835
  applySessionControl(conversationId, control);
1915
1836
  },
1916
1837
  onSignal: handleControlSignal,
1917
1838
  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);
1839
+ onError: (error, kind, conversationId) => {
1840
+ console.error(`[canon-codex] [${(conversationId ?? 'unknown').slice(0, 8)}] Control ${kind} handler failed:`, error instanceof Error ? error.message : error);
1924
1841
  },
1925
1842
  });
1926
1843
  let publishRuntimeDetailsInFlight = false;
@@ -1933,7 +1850,7 @@ export async function main() {
1933
1850
  });
1934
1851
  if (!streamConnected)
1935
1852
  return;
1936
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1853
+ await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1937
1854
  console.error('[canon-codex] Failed to publish agent runtime:', error);
1938
1855
  });
1939
1856
  if (publishRuntimeDetailsInFlight)
@@ -1943,15 +1860,14 @@ export async function main() {
1943
1860
  await refreshKnownConversationIds().catch((error) => {
1944
1861
  console.error('[canon-codex] Failed to refresh known conversations:', error);
1945
1862
  });
1946
- await publishHostSessionSnapshots({
1863
+ await port.publishHostSessionSnapshots({
1947
1864
  conversationIds: Array.from(knownConversationIds),
1948
- agentId,
1949
1865
  clientType: 'codex',
1950
1866
  runtime: runtimeDescriptor,
1951
- workspaceOptions,
1867
+ workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1952
1868
  defaultCwd: workingDir,
1953
- extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
1954
- liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1869
+ extraSessionConfigFields: [...CODEX_SESSION_CONFIG_FIELDS],
1870
+ liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1955
1871
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1956
1872
  return [
1957
1873
  session.conversationId,
@@ -2033,7 +1949,10 @@ export async function main() {
2033
1949
  : '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
1950
  ],
2035
1951
  };
2036
- await runtimeState.writeRuntimeInfo(conversationId, payload);
1952
+ await port.publishRuntimeStatus({
1953
+ conversationId,
1954
+ presentation: payload,
1955
+ });
2037
1956
  })).catch((error) => {
2038
1957
  console.error('[canon-codex] Failed to publish runtime info:', error);
2039
1958
  });
@@ -2042,66 +1961,91 @@ export async function main() {
2042
1961
  publishRuntimeDetailsInFlight = false;
2043
1962
  }
2044
1963
  };
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: () => {
1964
+ // ── Inbound Canon events (bridge notifications) ──
1965
+ // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering.
1966
+ // HITL control-reply messages (approval_reply / plan_approval_reply) are
1967
+ // STRIPPED from onMessage bridge-side; plan replies arrive as
1968
+ // onControlReply and re-enter the plan flow below.
1969
+ const handleBridgeMessage = (payload) => {
1970
+ const message = payload.message;
1971
+ if (message.senderId === agentId)
1972
+ return;
1973
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
1974
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
1975
+ return;
1976
+ }
1977
+ void enqueueInboundMessage({
1978
+ conversationId: payload.conversationId,
1979
+ message,
1980
+ senderName: message.senderName || message.senderId,
1981
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
1982
+ behavior: payload.behavior,
1983
+ activeSelfContextId: payload.activeSelfContextId,
1984
+ selfContexts: payload.selfContexts,
1985
+ provenance: payload.provenance,
1986
+ turnDispatch: payload.turnDispatch,
1987
+ });
1988
+ if (message.id) {
1989
+ saveRuntimeSessionState(runtimeId, {
1990
+ conversationId: payload.conversationId,
1991
+ baseCwd: workingDir,
1992
+ lastInboundMessageId: message.id,
1993
+ });
1994
+ }
1995
+ };
1996
+ const attachInboundNotifications = () => notifications.attach({
1997
+ onMessage: (params) => {
1998
+ handleBridgeMessage(params);
1999
+ },
2000
+ onMessageDeleted: (params) => {
2001
+ const payload = params;
2002
+ removeQueuedInput(payload.conversationId, payload.messageId);
2003
+ },
2004
+ onConversationUpdated: (params) => {
2005
+ handleConversationUpdated(params);
2006
+ },
2007
+ onControlReply: (params) => {
2008
+ const payload = params;
2009
+ const message = payload.message;
2010
+ // Only the plan flow consumes stripped control replies host-side —
2011
+ // approval/card replies resolve against the bridge's HITL records.
2012
+ if (!isRecord(message.metadata) || message.metadata.type !== 'plan_approval_reply') {
2013
+ return;
2014
+ }
2015
+ void enqueueInboundMessage({
2016
+ conversationId: payload.conversationId,
2017
+ message,
2018
+ senderName: message.senderName || message.senderId,
2019
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
2020
+ });
2021
+ },
2022
+ ready: () => {
2023
+ streamConnected = true;
2024
+ void publishRuntimeHeartbeat();
2025
+ console.error('[canon-codex] Bridge ready');
2026
+ },
2027
+ connectionState: (params) => {
2028
+ const { upstream } = params;
2029
+ if (upstream === 'connected') {
2083
2030
  streamConnected = true;
2084
2031
  void publishRuntimeHeartbeat();
2085
- console.error('[canon-codex] SSE connected');
2086
- },
2087
- onDisconnected: () => {
2032
+ console.error('[canon-codex] Canon upstream connected');
2033
+ }
2034
+ else {
2088
2035
  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}`),
2036
+ port.clearAgentRuntime().catch(() => { });
2037
+ console.error(`[canon-codex] Canon upstream ${upstream}`);
2038
+ }
2093
2039
  },
2094
2040
  });
2095
2041
  await refreshCodexSkillInventory();
2096
2042
  try {
2097
- const conversations = await client.getConversations();
2098
- lastKnownConversationRefreshAt = Date.now();
2043
+ const conversations = await directory.listConversations();
2044
+ directory.primeFromStartup(conversations);
2099
2045
  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(() => { });
2046
+ port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
2047
+ port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
2048
+ port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
2105
2049
  }
2106
2050
  for (const conversation of conversations) {
2107
2051
  const cursor = loadRuntimeSessionState(runtimeId, {
@@ -2109,7 +2053,12 @@ export async function main() {
2109
2053
  baseCwd: workingDir,
2110
2054
  })?.lastInboundMessageId;
2111
2055
  const recovery = await collectMissedInboundMessages({
2112
- fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
2056
+ fetchPage: (before) => port.getMessages({
2057
+ conversationId: conversation.id,
2058
+ limit: STARTUP_RECOVERY_PAGE_SIZE,
2059
+ ...(before ? { before } : {}),
2060
+ includeBehavior: true,
2061
+ }),
2113
2062
  cursor,
2114
2063
  agentId,
2115
2064
  });
@@ -2152,56 +2101,17 @@ export async function main() {
2152
2101
  catch (error) {
2153
2102
  console.error('[canon-codex] Failed to load startup conversations:', error);
2154
2103
  }
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);
2104
+ attachInboundNotifications();
2105
+ // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2106
+ lifecycle.startTimers();
2107
+ lifecycle.installSignalHandlers();
2198
2108
  console.error('[canon-codex] Ready — sessions created on demand');
2199
2109
  await new Promise(() => { });
2200
2110
  }
2201
2111
  runCli(import.meta.url, main, (error) => {
2202
2112
  const message = error instanceof Error ? error.message : String(error);
2203
2113
  console.error(`[canon-codex] ${message}`);
2204
- getActiveProfileLock()?.release();
2114
+ activeLockHandle?.release();
2205
2115
  process.exit(1);
2206
2116
  }, {
2207
2117
  name: 'canon-codex',