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