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