@canonmsg/codex-plugin 0.20.0 → 0.22.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, 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, RuntimeRequestManager, 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,95 @@ 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}`);
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
+ }
525
+ // Shared poll/timeout engine. Built-in `input`/`card` descriptors own
526
+ // create+poll (codex passes native/responder policy via payload/options).
527
+ // Approval keeps codex's own resolution shape (no owner-authored outcome
528
+ // messages / session rules — that is the SDK ApprovalManager's job).
529
+ const runtimeRequests = new RuntimeRequestManager(client, {
530
+ agentId,
531
+ ownerId: ownerId ?? '',
532
+ });
533
+ runtimeRequests.register('approval', {
534
+ kind: 'approval',
535
+ generateId: () => randomUUID(),
536
+ create: async ({ client: apiClient, ctx, conversationId, requestId, payload, expiresAt }) => {
537
+ await apiClient.createRuntimeApprovalRequest({
538
+ conversationId,
539
+ approvalId: requestId,
540
+ toolName: payload.toolName,
541
+ toolSummary: payload.toolSummary,
542
+ category: payload.category,
543
+ risk: payload.risk,
544
+ riskLevel: payload.riskLevel,
545
+ native: payload.native,
546
+ details: payload.details,
547
+ ...(payload.diff ? { diff: payload.diff } : {}),
548
+ responseUserId: ctx.ownerId || undefined,
549
+ allowSessionRule: true,
550
+ expiresAt,
551
+ ...(payload.turnId ? { turnId: payload.turnId } : {}),
552
+ });
553
+ return { requestId };
554
+ },
555
+ consume: async ({ client: apiClient, conversationId, requestId }) => {
556
+ const response = await apiClient
557
+ .consumeRuntimeApprovalResponse({ conversationId, approvalId: requestId })
558
+ .catch(() => null);
559
+ if (response?.status === 'allow') {
560
+ return {
561
+ state: 'resolved',
562
+ result: {
563
+ decision: 'allow',
564
+ ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
565
+ },
566
+ };
567
+ }
568
+ if (response?.status === 'deny') {
569
+ return { state: 'resolved', result: { decision: 'deny' } };
570
+ }
571
+ if (response?.status === 'timeout')
572
+ return { state: 'timeout' };
573
+ return { state: 'pending' };
574
+ },
575
+ timeoutResult: () => ({ decision: 'deny' }),
569
576
  });
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})`);
576
577
  const launchArgs = [...process.argv.slice(2)];
577
578
  if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
578
579
  launchArgs.push('--cwd', workingDir);
@@ -601,81 +602,292 @@ export async function main() {
601
602
  lastStartedAt: new Date().toISOString(),
602
603
  lastHeartbeatAt: new Date().toISOString(),
603
604
  });
604
- // ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
605
- const directory = createConversationDirectory({
605
+ const runtimeState = createRuntimeStatePublisher({
606
606
  agentId,
607
- ownerId,
608
- ownerName,
609
- refreshFloorMs: HEARTBEAT_MS,
610
- }, { port });
611
- const { conversationCache, knownConversationIds, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
607
+ clientType: 'codex',
608
+ hostMode: true,
609
+ });
610
+ const sessions = new Map();
611
+ const pendingSessionCreations = new Map();
612
+ const conversationCache = new Map();
613
+ const knownConversationIds = new Set();
614
+ const promptedGroupContextConversationIds = new Set();
615
+ const pendingMembershipChanges = new Map();
616
+ let lastKnownConversationRefreshAt = 0;
617
+ const { getConversationMeta } = createConversationMetadataLoader({
618
+ client,
619
+ conversationCache,
620
+ });
612
621
  function resolveWorkspaceIdForBaseCwd(baseCwd) {
613
622
  return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
614
623
  }
624
+ async function refreshKnownConversationIds(force = false) {
625
+ if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
626
+ return;
627
+ }
628
+ const conversations = await client.getConversations();
629
+ knownConversationIds.clear();
630
+ for (const conversation of conversations) {
631
+ knownConversationIds.add(conversation.id);
632
+ conversationCache.set(conversation.id, conversation);
633
+ }
634
+ lastKnownConversationRefreshAt = Date.now();
635
+ }
636
+ function handleConversationUpdated(payload) {
637
+ const rawMemberIds = payload.changes.memberIds;
638
+ if (!Array.isArray(rawMemberIds))
639
+ return;
640
+ const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
641
+ const cached = conversationCache.get(payload.conversationId);
642
+ const membershipChange = payload.membershipChange
643
+ ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
644
+ if (cached) {
645
+ conversationCache.set(payload.conversationId, {
646
+ ...cached,
647
+ memberIds,
648
+ });
649
+ }
650
+ if (membershipChange) {
651
+ pendingMembershipChanges.set(payload.conversationId, membershipChange);
652
+ }
653
+ if (!memberIds.includes(agentId)) {
654
+ knownConversationIds.delete(payload.conversationId);
655
+ conversationCache.delete(payload.conversationId);
656
+ }
657
+ }
658
+ function getGroupContextMode(conversationId, conversation) {
659
+ if (conversation?.type !== 'group')
660
+ return undefined;
661
+ if (pendingMembershipChanges.has(conversationId))
662
+ return 'membership_change';
663
+ if (!promptedGroupContextConversationIds.has(conversationId))
664
+ return 'initial';
665
+ return undefined;
666
+ }
667
+ function markGroupContextModeUsed(conversationId, mode) {
668
+ if (!mode)
669
+ return;
670
+ promptedGroupContextConversationIds.add(conversationId);
671
+ if (mode === 'membership_change') {
672
+ pendingMembershipChanges.delete(conversationId);
673
+ }
674
+ }
675
+ async function loadHydratedInboundContext(input) {
676
+ const [conversation, page] = await Promise.all([
677
+ getConversationMeta(input.conversationId),
678
+ input.hydratedPage
679
+ ? Promise.resolve(input.hydratedPage)
680
+ : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
681
+ ]);
682
+ return buildHydratedInboundContext({
683
+ agentId,
684
+ conversationId: input.conversationId,
685
+ conversation,
686
+ page,
687
+ activeSelfContextId: input.activeSelfContextId,
688
+ selfContexts: input.selfContexts,
689
+ provenance: input.provenance,
690
+ message: input.message,
691
+ senderName: input.senderName,
692
+ isOwner: input.isOwner,
693
+ ownerId,
694
+ ownerName,
695
+ membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
696
+ groupContextMode: getGroupContextMode(input.conversationId, conversation),
697
+ });
698
+ }
699
+ function writeState(session) {
700
+ const appliedAt = Date.now();
701
+ const controlState = {};
702
+ if (session.state.model !== undefined) {
703
+ controlState.model = { value: session.state.model, source: 'applied', appliedAt };
704
+ }
705
+ if (session.state.permissionMode !== undefined) {
706
+ controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
707
+ }
708
+ if (session.state.effort !== undefined) {
709
+ controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
710
+ }
711
+ runtimeState.writeSessionState(session.conversationId, {
712
+ lastError: session.state.lastError,
713
+ model: session.state.model,
714
+ permissionMode: session.state.permissionMode,
715
+ effort: session.state.effort,
716
+ controlState,
717
+ cwd: session.cwd,
718
+ executionMode: session.environment.mode,
719
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
720
+ ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
721
+ ...(resolveExecutionFallbackReason(session.environment)
722
+ ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
723
+ : {}),
724
+ hostMode: true,
725
+ clientType: 'codex',
726
+ isActive: true,
727
+ ...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
728
+ }).catch(() => { });
729
+ }
730
+ function writeTurn(session) {
731
+ const isOpenTurn = session.turnState === 'thinking'
732
+ || session.turnState === 'streaming'
733
+ || session.turnState === 'tool'
734
+ || session.turnState === 'waiting_input';
735
+ runtimeState.writeTurnState(session.conversationId, {
736
+ turnId: session.currentTurnId,
737
+ state: session.turnState,
738
+ queueDepth: session.queue.length,
739
+ currentSpeakerId: agentId,
740
+ lastAcceptedIntent: session.lastAcceptedIntent,
741
+ capabilities: CODEX_RUNTIME_CAPABILITIES,
742
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
743
+ ...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
744
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
745
+ ? { completedAt: { '.sv': 'timestamp' } }
746
+ : {}),
747
+ }).catch(() => { });
748
+ }
749
+ function markTurnProgress(session) {
750
+ session.currentTurnUpdatedAt = Date.now();
751
+ }
615
752
  async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
616
753
  if (!markAccepted || !sourceMessageId)
617
754
  return;
618
- await port.setDisposition({
619
- conversationId,
620
- messageId: sourceMessageId,
621
- inboundDisposition: 'accepted_now',
755
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
756
+ }
757
+ async function markQueuedPromptsRejected(conversationId, prompts) {
758
+ await Promise.all(prompts.map((prompt) => {
759
+ if (!prompt.markAccepted || !prompt.sourceMessageId)
760
+ return Promise.resolve();
761
+ return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
762
+ }));
763
+ }
764
+ function removeQueuedPrompt(conversationId, sourceMessageId) {
765
+ const session = sessions.get(conversationId);
766
+ if (!session || session.queue.length === 0)
767
+ return;
768
+ const before = session.queue.length;
769
+ session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
770
+ if (session.queue.length !== before) {
771
+ writeTurn(session);
772
+ }
773
+ }
774
+ function clearStreaming(conversationId) {
775
+ runtimeState.clearStreaming(conversationId).catch(() => { });
776
+ }
777
+ function writeCodexStreaming(session, text, status) {
778
+ if (text !== null) {
779
+ session.turnLiveText = text;
780
+ }
781
+ runtimeState.writeStreaming(session.conversationId, {
782
+ text: session.turnLiveText,
783
+ status,
784
+ messageId: session.currentTurnId ?? undefined,
785
+ turnId: session.currentTurnId,
786
+ blocks: session.turnBlocks,
622
787
  }).catch(() => { });
623
788
  }
789
+ function upsertCodexTextSegment(session, event) {
790
+ const next = applyTextSegmentBlock({
791
+ turnLiveText: session.turnLiveText,
792
+ turnBlocks: session.turnBlocks,
793
+ }, {
794
+ turnId: session.currentTurnId,
795
+ itemId: event.itemId,
796
+ text: event.text,
797
+ });
798
+ session.turnLiveText = next.turnLiveText;
799
+ session.turnBlocks = next.turnBlocks;
800
+ }
801
+ function upsertTurnBlock(session, block) {
802
+ const now = Date.now();
803
+ const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
804
+ const existing = index >= 0 ? session.turnBlocks[index] : null;
805
+ const next = {
806
+ ...(existing ?? {
807
+ sequence: session.turnBlocks.length + 1,
808
+ createdAt: now,
809
+ }),
810
+ ...block,
811
+ turnId: session.currentTurnId ?? block.id,
812
+ updatedAt: now,
813
+ };
814
+ session.turnBlocks = index >= 0
815
+ ? [
816
+ ...session.turnBlocks.slice(0, index),
817
+ next,
818
+ ...session.turnBlocks.slice(index + 1),
819
+ ]
820
+ : [...session.turnBlocks, next];
821
+ }
822
+ function completeTurnBlock(session, id, summary) {
823
+ const existing = session.turnBlocks.find((block) => block.id === id);
824
+ if (!existing)
825
+ return;
826
+ upsertTurnBlock(session, {
827
+ id,
828
+ kind: existing.kind,
829
+ status: 'completed',
830
+ title: existing.title,
831
+ text: existing.text,
832
+ summary: summary ?? existing.summary,
833
+ });
834
+ }
835
+ function buildFinalTurnTrail(session) {
836
+ return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
837
+ ...block,
838
+ turnId: session.currentTurnId ?? block.turnId,
839
+ })));
840
+ }
624
841
  function buildCodexMessageId(session, kind) {
625
842
  return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
626
843
  }
627
844
  function buildCodexRuntimeCardOutcomeMessageId(cardId, status) {
628
845
  return `codex-card-${cardId}-${status}`;
629
846
  }
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
- */
847
+ async function handoffFinalMessage(conversationId) {
848
+ await sleep(FINAL_MESSAGE_HANDOFF_MS);
849
+ clearStreaming(conversationId);
850
+ typingSignals.clear(conversationId).catch(() => { });
851
+ }
852
+ function refreshVisibleWorkSignal(session) {
853
+ if (!session.running || session.closed)
854
+ return;
855
+ if (session.turnState !== 'thinking' && session.turnState !== 'tool')
856
+ return;
857
+ typingSignals.start(session.conversationId, 'thinking').catch(() => { });
858
+ }
859
+ function startVisibleWorkSignal(session) {
860
+ refreshVisibleWorkSignal(session);
861
+ }
862
+ function stopVisibleWorkSignal(session) {
863
+ if (session.typingKeepaliveTimer) {
864
+ clearInterval(session.typingKeepaliveTimer);
865
+ session.typingKeepaliveTimer = null;
866
+ }
867
+ typingSignals.clear(session.conversationId).catch(() => { });
868
+ }
869
+ function closeSession(conversationId) {
870
+ const session = sessions.get(conversationId);
871
+ if (!session)
872
+ return;
873
+ session.closed = true;
874
+ stopVisibleWorkSignal(session);
875
+ if ('close' in session.adapter && typeof session.adapter.close === 'function') {
876
+ session.adapter.close();
877
+ }
878
+ releaseConversationEnvironment(session.environment);
879
+ clearStreaming(conversationId);
880
+ runtimeState.clearSessionState(conversationId).catch(() => { });
881
+ runtimeState.clearTurnState(conversationId).catch(() => { });
882
+ typingSignals.clear(conversationId).catch(() => { });
883
+ typingSignals.dispose(conversationId);
884
+ sessions.delete(conversationId);
885
+ }
674
886
  async function resetRuntimeSession(session) {
675
887
  const conversationId = session.conversationId;
676
888
  session.resetRequested = true;
677
- const droppedInputs = session.pendingInputs.splice(0);
678
- await markSessionPendingInputsRejected(conversationId, droppedInputs);
889
+ const droppedPrompts = session.queue.splice(0);
890
+ await markQueuedPromptsRejected(conversationId, droppedPrompts);
679
891
  clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
680
892
  session.adapter.clearThreadId();
681
893
  session.activeSelfContextId = null;
@@ -693,16 +905,40 @@ export async function main() {
693
905
  session.lastAcceptedIntent = null;
694
906
  session.resetRequested = false;
695
907
  }
696
- session.stopVisibleWork();
697
- session.clearStreaming().catch(() => { });
908
+ stopVisibleWorkSignal(session);
909
+ clearStreaming(conversationId);
698
910
  typingSignals.clear(conversationId).catch(() => { });
699
- session.writeState();
700
- session.writeTurn();
911
+ writeState(session);
912
+ writeTurn(session);
913
+ }
914
+ function evictOldestIdle() {
915
+ let oldest = null;
916
+ for (const session of sessions.values()) {
917
+ if (session.running)
918
+ continue;
919
+ if (!oldest || session.lastActivity < oldest.lastActivity)
920
+ oldest = session;
921
+ }
922
+ if (oldest) {
923
+ console.error(`[canon-codex] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
924
+ closeSession(oldest.conversationId);
925
+ }
701
926
  }
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);
927
+ async function getOrCreateSession(conversationId) {
928
+ knownConversationIds.add(conversationId);
929
+ const existing = sessions.get(conversationId);
930
+ if (existing && !existing.closed) {
931
+ existing.lastActivity = Date.now();
932
+ return existing;
933
+ }
934
+ const pending = pendingSessionCreations.get(conversationId);
935
+ if (pending)
936
+ return pending;
937
+ if (sessions.size >= MAX_SESSIONS) {
938
+ evictOldestIdle();
939
+ }
940
+ const creation = (async () => {
941
+ const config = await loadSessionConfig(conversationId, agentId);
706
942
  const sessionExecutionMode = resolveSessionExecutionMode(config);
707
943
  const workspaceCwd = resolveWorkspaceCwd(config);
708
944
  const environment = prepareConversationEnvironment({
@@ -727,123 +963,41 @@ export async function main() {
727
963
  const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
728
964
  ? config.effort
729
965
  : 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 = {
966
+ const adapter = useAppServer
967
+ ? new CodexAppServerAdapter({
968
+ cwd: sessionCwd,
969
+ threadId: storedThreadId,
970
+ codexBin,
971
+ model: policy.model ?? null,
972
+ reasoningEffort: initialEffort,
973
+ sandbox: policy.sandbox,
974
+ approvalPolicy: policy.approvalPolicy,
975
+ addDirs: args['add-dir'] ?? [],
976
+ configOverrides: args.config ?? [],
977
+ fullAuto: policy.fullAuto,
978
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
979
+ dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
980
+ })
981
+ : new CodexConversationAdapter({
982
+ cwd: sessionCwd,
983
+ threadId: storedThreadId,
984
+ codexBin,
985
+ model: policy.model ?? null,
986
+ reasoningEffort: initialEffort,
987
+ sandbox: policy.sandbox,
988
+ approvalPolicy: policy.approvalPolicy,
989
+ codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
990
+ addDirs: args['add-dir'] ?? [],
991
+ configOverrides: args.config ?? [],
992
+ fullAuto: policy.fullAuto,
993
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
994
+ });
995
+ const session = {
841
996
  conversationId,
842
997
  cwd: sessionCwd,
843
998
  environment,
844
999
  adapter,
845
- pendingInputs: [],
846
- activeInput: null,
1000
+ queue: [],
847
1001
  running: false,
848
1002
  state: buildCodexInitialSessionState({
849
1003
  model: policy.model,
@@ -858,377 +1012,19 @@ export async function main() {
858
1012
  currentTurnCanUseCodexAppTools: false,
859
1013
  activeSelfContextId: null,
860
1014
  lastAcceptedIntent: null,
861
- interruptedTurnKeys: new Set(),
862
- finalizedTurnKeys: new Set(),
863
- pendingFinalText: null,
864
- pendingFinalDelivery: null,
865
1015
  resetRequested: false,
866
1016
  lastActivity: Date.now(),
867
1017
  typingKeepaliveTimer: null,
868
- idleResetTimer: null,
869
- finalDeliveryTimer: null,
870
1018
  closed: false,
871
- streamingText: '',
872
- availableModels: [],
873
- runtimeControlErrors: {},
874
- pendingReply: null,
1019
+ turnLiveText: '',
1020
+ turnBlocks: [],
875
1021
  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
1022
  };
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
1023
  sessions.set(conversationId, session);
1024
+ await controlPoller.baseline([conversationId]);
1229
1025
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
1230
- session.writeState();
1231
- session.writeTurn();
1026
+ writeState(session);
1027
+ writeTurn(session);
1232
1028
  return session;
1233
1029
  }
1234
1030
  catch (error) {
@@ -1236,58 +1032,41 @@ export async function main() {
1236
1032
  throw error;
1237
1033
  }
1238
1034
  })();
1035
+ pendingSessionCreations.set(conversationId, creation);
1036
+ try {
1037
+ return await creation;
1038
+ }
1039
+ finally {
1040
+ pendingSessionCreations.delete(conversationId);
1041
+ }
1042
+ }
1043
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false) {
1044
+ const nextPrompt = {
1045
+ prompt,
1046
+ intent,
1047
+ sourceMessageId,
1048
+ markAccepted,
1049
+ imagePaths,
1050
+ mediaAddDirs,
1051
+ planMode,
1052
+ artifactRoutingMode,
1053
+ canUseCodexAppTools,
1054
+ };
1055
+ if (toFront) {
1056
+ session.queue.unshift(nextPrompt);
1057
+ }
1058
+ else {
1059
+ session.queue.push(nextPrompt);
1060
+ }
1061
+ session.lastActivity = Date.now();
1062
+ writeTurn(session);
1063
+ void runNextTurn(session);
1239
1064
  }
1240
1065
  function resolveArtifactRoutingMode(participantContext) {
1241
1066
  return participantContext.conversationType === 'direct' && participantContext.isOwner
1242
1067
  ? 'workspace-generated'
1243
1068
  : 'disabled';
1244
1069
  }
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
- */
1250
- async function waitForRuntimeApprovalResponse(input) {
1251
- const read = async () => port.getHitlState({ requestId: input.approvalId }).catch(() => null);
1252
- 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 };
1259
- }
1260
- if (state?.status === 'cancelled' || state?.status === 'timeout') {
1261
- return { decision: 'deny' };
1262
- }
1263
- await sleep(1_000);
1264
- }
1265
- return { decision: 'deny' };
1266
- }
1267
- 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 ?? {});
1272
- return {
1273
- status: 'submitted',
1274
- ...(answer.actionId ? { actionId: answer.actionId } : {}),
1275
- ...(answer.values ? { values: answer.values } : {}),
1276
- };
1277
- }
1278
- if (state?.status === 'cancelled' || state?.status === 'timeout') {
1279
- return { status: state.status };
1280
- }
1281
- return null;
1282
- };
1283
- while (Date.now() < input.expiresAt) {
1284
- const mapped = map(await read());
1285
- if (mapped)
1286
- return mapped;
1287
- await sleep(1_000);
1288
- }
1289
- return map(await read()) ?? { status: 'timeout' };
1290
- }
1291
1070
  function runtimeCardRequestPayload(method, params) {
1292
1071
  if (method !== 'item/runtimeCard/request'
1293
1072
  && method !== 'runtimeCard/request') {
@@ -1302,6 +1081,9 @@ export async function main() {
1302
1081
  const params = request.params;
1303
1082
  const expiresAt = Date.now() + 30 * 60_000;
1304
1083
  if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
1084
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1085
+ return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
1086
+ }
1305
1087
  if (!session.currentTurnCanUseCodexAppTools) {
1306
1088
  return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
1307
1089
  }
@@ -1327,15 +1109,12 @@ export async function main() {
1327
1109
  let requestCreated = false;
1328
1110
  let requestResolved = false;
1329
1111
  try {
1330
- await port.requestCard({
1331
- conversationId: session.conversationId,
1332
- cardId,
1333
- 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,
1112
+ // Built-in card descriptor owns create+poll; codex passes native/turnId
1113
+ // via payload, post-create turn side effects via onCreated. responseUserId
1114
+ // is omitted (default `infer` policy) so the backend targets a reachable
1115
+ // member (owner is often not a member of agent-to-user DMs).
1116
+ const cardResult = await runtimeRequests.request('card', session.conversationId, {
1117
+ card,
1339
1118
  native: {
1340
1119
  runtime: 'codex',
1341
1120
  method: request.method,
@@ -1346,30 +1125,36 @@ export async function main() {
1346
1125
  threadId: readString(params, 'threadId') ?? '',
1347
1126
  },
1348
1127
  },
1349
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1350
- });
1351
- requestCreated = true;
1352
- session.turnState = 'waiting_input';
1353
- session.stageTurnBlock({
1354
- id: `card:${cardId}`,
1355
- kind: 'input',
1356
- status: 'pending',
1357
- title: card.title,
1358
- summary: card.template ?? 'runtime card',
1359
- });
1360
- session.writeTurn();
1361
- session.stopVisibleWork();
1362
- session.setStreamingStatus('waiting_input');
1363
- const response = await waitForRuntimeCardResponse({
1364
- conversationId: session.conversationId,
1365
- cardId,
1128
+ turnId: session.currentTurnId ?? undefined,
1129
+ }, {
1130
+ requestId: cardId,
1366
1131
  expiresAt,
1132
+ onCreated: () => {
1133
+ requestCreated = true;
1134
+ session.turnState = 'waiting_input';
1135
+ markTurnProgress(session);
1136
+ upsertTurnBlock(session, {
1137
+ id: `card:${cardId}`,
1138
+ kind: 'input',
1139
+ status: 'pending',
1140
+ title: card.title,
1141
+ summary: card.template ?? 'runtime card',
1142
+ });
1143
+ writeTurn(session);
1144
+ stopVisibleWorkSignal(session);
1145
+ writeCodexStreaming(session, null, 'waiting_input');
1146
+ },
1367
1147
  });
1148
+ const response = cardResult.status === 'submitted'
1149
+ ? {
1150
+ status: 'submitted',
1151
+ ...(cardResult.actionId ? { actionId: cardResult.actionId } : {}),
1152
+ ...(cardResult.values ? { values: cardResult.values } : {}),
1153
+ }
1154
+ : { status: cardResult.status };
1368
1155
  requestResolved = true;
1369
1156
  const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1370
- await port.sendMessage({
1371
- conversationId: session.conversationId,
1372
- text: outcome.text,
1157
+ await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1373
1158
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1374
1159
  metadata: {
1375
1160
  ...outcome.metadata,
@@ -1378,27 +1163,25 @@ export async function main() {
1378
1163
  replyBehavior: 'suppress_auto_reply',
1379
1164
  },
1380
1165
  });
1381
- session.stageTurnBlock({
1382
- id: `card:${cardId}`,
1383
- kind: 'input',
1384
- status: 'completed',
1385
- summary: `Card ${response.status}`,
1386
- });
1166
+ completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1387
1167
  if (session.turnState === 'waiting_input') {
1388
1168
  session.turnState = 'thinking';
1389
- session.writeTurn();
1390
- session.startVisibleWork();
1391
- session.setStreamingStatus('thinking');
1169
+ markTurnProgress(session);
1170
+ writeTurn(session);
1171
+ startVisibleWorkSignal(session);
1172
+ writeCodexStreaming(session, null, 'thinking');
1392
1173
  }
1393
1174
  return response;
1394
1175
  }
1395
1176
  catch (error) {
1396
1177
  if (requestCreated && !requestResolved) {
1397
- await port.cancelHitl({ requestId: cardId }).catch(() => null);
1398
- const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1399
- await port.sendMessage({
1178
+ await client.consumeRuntimeCardResponse({
1400
1179
  conversationId: session.conversationId,
1401
- text: outcome.text,
1180
+ cardId,
1181
+ cancel: true,
1182
+ }).catch(() => null);
1183
+ const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1184
+ await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1402
1185
  messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1403
1186
  metadata: {
1404
1187
  ...outcome.metadata,
@@ -1416,19 +1199,15 @@ export async function main() {
1416
1199
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1417
1200
  const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1418
1201
  const inputId = readString(params, 'itemId') ?? requestId;
1419
- await port.requestInput({
1420
- conversationId: session.conversationId,
1421
- inputId,
1202
+ // Built-in input descriptor owns create+poll; default `owner` responder
1203
+ // policy resolves to ctx.ownerId (matching the former responseUserId).
1204
+ const response = await runtimeRequests.request('input', session.conversationId, {
1422
1205
  kind: 'clarify',
1423
- expiresAt: new Date(expiresAt).toISOString(),
1424
- ...(ownerId ? { responseUserId: ownerId } : {}),
1425
1206
  title: 'Codex needs input',
1426
1207
  prompt: questions?.length
1427
1208
  ? 'Codex needs your input to continue.'
1428
1209
  : 'Codex needs input.',
1429
- ...(questions
1430
- ? { questions: questions }
1431
- : { questions: [] }),
1210
+ ...(questions ? { questions } : {}),
1432
1211
  sensitive: Boolean(questions?.some((question) => question.isSecret)),
1433
1212
  native: {
1434
1213
  runtime: 'codex',
@@ -1440,9 +1219,8 @@ export async function main() {
1440
1219
  threadId: readString(params, 'threadId') ?? '',
1441
1220
  },
1442
1221
  },
1443
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1444
- });
1445
- const response = await session.waitForRuntimeInputResponse({ inputId, expiresAt });
1222
+ turnId: session.currentTurnId ?? undefined,
1223
+ }, { requestId: inputId, expiresAt });
1446
1224
  return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1447
1225
  }
1448
1226
  const mappedApproval = mapCodexAppServerApprovalRequest({
@@ -1451,35 +1229,12 @@ export async function main() {
1451
1229
  });
1452
1230
  if (mappedApproval) {
1453
1231
  const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1454
- await port.requestApproval({
1455
- conversationId: session.conversationId,
1456
- approvalId,
1457
- toolName: mappedApproval.toolName,
1458
- detail: mappedApproval.toolSummary,
1459
- category: mappedApproval.category,
1460
- risk: mappedApproval.risk ?? 'normal',
1461
- ...(mappedApproval.riskLevel ? { riskLevel: mappedApproval.riskLevel } : {}),
1462
- native: {
1463
- ...mappedApproval.native,
1464
- requestId,
1465
- method: request.method,
1466
- },
1467
- ...(mappedApproval.details
1468
- ? { details: mappedApproval.details }
1469
- : {}),
1470
- ...(mappedApproval.diff
1471
- ? { diff: mappedApproval.diff }
1472
- : {}),
1473
- ...(ownerId ? { responseUserId: ownerId } : {}),
1474
- allowSessionRule: true,
1475
- expiresAt: new Date(expiresAt).toISOString(),
1476
- ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1477
- });
1478
- const response = await waitForRuntimeApprovalResponse({
1479
- conversationId: session.conversationId,
1480
- approvalId,
1481
- expiresAt,
1482
- });
1232
+ // Approval descriptor's create authors the server request (responder = ctx.ownerId).
1233
+ const response = await runtimeRequests.request('approval', session.conversationId, {
1234
+ ...mappedApproval,
1235
+ native: { ...mappedApproval.native, requestId, method: request.method },
1236
+ turnId: session.currentTurnId ?? undefined,
1237
+ }, { requestId: approvalId, expiresAt });
1483
1238
  if (request.method === 'item/permissions/requestApproval') {
1484
1239
  return response.decision === 'allow'
1485
1240
  ? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
@@ -1509,12 +1264,7 @@ export async function main() {
1509
1264
  : decision === 'reject'
1510
1265
  ? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
1511
1266
  : `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
- }));
1267
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner);
1518
1268
  return;
1519
1269
  }
1520
1270
  let materialized = [];
@@ -1532,9 +1282,11 @@ export async function main() {
1532
1282
  const renderedContent = renderInboundContent(input.message, materialized);
1533
1283
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
1534
1284
  const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
1535
- const planCommand = requestedPlanMode
1536
- ? { planMode: true, content: renderedContent }
1537
- : parsePlanCommand(renderedContent);
1285
+ const planCommand = useAppServer
1286
+ ? requestedPlanMode
1287
+ ? { planMode: true, content: renderedContent }
1288
+ : parsePlanCommand(renderedContent)
1289
+ : { planMode: false, content: renderedContent };
1538
1290
  const content = planCommand.content;
1539
1291
  const hydrated = await loadHydratedInboundContext({
1540
1292
  conversationId: input.conversationId,
@@ -1585,9 +1337,7 @@ export async function main() {
1585
1337
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1586
1338
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
1587
1339
  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}`,
1340
+ await sendMessageWithRetryChunked(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1591
1341
  messageId: `codex-start-failed-${input.message.id}`,
1592
1342
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1593
1343
  metadata: {
@@ -1610,36 +1360,19 @@ export async function main() {
1610
1360
  message: input.message,
1611
1361
  });
1612
1362
  if (session.running && deliveryIntent === 'interrupt') {
1363
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1364
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1613
1365
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1366
+ await session.adapter.interrupt().catch(() => { });
1367
+ clearStreaming(input.conversationId);
1368
+ typingSignals.clear(input.conversationId).catch(() => { });
1369
+ return;
1614
1370
  }
1615
1371
  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
- }));
1372
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
1628
1373
  }
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],
1374
+ function sendTurnArtifactFile(session, file) {
1375
+ return sendMediaFileMessage(client, session.conversationId, file.path, '', {
1643
1376
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1644
1377
  metadata: {
1645
1378
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
@@ -1679,6 +1412,296 @@ export async function main() {
1679
1412
  console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1680
1413
  }
1681
1414
  }
1415
+ async function runNextTurn(session) {
1416
+ if (session.running || session.closed)
1417
+ return;
1418
+ const nextTurn = session.queue.shift();
1419
+ if (!nextTurn)
1420
+ return;
1421
+ session.running = true;
1422
+ session.state.lastError = undefined;
1423
+ session.state.state = 'running';
1424
+ session.currentTurnId = randomUUID();
1425
+ session.turnLiveText = '';
1426
+ session.turnBlocks = [];
1427
+ session.turnCommandBlocks = createCommandBlockTracker();
1428
+ session.currentTurnOpenedAt = Date.now();
1429
+ session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
1430
+ session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
1431
+ session.lastAcceptedIntent = nextTurn.intent;
1432
+ session.turnState = 'thinking';
1433
+ session.lastActivity = Date.now();
1434
+ await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
1435
+ writeState(session);
1436
+ writeTurn(session);
1437
+ startVisibleWorkSignal(session);
1438
+ // Status-only seed: 'thinking' renders as a working filament row on the
1439
+ // clients; text here would be bubbled as speech (v4 register rule).
1440
+ writeCodexStreaming(session, '', 'thinking');
1441
+ let artifactBaseline = null;
1442
+ let artifactsRouted = false;
1443
+ const routeArtifactsOnce = async () => {
1444
+ if (artifactsRouted)
1445
+ return;
1446
+ artifactsRouted = true;
1447
+ if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1448
+ await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
1449
+ }
1450
+ };
1451
+ try {
1452
+ const turnId = session.currentTurnId ?? randomUUID();
1453
+ session.currentTurnId = turnId;
1454
+ let turnPrompt = nextTurn.prompt;
1455
+ if (nextTurn.artifactRoutingMode === 'workspace-generated') {
1456
+ artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
1457
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
1458
+ return null;
1459
+ });
1460
+ }
1461
+ const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
1462
+ if (modelGuard) {
1463
+ throw new ExecutionEnvironmentError(modelGuard, modelGuard);
1464
+ }
1465
+ const turnImagePaths = nextTurn.imagePaths ?? [];
1466
+ const turnMediaAddDirs = nextTurn.mediaAddDirs ?? [];
1467
+ const handleCodexEvent = (event) => {
1468
+ session.lastActivity = Date.now();
1469
+ if (event.type === 'thread.started') {
1470
+ if (session.resetRequested) {
1471
+ return;
1472
+ }
1473
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
1474
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
1475
+ return;
1476
+ }
1477
+ if (event.type === 'skills.changed') {
1478
+ void refreshCodexSkillInventory(true).then(() => publishRuntimeHeartbeat());
1479
+ return;
1480
+ }
1481
+ if (event.type === 'message') {
1482
+ session.turnState = 'streaming';
1483
+ markTurnProgress(session);
1484
+ writeTurn(session);
1485
+ stopVisibleWorkSignal(session);
1486
+ upsertCodexTextSegment(session, event);
1487
+ writeCodexStreaming(session, null, 'streaming');
1488
+ return;
1489
+ }
1490
+ if (event.type === 'plan.updated') {
1491
+ session.turnState = 'streaming';
1492
+ markTurnProgress(session);
1493
+ writeTurn(session);
1494
+ stopVisibleWorkSignal(session);
1495
+ upsertTurnBlock(session, {
1496
+ id: `plan:${session.currentTurnId}`,
1497
+ kind: 'plan',
1498
+ status: 'running',
1499
+ title: 'Plan',
1500
+ text: event.text,
1501
+ });
1502
+ writeCodexStreaming(session, event.text, 'streaming');
1503
+ return;
1504
+ }
1505
+ if (event.type === 'waiting') {
1506
+ session.turnState = 'waiting_input';
1507
+ markTurnProgress(session);
1508
+ writeTurn(session);
1509
+ stopVisibleWorkSignal(session);
1510
+ writeCodexStreaming(session, null, 'waiting_input');
1511
+ return;
1512
+ }
1513
+ if (event.type === 'command.started') {
1514
+ session.turnState = 'tool';
1515
+ markTurnProgress(session);
1516
+ writeTurn(session);
1517
+ startVisibleWorkSignal(session);
1518
+ const blockId = beginCommandBlock(session.turnCommandBlocks, {
1519
+ turnId: session.currentTurnId,
1520
+ command: event.command,
1521
+ itemId: event.itemId,
1522
+ });
1523
+ upsertTurnBlock(session, {
1524
+ id: blockId,
1525
+ kind: 'tool',
1526
+ status: 'running',
1527
+ title: summarizeCommand(event.command),
1528
+ summary: 'Command running',
1529
+ });
1530
+ writeCodexStreaming(session, null, 'tool');
1531
+ return;
1532
+ }
1533
+ if (event.type === 'command.completed') {
1534
+ const blockId = claimCommandBlock(session.turnCommandBlocks, {
1535
+ turnId: session.currentTurnId,
1536
+ command: event.command,
1537
+ itemId: event.itemId,
1538
+ });
1539
+ completeTurnBlock(session, blockId, 'Command completed');
1540
+ if (session.turnState === 'tool') {
1541
+ session.turnState = 'thinking';
1542
+ markTurnProgress(session);
1543
+ writeTurn(session);
1544
+ startVisibleWorkSignal(session);
1545
+ writeCodexStreaming(session, null, 'thinking');
1546
+ }
1547
+ return;
1548
+ }
1549
+ if (event.type === 'turn.completed') {
1550
+ // Codex reports per-turn token usage but no context window, so the
1551
+ // meter publishes tokens only (input + cached covers the full
1552
+ // prompt context of the completed turn).
1553
+ const totalTokens = (event.usage?.input_tokens ?? 0)
1554
+ + (event.usage?.cached_input_tokens ?? 0)
1555
+ + (event.usage?.output_tokens ?? 0);
1556
+ if (totalTokens > 0) {
1557
+ session.state.contextUsage = { totalTokens };
1558
+ }
1559
+ writeState(session);
1560
+ }
1561
+ };
1562
+ const logCodexLine = (line) => {
1563
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
1564
+ };
1565
+ const clearStoredThread = () => {
1566
+ clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1567
+ session.adapter.clearThreadId();
1568
+ };
1569
+ const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1570
+ planMode: nextTurn.planMode,
1571
+ onServerRequest: (request) => handleCodexServerRequest(session, request),
1572
+ });
1573
+ let result = await runTurnOnce();
1574
+ if (!result.interrupted
1575
+ && !result.finalMessage
1576
+ && result.exitCode
1577
+ && result.exitCode !== 0
1578
+ && isRecoverableCodexThreadError(result.errorText)) {
1579
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
1580
+ clearStoredThread();
1581
+ result = await runTurnOnce();
1582
+ }
1583
+ if (result.threadId && !session.resetRequested) {
1584
+ saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1585
+ }
1586
+ if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1587
+ await routeArtifactsOnce();
1588
+ // Route plan-CREATE through the unified spine (`/runtime-plan/request`):
1589
+ // seeds the server-owned pending node/attention/state and authors the
1590
+ // `plan_approval` card. Resolution is UNCHANGED — Codex still re-queues a
1591
+ // fresh turn off the server-authored `plan_approval_reply`.
1592
+ await client.createRuntimePlanRequest({
1593
+ conversationId: session.conversationId,
1594
+ planId: session.currentTurnId ?? randomUUID(),
1595
+ title: 'Codex Plan',
1596
+ body: result.finalMessage,
1597
+ ...(ownerId ? { responseUserId: ownerId } : {}),
1598
+ ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
1599
+ });
1600
+ await handoffFinalMessage(session.conversationId);
1601
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1602
+ }
1603
+ else if (!result.interrupted && result.finalMessage) {
1604
+ if (isRecoverableCodexThreadError(result.errorText)) {
1605
+ clearStoredThread();
1606
+ }
1607
+ await routeArtifactsOnce();
1608
+ const turnTrail = buildFinalTurnTrail(session);
1609
+ await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
1610
+ messageId: buildCodexMessageId(session, 'final'),
1611
+ ...(session.activeSelfContextId
1612
+ ? { selfContextId: session.activeSelfContextId }
1613
+ : {}),
1614
+ metadata: {
1615
+ turnId: session.currentTurnId,
1616
+ turnSemantics: 'turn_complete',
1617
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1618
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
1619
+ },
1620
+ });
1621
+ await handoffFinalMessage(session.conversationId);
1622
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
1623
+ }
1624
+ else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
1625
+ await routeArtifactsOnce();
1626
+ const userVisibleError = formatCodexTurnFailure(result.errorText);
1627
+ session.state.lastError = userVisibleError;
1628
+ writeState(session);
1629
+ if (result.errorText) {
1630
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
1631
+ }
1632
+ const turnTrail = buildFinalTurnTrail(session);
1633
+ await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
1634
+ messageId: buildCodexMessageId(session, 'error'),
1635
+ ...(session.activeSelfContextId
1636
+ ? { selfContextId: session.activeSelfContextId }
1637
+ : {}),
1638
+ metadata: {
1639
+ turnId: session.currentTurnId,
1640
+ turnSemantics: 'turn_complete',
1641
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1642
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
1643
+ },
1644
+ });
1645
+ await handoffFinalMessage(session.conversationId);
1646
+ }
1647
+ else if (!result.interrupted) {
1648
+ await routeArtifactsOnce();
1649
+ await handoffFinalMessage(session.conversationId);
1650
+ }
1651
+ else if (result.interrupted) {
1652
+ session.turnState = 'interrupted';
1653
+ writeTurn(session);
1654
+ stopVisibleWorkSignal(session);
1655
+ clearStreaming(session.conversationId);
1656
+ typingSignals.clear(session.conversationId).catch(() => { });
1657
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
1658
+ }
1659
+ }
1660
+ catch (error) {
1661
+ const message = error instanceof ExecutionEnvironmentError
1662
+ ? error.userMessage
1663
+ : error instanceof CanonApiError
1664
+ ? `The Codex host completed the turn, but Canon could not deliver the reply: ${error.message}`
1665
+ : `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
1666
+ session.state.lastError = message;
1667
+ writeState(session);
1668
+ await routeArtifactsOnce();
1669
+ await sendMessageWithRetryChunked(client, session.conversationId, message, {
1670
+ messageId: buildCodexMessageId(session, 'failure'),
1671
+ ...(session.activeSelfContextId
1672
+ ? { selfContextId: session.activeSelfContextId }
1673
+ : {}),
1674
+ metadata: {
1675
+ turnId: session.currentTurnId,
1676
+ turnSemantics: 'turn_complete',
1677
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
1678
+ },
1679
+ }).catch(() => { });
1680
+ await handoffFinalMessage(session.conversationId);
1681
+ if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
1682
+ clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1683
+ }
1684
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
1685
+ }
1686
+ finally {
1687
+ stopVisibleWorkSignal(session);
1688
+ session.running = false;
1689
+ session.state.state = 'idle';
1690
+ session.turnState = 'idle';
1691
+ session.currentTurnId = null;
1692
+ session.currentTurnOpenedAt = null;
1693
+ session.currentTurnUpdatedAt = null;
1694
+ session.currentTurnCanUseCodexAppTools = false;
1695
+ session.lastAcceptedIntent = null;
1696
+ session.resetRequested = false;
1697
+ session.lastActivity = Date.now();
1698
+ writeState(session);
1699
+ writeTurn(session);
1700
+ if (session.queue.length > 0) {
1701
+ void runNextTurn(session);
1702
+ }
1703
+ }
1704
+ }
1682
1705
  let streamConnected = false;
1683
1706
  const hostAvailableExecutionModes = [
1684
1707
  ...EXECUTION_ENVIRONMENT_MODES,
@@ -1709,14 +1732,16 @@ export async function main() {
1709
1732
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1710
1733
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1711
1734
  presentation: runtimePresentation,
1712
- supportsPlanMode: true,
1713
- supportsCompact: true,
1714
- supportsRichCards: true,
1735
+ supportsPlanMode: useAppServer,
1736
+ supportsCompact: useAppServer,
1737
+ supportsRichCards: useAppServer,
1715
1738
  skills: codexSkills,
1716
1739
  }),
1717
1740
  });
1718
1741
  let runtimeDescriptor = buildCurrentRuntimeDescriptor();
1719
1742
  async function refreshCodexSkillInventory(forceReload = false) {
1743
+ if (!useAppServer)
1744
+ return;
1720
1745
  const probe = new CodexAppServerAdapter({
1721
1746
  cwd: workingDir,
1722
1747
  codexBin,
@@ -1745,7 +1770,7 @@ export async function main() {
1745
1770
  if (modelGuard) {
1746
1771
  session.state.lastError = modelGuard;
1747
1772
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
1748
- session.writeState();
1773
+ writeState(session);
1749
1774
  // The poller consumes the node; skip effort handling for this pass,
1750
1775
  // matching the legacy loop.
1751
1776
  return;
@@ -1753,42 +1778,58 @@ export async function main() {
1753
1778
  session.adapter.setModel(control.model);
1754
1779
  session.state.model = control.model;
1755
1780
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
1756
- session.writeState();
1781
+ writeState(session);
1757
1782
  }
1758
1783
  if (control.permissionMode) {
1759
1784
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
1760
1785
  // Convergence contract: a consumed session control must always be
1761
1786
  // answered. Re-publish the currently applied state so clients settle on
1762
1787
  // the authoritative value instead of holding the composer until timeout.
1763
- session.writeState();
1788
+ writeState(session);
1764
1789
  }
1765
1790
  if (control.effort) {
1766
1791
  if (CODEX_EFFORT_VALUES.has(control.effort)) {
1767
1792
  session.adapter.setReasoningEffort(control.effort);
1768
1793
  session.state.effort = control.effort;
1769
1794
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
1770
- session.writeState();
1795
+ writeState(session);
1771
1796
  }
1772
1797
  else {
1773
1798
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
1774
1799
  // Same contract: ignored values still get an authoritative re-publish.
1775
- session.writeState();
1800
+ writeState(session);
1776
1801
  }
1777
1802
  }
1778
1803
  }
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
- });
1804
+ async function handleControlSignal(event) {
1805
+ const { conversationId, type } = event;
1806
+ const session = sessions.get(conversationId);
1807
+ // No live session: dedupe already advanced, but the node stays in place,
1808
+ // matching the legacy loop.
1809
+ if (!session || session.closed)
1810
+ return { consume: false };
1811
+ if (type === 'new_session') {
1812
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
1813
+ await resetRuntimeSession(session);
1814
+ return;
1815
+ }
1816
+ if (!session.running && (type !== 'stop_and_drop' || session.queue.length === 0)) {
1817
+ // Nothing to interrupt or drop — just consume the signal.
1818
+ return;
1819
+ }
1820
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
1821
+ if (type === 'stop_and_drop') {
1822
+ const droppedPrompts = session.queue.splice(0);
1823
+ await markQueuedPromptsRejected(conversationId, droppedPrompts);
1824
+ }
1825
+ if (session.running) {
1826
+ await session.adapter.interrupt();
1827
+ }
1828
+ session.turnState = 'interrupted';
1829
+ writeTurn(session);
1830
+ clearStreaming(conversationId);
1831
+ typingSignals.clear(conversationId).catch(() => { });
1832
+ }
1792
1833
  async function handleControlPrimitive(event) {
1793
1834
  const { conversationId, value } = event;
1794
1835
  const primitiveId = typeof value.id === 'string' ? value.id : '';
@@ -1801,30 +1842,39 @@ export async function main() {
1801
1842
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: no live Codex session`);
1802
1843
  return;
1803
1844
  }
1845
+ if (!(session.adapter instanceof CodexAppServerAdapter)) {
1846
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: compact is only available for Codex app-server sessions`);
1847
+ return;
1848
+ }
1804
1849
  try {
1805
1850
  await session.adapter.compactThread();
1806
1851
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
1807
- session.writeState();
1852
+ writeState(session);
1808
1853
  }
1809
1854
  catch (error) {
1810
1855
  const message = error instanceof Error ? error.message : String(error);
1811
1856
  session.state.lastError = `Could not compact Codex context: ${message}`;
1812
1857
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${session.state.lastError}`);
1813
- session.writeState();
1858
+ writeState(session);
1814
1859
  }
1815
1860
  }
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,
1861
+ const controlPoller = createCodexControlPoller({
1862
+ rtdb,
1863
+ agentId,
1864
+ conversationIds: () => sessions.keys(),
1865
+ hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
1866
+ && (session.running || session.queue.length > 0 || session.turnState === 'waiting_input')),
1821
1867
  onSessionControl: ({ conversationId, control }) => {
1822
1868
  applySessionControl(conversationId, control);
1823
1869
  },
1824
1870
  onSignal: handleControlSignal,
1825
1871
  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);
1872
+ onError: (error) => {
1873
+ // The legacy loop ignored transient RTDB failures; keep read/consume
1874
+ // errors quiet but surface handler failures.
1875
+ if (error.scope !== 'handler')
1876
+ return;
1877
+ 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
1878
  },
1829
1879
  });
1830
1880
  let publishRuntimeDetailsInFlight = false;
@@ -1837,7 +1887,7 @@ export async function main() {
1837
1887
  });
1838
1888
  if (!streamConnected)
1839
1889
  return;
1840
- await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
1890
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
1841
1891
  console.error('[canon-codex] Failed to publish agent runtime:', error);
1842
1892
  });
1843
1893
  if (publishRuntimeDetailsInFlight)
@@ -1847,14 +1897,15 @@ export async function main() {
1847
1897
  await refreshKnownConversationIds().catch((error) => {
1848
1898
  console.error('[canon-codex] Failed to refresh known conversations:', error);
1849
1899
  });
1850
- await port.publishHostSessionSnapshots({
1900
+ await publishHostSessionSnapshots({
1851
1901
  conversationIds: Array.from(knownConversationIds),
1902
+ agentId,
1852
1903
  clientType: 'codex',
1853
1904
  runtime: runtimeDescriptor,
1854
- workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
1905
+ workspaceOptions,
1855
1906
  defaultCwd: workingDir,
1856
- extraSessionConfigFields: [...CODEX_SESSION_CONFIG_FIELDS],
1857
- liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
1907
+ extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
1908
+ liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1858
1909
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1859
1910
  return [
1860
1911
  session.conversationId,
@@ -1883,16 +1934,24 @@ export async function main() {
1883
1934
  const payload = {
1884
1935
  descriptor,
1885
1936
  surfaceMode: 'host',
1937
+ // The exec --json transport cannot block on approvals — without a
1938
+ // strip-level warning a user can believe they have an approval
1939
+ // gate they do not have.
1940
+ ...(useAppServer
1941
+ ? {}
1942
+ : { warning: "Approvals can't block on this Codex CLI — update Codex to enable the app-server transport and approval gates." }),
1886
1943
  statusItems: [
1887
1944
  {
1888
1945
  id: 'transport',
1889
1946
  label: 'Transport',
1890
- value: 'app-server',
1947
+ value: useAppServer ? 'app-server' : 'exec --json',
1891
1948
  },
1892
1949
  {
1893
1950
  id: 'streaming',
1894
1951
  label: 'Live output',
1895
- value: 'Plans, questions, approvals, tools, and message deltas',
1952
+ value: useAppServer
1953
+ ? 'Plans, questions, approvals, tools, and message deltas'
1954
+ : 'Thinking, tools, and completed-message previews',
1896
1955
  },
1897
1956
  {
1898
1957
  id: 'codex-cli',
@@ -1903,7 +1962,8 @@ export async function main() {
1903
1962
  {
1904
1963
  id: 'nativeActions',
1905
1964
  label: 'Native actions',
1906
- value: 'Enabled',
1965
+ value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1966
+ ...(useAppServer ? {} : { tone: 'warning' }),
1907
1967
  },
1908
1968
  {
1909
1969
  id: 'mediaOut',
@@ -1922,13 +1982,12 @@ export async function main() {
1922
1982
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1923
1983
  },
1924
1984
  notes: [
1925
- 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.',
1985
+ useAppServer
1986
+ ? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
1987
+ : '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
1988
  ],
1927
1989
  };
1928
- await port.publishRuntimeStatus({
1929
- conversationId,
1930
- presentation: payload,
1931
- });
1990
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
1932
1991
  })).catch((error) => {
1933
1992
  console.error('[canon-codex] Failed to publish runtime info:', error);
1934
1993
  });
@@ -1937,157 +1996,122 @@ export async function main() {
1937
1996
  publishRuntimeDetailsInFlight = false;
1938
1997
  }
1939
1998
  };
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') {
1999
+ const stream = new CanonStream({
2000
+ apiKey,
2001
+ agentId,
2002
+ handler: {
2003
+ onMessage: (payload) => {
2004
+ const message = payload.message;
2005
+ if (message.senderId === agentId)
2006
+ return;
2007
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2008
+ console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2009
+ return;
2010
+ }
2011
+ void enqueueInboundMessage({
2012
+ conversationId: payload.conversationId,
2013
+ message,
2014
+ senderName: message.senderName || message.senderId,
2015
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
2016
+ behavior: payload.behavior,
2017
+ activeSelfContextId: payload.activeSelfContextId,
2018
+ selfContexts: payload.selfContexts,
2019
+ provenance: payload.provenance,
2020
+ turnDispatch: payload.turnDispatch,
2021
+ });
2022
+ if (message.id) {
2023
+ saveRuntimeSessionState(runtimeId, {
2024
+ conversationId: payload.conversationId,
2025
+ baseCwd: workingDir,
2026
+ lastInboundMessageId: message.id,
2027
+ });
2028
+ }
2029
+ },
2030
+ onMessageDeleted: (payload) => {
2031
+ removeQueuedPrompt(payload.conversationId, payload.messageId);
2032
+ },
2033
+ onConversationUpdated: (payload) => {
2034
+ handleConversationUpdated(payload);
2035
+ },
2036
+ onConnected: () => {
2006
2037
  streamConnected = true;
2007
2038
  void publishRuntimeHeartbeat();
2008
- console.error('[canon-codex] Canon upstream connected');
2009
- }
2010
- else {
2039
+ console.error('[canon-codex] SSE connected');
2040
+ },
2041
+ onDisconnected: () => {
2011
2042
  streamConnected = false;
2012
- port.clearAgentRuntime().catch(() => { });
2013
- console.error(`[canon-codex] Canon upstream ${upstream}`);
2014
- }
2043
+ runtimeState.clearAgentRuntime().catch(() => { });
2044
+ console.error('[canon-codex] SSE disconnected');
2045
+ },
2046
+ onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
2015
2047
  },
2016
2048
  });
2017
2049
  await refreshCodexSkillInventory();
2018
2050
  try {
2019
- const conversations = await directory.listConversations();
2020
- directory.primeFromStartup(conversations);
2021
- 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
- }
2051
+ const conversations = await client.getConversations();
2052
+ lastKnownConversationRefreshAt = Date.now();
2026
2053
  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
- }
2054
+ knownConversationIds.add(conversation.id);
2055
+ conversationCache.set(conversation.id, conversation);
2056
+ clearStreaming(conversation.id);
2057
+ runtimeState.clearSessionState(conversation.id).catch(() => { });
2058
+ runtimeState.clearTurnState(conversation.id).catch(() => { });
2075
2059
  }
2076
2060
  }
2077
2061
  catch (error) {
2078
2062
  console.error('[canon-codex] Failed to load startup conversations:', error);
2079
2063
  }
2080
- attachInboundNotifications();
2081
- // ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
2082
- lifecycle.startTimers();
2083
- lifecycle.installSignalHandlers();
2064
+ startCodexStreamInBackground(stream, (error) => {
2065
+ console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
2066
+ });
2067
+ controlPoller.start();
2068
+ const heartbeat = setInterval(() => {
2069
+ for (const session of sessions.values()) {
2070
+ writeState(session);
2071
+ if (!session.running) {
2072
+ writeTurn(session);
2073
+ }
2074
+ }
2075
+ void publishRuntimeHeartbeat();
2076
+ }, HEARTBEAT_MS);
2077
+ const idleCheck = setInterval(() => {
2078
+ const now = Date.now();
2079
+ for (const conversationId of [...sessions.keys()]) {
2080
+ const session = sessions.get(conversationId);
2081
+ if (!session || session.running)
2082
+ continue;
2083
+ if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
2084
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Idle timeout`);
2085
+ closeSession(conversationId);
2086
+ }
2087
+ }
2088
+ }, IDLE_CHECK_MS);
2089
+ const shutdown = async () => {
2090
+ console.error('[canon-codex] Shutting down...');
2091
+ controlPoller.stop();
2092
+ clearInterval(heartbeat);
2093
+ clearInterval(idleCheck);
2094
+ runtimeRequests.dispose();
2095
+ stream.stop();
2096
+ await runtimeState.clearAgentRuntime().catch(() => { });
2097
+ for (const session of [...sessions.values()]) {
2098
+ await session.adapter.interrupt().catch(() => { });
2099
+ closeSession(session.conversationId);
2100
+ }
2101
+ markLocalRuntimeStopped(runtimeId);
2102
+ (lockHandle ?? getActiveProfileLock())?.release();
2103
+ process.exit(0);
2104
+ };
2105
+ process.on('SIGINT', shutdown);
2106
+ process.on('SIGTERM', shutdown);
2107
+ process.on('SIGHUP', shutdown);
2084
2108
  console.error('[canon-codex] Ready — sessions created on demand');
2085
2109
  await new Promise(() => { });
2086
2110
  }
2087
2111
  runCli(import.meta.url, main, (error) => {
2088
2112
  const message = error instanceof Error ? error.message : String(error);
2089
2113
  console.error(`[canon-codex] ${message}`);
2090
- activeLockHandle?.release();
2114
+ getActiveProfileLock()?.release();
2091
2115
  process.exit(1);
2092
2116
  }, {
2093
2117
  name: 'canon-codex',