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