@canonmsg/codex-plugin 0.19.2 → 0.21.0

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