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