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