@canonmsg/codex-plugin 0.20.0 → 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
@@ -1,30 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
4
5
  import { dirname } from 'node:path';
5
6
  import { parseArgs } from 'node:util';
6
- import { getCodexImagePath, inferUploadMimeType, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
7
- import { resolvePackagedBridgeBin } from './bridge-bin.js';
8
- import { connectBridge } from '@canonmsg/framework';
9
- import { ConnectionClosedError, JsonRpcError } from '@canonmsg/framework/protocol';
10
- /** Send failed at the Canon/bridge boundary (turn itself completed). */
11
- function isBridgeDeliveryError(error) {
12
- return error instanceof JsonRpcError || error instanceof ConnectionClosedError;
13
- }
14
- 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';
15
- 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';
16
- import { ExecutionEnvironmentError, buildConfiguredWorkspaceOptionsWithRoots, buildLocalRuntimeId, buildPublicWorkspaceOptions, buildPublicWorkspaceRoots, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, prepareConversationEnvironment, releaseConversationEnvironment, resolveCanonAgent, saveRuntimeSessionState, upsertLocalRuntimeEntry, } from '@canonmsg/core/local';
17
- import { readHostSessionConfig, resolveHostWorkspaceCwd, } from '@canonmsg/core/host';
18
- import { CodexAppServerAdapter, } from './app-server-adapter.js';
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';
11
+ import { CodexConversationAdapter, } from './adapter.js';
12
+ import { CodexAppServerAdapter } from './app-server-adapter.js';
19
13
  import { CODEX_APP_DYNAMIC_TOOLS, deniedCodexAppToolResult, handleCodexAppToolCall, isCodexAppToolCall, } from './codex-app-tools.js';
20
14
  import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
21
15
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
22
16
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
23
17
  import { detectCodexCliVersion } from './codex-cli-version.js';
24
18
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
25
- import { attachCodexControlNotifications } from './control-channel.js';
19
+ import { startCodexStreamInBackground } from './host-lifecycle.js';
20
+ import { createCodexControlPoller } from './control-channel.js';
26
21
  import { runCli } from './cli-entry.js';
27
- import { beginCommandBlock, applyCodexMessageToStreamingOutput, claimCommandBlock, createCommandBlockTracker, hasSpeechSegmentText, } from './turn-activity.js';
22
+ import { applyTextSegmentBlock, beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
28
23
  const HELP = `canon-codex — run a local Codex agent host for Canon
29
24
 
30
25
  USAGE
@@ -76,29 +71,10 @@ export function buildCodexLiveSessionConfig(input) {
76
71
  executionBranch: input.executionBranch ?? null,
77
72
  };
78
73
  }
79
- function buildCodexQueuedInput(input) {
80
- return {
81
- turnKey: randomUUID(),
82
- prompt: input.prompt,
83
- intent: input.intent ?? 'queue',
84
- sourceMessageId: input.sourceMessageId ?? null,
85
- markAccepted: Boolean(input.markAccepted),
86
- imagePaths: input.imagePaths ?? [],
87
- mediaAddDirs: input.mediaAddDirs ?? [],
88
- planMode: Boolean(input.planMode),
89
- artifactRoutingMode: input.artifactRoutingMode ?? 'disabled',
90
- canUseCodexAppTools: Boolean(input.canUseCodexAppTools),
91
- };
92
- }
93
74
  const MAX_SESSIONS = 12;
94
75
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
95
- /** Claude's streaming write throttle, adopted for both hosts (bridge plan Phase 2). */
96
- const STREAMING_THROTTLE_MS = 250;
97
76
  const HEARTBEAT_MS = 30_000;
98
77
  const IDLE_CHECK_MS = 60_000;
99
- /** Claude's bounded final-delivery retry machine, adopted for both hosts (bridge plan Phase 2). */
100
- const FINAL_DELIVERY_RETRY_MS = 30_000;
101
- const MAX_FINAL_DELIVERY_RETRIES = 1;
102
78
  const CODEX_RUNTIME_CAPABILITIES = {
103
79
  ...DEFAULT_RUNTIME_CAPABILITIES,
104
80
  supportsInterrupt: true,
@@ -106,40 +82,6 @@ const CODEX_RUNTIME_CAPABILITIES = {
106
82
  supportsQueue: true,
107
83
  supportsNonFinalPermanentMessages: false,
108
84
  };
109
- function isCodexTraceEnabled() {
110
- return process.env.CANON_CODEX_TRACE_EVENTS === '1';
111
- }
112
- function createCodexTracePort(port) {
113
- if (!isCodexTraceEnabled())
114
- return port;
115
- return new Proxy(port, {
116
- get(target, property, receiver) {
117
- if (property !== 'publishStreaming') {
118
- return Reflect.get(target, property, receiver);
119
- }
120
- return async (input) => {
121
- const startedAt = Date.now();
122
- const textLength = typeof input.text === 'string' ? input.text.length : 0;
123
- console.error(`[canon-codex-trace] publishStreaming sent conversation=${input.conversationId} turn=${input.turnId ?? input.messageId ?? 'unknown'} textLen=${textLength}`);
124
- try {
125
- const result = await target.publishStreaming(input);
126
- console.error(`[canon-codex-trace] publishStreaming ack conversation=${input.conversationId} ackMs=${Date.now() - startedAt}`);
127
- return result;
128
- }
129
- catch (error) {
130
- const message = error instanceof Error ? error.message : String(error);
131
- console.error(`[canon-codex-trace] publishStreaming error conversation=${input.conversationId} ackMs=${Date.now() - startedAt} error=${message}`);
132
- throw error;
133
- }
134
- };
135
- },
136
- });
137
- }
138
- // This host process resolves and locks exactly one agent profile. The lock
139
- // handle returned by resolveCanonAgent is held here so the top-level runCli
140
- // error handler (outside main's scope) can release it on a failed start —
141
- // there is no core module-global profile-lock to fall back on.
142
- let activeLockHandle = null;
143
85
  let workingDir = process.cwd();
144
86
  let workspaceOptions = [];
145
87
  let workspaceRoots = [];
@@ -300,15 +242,15 @@ function buildCodexModelOptions(model) {
300
242
  ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
301
243
  : [];
302
244
  }
303
- async function publishAgentRuntime(port, runtime) {
304
- await port.publishHostAgentRuntime({
305
- clientType: 'codex',
306
- runtime: runtime,
307
- });
245
+ async function publishAgentRuntime(agentId, runtime) {
246
+ await publishHostAgentRuntime(agentId, 'codex', runtime);
308
247
  }
309
- async function loadSessionConfig(port, conversationId) {
310
- const raw = await port.getSessionConfig({ conversationId });
311
- 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
+ });
312
254
  }
313
255
  function resolveSessionExecutionMode(config) {
314
256
  if (config?.executionMode)
@@ -346,7 +288,9 @@ function resolveCodexEffectiveRuntimePolicy(input) {
346
288
  }
347
289
  const approvalOverride = mapCanonPermissionToCodex(permissionMode);
348
290
  const defaultSandbox = (stringArg(input.args, 'sandbox') ?? null);
291
+ const defaultApprovalPolicy = (stringArg(input.args, 'ask-for-approval') ?? null);
349
292
  const sandbox = approvalOverride ? approvalOverride.sandbox : defaultSandbox;
293
+ const approvalPolicy = approvalOverride ? null : defaultApprovalPolicy;
350
294
  const fullAuto = approvalOverride ? approvalOverride.fullAuto : boolArg(input.args, 'full-auto');
351
295
  const bypassApprovalsAndSandbox = approvalOverride
352
296
  ? approvalOverride.bypassApprovalsAndSandbox
@@ -356,6 +300,7 @@ function resolveCodexEffectiveRuntimePolicy(input) {
356
300
  executionMode: input.environment.mode,
357
301
  permissionMode: permissionMode ?? null,
358
302
  sandbox,
303
+ approvalPolicy,
359
304
  fullAuto,
360
305
  bypassApprovalsAndSandbox,
361
306
  });
@@ -363,6 +308,7 @@ function resolveCodexEffectiveRuntimePolicy(input) {
363
308
  ...(model ? { model } : {}),
364
309
  ...(permissionMode ? { permissionMode } : {}),
365
310
  sandbox,
311
+ approvalPolicy,
366
312
  fullAuto,
367
313
  bypassApprovalsAndSandbox,
368
314
  fingerprint,
@@ -420,6 +366,17 @@ function stringArgs(value) {
420
366
  ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
421
367
  : undefined;
422
368
  }
369
+ function supportsCodexAppServer(codexBin) {
370
+ if (process.env.CANON_CODEX_TRANSPORT === 'exec')
371
+ return false;
372
+ if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
373
+ return true;
374
+ const result = spawnSync(codexBin, ['app-server', '--help'], {
375
+ encoding: 'utf8',
376
+ stdio: ['ignore', 'ignore', 'ignore'],
377
+ });
378
+ return result.status === 0;
379
+ }
423
380
  function parsePlanCommand(content) {
424
381
  const trimmed = content.trimStart();
425
382
  if (!trimmed.startsWith('/plan'))
@@ -495,6 +452,7 @@ export async function main() {
495
452
  model: { type: 'string' },
496
453
  sandbox: { type: 'string' },
497
454
  'ask-for-approval': { type: 'string' },
455
+ 'codex-profile': { type: 'string' },
498
456
  'add-dir': { type: 'string', multiple: true },
499
457
  workspace: { type: 'string', multiple: true },
500
458
  'workspace-root': { type: 'string', multiple: true },
@@ -527,52 +485,43 @@ export async function main() {
527
485
  }
528
486
  const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
529
487
  const codexCliStatus = detectCodexCliVersion(codexBin);
488
+ const useAppServer = supportsCodexAppServer(codexBin);
530
489
  if (codexCliStatus.version) {
531
490
  console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
532
491
  }
533
492
  else {
534
493
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
535
494
  }
536
- const { agentName: profileAgentName, profile, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
537
- activeLockHandle = lockHandle ?? null;
495
+ console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
496
+ const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
538
497
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
539
- // ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
540
- // The daemon owns the SSE stream, RTDB auth, control polling, dedup and
541
- // reconnect; notification handlers register through a buffered source
542
- // inside `configure` (replay-safe) and attach after the machinery exists.
543
- const notifications = createBufferedNotificationSource([
544
- 'ready',
545
- 'connectionState',
546
- 'onMessage',
547
- 'onMessageDeleted',
548
- 'onConversationUpdated',
549
- 'onSessionControl',
550
- 'onControlSignal',
551
- 'onControlPrimitive',
552
- 'onControlReply',
553
- ]);
554
- const bridge = await connectBridge({
555
- ...(profile ? { profile } : {}),
556
- mode: 'auto',
557
- ...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
558
- hello: {
559
- clientType: 'codex',
560
- wantFamilies: ['messages', 'runtime_turn'],
561
- },
562
- 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),
563
504
  });
564
- // The BridgeClient's zod-derived result shapes mirror the core wire types
565
- // structurally; the port pins the strong types the host machinery uses.
566
- const port = createCodexTracePort(bridge.client);
567
- bridge.client.onProtocolError((error) => {
568
- console.error(`[canon-codex] Bridge protocol error: ${error.message}`);
569
- });
570
- const typingSignals = createPortTypingSignals(port);
571
- const agentId = bridge.hello.agentId;
572
- const ownerId = bridge.hello.agentContext.ownerId ?? null;
573
- const ownerName = bridge.hello.agentContext.ownerName ?? null;
574
- console.error(`[canon-codex] Connected as ${bridge.hello.agentName || agentId} `
575
- + `(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
+ }
576
525
  const launchArgs = [...process.argv.slice(2)];
577
526
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
578
527
  launchArgs.push('--cwd', workingDir);
@@ -601,81 +550,292 @@ export async function main() {
601
550
  lastStartedAt: new Date().toISOString(),
602
551
  lastHeartbeatAt: new Date().toISOString(),
603
552
  });
604
- // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
605
- const directory = createConversationDirectory({
553
+ const runtimeState = createRuntimeStatePublisher({
606
554
  agentId,
607
- ownerId,
608
- ownerName,
609
- refreshFloorMs: HEARTBEAT_MS,
610
- }, { port });
611
- 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
+ });
612
569
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
613
570
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
614
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
+ }
615
700
  async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
616
701
  if (!markAccepted || !sourceMessageId)
617
702
  return;
618
- await port.setDisposition({
619
- conversationId,
620
- messageId: sourceMessageId,
621
- 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,
622
735
  }).catch(() => { });
623
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
+ }
624
789
  function buildCodexMessageId(session, kind) {
625
790
  return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
626
791
  }
627
792
  function buildCodexRuntimeCardOutcomeMessageId(cardId, status) {
628
793
  return `codex-card-${cardId}-${status}`;
629
794
  }
630
- // ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
631
- const lifecycle = createSessionLifecycle({
632
- maxSessions: MAX_SESSIONS,
633
- idleTimeoutMs: IDLE_TIMEOUT_MS,
634
- heartbeatMs: HEARTBEAT_MS,
635
- idleCheckMs: IDLE_CHECK_MS,
636
- logPrefix: '[canon-codex]',
637
- }, {
638
- knownConversationIds,
639
- createSession: (conversationId) => createSessionForConversation(conversationId),
640
- isSessionRunning: (session) => session.running,
641
- writeTurnOnHeartbeat: (session) => !session.running,
642
- writeSessionState: (session) => session.writeState(),
643
- writeSessionTurn: (session) => session.writeTurn(),
644
- releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
645
- // Codex resume state (thread ids) is persisted during turns, not on close.
646
- persistResumeState: () => { },
647
- disposeRuntime: (session) => {
648
- if ('close' in session.adapter && typeof session.adapter.close === 'function') {
649
- session.adapter.close();
650
- }
651
- },
652
- interruptRuntime: (session) => session.adapter.interrupt(),
653
- port,
654
- publishRuntimeHeartbeat: () => publishRuntimeHeartbeat(),
655
- onShutdown: async () => {
656
- detachControlNotifications();
657
- notifications.detach();
658
- await port.clearAgentRuntime().catch(() => { });
659
- for (const session of [...sessions.values()]) {
660
- await session.adapter.interrupt().catch(() => { });
661
- }
662
- bridge.client.close();
663
- },
664
- onShutdownComplete: () => {
665
- markLocalRuntimeStopped(runtimeId);
666
- lockHandle?.release();
667
- },
668
- });
669
- const { sessions, getOrCreateSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
670
- /**
671
- * Codex-specific new-session reset: clear the stored Codex thread and keep
672
- * the Canon session alive (unlike claude, which closes and re-creates).
673
- */
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
+ }
674
834
  async function resetRuntimeSession(session) {
675
835
  const conversationId = session.conversationId;
676
836
  session.resetRequested = true;
677
- const droppedInputs = session.pendingInputs.splice(0);
678
- await markSessionPendingInputsRejected(conversationId, droppedInputs);
837
+ const droppedPrompts = session.queue.splice(0);
838
+ await markQueuedPromptsRejected(conversationId, droppedPrompts);
679
839
  clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
680
840
  session.adapter.clearThreadId();
681
841
  session.activeSelfContextId = null;
@@ -693,16 +853,40 @@ export async function main() {
693
853
  session.lastAcceptedIntent = null;
694
854
  session.resetRequested = false;
695
855
  }
696
- session.stopVisibleWork();
697
- session.clearStreaming().catch(() => { });
856
+ stopVisibleWorkSignal(session);
857
+ clearStreaming(conversationId);
698
858
  typingSignals.clear(conversationId).catch(() => { });
699
- session.writeState();
700
- session.writeTurn();
859
+ writeState(session);
860
+ writeTurn(session);
701
861
  }
702
- /** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
703
- function createSessionForConversation(conversationId) {
704
- return (async () => {
705
- 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);
706
890
  const sessionExecutionMode = resolveSessionExecutionMode(config);
707
891
  const workspaceCwd = resolveWorkspaceCwd(config);
708
892
  const environment = prepareConversationEnvironment({
@@ -727,123 +911,41 @@ export async function main() {
727
911
  const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
728
912
  ? config.effort
729
913
  : null;
730
- const adapter = new CodexAppServerAdapter({
731
- cwd: sessionCwd,
732
- threadId: storedThreadId,
733
- codexBin,
734
- model: policy.model ?? null,
735
- reasoningEffort: initialEffort,
736
- sandbox: policy.sandbox,
737
- addDirs: args['add-dir'] ?? [],
738
- configOverrides: args.config ?? [],
739
- fullAuto: policy.fullAuto,
740
- bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
741
- dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
742
- });
743
- // eslint-disable-next-line prefer-const -- session must be declared before the module closures but assigned after them
744
- let session;
745
- // ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
746
- // Snapshot streaming behind claude's 250ms min-interval write gate —
747
- // deliberate behavior parity (bridge plan Phase 2): rapid runtime
748
- // events coalesce into one trailing RTDB write; terminal states
749
- // (waiting_input, clear) still bypass the gate.
750
- const writers = createRuntimeWriters({
751
- conversationId,
752
- agentId,
753
- capabilities: CODEX_RUNTIME_CAPABILITIES,
754
- fallbackTurnId: `codex-${conversationId}`,
755
- streamingMode: 'snapshot',
756
- streamingThrottleMs: STREAMING_THROTTLE_MS,
757
- requestedControls: {},
758
- }, {
759
- session: () => session,
760
- port,
761
- buildSessionStateExtras: (writerSession) => ({
762
- ...(writerSession.state.lastError !== undefined
763
- ? { lastError: writerSession.state.lastError }
764
- : {}),
765
- cwd: writerSession.cwd,
766
- executionMode: writerSession.environment.mode,
767
- ...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
768
- ...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
769
- ...(resolveExecutionFallbackReason(writerSession.environment)
770
- ? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
771
- : {}),
772
- clientType: 'codex',
773
- }),
774
- formatControlError: (error) => error instanceof Error && error.message.trim()
775
- ? error.message.trim()
776
- : 'Codex did not apply that control.',
777
- });
778
- // ── HITL input waiter (generic skeleton from @canonmsg/agent-host) ──
779
- const hitlWaiters = createHitlWaiters({
780
- conversationId,
781
- replyTimeoutMs: 30 * 60_000,
782
- }, {
783
- session: () => session,
784
- port,
785
- });
786
- // ── Canon turn queue (generic skeleton from @canonmsg/agent-host) ──
787
- const turnQueue = createTurnQueue({ conversationId }, {
788
- session: () => session,
789
- isSessionRunning: (queueSession) => queueSession.running,
790
- markSessionRunning: (queueSession) => { queueSession.running = true; },
791
- prepareInput: async () => { },
792
- sendInput: (input) => { void executeTurn(input); },
793
- interruptRuntime: (queueSession) => queueSession.adapter.interrupt(),
794
- port,
795
- writers: {
796
- writeTurn: () => writers.writeTurn(),
797
- stopVisibleWorkSignal: () => writers.stopVisibleWorkSignal(),
798
- clearStreaming: () => writers.clearStreaming(),
799
- },
800
- });
801
- // ── Final delivery (claude's bounded retry machine, adopted for codex) ──
802
- const finalDelivery = createFinalDelivery({
803
- conversationId,
804
- logPrefix: '[canon-codex]',
805
- delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
806
- }, {
807
- session: () => session,
808
- port,
809
- typingSignals,
810
- writers: {
811
- writeState: () => writers.writeState(),
812
- writeTurn: () => writers.writeTurn(),
813
- stopVisibleWorkSignal: () => writers.stopVisibleWorkSignal(),
814
- clearStreaming: () => writers.clearStreaming(),
815
- getFinalTurnTrail: () => writers.getFinalTurnTrail(),
816
- replaceStreamingSnapshot: (text, status) => writers.streamingOutput.replaceSnapshot(text, status),
817
- },
818
- resetCompletedTurnState: (deliverySession) => {
819
- deliverySession.running = false;
820
- deliverySession.state.state = 'idle';
821
- deliverySession.turnState = 'idle';
822
- deliverySession.currentTurnId = null;
823
- deliverySession.currentTurnOpenedAt = null;
824
- deliverySession.currentTurnUpdatedAt = null;
825
- deliverySession.currentTurnCanUseCodexAppTools = false;
826
- deliverySession.lastAcceptedIntent = null;
827
- deliverySession.resetRequested = false;
828
- deliverySession.activeInput = null;
829
- deliverySession.pendingFinalText = null;
830
- deliverySession.pendingFinalDelivery = null;
831
- },
832
- shouldDeliverFinal: (turn, deliverySession) => Boolean(turn)
833
- && !deliverySession.finalizedTurnKeys.has(turn.turnKey)
834
- && !deliverySession.interruptedTurnKeys.has(turn.turnKey),
835
- buildFinalMessageId: (turnKey) => `codex-final-${turnKey}`,
836
- drainPendingInput: () => turnQueue.drainPendingInput(),
837
- // Oversized finals chunk BRIDGE-side (its sendMessage runs core's
838
- // retry+chunking) — the host-side chunked sender is deleted.
839
- });
840
- session = {
914
+ const adapter = useAppServer
915
+ ? new CodexAppServerAdapter({
916
+ cwd: sessionCwd,
917
+ threadId: storedThreadId,
918
+ codexBin,
919
+ model: policy.model ?? null,
920
+ reasoningEffort: initialEffort,
921
+ sandbox: policy.sandbox,
922
+ approvalPolicy: policy.approvalPolicy,
923
+ addDirs: args['add-dir'] ?? [],
924
+ configOverrides: args.config ?? [],
925
+ fullAuto: policy.fullAuto,
926
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
927
+ dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
928
+ })
929
+ : new CodexConversationAdapter({
930
+ cwd: sessionCwd,
931
+ threadId: storedThreadId,
932
+ codexBin,
933
+ model: policy.model ?? null,
934
+ reasoningEffort: initialEffort,
935
+ sandbox: policy.sandbox,
936
+ approvalPolicy: policy.approvalPolicy,
937
+ codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
938
+ addDirs: args['add-dir'] ?? [],
939
+ configOverrides: args.config ?? [],
940
+ fullAuto: policy.fullAuto,
941
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
942
+ });
943
+ const session = {
841
944
  conversationId,
842
945
  cwd: sessionCwd,
843
946
  environment,
844
947
  adapter,
845
- pendingInputs: [],
846
- activeInput: null,
948
+ queue: [],
847
949
  running: false,
848
950
  state: buildCodexInitialSessionState({
849
951
  model: policy.model,
@@ -858,377 +960,19 @@ export async function main() {
858
960
  currentTurnCanUseCodexAppTools: false,
859
961
  activeSelfContextId: null,
860
962
  lastAcceptedIntent: null,
861
- interruptedTurnKeys: new Set(),
862
- finalizedTurnKeys: new Set(),
863
- pendingFinalText: null,
864
- pendingFinalDelivery: null,
865
963
  resetRequested: false,
866
964
  lastActivity: Date.now(),
867
965
  typingKeepaliveTimer: null,
868
- idleResetTimer: null,
869
- finalDeliveryTimer: null,
870
966
  closed: false,
871
- streamingText: '',
872
- availableModels: [],
873
- runtimeControlErrors: {},
874
- pendingReply: null,
967
+ turnLiveText: '',
968
+ turnBlocks: [],
875
969
  turnCommandBlocks: createCommandBlockTracker(),
876
- writeState: () => writers.writeState(),
877
- writeTurn: () => writers.writeTurn(),
878
- clearStreaming: () => writers.clearStreaming(),
879
- startVisibleWork: () => writers.startVisibleWorkSignal(),
880
- stopVisibleWork: () => writers.stopVisibleWorkSignal(),
881
- stageTurnBlock: (block) => { writers.streamingOutput.stageBlock(block); },
882
- setStreamingStatus: (status) => { writers.streamingOutput.setStatus(status).catch(() => { }); },
883
- waitForRuntimeInputResponse: (input) => hitlWaiters.waitForRuntimeInputResponse(input),
884
- enqueueInbound: (input) => turnQueue.enqueueCanonInput(input),
885
970
  };
886
- const handoffFinal = () => handoffFinalMessage({
887
- conversationId,
888
- clearStreaming: () => writers.clearStreaming(),
889
- typingSignals,
890
- });
891
- /** Drive the Codex runtime for one queued Canon turn (the adapter's input transport). */
892
- async function executeTurn(turn) {
893
- if (session.closed)
894
- return;
895
- if (session.activeInput) {
896
- // Reentrancy guard: a concurrent start raced the in-flight turn —
897
- // requeue at the front; the post-turn drain picks it up.
898
- session.pendingInputs.unshift(turn);
899
- return;
900
- }
901
- // A turn starting during the final-handoff window owns the session
902
- // again — cancel the pending reset and drop any stale streamed state
903
- // from the previous turn (claude's 'running' transition parity).
904
- finalDelivery.clearIdleResetTimer();
905
- session.pendingFinalText = null;
906
- if (writers.streamingOutput.getBlocks().length > 0 || writers.streamingOutput.getText()) {
907
- writers.clearStreaming().catch(() => { });
908
- }
909
- session.activeInput = turn;
910
- session.running = true;
911
- session.state.lastError = undefined;
912
- session.state.state = 'running';
913
- session.currentTurnId = turn.turnKey;
914
- session.turnCommandBlocks = createCommandBlockTracker();
915
- session.currentTurnOpenedAt = Date.now();
916
- session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
917
- session.currentTurnCanUseCodexAppTools = turn.canUseCodexAppTools;
918
- session.lastAcceptedIntent = turn.intent;
919
- session.turnState = 'thinking';
920
- session.lastActivity = Date.now();
921
- writers.writeState();
922
- writers.writeTurn();
923
- writers.startVisibleWorkSignal();
924
- // Status-only seed: 'thinking' renders as a working filament row on the
925
- // clients; text here would be bubbled as speech (v4 register rule).
926
- writers.streamingOutput.startThinking('').catch(() => { });
927
- let artifactBaseline = null;
928
- let artifactsRouted = false;
929
- const routeArtifactsOnce = async () => {
930
- if (artifactsRouted)
931
- return;
932
- artifactsRouted = true;
933
- if (turn.artifactRoutingMode === 'workspace-generated') {
934
- await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
935
- }
936
- };
937
- // True once the final-delivery machine owns the turn reset (handoff
938
- // timer or bounded retry) — the finally block must not clobber it.
939
- let turnSettledByFinalDelivery = false;
940
- try {
941
- const turnPrompt = turn.prompt;
942
- if (turn.artifactRoutingMode === 'workspace-generated') {
943
- artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
944
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
945
- return null;
946
- });
947
- }
948
- const modelGuardForTurn = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
949
- if (modelGuardForTurn) {
950
- throw new ExecutionEnvironmentError(modelGuardForTurn, modelGuardForTurn);
951
- }
952
- const turnImagePaths = turn.imagePaths;
953
- const turnMediaAddDirs = turn.mediaAddDirs;
954
- const handleCodexEvent = (event) => {
955
- session.lastActivity = Date.now();
956
- if (event.type === 'thread.started') {
957
- if (session.resetRequested) {
958
- return;
959
- }
960
- saveStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
961
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Thread ${event.threadId}`);
962
- return;
963
- }
964
- if (event.type === 'skills.changed') {
965
- void refreshCodexSkillInventory(true).then(() => publishRuntimeHeartbeat());
966
- return;
967
- }
968
- if (event.type === 'message') {
969
- session.turnState = 'streaming';
970
- writers.writeTurn();
971
- writers.stopVisibleWorkSignal();
972
- applyCodexMessageToStreamingOutput(writers.streamingOutput, {
973
- turnId: session.currentTurnId,
974
- itemId: event.itemId,
975
- text: event.text,
976
- delta: event.delta,
977
- });
978
- return;
979
- }
980
- if (event.type === 'plan.updated') {
981
- session.turnState = 'streaming';
982
- writers.writeTurn();
983
- writers.stopVisibleWorkSignal();
984
- writers.streamingOutput.stageBlock({
985
- id: `plan:${session.currentTurnId}`,
986
- kind: 'plan',
987
- status: 'running',
988
- title: 'Plan',
989
- text: event.text,
990
- });
991
- if (hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
992
- // Speech is already flowing: publish the staged plan block
993
- // only. Replacing the whole snapshot with plan text would be
994
- // undone by the next agentMessage snapshot and make the
995
- // smoothed bubble snap back and forth.
996
- writers.streamingOutput.flush().catch(() => { });
997
- }
998
- else {
999
- writers.streamingOutput.replaceSnapshot(event.text, 'streaming').catch(() => { });
1000
- }
1001
- return;
1002
- }
1003
- if (event.type === 'waiting') {
1004
- session.turnState = 'waiting_input';
1005
- writers.writeTurn();
1006
- writers.stopVisibleWorkSignal();
1007
- writers.streamingOutput.waitingInput().catch(() => { });
1008
- return;
1009
- }
1010
- if (event.type === 'command.started') {
1011
- const blockId = beginCommandBlock(session.turnCommandBlocks, {
1012
- turnId: session.currentTurnId,
1013
- command: event.command,
1014
- itemId: event.itemId,
1015
- });
1016
- writers.streamingOutput.stageBlock({
1017
- id: blockId,
1018
- kind: 'tool',
1019
- status: 'running',
1020
- title: summarizeCommand(event.command),
1021
- summary: 'Command running',
1022
- });
1023
- if (hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
1024
- // Speech is already flowing: the staged tool block narrates
1025
- // the command in the margin. Publish it without flapping the
1026
- // bubble status away from 'streaming'.
1027
- writers.streamingOutput.flush().catch(() => { });
1028
- return;
1029
- }
1030
- session.turnState = 'tool';
1031
- writers.writeTurn();
1032
- writers.startVisibleWorkSignal();
1033
- writers.streamingOutput.setStatus('tool').catch(() => { });
1034
- return;
1035
- }
1036
- if (event.type === 'command.completed') {
1037
- const blockId = claimCommandBlock(session.turnCommandBlocks, {
1038
- turnId: session.currentTurnId,
1039
- command: event.command,
1040
- itemId: event.itemId,
1041
- });
1042
- const existing = writers.streamingOutput.getBlocks().find((block) => block.id === blockId);
1043
- if (existing) {
1044
- writers.streamingOutput.stageBlock({
1045
- id: blockId,
1046
- kind: existing.kind,
1047
- status: 'completed',
1048
- summary: 'Command completed',
1049
- });
1050
- }
1051
- if (session.turnState === 'tool'
1052
- && !hasSpeechSegmentText(writers.streamingOutput.getBlocks())) {
1053
- session.turnState = 'thinking';
1054
- writers.writeTurn();
1055
- writers.startVisibleWorkSignal();
1056
- writers.streamingOutput.setStatus('thinking').catch(() => { });
1057
- }
1058
- else {
1059
- // Speech is flowing (or the turn never left streaming):
1060
- // publish the staged completion without a status flap.
1061
- writers.streamingOutput.flush().catch(() => { });
1062
- }
1063
- return;
1064
- }
1065
- if (event.type === 'turn.completed') {
1066
- // Codex reports per-turn token usage but no context window, so the
1067
- // meter publishes tokens only (input + cached covers the full
1068
- // prompt context of the completed turn).
1069
- const totalTokens = (event.usage?.input_tokens ?? 0)
1070
- + (event.usage?.cached_input_tokens ?? 0)
1071
- + (event.usage?.output_tokens ?? 0);
1072
- if (totalTokens > 0) {
1073
- session.state.contextUsage = { totalTokens };
1074
- }
1075
- writers.writeState();
1076
- }
1077
- };
1078
- const logCodexLine = (line) => {
1079
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${line}`);
1080
- };
1081
- const clearStoredThread = () => {
1082
- clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
1083
- session.adapter.clearThreadId();
1084
- };
1085
- const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1086
- planMode: turn.planMode,
1087
- onServerRequest: (request) => handleCodexServerRequest(session, request),
1088
- });
1089
- let result = await runTurnOnce();
1090
- if (!result.interrupted
1091
- && !result.finalMessage
1092
- && result.exitCode
1093
- && result.exitCode !== 0
1094
- && isRecoverableCodexThreadError(result.errorText)) {
1095
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1096
- clearStoredThread();
1097
- result = await runTurnOnce();
1098
- }
1099
- if (result.threadId && !session.resetRequested) {
1100
- saveStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1101
- }
1102
- if (!result.interrupted && result.finalMessage && turn.planMode) {
1103
- await routeArtifactsOnce();
1104
- const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1105
- responseUserId: ownerId ?? undefined,
1106
- title: 'Codex Plan',
1107
- body: result.finalMessage,
1108
- });
1109
- await port.sendMessage({
1110
- conversationId,
1111
- text: planApproval.text,
1112
- messageId: buildCodexMessageId(session, 'plan'),
1113
- metadata: {
1114
- ...planApproval.metadata,
1115
- turnId: session.currentTurnId,
1116
- turnSemantics: 'control',
1117
- replyBehavior: 'suppress_auto_reply',
1118
- },
1119
- });
1120
- await handoffFinal();
1121
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Sent plan approval card`);
1122
- }
1123
- else if (!result.interrupted && result.finalMessage) {
1124
- if (isRecoverableCodexThreadError(result.errorText)) {
1125
- clearStoredThread();
1126
- }
1127
- await routeArtifactsOnce();
1128
- // Claude's final-delivery machine (deliberate behavior parity):
1129
- // failed sends are re-marked pending and retried on a bounded
1130
- // timer instead of throwing into the generic failure path.
1131
- const delivered = await finalDelivery.deliverFinalReply(result.finalMessage, turn);
1132
- if (delivered) {
1133
- session.activeInput = null;
1134
- session.running = false;
1135
- finalDelivery.scheduleFinalHandoffReset();
1136
- }
1137
- else {
1138
- finalDelivery.scheduleFinalDeliveryRetry();
1139
- }
1140
- turnSettledByFinalDelivery = true;
1141
- }
1142
- else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
1143
- await routeArtifactsOnce();
1144
- const userVisibleError = formatCodexTurnFailure(result.errorText);
1145
- session.state.lastError = userVisibleError;
1146
- writers.writeState();
1147
- if (result.errorText) {
1148
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
1149
- }
1150
- const turnTrail = writers.getFinalTurnTrail();
1151
- await port.sendMessage({
1152
- conversationId,
1153
- text: userVisibleError,
1154
- messageId: buildCodexMessageId(session, 'error'),
1155
- ...(session.activeSelfContextId
1156
- ? { selfContextId: session.activeSelfContextId }
1157
- : {}),
1158
- metadata: {
1159
- turnId: session.currentTurnId,
1160
- turnSemantics: 'turn_complete',
1161
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1162
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
1163
- },
1164
- });
1165
- await handoffFinal();
1166
- }
1167
- else if (!result.interrupted) {
1168
- await routeArtifactsOnce();
1169
- await handoffFinal();
1170
- }
1171
- else if (result.interrupted) {
1172
- session.turnState = 'interrupted';
1173
- writers.writeTurn();
1174
- writers.stopVisibleWorkSignal();
1175
- writers.clearStreaming().catch(() => { });
1176
- typingSignals.clear(conversationId).catch(() => { });
1177
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn interrupted`);
1178
- }
1179
- }
1180
- catch (error) {
1181
- const message = error instanceof ExecutionEnvironmentError
1182
- ? error.userMessage
1183
- : isBridgeDeliveryError(error)
1184
- ? `The Codex host completed the turn, but Canon could not deliver the reply: ${error.message}`
1185
- : `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
1186
- session.state.lastError = message;
1187
- writers.writeState();
1188
- await routeArtifactsOnce();
1189
- await port.sendMessage({
1190
- conversationId,
1191
- text: message,
1192
- messageId: buildCodexMessageId(session, 'failure'),
1193
- ...(session.activeSelfContextId
1194
- ? { selfContextId: session.activeSelfContextId }
1195
- : {}),
1196
- metadata: {
1197
- turnId: session.currentTurnId,
1198
- turnSemantics: 'turn_complete',
1199
- deliveryIntent: session.lastAcceptedIntent ?? undefined,
1200
- },
1201
- }).catch(() => { });
1202
- await handoffFinal();
1203
- if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
1204
- clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
1205
- }
1206
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Turn failed:`, error);
1207
- }
1208
- finally {
1209
- session.lastActivity = Date.now();
1210
- if (!turnSettledByFinalDelivery) {
1211
- writers.stopVisibleWorkSignal();
1212
- session.running = false;
1213
- session.state.state = 'idle';
1214
- session.turnState = 'idle';
1215
- session.currentTurnId = null;
1216
- session.currentTurnOpenedAt = null;
1217
- session.currentTurnUpdatedAt = null;
1218
- session.currentTurnCanUseCodexAppTools = false;
1219
- session.lastAcceptedIntent = null;
1220
- session.resetRequested = false;
1221
- session.activeInput = null;
1222
- writers.writeState();
1223
- writers.writeTurn();
1224
- turnQueue.drainPendingInput();
1225
- }
1226
- }
1227
- }
1228
971
  sessions.set(conversationId, session);
972
+ await controlPoller.baseline([conversationId]);
1229
973
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
1230
- session.writeState();
1231
- session.writeTurn();
974
+ writeState(session);
975
+ writeTurn(session);
1232
976
  return session;
1233
977
  }
1234
978
  catch (error) {
@@ -1236,57 +980,114 @@ export async function main() {
1236
980
  throw error;
1237
981
  }
1238
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);
1239
1012
  }
1240
1013
  function resolveArtifactRoutingMode(participantContext) {
1241
1014
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1242
1015
  ? 'workspace-generated'
1243
1016
  : 'disabled';
1244
1017
  }
1245
- /**
1246
- * Approval answers read the bridge's DURABLE record (the bridge owns the
1247
- * single destructive consume loop + the approval_reply fast path); same 1s
1248
- * cadence as the legacy consume poll.
1249
- */
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
+ }
1250
1041
  async function waitForRuntimeApprovalResponse(input) {
1251
- const read = async () => port.getHitlState({ requestId: input.approvalId }).catch(() => null);
1252
1042
  while (Date.now() < input.expiresAt) {
1253
- const state = await read();
1254
- if (state?.status === 'submitted') {
1255
- const answer = (state.answer ?? {});
1256
- return answer.decision === 'deny'
1257
- ? { decision: 'deny' }
1258
- : { 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 };
1259
1049
  }
1260
- if (state?.status === 'cancelled' || state?.status === 'timeout') {
1050
+ if (response?.status === 'deny' || response?.status === 'timeout') {
1261
1051
  return { decision: 'deny' };
1262
1052
  }
1263
1053
  await sleep(1_000);
1264
1054
  }
1055
+ await client.consumeRuntimeApprovalResponse({
1056
+ conversationId: input.conversationId,
1057
+ approvalId: input.approvalId,
1058
+ }).catch(() => null);
1265
1059
  return { decision: 'deny' };
1266
1060
  }
1267
1061
  async function waitForRuntimeCardResponse(input) {
1268
- const read = async () => port.getHitlState({ requestId: input.cardId }).catch(() => null);
1269
- const map = (state) => {
1270
- if (state?.status === 'submitted') {
1271
- 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') {
1272
1068
  return {
1273
1069
  status: 'submitted',
1274
- ...(answer.actionId ? { actionId: answer.actionId } : {}),
1275
- ...(answer.values ? { values: answer.values } : {}),
1070
+ ...(response.actionId ? { actionId: response.actionId } : {}),
1071
+ ...(response.values ? { values: response.values } : {}),
1276
1072
  };
1277
1073
  }
1278
- if (state?.status === 'cancelled' || state?.status === 'timeout') {
1279
- return { status: state.status };
1074
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
1075
+ return { status: response.status };
1280
1076
  }
1281
- return null;
1282
- };
1283
- while (Date.now() < input.expiresAt) {
1284
- const mapped = map(await read());
1285
- if (mapped)
1286
- return mapped;
1287
1077
  await sleep(1_000);
1288
1078
  }
1289
- 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' };
1290
1091
  }
1291
1092
  function runtimeCardRequestPayload(method, params) {
1292
1093
  if (method !== 'item/runtimeCard/request'
@@ -1302,6 +1103,9 @@ export async function main() {
1302
1103
  const params = request.params;
1303
1104
  const expiresAt = Date.now() + 30 * 60_000;
1304
1105
  if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1106
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1107
+ return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1108
+ }
1305
1109
  if (!session.currentTurnCanUseCodexAppTools) {
1306
1110
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1307
1111
  }
@@ -1327,15 +1131,14 @@ export async function main() {
1327
1131
  let requestCreated = false;
1328
1132
  let requestResolved = false;
1329
1133
  try {
1330
- await port.requestCard({
1134
+ await client.createRuntimeCardRequest({
1331
1135
  conversationId: session.conversationId,
1332
1136
  cardId,
1333
1137
  card: { ...card, cardId },
1334
- expiresAt: new Date(expiresAt).toISOString(),
1335
- // responseUserId: null → the backend targets a reachable member
1336
- // (the owner if present, else the sole other member) — the owner is
1337
- // often not a member of agent-to-user DMs.
1338
- 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.
1339
1142
  native: {
1340
1143
  runtime: 'codex',
1341
1144
  method: request.method,
@@ -1346,20 +1149,21 @@ export async function main() {
1346
1149
  threadId: readString(params, 'threadId') ?? '',
1347
1150
  },
1348
1151
  },
1349
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1152
+ turnId: session.currentTurnId ?? undefined,
1350
1153
  });
1351
1154
  requestCreated = true;
1352
1155
  session.turnState = 'waiting_input';
1353
- session.stageTurnBlock({
1156
+ markTurnProgress(session);
1157
+ upsertTurnBlock(session, {
1354
1158
  id: `card:${cardId}`,
1355
1159
  kind: 'input',
1356
1160
  status: 'pending',
1357
1161
  title: card.title,
1358
1162
  summary: card.template ?? 'runtime card',
1359
1163
  });
1360
- session.writeTurn();
1361
- session.stopVisibleWork();
1362
- session.setStreamingStatus('waiting_input');
1164
+ writeTurn(session);
1165
+ stopVisibleWorkSignal(session);
1166
+ writeCodexStreaming(session, null, 'waiting_input');
1363
1167
  const response = await waitForRuntimeCardResponse({
1364
1168
  conversationId: session.conversationId,
1365
1169
  cardId,
@@ -1367,9 +1171,7 @@ export async function main() {
1367
1171
  });
1368
1172
  requestResolved = true;
1369
1173
  const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1370
- await port.sendMessage({
1371
- conversationId: session.conversationId,
1372
- text: outcome.text,
1174
+ await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1373
1175
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1374
1176
  metadata: {
1375
1177
  ...outcome.metadata,
@@ -1378,27 +1180,25 @@ export async function main() {
1378
1180
  replyBehavior: 'suppress_auto_reply',
1379
1181
  },
1380
1182
  });
1381
- session.stageTurnBlock({
1382
- id: `card:${cardId}`,
1383
- kind: 'input',
1384
- status: 'completed',
1385
- summary: `Card ${response.status}`,
1386
- });
1183
+ completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1387
1184
  if (session.turnState === 'waiting_input') {
1388
1185
  session.turnState = 'thinking';
1389
- session.writeTurn();
1390
- session.startVisibleWork();
1391
- session.setStreamingStatus('thinking');
1186
+ markTurnProgress(session);
1187
+ writeTurn(session);
1188
+ startVisibleWorkSignal(session);
1189
+ writeCodexStreaming(session, null, 'thinking');
1392
1190
  }
1393
1191
  return response;
1394
1192
  }
1395
1193
  catch (error) {
1396
1194
  if (requestCreated && !requestResolved) {
1397
- await port.cancelHitl({ requestId: cardId }).catch(() => null);
1398
- const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1399
- await port.sendMessage({
1195
+ await client.consumeRuntimeCardResponse({
1400
1196
  conversationId: session.conversationId,
1401
- 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, {
1402
1202
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1403
1203
  metadata: {
1404
1204
  ...outcome.metadata,
@@ -1416,19 +1216,17 @@ export async function main() {
1416
1216
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1417
1217
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1418
1218
  const inputId = readString(params, 'itemId') ?? requestId;
1419
- await port.requestInput({
1219
+ await client.createRuntimeInputRequest({
1420
1220
  conversationId: session.conversationId,
1421
1221
  inputId,
1422
1222
  kind: 'clarify',
1423
- expiresAt: new Date(expiresAt).toISOString(),
1424
- ...(ownerId ? { responseUserId: ownerId } : {}),
1223
+ expiresAt,
1224
+ responseUserId: ownerId ?? undefined,
1425
1225
  title: 'Codex needs input',
1426
1226
  prompt: questions?.length
1427
1227
  ? 'Codex needs your input to continue.'
1428
1228
  : 'Codex needs input.',
1429
- ...(questions
1430
- ? { questions: questions }
1431
- : { questions: [] }),
1229
+ ...(questions ? { questions } : {}),
1432
1230
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
1433
1231
  native: {
1434
1232
  runtime: 'codex',
@@ -1440,9 +1238,13 @@ export async function main() {
1440
1238
  threadId: readString(params, 'threadId') ?? '',
1441
1239
  },
1442
1240
  },
1443
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1241
+ turnId: session.currentTurnId ?? undefined,
1242
+ });
1243
+ const response = await waitForRuntimeInputResponse({
1244
+ conversationId: session.conversationId,
1245
+ inputId,
1246
+ expiresAt,
1444
1247
  });
1445
- const response = await session.waitForRuntimeInputResponse({ inputId, expiresAt });
1446
1248
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1447
1249
  }
1448
1250
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1451,29 +1253,25 @@ export async function main() {
1451
1253
  });
1452
1254
  if (mappedApproval) {
1453
1255
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1454
- await port.requestApproval({
1256
+ await client.createRuntimeApprovalRequest({
1455
1257
  conversationId: session.conversationId,
1456
1258
  approvalId,
1457
1259
  toolName: mappedApproval.toolName,
1458
- detail: mappedApproval.toolSummary,
1260
+ toolSummary: mappedApproval.toolSummary,
1459
1261
  category: mappedApproval.category,
1460
- risk: mappedApproval.risk ?? 'normal',
1461
- ...(mappedApproval.riskLevel ? { riskLevel: mappedApproval.riskLevel } : {}),
1262
+ risk: mappedApproval.risk,
1263
+ riskLevel: mappedApproval.riskLevel,
1462
1264
  native: {
1463
1265
  ...mappedApproval.native,
1464
1266
  requestId,
1465
1267
  method: request.method,
1466
1268
  },
1467
- ...(mappedApproval.details
1468
- ? { details: mappedApproval.details }
1469
- : {}),
1470
- ...(mappedApproval.diff
1471
- ? { diff: mappedApproval.diff }
1472
- : {}),
1473
- ...(ownerId ? { responseUserId: ownerId } : {}),
1269
+ details: mappedApproval.details,
1270
+ ...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
1271
+ responseUserId: ownerId ?? undefined,
1474
1272
  allowSessionRule: true,
1475
- expiresAt: new Date(expiresAt).toISOString(),
1476
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1273
+ expiresAt,
1274
+ turnId: session.currentTurnId ?? undefined,
1477
1275
  });
1478
1276
  const response = await waitForRuntimeApprovalResponse({
1479
1277
  conversationId: session.conversationId,
@@ -1509,12 +1307,7 @@ export async function main() {
1509
1307
  : decision === 'reject'
1510
1308
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1511
1309
  : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1512
- session.enqueueInbound(buildCodexQueuedInput({
1513
- prompt,
1514
- sourceMessageId: input.message.id,
1515
- planMode: decision !== 'approve',
1516
- canUseCodexAppTools: input.isOwner,
1517
- }));
1310
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner);
1518
1311
  return;
1519
1312
  }
1520
1313
  let materialized = [];
@@ -1532,9 +1325,11 @@ export async function main() {
1532
1325
  const renderedContent = renderInboundContent(input.message, materialized);
1533
1326
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1534
1327
  const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1535
- const planCommand = requestedPlanMode
1536
- ? { planMode: true, content: renderedContent }
1537
- : parsePlanCommand(renderedContent);
1328
+ const planCommand = useAppServer
1329
+ ? requestedPlanMode
1330
+ ? { planMode: true, content: renderedContent }
1331
+ : parsePlanCommand(renderedContent)
1332
+ : { planMode: false, content: renderedContent };
1538
1333
  const content = planCommand.content;
1539
1334
  const hydrated = await loadHydratedInboundContext({
1540
1335
  conversationId: input.conversationId,
@@ -1585,9 +1380,7 @@ export async function main() {
1585
1380
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1586
1381
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
1587
1382
  await markQueuedMessageAccepted(input.conversationId, input.message.id, shouldMarkAccepted);
1588
- await port.sendMessage({
1589
- conversationId: input.conversationId,
1590
- 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}`, {
1591
1384
  messageId: `codex-start-failed-${input.message.id}`,
1592
1385
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1593
1386
  metadata: {
@@ -1610,36 +1403,19 @@ export async function main() {
1610
1403
  message: input.message,
1611
1404
  });
1612
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);
1613
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;
1614
1413
  }
1615
1414
  const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1616
- // The shared turn queue handles queue/interrupt/start semantics.
1617
- session.enqueueInbound(buildCodexQueuedInput({
1618
- prompt,
1619
- intent: deliveryIntent,
1620
- sourceMessageId: input.message.id,
1621
- markAccepted: shouldMarkAccepted,
1622
- imagePaths,
1623
- mediaAddDirs,
1624
- planMode: planCommand.planMode,
1625
- artifactRoutingMode,
1626
- canUseCodexAppTools: participantContext.isOwner,
1627
- }));
1415
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1628
1416
  }
1629
- async function sendTurnArtifactFile(session, file) {
1630
- // Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
1631
- // the upload; then send the permanent message with the attachment ref.
1632
- const uploaded = await port.uploadMedia({
1633
- path: file.path,
1634
- mime: inferUploadMimeType(file.path),
1635
- fileName: file.fileName,
1636
- conversationId: session.conversationId,
1637
- });
1638
- return port.sendMessage({
1639
- conversationId: session.conversationId,
1640
- text: '',
1641
- contentType: uploaded.attachment.kind,
1642
- attachments: [uploaded.attachment],
1417
+ function sendTurnArtifactFile(session, file) {
1418
+ return sendMediaFileMessage(client, session.conversationId, file.path, '', {
1643
1419
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1644
1420
  metadata: {
1645
1421
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -1679,6 +1455,298 @@ export async function main() {
1679
1455
  console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1680
1456
  }
1681
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
+ }
1682
1750
  let streamConnected = false;
1683
1751
  const hostAvailableExecutionModes = [
1684
1752
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1709,14 +1777,16 @@ export async function main() {
1709
1777
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1710
1778
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1711
1779
  presentation: runtimePresentation,
1712
- supportsPlanMode: true,
1713
- supportsCompact: true,
1714
- supportsRichCards: true,
1780
+ supportsPlanMode: useAppServer,
1781
+ supportsCompact: useAppServer,
1782
+ supportsRichCards: useAppServer,
1715
1783
  skills: codexSkills,
1716
1784
  }),
1717
1785
  });
1718
1786
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
1719
1787
  async function refreshCodexSkillInventory(forceReload = false) {
1788
+ if (!useAppServer)
1789
+ return;
1720
1790
  const probe = new CodexAppServerAdapter({
1721
1791
  cwd: workingDir,
1722
1792
  codexBin,
@@ -1745,7 +1815,7 @@ export async function main() {
1745
1815
  if (modelGuard) {
1746
1816
  session.state.lastError = modelGuard;
1747
1817
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1748
- session.writeState();
1818
+ writeState(session);
1749
1819
  // The poller consumes the node; skip effort handling for this pass,
1750
1820
  // matching the legacy loop.
1751
1821
  return;
@@ -1753,42 +1823,58 @@ export async function main() {
1753
1823
  session.adapter.setModel(control.model);
1754
1824
  session.state.model = control.model;
1755
1825
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1756
- session.writeState();
1826
+ writeState(session);
1757
1827
  }
1758
1828
  if (control.permissionMode) {
1759
1829
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1760
1830
  // Convergence contract: a consumed session control must always be
1761
1831
  // answered. Re-publish the currently applied state so clients settle on
1762
1832
  // the authoritative value instead of holding the composer until timeout.
1763
- session.writeState();
1833
+ writeState(session);
1764
1834
  }
1765
1835
  if (control.effort) {
1766
1836
  if (CODEX_EFFORT_VALUES.has(control.effort)) {
1767
1837
  session.adapter.setReasoningEffort(control.effort);
1768
1838
  session.state.effort = control.effort;
1769
1839
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
1770
- session.writeState();
1840
+ writeState(session);
1771
1841
  }
1772
1842
  else {
1773
1843
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
1774
1844
  // Same contract: ignored values still get an authoritative re-publish.
1775
- session.writeState();
1845
+ writeState(session);
1776
1846
  }
1777
1847
  }
1778
1848
  }
1779
- // Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
1780
- const handleControlSignal = createRuntimeSignalHandler({
1781
- agentId,
1782
- logPrefix: '[canon-codex]',
1783
- capabilities: CODEX_RUNTIME_CAPABILITIES,
1784
- }, {
1785
- sessions,
1786
- port,
1787
- isSessionRunning: (session) => session.running,
1788
- interruptRuntime: (session) => session.adapter.interrupt(),
1789
- resetSession: (session) => resetRuntimeSession(session),
1790
- markSessionPendingInputsRejected,
1791
- });
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
+ }
1792
1878
  async function handleControlPrimitive(event) {
1793
1879
  const { conversationId, value } = event;
1794
1880
  const primitiveId = typeof value.id === 'string' ? value.id : '';
@@ -1801,30 +1887,39 @@ export async function main() {
1801
1887
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: no live Codex session`);
1802
1888
  return;
1803
1889
  }
1890
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1891
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: compact is only available for Codex app-server sessions`);
1892
+ return;
1893
+ }
1804
1894
  try {
1805
1895
  await session.adapter.compactThread();
1806
1896
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
1807
- session.writeState();
1897
+ writeState(session);
1808
1898
  }
1809
1899
  catch (error) {
1810
1900
  const message = error instanceof Error ? error.message : String(error);
1811
1901
  session.state.lastError = `Could not compact Codex context: ${message}`;
1812
1902
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${session.state.lastError}`);
1813
- session.writeState();
1903
+ writeState(session);
1814
1904
  }
1815
1905
  }
1816
- // Control events arrive as bridge notifications — the bridge owns the ONE
1817
- // authoritative /control consumer for this identity (plan §2); the host's
1818
- // adaptive poller (cadence, jitter, baselines) is deleted with it.
1819
- const detachControlNotifications = attachCodexControlNotifications({
1820
- 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')),
1821
1912
  onSessionControl: ({ conversationId, control }) => {
1822
1913
  applySessionControl(conversationId, control);
1823
1914
  },
1824
1915
  onSignal: handleControlSignal,
1825
1916
  onPrimitive: handleControlPrimitive,
1826
- onError: (error, kind, conversationId) => {
1827
- 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);
1828
1923
  },
1829
1924
  });
1830
1925
  let publishRuntimeDetailsInFlight = false;
@@ -1837,7 +1932,7 @@ export async function main() {
1837
1932
  });
1838
1933
  if (!streamConnected)
1839
1934
  return;
1840
- await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1935
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1841
1936
  console.error('[canon-codex] Failed to publish agent runtime:', error);
1842
1937
  });
1843
1938
  if (publishRuntimeDetailsInFlight)
@@ -1847,14 +1942,15 @@ export async function main() {
1847
1942
  await refreshKnownConversationIds().catch((error) => {
1848
1943
  console.error('[canon-codex] Failed to refresh known conversations:', error);
1849
1944
  });
1850
- await port.publishHostSessionSnapshots({
1945
+ await publishHostSessionSnapshots({
1851
1946
  conversationIds: Array.from(knownConversationIds),
1947
+ agentId,
1852
1948
  clientType: 'codex',
1853
1949
  runtime: runtimeDescriptor,
1854
- workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1950
+ workspaceOptions,
1855
1951
  defaultCwd: workingDir,
1856
- extraSessionConfigFields: [...CODEX_SESSION_CONFIG_FIELDS],
1857
- 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) => {
1858
1954
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1859
1955
  return [
1860
1956
  session.conversationId,
@@ -1883,16 +1979,24 @@ export async function main() {
1883
1979
  const payload = {
1884
1980
  descriptor,
1885
1981
  surfaceMode: 'host',
1982
+ // The exec --json transport cannot block on approvals — without a
1983
+ // strip-level warning a user can believe they have an approval
1984
+ // gate they do not have.
1985
+ ...(useAppServer
1986
+ ? {}
1987
+ : { warning: "Approvals can't block on this Codex CLI — update Codex to enable the app-server transport and approval gates." }),
1886
1988
  statusItems: [
1887
1989
  {
1888
1990
  id: 'transport',
1889
1991
  label: 'Transport',
1890
- value: 'app-server',
1992
+ value: useAppServer ? 'app-server' : 'exec --json',
1891
1993
  },
1892
1994
  {
1893
1995
  id: 'streaming',
1894
1996
  label: 'Live output',
1895
- value: 'Plans, questions, approvals, tools, and message deltas',
1997
+ value: useAppServer
1998
+ ? 'Plans, questions, approvals, tools, and message deltas'
1999
+ : 'Thinking, tools, and completed-message previews',
1896
2000
  },
1897
2001
  {
1898
2002
  id: 'codex-cli',
@@ -1903,7 +2007,8 @@ export async function main() {
1903
2007
  {
1904
2008
  id: 'nativeActions',
1905
2009
  label: 'Native actions',
1906
- value: 'Enabled',
2010
+ value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
2011
+ ...(useAppServer ? {} : { tone: 'warning' }),
1907
2012
  },
1908
2013
  {
1909
2014
  id: 'mediaOut',
@@ -1922,13 +2027,12 @@ export async function main() {
1922
2027
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1923
2028
  },
1924
2029
  notes: [
1925
- 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.',
2030
+ useAppServer
2031
+ ? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
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.',
1926
2033
  ],
1927
2034
  };
1928
- await port.publishRuntimeStatus({
1929
- conversationId,
1930
- presentation: payload,
1931
- });
2035
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
1932
2036
  })).catch((error) => {
1933
2037
  console.error('[canon-codex] Failed to publish runtime info:', error);
1934
2038
  });
@@ -1937,157 +2041,121 @@ export async function main() {
1937
2041
  publishRuntimeDetailsInFlight = false;
1938
2042
  }
1939
2043
  };
1940
- // ── Inbound Canon events (bridge notifications) ──
1941
- // The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering.
1942
- // HITL control-reply messages (approval_reply / plan_approval_reply) are
1943
- // STRIPPED from onMessage bridge-side; plan replies arrive as
1944
- // onControlReply and re-enter the plan flow below.
1945
- const handleBridgeMessage = (payload) => {
1946
- const message = payload.message;
1947
- if (message.senderId === agentId)
1948
- return;
1949
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
1950
- console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
1951
- return;
1952
- }
1953
- void enqueueInboundMessage({
1954
- conversationId: payload.conversationId,
1955
- message,
1956
- senderName: message.senderName || message.senderId,
1957
- isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
1958
- behavior: payload.behavior,
1959
- activeSelfContextId: payload.activeSelfContextId,
1960
- selfContexts: payload.selfContexts,
1961
- provenance: payload.provenance,
1962
- turnDispatch: payload.turnDispatch,
1963
- });
1964
- if (message.id) {
1965
- saveRuntimeSessionState(runtimeId, {
1966
- conversationId: payload.conversationId,
1967
- baseCwd: workingDir,
1968
- lastInboundMessageId: message.id,
1969
- });
1970
- }
1971
- };
1972
- const attachInboundNotifications = () => notifications.attach({
1973
- onMessage: (params) => {
1974
- handleBridgeMessage(params);
1975
- },
1976
- onMessageDeleted: (params) => {
1977
- const payload = params;
1978
- removeQueuedInput(payload.conversationId, payload.messageId);
1979
- },
1980
- onConversationUpdated: (params) => {
1981
- handleConversationUpdated(params);
1982
- },
1983
- onControlReply: (params) => {
1984
- const payload = params;
1985
- const message = payload.message;
1986
- // Only the plan flow consumes stripped control replies host-side —
1987
- // approval/card replies resolve against the bridge's HITL records.
1988
- if (!isRecord(message.metadata) || message.metadata.type !== 'plan_approval_reply') {
1989
- return;
1990
- }
1991
- void enqueueInboundMessage({
1992
- conversationId: payload.conversationId,
1993
- message,
1994
- senderName: message.senderName || message.senderId,
1995
- isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
1996
- });
1997
- },
1998
- ready: () => {
1999
- streamConnected = true;
2000
- void publishRuntimeHeartbeat();
2001
- console.error('[canon-codex] Bridge ready');
2002
- },
2003
- connectionState: (params) => {
2004
- const { upstream } = params;
2005
- 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: () => {
2006
2082
  streamConnected = true;
2007
2083
  void publishRuntimeHeartbeat();
2008
- console.error('[canon-codex] Canon upstream connected');
2009
- }
2010
- else {
2084
+ console.error('[canon-codex] SSE connected');
2085
+ },
2086
+ onDisconnected: () => {
2011
2087
  streamConnected = false;
2012
- port.clearAgentRuntime().catch(() => { });
2013
- console.error(`[canon-codex] Canon upstream ${upstream}`);
2014
- }
2088
+ runtimeState.clearAgentRuntime().catch(() => { });
2089
+ console.error('[canon-codex] SSE disconnected');
2090
+ },
2091
+ onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
2015
2092
  },
2016
2093
  });
2017
2094
  await refreshCodexSkillInventory();
2018
2095
  try {
2019
- const conversations = await directory.listConversations();
2020
- directory.primeFromStartup(conversations);
2096
+ const conversations = await client.getConversations();
2097
+ lastKnownConversationRefreshAt = Date.now();
2021
2098
  for (const conversation of conversations) {
2022
- port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
2023
- port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
2024
- port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
2025
- }
2026
- for (const conversation of conversations) {
2027
- const cursor = loadRuntimeSessionState(runtimeId, {
2028
- conversationId: conversation.id,
2029
- baseCwd: workingDir,
2030
- })?.lastInboundMessageId;
2031
- const recovery = await collectMissedInboundMessages({
2032
- fetchPage: (before) => port.getMessages({
2033
- conversationId: conversation.id,
2034
- limit: STARTUP_RECOVERY_PAGE_SIZE,
2035
- ...(before ? { before } : {}),
2036
- includeBehavior: true,
2037
- }),
2038
- cursor,
2039
- agentId,
2040
- });
2041
- const latestPage = recovery.newestPage;
2042
- if (recovery.mode === 'truncated-window') {
2043
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
2044
- }
2045
- if (recovery.messages.length > 1) {
2046
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
2047
- }
2048
- for (const latestMessage of recovery.messages) {
2049
- const triggerDecision = shouldTriggerAgentTurn({
2050
- senderType: latestMessage.senderType ?? 'human',
2051
- metadata: latestMessage.metadata,
2052
- });
2053
- if (!triggerDecision.allow) {
2054
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
2055
- continue;
2056
- }
2057
- console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
2058
- await enqueueInboundMessage({
2059
- conversationId: conversation.id,
2060
- message: latestMessage,
2061
- senderName: latestMessage.senderName || latestMessage.senderId,
2062
- isOwner: ownerId != null && latestMessage.senderId === ownerId,
2063
- behavior: latestPage.behavior,
2064
- selfContexts: latestPage.selfContexts,
2065
- hydratedPage: latestPage,
2066
- });
2067
- if (latestMessage.id) {
2068
- saveRuntimeSessionState(runtimeId, {
2069
- conversationId: conversation.id,
2070
- baseCwd: workingDir,
2071
- lastInboundMessageId: latestMessage.id,
2072
- });
2073
- }
2074
- }
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(() => { });
2075
2104
  }
2076
2105
  }
2077
2106
  catch (error) {
2078
2107
  console.error('[canon-codex] Failed to load startup conversations:', error);
2079
2108
  }
2080
- attachInboundNotifications();
2081
- // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2082
- lifecycle.startTimers();
2083
- 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);
2084
2152
  console.error('[canon-codex] Ready — sessions created on demand');
2085
2153
  await new Promise(() => { });
2086
2154
  }
2087
2155
  runCli(import.meta.url, main, (error) => {
2088
2156
  const message = error instanceof Error ? error.message : String(error);
2089
2157
  console.error(`[canon-codex] ${message}`);
2090
- activeLockHandle?.release();
2158
+ getActiveProfileLock()?.release();
2091
2159
  process.exit(1);
2092
2160
  }, {
2093
2161
  name: 'canon-codex',