@canonmsg/codex-plugin 0.20.0 → 0.22.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 +1019 -995
- 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, 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, RuntimeRequestManager, 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,95 @@ 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
|
-
|
|
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
|
+
}
|
|
525
|
+
// Shared poll/timeout engine. Built-in `input`/`card` descriptors own
|
|
526
|
+
// create+poll (codex passes native/responder policy via payload/options).
|
|
527
|
+
// Approval keeps codex's own resolution shape (no owner-authored outcome
|
|
528
|
+
// messages / session rules — that is the SDK ApprovalManager's job).
|
|
529
|
+
const runtimeRequests = new RuntimeRequestManager(client, {
|
|
530
|
+
agentId,
|
|
531
|
+
ownerId: ownerId ?? '',
|
|
532
|
+
});
|
|
533
|
+
runtimeRequests.register('approval', {
|
|
534
|
+
kind: 'approval',
|
|
535
|
+
generateId: () => randomUUID(),
|
|
536
|
+
create: async ({ client: apiClient, ctx, conversationId, requestId, payload, expiresAt }) => {
|
|
537
|
+
await apiClient.createRuntimeApprovalRequest({
|
|
538
|
+
conversationId,
|
|
539
|
+
approvalId: requestId,
|
|
540
|
+
toolName: payload.toolName,
|
|
541
|
+
toolSummary: payload.toolSummary,
|
|
542
|
+
category: payload.category,
|
|
543
|
+
risk: payload.risk,
|
|
544
|
+
riskLevel: payload.riskLevel,
|
|
545
|
+
native: payload.native,
|
|
546
|
+
details: payload.details,
|
|
547
|
+
...(payload.diff ? { diff: payload.diff } : {}),
|
|
548
|
+
responseUserId: ctx.ownerId || undefined,
|
|
549
|
+
allowSessionRule: true,
|
|
550
|
+
expiresAt,
|
|
551
|
+
...(payload.turnId ? { turnId: payload.turnId } : {}),
|
|
552
|
+
});
|
|
553
|
+
return { requestId };
|
|
554
|
+
},
|
|
555
|
+
consume: async ({ client: apiClient, conversationId, requestId }) => {
|
|
556
|
+
const response = await apiClient
|
|
557
|
+
.consumeRuntimeApprovalResponse({ conversationId, approvalId: requestId })
|
|
558
|
+
.catch(() => null);
|
|
559
|
+
if (response?.status === 'allow') {
|
|
560
|
+
return {
|
|
561
|
+
state: 'resolved',
|
|
562
|
+
result: {
|
|
563
|
+
decision: 'allow',
|
|
564
|
+
...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
if (response?.status === 'deny') {
|
|
569
|
+
return { state: 'resolved', result: { decision: 'deny' } };
|
|
570
|
+
}
|
|
571
|
+
if (response?.status === 'timeout')
|
|
572
|
+
return { state: 'timeout' };
|
|
573
|
+
return { state: 'pending' };
|
|
574
|
+
},
|
|
575
|
+
timeoutResult: () => ({ decision: 'deny' }),
|
|
569
576
|
});
|
|
570
|
-
const typingSignals = createPortTypingSignals(port);
|
|
571
|
-
const agentId = bridge.hello.agentId;
|
|
572
|
-
const ownerId = bridge.hello.agentContext.ownerId ?? null;
|
|
573
|
-
const ownerName = bridge.hello.agentContext.ownerName ?? null;
|
|
574
|
-
console.error(`[canon-codex] Connected as ${bridge.hello.agentName || agentId} `
|
|
575
|
-
+ `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
|
|
576
577
|
const launchArgs = [...process.argv.slice(2)];
|
|
577
578
|
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
578
579
|
launchArgs.push('--cwd', workingDir);
|
|
@@ -601,81 +602,292 @@ export async function main() {
|
|
|
601
602
|
lastStartedAt: new Date().toISOString(),
|
|
602
603
|
lastHeartbeatAt: new Date().toISOString(),
|
|
603
604
|
});
|
|
604
|
-
|
|
605
|
-
const directory = createConversationDirectory({
|
|
605
|
+
const runtimeState = createRuntimeStatePublisher({
|
|
606
606
|
agentId,
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
607
|
+
clientType: 'codex',
|
|
608
|
+
hostMode: true,
|
|
609
|
+
});
|
|
610
|
+
const sessions = new Map();
|
|
611
|
+
const pendingSessionCreations = new Map();
|
|
612
|
+
const conversationCache = new Map();
|
|
613
|
+
const knownConversationIds = new Set();
|
|
614
|
+
const promptedGroupContextConversationIds = new Set();
|
|
615
|
+
const pendingMembershipChanges = new Map();
|
|
616
|
+
let lastKnownConversationRefreshAt = 0;
|
|
617
|
+
const { getConversationMeta } = createConversationMetadataLoader({
|
|
618
|
+
client,
|
|
619
|
+
conversationCache,
|
|
620
|
+
});
|
|
612
621
|
function resolveWorkspaceIdForBaseCwd(baseCwd) {
|
|
613
622
|
return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
|
|
614
623
|
}
|
|
624
|
+
async function refreshKnownConversationIds(force = false) {
|
|
625
|
+
if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const conversations = await client.getConversations();
|
|
629
|
+
knownConversationIds.clear();
|
|
630
|
+
for (const conversation of conversations) {
|
|
631
|
+
knownConversationIds.add(conversation.id);
|
|
632
|
+
conversationCache.set(conversation.id, conversation);
|
|
633
|
+
}
|
|
634
|
+
lastKnownConversationRefreshAt = Date.now();
|
|
635
|
+
}
|
|
636
|
+
function handleConversationUpdated(payload) {
|
|
637
|
+
const rawMemberIds = payload.changes.memberIds;
|
|
638
|
+
if (!Array.isArray(rawMemberIds))
|
|
639
|
+
return;
|
|
640
|
+
const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
|
|
641
|
+
const cached = conversationCache.get(payload.conversationId);
|
|
642
|
+
const membershipChange = payload.membershipChange
|
|
643
|
+
?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
|
|
644
|
+
if (cached) {
|
|
645
|
+
conversationCache.set(payload.conversationId, {
|
|
646
|
+
...cached,
|
|
647
|
+
memberIds,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
if (membershipChange) {
|
|
651
|
+
pendingMembershipChanges.set(payload.conversationId, membershipChange);
|
|
652
|
+
}
|
|
653
|
+
if (!memberIds.includes(agentId)) {
|
|
654
|
+
knownConversationIds.delete(payload.conversationId);
|
|
655
|
+
conversationCache.delete(payload.conversationId);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function getGroupContextMode(conversationId, conversation) {
|
|
659
|
+
if (conversation?.type !== 'group')
|
|
660
|
+
return undefined;
|
|
661
|
+
if (pendingMembershipChanges.has(conversationId))
|
|
662
|
+
return 'membership_change';
|
|
663
|
+
if (!promptedGroupContextConversationIds.has(conversationId))
|
|
664
|
+
return 'initial';
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
function markGroupContextModeUsed(conversationId, mode) {
|
|
668
|
+
if (!mode)
|
|
669
|
+
return;
|
|
670
|
+
promptedGroupContextConversationIds.add(conversationId);
|
|
671
|
+
if (mode === 'membership_change') {
|
|
672
|
+
pendingMembershipChanges.delete(conversationId);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async function loadHydratedInboundContext(input) {
|
|
676
|
+
const [conversation, page] = await Promise.all([
|
|
677
|
+
getConversationMeta(input.conversationId),
|
|
678
|
+
input.hydratedPage
|
|
679
|
+
? Promise.resolve(input.hydratedPage)
|
|
680
|
+
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
681
|
+
]);
|
|
682
|
+
return buildHydratedInboundContext({
|
|
683
|
+
agentId,
|
|
684
|
+
conversationId: input.conversationId,
|
|
685
|
+
conversation,
|
|
686
|
+
page,
|
|
687
|
+
activeSelfContextId: input.activeSelfContextId,
|
|
688
|
+
selfContexts: input.selfContexts,
|
|
689
|
+
provenance: input.provenance,
|
|
690
|
+
message: input.message,
|
|
691
|
+
senderName: input.senderName,
|
|
692
|
+
isOwner: input.isOwner,
|
|
693
|
+
ownerId,
|
|
694
|
+
ownerName,
|
|
695
|
+
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
696
|
+
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
function writeState(session) {
|
|
700
|
+
const appliedAt = Date.now();
|
|
701
|
+
const controlState = {};
|
|
702
|
+
if (session.state.model !== undefined) {
|
|
703
|
+
controlState.model = { value: session.state.model, source: 'applied', appliedAt };
|
|
704
|
+
}
|
|
705
|
+
if (session.state.permissionMode !== undefined) {
|
|
706
|
+
controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
|
|
707
|
+
}
|
|
708
|
+
if (session.state.effort !== undefined) {
|
|
709
|
+
controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
|
|
710
|
+
}
|
|
711
|
+
runtimeState.writeSessionState(session.conversationId, {
|
|
712
|
+
lastError: session.state.lastError,
|
|
713
|
+
model: session.state.model,
|
|
714
|
+
permissionMode: session.state.permissionMode,
|
|
715
|
+
effort: session.state.effort,
|
|
716
|
+
controlState,
|
|
717
|
+
cwd: session.cwd,
|
|
718
|
+
executionMode: session.environment.mode,
|
|
719
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
720
|
+
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
721
|
+
...(resolveExecutionFallbackReason(session.environment)
|
|
722
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
723
|
+
: {}),
|
|
724
|
+
hostMode: true,
|
|
725
|
+
clientType: 'codex',
|
|
726
|
+
isActive: true,
|
|
727
|
+
...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
|
|
728
|
+
}).catch(() => { });
|
|
729
|
+
}
|
|
730
|
+
function writeTurn(session) {
|
|
731
|
+
const isOpenTurn = session.turnState === 'thinking'
|
|
732
|
+
|| session.turnState === 'streaming'
|
|
733
|
+
|| session.turnState === 'tool'
|
|
734
|
+
|| session.turnState === 'waiting_input';
|
|
735
|
+
runtimeState.writeTurnState(session.conversationId, {
|
|
736
|
+
turnId: session.currentTurnId,
|
|
737
|
+
state: session.turnState,
|
|
738
|
+
queueDepth: session.queue.length,
|
|
739
|
+
currentSpeakerId: agentId,
|
|
740
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
741
|
+
capabilities: CODEX_RUNTIME_CAPABILITIES,
|
|
742
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
743
|
+
...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
|
|
744
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
745
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
746
|
+
: {}),
|
|
747
|
+
}).catch(() => { });
|
|
748
|
+
}
|
|
749
|
+
function markTurnProgress(session) {
|
|
750
|
+
session.currentTurnUpdatedAt = Date.now();
|
|
751
|
+
}
|
|
615
752
|
async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
|
|
616
753
|
if (!markAccepted || !sourceMessageId)
|
|
617
754
|
return;
|
|
618
|
-
await
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
755
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
756
|
+
}
|
|
757
|
+
async function markQueuedPromptsRejected(conversationId, prompts) {
|
|
758
|
+
await Promise.all(prompts.map((prompt) => {
|
|
759
|
+
if (!prompt.markAccepted || !prompt.sourceMessageId)
|
|
760
|
+
return Promise.resolve();
|
|
761
|
+
return client.updateMessageDisposition(conversationId, prompt.sourceMessageId, 'rejected').catch(() => { });
|
|
762
|
+
}));
|
|
763
|
+
}
|
|
764
|
+
function removeQueuedPrompt(conversationId, sourceMessageId) {
|
|
765
|
+
const session = sessions.get(conversationId);
|
|
766
|
+
if (!session || session.queue.length === 0)
|
|
767
|
+
return;
|
|
768
|
+
const before = session.queue.length;
|
|
769
|
+
session.queue = session.queue.filter((prompt) => prompt.sourceMessageId !== sourceMessageId);
|
|
770
|
+
if (session.queue.length !== before) {
|
|
771
|
+
writeTurn(session);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function clearStreaming(conversationId) {
|
|
775
|
+
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
776
|
+
}
|
|
777
|
+
function writeCodexStreaming(session, text, status) {
|
|
778
|
+
if (text !== null) {
|
|
779
|
+
session.turnLiveText = text;
|
|
780
|
+
}
|
|
781
|
+
runtimeState.writeStreaming(session.conversationId, {
|
|
782
|
+
text: session.turnLiveText,
|
|
783
|
+
status,
|
|
784
|
+
messageId: session.currentTurnId ?? undefined,
|
|
785
|
+
turnId: session.currentTurnId,
|
|
786
|
+
blocks: session.turnBlocks,
|
|
622
787
|
}).catch(() => { });
|
|
623
788
|
}
|
|
789
|
+
function upsertCodexTextSegment(session, event) {
|
|
790
|
+
const next = applyTextSegmentBlock({
|
|
791
|
+
turnLiveText: session.turnLiveText,
|
|
792
|
+
turnBlocks: session.turnBlocks,
|
|
793
|
+
}, {
|
|
794
|
+
turnId: session.currentTurnId,
|
|
795
|
+
itemId: event.itemId,
|
|
796
|
+
text: event.text,
|
|
797
|
+
});
|
|
798
|
+
session.turnLiveText = next.turnLiveText;
|
|
799
|
+
session.turnBlocks = next.turnBlocks;
|
|
800
|
+
}
|
|
801
|
+
function upsertTurnBlock(session, block) {
|
|
802
|
+
const now = Date.now();
|
|
803
|
+
const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
|
|
804
|
+
const existing = index >= 0 ? session.turnBlocks[index] : null;
|
|
805
|
+
const next = {
|
|
806
|
+
...(existing ?? {
|
|
807
|
+
sequence: session.turnBlocks.length + 1,
|
|
808
|
+
createdAt: now,
|
|
809
|
+
}),
|
|
810
|
+
...block,
|
|
811
|
+
turnId: session.currentTurnId ?? block.id,
|
|
812
|
+
updatedAt: now,
|
|
813
|
+
};
|
|
814
|
+
session.turnBlocks = index >= 0
|
|
815
|
+
? [
|
|
816
|
+
...session.turnBlocks.slice(0, index),
|
|
817
|
+
next,
|
|
818
|
+
...session.turnBlocks.slice(index + 1),
|
|
819
|
+
]
|
|
820
|
+
: [...session.turnBlocks, next];
|
|
821
|
+
}
|
|
822
|
+
function completeTurnBlock(session, id, summary) {
|
|
823
|
+
const existing = session.turnBlocks.find((block) => block.id === id);
|
|
824
|
+
if (!existing)
|
|
825
|
+
return;
|
|
826
|
+
upsertTurnBlock(session, {
|
|
827
|
+
id,
|
|
828
|
+
kind: existing.kind,
|
|
829
|
+
status: 'completed',
|
|
830
|
+
title: existing.title,
|
|
831
|
+
text: existing.text,
|
|
832
|
+
summary: summary ?? existing.summary,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
function buildFinalTurnTrail(session) {
|
|
836
|
+
return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
|
|
837
|
+
...block,
|
|
838
|
+
turnId: session.currentTurnId ?? block.turnId,
|
|
839
|
+
})));
|
|
840
|
+
}
|
|
624
841
|
function buildCodexMessageId(session, kind) {
|
|
625
842
|
return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
|
|
626
843
|
}
|
|
627
844
|
function buildCodexRuntimeCardOutcomeMessageId(cardId, status) {
|
|
628
845
|
return `codex-card-${cardId}-${status}`;
|
|
629
846
|
}
|
|
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
|
-
*/
|
|
847
|
+
async function handoffFinalMessage(conversationId) {
|
|
848
|
+
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
849
|
+
clearStreaming(conversationId);
|
|
850
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
851
|
+
}
|
|
852
|
+
function refreshVisibleWorkSignal(session) {
|
|
853
|
+
if (!session.running || session.closed)
|
|
854
|
+
return;
|
|
855
|
+
if (session.turnState !== 'thinking' && session.turnState !== 'tool')
|
|
856
|
+
return;
|
|
857
|
+
typingSignals.start(session.conversationId, 'thinking').catch(() => { });
|
|
858
|
+
}
|
|
859
|
+
function startVisibleWorkSignal(session) {
|
|
860
|
+
refreshVisibleWorkSignal(session);
|
|
861
|
+
}
|
|
862
|
+
function stopVisibleWorkSignal(session) {
|
|
863
|
+
if (session.typingKeepaliveTimer) {
|
|
864
|
+
clearInterval(session.typingKeepaliveTimer);
|
|
865
|
+
session.typingKeepaliveTimer = null;
|
|
866
|
+
}
|
|
867
|
+
typingSignals.clear(session.conversationId).catch(() => { });
|
|
868
|
+
}
|
|
869
|
+
function closeSession(conversationId) {
|
|
870
|
+
const session = sessions.get(conversationId);
|
|
871
|
+
if (!session)
|
|
872
|
+
return;
|
|
873
|
+
session.closed = true;
|
|
874
|
+
stopVisibleWorkSignal(session);
|
|
875
|
+
if ('close' in session.adapter && typeof session.adapter.close === 'function') {
|
|
876
|
+
session.adapter.close();
|
|
877
|
+
}
|
|
878
|
+
releaseConversationEnvironment(session.environment);
|
|
879
|
+
clearStreaming(conversationId);
|
|
880
|
+
runtimeState.clearSessionState(conversationId).catch(() => { });
|
|
881
|
+
runtimeState.clearTurnState(conversationId).catch(() => { });
|
|
882
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
883
|
+
typingSignals.dispose(conversationId);
|
|
884
|
+
sessions.delete(conversationId);
|
|
885
|
+
}
|
|
674
886
|
async function resetRuntimeSession(session) {
|
|
675
887
|
const conversationId = session.conversationId;
|
|
676
888
|
session.resetRequested = true;
|
|
677
|
-
const
|
|
678
|
-
await
|
|
889
|
+
const droppedPrompts = session.queue.splice(0);
|
|
890
|
+
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
679
891
|
clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
|
|
680
892
|
session.adapter.clearThreadId();
|
|
681
893
|
session.activeSelfContextId = null;
|
|
@@ -693,16 +905,40 @@ export async function main() {
|
|
|
693
905
|
session.lastAcceptedIntent = null;
|
|
694
906
|
session.resetRequested = false;
|
|
695
907
|
}
|
|
696
|
-
session
|
|
697
|
-
|
|
908
|
+
stopVisibleWorkSignal(session);
|
|
909
|
+
clearStreaming(conversationId);
|
|
698
910
|
typingSignals.clear(conversationId).catch(() => { });
|
|
699
|
-
|
|
700
|
-
|
|
911
|
+
writeState(session);
|
|
912
|
+
writeTurn(session);
|
|
913
|
+
}
|
|
914
|
+
function evictOldestIdle() {
|
|
915
|
+
let oldest = null;
|
|
916
|
+
for (const session of sessions.values()) {
|
|
917
|
+
if (session.running)
|
|
918
|
+
continue;
|
|
919
|
+
if (!oldest || session.lastActivity < oldest.lastActivity)
|
|
920
|
+
oldest = session;
|
|
921
|
+
}
|
|
922
|
+
if (oldest) {
|
|
923
|
+
console.error(`[canon-codex] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
|
|
924
|
+
closeSession(oldest.conversationId);
|
|
925
|
+
}
|
|
701
926
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
927
|
+
async function getOrCreateSession(conversationId) {
|
|
928
|
+
knownConversationIds.add(conversationId);
|
|
929
|
+
const existing = sessions.get(conversationId);
|
|
930
|
+
if (existing && !existing.closed) {
|
|
931
|
+
existing.lastActivity = Date.now();
|
|
932
|
+
return existing;
|
|
933
|
+
}
|
|
934
|
+
const pending = pendingSessionCreations.get(conversationId);
|
|
935
|
+
if (pending)
|
|
936
|
+
return pending;
|
|
937
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
938
|
+
evictOldestIdle();
|
|
939
|
+
}
|
|
940
|
+
const creation = (async () => {
|
|
941
|
+
const config = await loadSessionConfig(conversationId, agentId);
|
|
706
942
|
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
707
943
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
708
944
|
const environment = prepareConversationEnvironment({
|
|
@@ -727,123 +963,41 @@ export async function main() {
|
|
|
727
963
|
const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
|
|
728
964
|
? config.effort
|
|
729
965
|
: 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 = {
|
|
966
|
+
const adapter = useAppServer
|
|
967
|
+
? new CodexAppServerAdapter({
|
|
968
|
+
cwd: sessionCwd,
|
|
969
|
+
threadId: storedThreadId,
|
|
970
|
+
codexBin,
|
|
971
|
+
model: policy.model ?? null,
|
|
972
|
+
reasoningEffort: initialEffort,
|
|
973
|
+
sandbox: policy.sandbox,
|
|
974
|
+
approvalPolicy: policy.approvalPolicy,
|
|
975
|
+
addDirs: args['add-dir'] ?? [],
|
|
976
|
+
configOverrides: args.config ?? [],
|
|
977
|
+
fullAuto: policy.fullAuto,
|
|
978
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
979
|
+
dynamicTools: CODEX_APP_DYNAMIC_TOOLS,
|
|
980
|
+
})
|
|
981
|
+
: new CodexConversationAdapter({
|
|
982
|
+
cwd: sessionCwd,
|
|
983
|
+
threadId: storedThreadId,
|
|
984
|
+
codexBin,
|
|
985
|
+
model: policy.model ?? null,
|
|
986
|
+
reasoningEffort: initialEffort,
|
|
987
|
+
sandbox: policy.sandbox,
|
|
988
|
+
approvalPolicy: policy.approvalPolicy,
|
|
989
|
+
codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
|
|
990
|
+
addDirs: args['add-dir'] ?? [],
|
|
991
|
+
configOverrides: args.config ?? [],
|
|
992
|
+
fullAuto: policy.fullAuto,
|
|
993
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
994
|
+
});
|
|
995
|
+
const session = {
|
|
841
996
|
conversationId,
|
|
842
997
|
cwd: sessionCwd,
|
|
843
998
|
environment,
|
|
844
999
|
adapter,
|
|
845
|
-
|
|
846
|
-
activeInput: null,
|
|
1000
|
+
queue: [],
|
|
847
1001
|
running: false,
|
|
848
1002
|
state: buildCodexInitialSessionState({
|
|
849
1003
|
model: policy.model,
|
|
@@ -858,377 +1012,19 @@ export async function main() {
|
|
|
858
1012
|
currentTurnCanUseCodexAppTools: false,
|
|
859
1013
|
activeSelfContextId: null,
|
|
860
1014
|
lastAcceptedIntent: null,
|
|
861
|
-
interruptedTurnKeys: new Set(),
|
|
862
|
-
finalizedTurnKeys: new Set(),
|
|
863
|
-
pendingFinalText: null,
|
|
864
|
-
pendingFinalDelivery: null,
|
|
865
1015
|
resetRequested: false,
|
|
866
1016
|
lastActivity: Date.now(),
|
|
867
1017
|
typingKeepaliveTimer: null,
|
|
868
|
-
idleResetTimer: null,
|
|
869
|
-
finalDeliveryTimer: null,
|
|
870
1018
|
closed: false,
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
runtimeControlErrors: {},
|
|
874
|
-
pendingReply: null,
|
|
1019
|
+
turnLiveText: '',
|
|
1020
|
+
turnBlocks: [],
|
|
875
1021
|
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
1022
|
};
|
|
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
1023
|
sessions.set(conversationId, session);
|
|
1024
|
+
await controlPoller.baseline([conversationId]);
|
|
1229
1025
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
1230
|
-
|
|
1231
|
-
|
|
1026
|
+
writeState(session);
|
|
1027
|
+
writeTurn(session);
|
|
1232
1028
|
return session;
|
|
1233
1029
|
}
|
|
1234
1030
|
catch (error) {
|
|
@@ -1236,58 +1032,41 @@ export async function main() {
|
|
|
1236
1032
|
throw error;
|
|
1237
1033
|
}
|
|
1238
1034
|
})();
|
|
1035
|
+
pendingSessionCreations.set(conversationId, creation);
|
|
1036
|
+
try {
|
|
1037
|
+
return await creation;
|
|
1038
|
+
}
|
|
1039
|
+
finally {
|
|
1040
|
+
pendingSessionCreations.delete(conversationId);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled', canUseCodexAppTools = false) {
|
|
1044
|
+
const nextPrompt = {
|
|
1045
|
+
prompt,
|
|
1046
|
+
intent,
|
|
1047
|
+
sourceMessageId,
|
|
1048
|
+
markAccepted,
|
|
1049
|
+
imagePaths,
|
|
1050
|
+
mediaAddDirs,
|
|
1051
|
+
planMode,
|
|
1052
|
+
artifactRoutingMode,
|
|
1053
|
+
canUseCodexAppTools,
|
|
1054
|
+
};
|
|
1055
|
+
if (toFront) {
|
|
1056
|
+
session.queue.unshift(nextPrompt);
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
session.queue.push(nextPrompt);
|
|
1060
|
+
}
|
|
1061
|
+
session.lastActivity = Date.now();
|
|
1062
|
+
writeTurn(session);
|
|
1063
|
+
void runNextTurn(session);
|
|
1239
1064
|
}
|
|
1240
1065
|
function resolveArtifactRoutingMode(participantContext) {
|
|
1241
1066
|
return participantContext.conversationType === 'direct' && participantContext.isOwner
|
|
1242
1067
|
? 'workspace-generated'
|
|
1243
1068
|
: 'disabled';
|
|
1244
1069
|
}
|
|
1245
|
-
/**
|
|
1246
|
-
* Approval answers read the bridge's DURABLE record (the bridge owns the
|
|
1247
|
-
* single destructive consume loop + the approval_reply fast path); same 1s
|
|
1248
|
-
* cadence as the legacy consume poll.
|
|
1249
|
-
*/
|
|
1250
|
-
async function waitForRuntimeApprovalResponse(input) {
|
|
1251
|
-
const read = async () => port.getHitlState({ requestId: input.approvalId }).catch(() => null);
|
|
1252
|
-
while (Date.now() < input.expiresAt) {
|
|
1253
|
-
const state = await read();
|
|
1254
|
-
if (state?.status === 'submitted') {
|
|
1255
|
-
const answer = (state.answer ?? {});
|
|
1256
|
-
return answer.decision === 'deny'
|
|
1257
|
-
? { decision: 'deny' }
|
|
1258
|
-
: { decision: 'allow', sessionRule: answer.sessionRule };
|
|
1259
|
-
}
|
|
1260
|
-
if (state?.status === 'cancelled' || state?.status === 'timeout') {
|
|
1261
|
-
return { decision: 'deny' };
|
|
1262
|
-
}
|
|
1263
|
-
await sleep(1_000);
|
|
1264
|
-
}
|
|
1265
|
-
return { decision: 'deny' };
|
|
1266
|
-
}
|
|
1267
|
-
async function waitForRuntimeCardResponse(input) {
|
|
1268
|
-
const read = async () => port.getHitlState({ requestId: input.cardId }).catch(() => null);
|
|
1269
|
-
const map = (state) => {
|
|
1270
|
-
if (state?.status === 'submitted') {
|
|
1271
|
-
const answer = (state.answer ?? {});
|
|
1272
|
-
return {
|
|
1273
|
-
status: 'submitted',
|
|
1274
|
-
...(answer.actionId ? { actionId: answer.actionId } : {}),
|
|
1275
|
-
...(answer.values ? { values: answer.values } : {}),
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
if (state?.status === 'cancelled' || state?.status === 'timeout') {
|
|
1279
|
-
return { status: state.status };
|
|
1280
|
-
}
|
|
1281
|
-
return null;
|
|
1282
|
-
};
|
|
1283
|
-
while (Date.now() < input.expiresAt) {
|
|
1284
|
-
const mapped = map(await read());
|
|
1285
|
-
if (mapped)
|
|
1286
|
-
return mapped;
|
|
1287
|
-
await sleep(1_000);
|
|
1288
|
-
}
|
|
1289
|
-
return map(await read()) ?? { status: 'timeout' };
|
|
1290
|
-
}
|
|
1291
1070
|
function runtimeCardRequestPayload(method, params) {
|
|
1292
1071
|
if (method !== 'item/runtimeCard/request'
|
|
1293
1072
|
&& method !== 'runtimeCard/request') {
|
|
@@ -1302,6 +1081,9 @@ export async function main() {
|
|
|
1302
1081
|
const params = request.params;
|
|
1303
1082
|
const expiresAt = Date.now() + 30 * 60_000;
|
|
1304
1083
|
if (request.method === 'item/tool/call' && isCodexAppToolCall(params)) {
|
|
1084
|
+
if (!(session.adapter instanceof CodexAppServerAdapter)) {
|
|
1085
|
+
return deniedCodexAppToolResult('codex_app tools require the Codex app-server transport.');
|
|
1086
|
+
}
|
|
1305
1087
|
if (!session.currentTurnCanUseCodexAppTools) {
|
|
1306
1088
|
return deniedCodexAppToolResult('Only the Canon owner can use codex_app tools.');
|
|
1307
1089
|
}
|
|
@@ -1327,15 +1109,12 @@ export async function main() {
|
|
|
1327
1109
|
let requestCreated = false;
|
|
1328
1110
|
let requestResolved = false;
|
|
1329
1111
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
// (the owner if present, else the sole other member) — the owner is
|
|
1337
|
-
// often not a member of agent-to-user DMs.
|
|
1338
|
-
responseUserId: null,
|
|
1112
|
+
// Built-in card descriptor owns create+poll; codex passes native/turnId
|
|
1113
|
+
// via payload, post-create turn side effects via onCreated. responseUserId
|
|
1114
|
+
// is omitted (default `infer` policy) so the backend targets a reachable
|
|
1115
|
+
// member (owner is often not a member of agent-to-user DMs).
|
|
1116
|
+
const cardResult = await runtimeRequests.request('card', session.conversationId, {
|
|
1117
|
+
card,
|
|
1339
1118
|
native: {
|
|
1340
1119
|
runtime: 'codex',
|
|
1341
1120
|
method: request.method,
|
|
@@ -1346,30 +1125,36 @@ export async function main() {
|
|
|
1346
1125
|
threadId: readString(params, 'threadId') ?? '',
|
|
1347
1126
|
},
|
|
1348
1127
|
},
|
|
1349
|
-
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
session.turnState = 'waiting_input';
|
|
1353
|
-
session.stageTurnBlock({
|
|
1354
|
-
id: `card:${cardId}`,
|
|
1355
|
-
kind: 'input',
|
|
1356
|
-
status: 'pending',
|
|
1357
|
-
title: card.title,
|
|
1358
|
-
summary: card.template ?? 'runtime card',
|
|
1359
|
-
});
|
|
1360
|
-
session.writeTurn();
|
|
1361
|
-
session.stopVisibleWork();
|
|
1362
|
-
session.setStreamingStatus('waiting_input');
|
|
1363
|
-
const response = await waitForRuntimeCardResponse({
|
|
1364
|
-
conversationId: session.conversationId,
|
|
1365
|
-
cardId,
|
|
1128
|
+
turnId: session.currentTurnId ?? undefined,
|
|
1129
|
+
}, {
|
|
1130
|
+
requestId: cardId,
|
|
1366
1131
|
expiresAt,
|
|
1132
|
+
onCreated: () => {
|
|
1133
|
+
requestCreated = true;
|
|
1134
|
+
session.turnState = 'waiting_input';
|
|
1135
|
+
markTurnProgress(session);
|
|
1136
|
+
upsertTurnBlock(session, {
|
|
1137
|
+
id: `card:${cardId}`,
|
|
1138
|
+
kind: 'input',
|
|
1139
|
+
status: 'pending',
|
|
1140
|
+
title: card.title,
|
|
1141
|
+
summary: card.template ?? 'runtime card',
|
|
1142
|
+
});
|
|
1143
|
+
writeTurn(session);
|
|
1144
|
+
stopVisibleWorkSignal(session);
|
|
1145
|
+
writeCodexStreaming(session, null, 'waiting_input');
|
|
1146
|
+
},
|
|
1367
1147
|
});
|
|
1148
|
+
const response = cardResult.status === 'submitted'
|
|
1149
|
+
? {
|
|
1150
|
+
status: 'submitted',
|
|
1151
|
+
...(cardResult.actionId ? { actionId: cardResult.actionId } : {}),
|
|
1152
|
+
...(cardResult.values ? { values: cardResult.values } : {}),
|
|
1153
|
+
}
|
|
1154
|
+
: { status: cardResult.status };
|
|
1368
1155
|
requestResolved = true;
|
|
1369
1156
|
const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
|
|
1370
|
-
await
|
|
1371
|
-
conversationId: session.conversationId,
|
|
1372
|
-
text: outcome.text,
|
|
1157
|
+
await sendMessageWithRetry(client, session.conversationId, outcome.text, {
|
|
1373
1158
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
|
|
1374
1159
|
metadata: {
|
|
1375
1160
|
...outcome.metadata,
|
|
@@ -1378,27 +1163,25 @@ export async function main() {
|
|
|
1378
1163
|
replyBehavior: 'suppress_auto_reply',
|
|
1379
1164
|
},
|
|
1380
1165
|
});
|
|
1381
|
-
session.
|
|
1382
|
-
id: `card:${cardId}`,
|
|
1383
|
-
kind: 'input',
|
|
1384
|
-
status: 'completed',
|
|
1385
|
-
summary: `Card ${response.status}`,
|
|
1386
|
-
});
|
|
1166
|
+
completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
|
|
1387
1167
|
if (session.turnState === 'waiting_input') {
|
|
1388
1168
|
session.turnState = 'thinking';
|
|
1389
|
-
session
|
|
1390
|
-
session
|
|
1391
|
-
session
|
|
1169
|
+
markTurnProgress(session);
|
|
1170
|
+
writeTurn(session);
|
|
1171
|
+
startVisibleWorkSignal(session);
|
|
1172
|
+
writeCodexStreaming(session, null, 'thinking');
|
|
1392
1173
|
}
|
|
1393
1174
|
return response;
|
|
1394
1175
|
}
|
|
1395
1176
|
catch (error) {
|
|
1396
1177
|
if (requestCreated && !requestResolved) {
|
|
1397
|
-
await
|
|
1398
|
-
const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
|
|
1399
|
-
await port.sendMessage({
|
|
1178
|
+
await client.consumeRuntimeCardResponse({
|
|
1400
1179
|
conversationId: session.conversationId,
|
|
1401
|
-
|
|
1180
|
+
cardId,
|
|
1181
|
+
cancel: true,
|
|
1182
|
+
}).catch(() => null);
|
|
1183
|
+
const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
|
|
1184
|
+
await sendMessageWithRetry(client, session.conversationId, outcome.text, {
|
|
1402
1185
|
messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
|
|
1403
1186
|
metadata: {
|
|
1404
1187
|
...outcome.metadata,
|
|
@@ -1416,19 +1199,15 @@ export async function main() {
|
|
|
1416
1199
|
const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
|
|
1417
1200
|
const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
|
|
1418
1201
|
const inputId = readString(params, 'itemId') ?? requestId;
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1202
|
+
// Built-in input descriptor owns create+poll; default `owner` responder
|
|
1203
|
+
// policy resolves to ctx.ownerId (matching the former responseUserId).
|
|
1204
|
+
const response = await runtimeRequests.request('input', session.conversationId, {
|
|
1422
1205
|
kind: 'clarify',
|
|
1423
|
-
expiresAt: new Date(expiresAt).toISOString(),
|
|
1424
|
-
...(ownerId ? { responseUserId: ownerId } : {}),
|
|
1425
1206
|
title: 'Codex needs input',
|
|
1426
1207
|
prompt: questions?.length
|
|
1427
1208
|
? 'Codex needs your input to continue.'
|
|
1428
1209
|
: 'Codex needs input.',
|
|
1429
|
-
...(questions
|
|
1430
|
-
? { questions: questions }
|
|
1431
|
-
: { questions: [] }),
|
|
1210
|
+
...(questions ? { questions } : {}),
|
|
1432
1211
|
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
1433
1212
|
native: {
|
|
1434
1213
|
runtime: 'codex',
|
|
@@ -1440,9 +1219,8 @@ export async function main() {
|
|
|
1440
1219
|
threadId: readString(params, 'threadId') ?? '',
|
|
1441
1220
|
},
|
|
1442
1221
|
},
|
|
1443
|
-
|
|
1444
|
-
});
|
|
1445
|
-
const response = await session.waitForRuntimeInputResponse({ inputId, expiresAt });
|
|
1222
|
+
turnId: session.currentTurnId ?? undefined,
|
|
1223
|
+
}, { requestId: inputId, expiresAt });
|
|
1446
1224
|
return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
|
|
1447
1225
|
}
|
|
1448
1226
|
const mappedApproval = mapCodexAppServerApprovalRequest({
|
|
@@ -1451,35 +1229,12 @@ export async function main() {
|
|
|
1451
1229
|
});
|
|
1452
1230
|
if (mappedApproval) {
|
|
1453
1231
|
const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
risk: mappedApproval.risk ?? 'normal',
|
|
1461
|
-
...(mappedApproval.riskLevel ? { riskLevel: mappedApproval.riskLevel } : {}),
|
|
1462
|
-
native: {
|
|
1463
|
-
...mappedApproval.native,
|
|
1464
|
-
requestId,
|
|
1465
|
-
method: request.method,
|
|
1466
|
-
},
|
|
1467
|
-
...(mappedApproval.details
|
|
1468
|
-
? { details: mappedApproval.details }
|
|
1469
|
-
: {}),
|
|
1470
|
-
...(mappedApproval.diff
|
|
1471
|
-
? { diff: mappedApproval.diff }
|
|
1472
|
-
: {}),
|
|
1473
|
-
...(ownerId ? { responseUserId: ownerId } : {}),
|
|
1474
|
-
allowSessionRule: true,
|
|
1475
|
-
expiresAt: new Date(expiresAt).toISOString(),
|
|
1476
|
-
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
1477
|
-
});
|
|
1478
|
-
const response = await waitForRuntimeApprovalResponse({
|
|
1479
|
-
conversationId: session.conversationId,
|
|
1480
|
-
approvalId,
|
|
1481
|
-
expiresAt,
|
|
1482
|
-
});
|
|
1232
|
+
// Approval descriptor's create authors the server request (responder = ctx.ownerId).
|
|
1233
|
+
const response = await runtimeRequests.request('approval', session.conversationId, {
|
|
1234
|
+
...mappedApproval,
|
|
1235
|
+
native: { ...mappedApproval.native, requestId, method: request.method },
|
|
1236
|
+
turnId: session.currentTurnId ?? undefined,
|
|
1237
|
+
}, { requestId: approvalId, expiresAt });
|
|
1483
1238
|
if (request.method === 'item/permissions/requestApproval') {
|
|
1484
1239
|
return response.decision === 'allow'
|
|
1485
1240
|
? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
|
|
@@ -1509,12 +1264,7 @@ export async function main() {
|
|
|
1509
1264
|
: decision === 'reject'
|
|
1510
1265
|
? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
|
|
1511
1266
|
: `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
|
-
}));
|
|
1267
|
+
enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve', 'disabled', input.isOwner);
|
|
1518
1268
|
return;
|
|
1519
1269
|
}
|
|
1520
1270
|
let materialized = [];
|
|
@@ -1532,9 +1282,11 @@ export async function main() {
|
|
|
1532
1282
|
const renderedContent = renderInboundContent(input.message, materialized);
|
|
1533
1283
|
const turnMetadata = normalizeTurnMetadata(input.message.metadata);
|
|
1534
1284
|
const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
|
|
1535
|
-
const planCommand =
|
|
1536
|
-
?
|
|
1537
|
-
|
|
1285
|
+
const planCommand = useAppServer
|
|
1286
|
+
? requestedPlanMode
|
|
1287
|
+
? { planMode: true, content: renderedContent }
|
|
1288
|
+
: parsePlanCommand(renderedContent)
|
|
1289
|
+
: { planMode: false, content: renderedContent };
|
|
1538
1290
|
const content = planCommand.content;
|
|
1539
1291
|
const hydrated = await loadHydratedInboundContext({
|
|
1540
1292
|
conversationId: input.conversationId,
|
|
@@ -1585,9 +1337,7 @@ export async function main() {
|
|
|
1585
1337
|
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
1586
1338
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
1587
1339
|
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}`,
|
|
1340
|
+
await sendMessageWithRetryChunked(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
|
|
1591
1341
|
messageId: `codex-start-failed-${input.message.id}`,
|
|
1592
1342
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
1593
1343
|
metadata: {
|
|
@@ -1610,36 +1360,19 @@ export async function main() {
|
|
|
1610
1360
|
message: input.message,
|
|
1611
1361
|
});
|
|
1612
1362
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
1363
|
+
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
1364
|
+
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
|
|
1613
1365
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
1366
|
+
await session.adapter.interrupt().catch(() => { });
|
|
1367
|
+
clearStreaming(input.conversationId);
|
|
1368
|
+
typingSignals.clear(input.conversationId).catch(() => { });
|
|
1369
|
+
return;
|
|
1614
1370
|
}
|
|
1615
1371
|
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
|
-
}));
|
|
1372
|
+
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode, participantContext.isOwner);
|
|
1628
1373
|
}
|
|
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],
|
|
1374
|
+
function sendTurnArtifactFile(session, file) {
|
|
1375
|
+
return sendMediaFileMessage(client, session.conversationId, file.path, '', {
|
|
1643
1376
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1644
1377
|
metadata: {
|
|
1645
1378
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
@@ -1679,6 +1412,296 @@ export async function main() {
|
|
|
1679
1412
|
console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
|
|
1680
1413
|
}
|
|
1681
1414
|
}
|
|
1415
|
+
async function runNextTurn(session) {
|
|
1416
|
+
if (session.running || session.closed)
|
|
1417
|
+
return;
|
|
1418
|
+
const nextTurn = session.queue.shift();
|
|
1419
|
+
if (!nextTurn)
|
|
1420
|
+
return;
|
|
1421
|
+
session.running = true;
|
|
1422
|
+
session.state.lastError = undefined;
|
|
1423
|
+
session.state.state = 'running';
|
|
1424
|
+
session.currentTurnId = randomUUID();
|
|
1425
|
+
session.turnLiveText = '';
|
|
1426
|
+
session.turnBlocks = [];
|
|
1427
|
+
session.turnCommandBlocks = createCommandBlockTracker();
|
|
1428
|
+
session.currentTurnOpenedAt = Date.now();
|
|
1429
|
+
session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
|
|
1430
|
+
session.currentTurnCanUseCodexAppTools = nextTurn.canUseCodexAppTools === true;
|
|
1431
|
+
session.lastAcceptedIntent = nextTurn.intent;
|
|
1432
|
+
session.turnState = 'thinking';
|
|
1433
|
+
session.lastActivity = Date.now();
|
|
1434
|
+
await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
|
|
1435
|
+
writeState(session);
|
|
1436
|
+
writeTurn(session);
|
|
1437
|
+
startVisibleWorkSignal(session);
|
|
1438
|
+
// Status-only seed: 'thinking' renders as a working filament row on the
|
|
1439
|
+
// clients; text here would be bubbled as speech (v4 register rule).
|
|
1440
|
+
writeCodexStreaming(session, '', 'thinking');
|
|
1441
|
+
let artifactBaseline = null;
|
|
1442
|
+
let artifactsRouted = false;
|
|
1443
|
+
const routeArtifactsOnce = async () => {
|
|
1444
|
+
if (artifactsRouted)
|
|
1445
|
+
return;
|
|
1446
|
+
artifactsRouted = true;
|
|
1447
|
+
if (nextTurn.artifactRoutingMode === 'workspace-generated') {
|
|
1448
|
+
await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
try {
|
|
1452
|
+
const turnId = session.currentTurnId ?? randomUUID();
|
|
1453
|
+
session.currentTurnId = turnId;
|
|
1454
|
+
let turnPrompt = nextTurn.prompt;
|
|
1455
|
+
if (nextTurn.artifactRoutingMode === 'workspace-generated') {
|
|
1456
|
+
artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
|
|
1457
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
|
|
1458
|
+
return null;
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
|
|
1462
|
+
if (modelGuard) {
|
|
1463
|
+
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
1464
|
+
}
|
|
1465
|
+
const turnImagePaths = nextTurn.imagePaths ?? [];
|
|
1466
|
+
const turnMediaAddDirs = nextTurn.mediaAddDirs ?? [];
|
|
1467
|
+
const handleCodexEvent = (event) => {
|
|
1468
|
+
session.lastActivity = Date.now();
|
|
1469
|
+
if (event.type === 'thread.started') {
|
|
1470
|
+
if (session.resetRequested) {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
|
|
1474
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (event.type === 'skills.changed') {
|
|
1478
|
+
void refreshCodexSkillInventory(true).then(() => publishRuntimeHeartbeat());
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
if (event.type === 'message') {
|
|
1482
|
+
session.turnState = 'streaming';
|
|
1483
|
+
markTurnProgress(session);
|
|
1484
|
+
writeTurn(session);
|
|
1485
|
+
stopVisibleWorkSignal(session);
|
|
1486
|
+
upsertCodexTextSegment(session, event);
|
|
1487
|
+
writeCodexStreaming(session, null, 'streaming');
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (event.type === 'plan.updated') {
|
|
1491
|
+
session.turnState = 'streaming';
|
|
1492
|
+
markTurnProgress(session);
|
|
1493
|
+
writeTurn(session);
|
|
1494
|
+
stopVisibleWorkSignal(session);
|
|
1495
|
+
upsertTurnBlock(session, {
|
|
1496
|
+
id: `plan:${session.currentTurnId}`,
|
|
1497
|
+
kind: 'plan',
|
|
1498
|
+
status: 'running',
|
|
1499
|
+
title: 'Plan',
|
|
1500
|
+
text: event.text,
|
|
1501
|
+
});
|
|
1502
|
+
writeCodexStreaming(session, event.text, 'streaming');
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (event.type === 'waiting') {
|
|
1506
|
+
session.turnState = 'waiting_input';
|
|
1507
|
+
markTurnProgress(session);
|
|
1508
|
+
writeTurn(session);
|
|
1509
|
+
stopVisibleWorkSignal(session);
|
|
1510
|
+
writeCodexStreaming(session, null, 'waiting_input');
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
if (event.type === 'command.started') {
|
|
1514
|
+
session.turnState = 'tool';
|
|
1515
|
+
markTurnProgress(session);
|
|
1516
|
+
writeTurn(session);
|
|
1517
|
+
startVisibleWorkSignal(session);
|
|
1518
|
+
const blockId = beginCommandBlock(session.turnCommandBlocks, {
|
|
1519
|
+
turnId: session.currentTurnId,
|
|
1520
|
+
command: event.command,
|
|
1521
|
+
itemId: event.itemId,
|
|
1522
|
+
});
|
|
1523
|
+
upsertTurnBlock(session, {
|
|
1524
|
+
id: blockId,
|
|
1525
|
+
kind: 'tool',
|
|
1526
|
+
status: 'running',
|
|
1527
|
+
title: summarizeCommand(event.command),
|
|
1528
|
+
summary: 'Command running',
|
|
1529
|
+
});
|
|
1530
|
+
writeCodexStreaming(session, null, 'tool');
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (event.type === 'command.completed') {
|
|
1534
|
+
const blockId = claimCommandBlock(session.turnCommandBlocks, {
|
|
1535
|
+
turnId: session.currentTurnId,
|
|
1536
|
+
command: event.command,
|
|
1537
|
+
itemId: event.itemId,
|
|
1538
|
+
});
|
|
1539
|
+
completeTurnBlock(session, blockId, 'Command completed');
|
|
1540
|
+
if (session.turnState === 'tool') {
|
|
1541
|
+
session.turnState = 'thinking';
|
|
1542
|
+
markTurnProgress(session);
|
|
1543
|
+
writeTurn(session);
|
|
1544
|
+
startVisibleWorkSignal(session);
|
|
1545
|
+
writeCodexStreaming(session, null, 'thinking');
|
|
1546
|
+
}
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (event.type === 'turn.completed') {
|
|
1550
|
+
// Codex reports per-turn token usage but no context window, so the
|
|
1551
|
+
// meter publishes tokens only (input + cached covers the full
|
|
1552
|
+
// prompt context of the completed turn).
|
|
1553
|
+
const totalTokens = (event.usage?.input_tokens ?? 0)
|
|
1554
|
+
+ (event.usage?.cached_input_tokens ?? 0)
|
|
1555
|
+
+ (event.usage?.output_tokens ?? 0);
|
|
1556
|
+
if (totalTokens > 0) {
|
|
1557
|
+
session.state.contextUsage = { totalTokens };
|
|
1558
|
+
}
|
|
1559
|
+
writeState(session);
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
const logCodexLine = (line) => {
|
|
1563
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
|
|
1564
|
+
};
|
|
1565
|
+
const clearStoredThread = () => {
|
|
1566
|
+
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
1567
|
+
session.adapter.clearThreadId();
|
|
1568
|
+
};
|
|
1569
|
+
const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
|
|
1570
|
+
planMode: nextTurn.planMode,
|
|
1571
|
+
onServerRequest: (request) => handleCodexServerRequest(session, request),
|
|
1572
|
+
});
|
|
1573
|
+
let result = await runTurnOnce();
|
|
1574
|
+
if (!result.interrupted
|
|
1575
|
+
&& !result.finalMessage
|
|
1576
|
+
&& result.exitCode
|
|
1577
|
+
&& result.exitCode !== 0
|
|
1578
|
+
&& isRecoverableCodexThreadError(result.errorText)) {
|
|
1579
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Stored thread was not found; clearing and retrying once`);
|
|
1580
|
+
clearStoredThread();
|
|
1581
|
+
result = await runTurnOnce();
|
|
1582
|
+
}
|
|
1583
|
+
if (result.threadId && !session.resetRequested) {
|
|
1584
|
+
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
1585
|
+
}
|
|
1586
|
+
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1587
|
+
await routeArtifactsOnce();
|
|
1588
|
+
// Route plan-CREATE through the unified spine (`/runtime-plan/request`):
|
|
1589
|
+
// seeds the server-owned pending node/attention/state and authors the
|
|
1590
|
+
// `plan_approval` card. Resolution is UNCHANGED — Codex still re-queues a
|
|
1591
|
+
// fresh turn off the server-authored `plan_approval_reply`.
|
|
1592
|
+
await client.createRuntimePlanRequest({
|
|
1593
|
+
conversationId: session.conversationId,
|
|
1594
|
+
planId: session.currentTurnId ?? randomUUID(),
|
|
1595
|
+
title: 'Codex Plan',
|
|
1596
|
+
body: result.finalMessage,
|
|
1597
|
+
...(ownerId ? { responseUserId: ownerId } : {}),
|
|
1598
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
1599
|
+
});
|
|
1600
|
+
await handoffFinalMessage(session.conversationId);
|
|
1601
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
|
|
1602
|
+
}
|
|
1603
|
+
else if (!result.interrupted && result.finalMessage) {
|
|
1604
|
+
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
1605
|
+
clearStoredThread();
|
|
1606
|
+
}
|
|
1607
|
+
await routeArtifactsOnce();
|
|
1608
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
1609
|
+
await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
|
|
1610
|
+
messageId: buildCodexMessageId(session, 'final'),
|
|
1611
|
+
...(session.activeSelfContextId
|
|
1612
|
+
? { selfContextId: session.activeSelfContextId }
|
|
1613
|
+
: {}),
|
|
1614
|
+
metadata: {
|
|
1615
|
+
turnId: session.currentTurnId,
|
|
1616
|
+
turnSemantics: 'turn_complete',
|
|
1617
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1618
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
1619
|
+
},
|
|
1620
|
+
});
|
|
1621
|
+
await handoffFinalMessage(session.conversationId);
|
|
1622
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
|
|
1623
|
+
}
|
|
1624
|
+
else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
|
|
1625
|
+
await routeArtifactsOnce();
|
|
1626
|
+
const userVisibleError = formatCodexTurnFailure(result.errorText);
|
|
1627
|
+
session.state.lastError = userVisibleError;
|
|
1628
|
+
writeState(session);
|
|
1629
|
+
if (result.errorText) {
|
|
1630
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
1631
|
+
}
|
|
1632
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
1633
|
+
await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
|
|
1634
|
+
messageId: buildCodexMessageId(session, 'error'),
|
|
1635
|
+
...(session.activeSelfContextId
|
|
1636
|
+
? { selfContextId: session.activeSelfContextId }
|
|
1637
|
+
: {}),
|
|
1638
|
+
metadata: {
|
|
1639
|
+
turnId: session.currentTurnId,
|
|
1640
|
+
turnSemantics: 'turn_complete',
|
|
1641
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1642
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
await handoffFinalMessage(session.conversationId);
|
|
1646
|
+
}
|
|
1647
|
+
else if (!result.interrupted) {
|
|
1648
|
+
await routeArtifactsOnce();
|
|
1649
|
+
await handoffFinalMessage(session.conversationId);
|
|
1650
|
+
}
|
|
1651
|
+
else if (result.interrupted) {
|
|
1652
|
+
session.turnState = 'interrupted';
|
|
1653
|
+
writeTurn(session);
|
|
1654
|
+
stopVisibleWorkSignal(session);
|
|
1655
|
+
clearStreaming(session.conversationId);
|
|
1656
|
+
typingSignals.clear(session.conversationId).catch(() => { });
|
|
1657
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
catch (error) {
|
|
1661
|
+
const message = error instanceof ExecutionEnvironmentError
|
|
1662
|
+
? error.userMessage
|
|
1663
|
+
: error instanceof CanonApiError
|
|
1664
|
+
? `The Codex host completed the turn, but Canon could not deliver the reply: ${error.message}`
|
|
1665
|
+
: `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
|
|
1666
|
+
session.state.lastError = message;
|
|
1667
|
+
writeState(session);
|
|
1668
|
+
await routeArtifactsOnce();
|
|
1669
|
+
await sendMessageWithRetryChunked(client, session.conversationId, message, {
|
|
1670
|
+
messageId: buildCodexMessageId(session, 'failure'),
|
|
1671
|
+
...(session.activeSelfContextId
|
|
1672
|
+
? { selfContextId: session.activeSelfContextId }
|
|
1673
|
+
: {}),
|
|
1674
|
+
metadata: {
|
|
1675
|
+
turnId: session.currentTurnId,
|
|
1676
|
+
turnSemantics: 'turn_complete',
|
|
1677
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1678
|
+
},
|
|
1679
|
+
}).catch(() => { });
|
|
1680
|
+
await handoffFinalMessage(session.conversationId);
|
|
1681
|
+
if (error instanceof Error && isRecoverableCodexThreadError(error.message)) {
|
|
1682
|
+
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
1683
|
+
}
|
|
1684
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
1685
|
+
}
|
|
1686
|
+
finally {
|
|
1687
|
+
stopVisibleWorkSignal(session);
|
|
1688
|
+
session.running = false;
|
|
1689
|
+
session.state.state = 'idle';
|
|
1690
|
+
session.turnState = 'idle';
|
|
1691
|
+
session.currentTurnId = null;
|
|
1692
|
+
session.currentTurnOpenedAt = null;
|
|
1693
|
+
session.currentTurnUpdatedAt = null;
|
|
1694
|
+
session.currentTurnCanUseCodexAppTools = false;
|
|
1695
|
+
session.lastAcceptedIntent = null;
|
|
1696
|
+
session.resetRequested = false;
|
|
1697
|
+
session.lastActivity = Date.now();
|
|
1698
|
+
writeState(session);
|
|
1699
|
+
writeTurn(session);
|
|
1700
|
+
if (session.queue.length > 0) {
|
|
1701
|
+
void runNextTurn(session);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1682
1705
|
let streamConnected = false;
|
|
1683
1706
|
const hostAvailableExecutionModes = [
|
|
1684
1707
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1709,14 +1732,16 @@ export async function main() {
|
|
|
1709
1732
|
permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
1710
1733
|
defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
1711
1734
|
presentation: runtimePresentation,
|
|
1712
|
-
supportsPlanMode:
|
|
1713
|
-
supportsCompact:
|
|
1714
|
-
supportsRichCards:
|
|
1735
|
+
supportsPlanMode: useAppServer,
|
|
1736
|
+
supportsCompact: useAppServer,
|
|
1737
|
+
supportsRichCards: useAppServer,
|
|
1715
1738
|
skills: codexSkills,
|
|
1716
1739
|
}),
|
|
1717
1740
|
});
|
|
1718
1741
|
let runtimeDescriptor = buildCurrentRuntimeDescriptor();
|
|
1719
1742
|
async function refreshCodexSkillInventory(forceReload = false) {
|
|
1743
|
+
if (!useAppServer)
|
|
1744
|
+
return;
|
|
1720
1745
|
const probe = new CodexAppServerAdapter({
|
|
1721
1746
|
cwd: workingDir,
|
|
1722
1747
|
codexBin,
|
|
@@ -1745,7 +1770,7 @@ export async function main() {
|
|
|
1745
1770
|
if (modelGuard) {
|
|
1746
1771
|
session.state.lastError = modelGuard;
|
|
1747
1772
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
|
|
1748
|
-
|
|
1773
|
+
writeState(session);
|
|
1749
1774
|
// The poller consumes the node; skip effort handling for this pass,
|
|
1750
1775
|
// matching the legacy loop.
|
|
1751
1776
|
return;
|
|
@@ -1753,42 +1778,58 @@ export async function main() {
|
|
|
1753
1778
|
session.adapter.setModel(control.model);
|
|
1754
1779
|
session.state.model = control.model;
|
|
1755
1780
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
|
|
1756
|
-
|
|
1781
|
+
writeState(session);
|
|
1757
1782
|
}
|
|
1758
1783
|
if (control.permissionMode) {
|
|
1759
1784
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
|
|
1760
1785
|
// Convergence contract: a consumed session control must always be
|
|
1761
1786
|
// answered. Re-publish the currently applied state so clients settle on
|
|
1762
1787
|
// the authoritative value instead of holding the composer until timeout.
|
|
1763
|
-
|
|
1788
|
+
writeState(session);
|
|
1764
1789
|
}
|
|
1765
1790
|
if (control.effort) {
|
|
1766
1791
|
if (CODEX_EFFORT_VALUES.has(control.effort)) {
|
|
1767
1792
|
session.adapter.setReasoningEffort(control.effort);
|
|
1768
1793
|
session.state.effort = control.effort;
|
|
1769
1794
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
|
|
1770
|
-
|
|
1795
|
+
writeState(session);
|
|
1771
1796
|
}
|
|
1772
1797
|
else {
|
|
1773
1798
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
|
|
1774
1799
|
// Same contract: ignored values still get an authoritative re-publish.
|
|
1775
|
-
|
|
1800
|
+
writeState(session);
|
|
1776
1801
|
}
|
|
1777
1802
|
}
|
|
1778
1803
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1804
|
+
async function handleControlSignal(event) {
|
|
1805
|
+
const { conversationId, type } = event;
|
|
1806
|
+
const session = sessions.get(conversationId);
|
|
1807
|
+
// No live session: dedupe already advanced, but the node stays in place,
|
|
1808
|
+
// matching the legacy loop.
|
|
1809
|
+
if (!session || session.closed)
|
|
1810
|
+
return { consume: false };
|
|
1811
|
+
if (type === 'new_session') {
|
|
1812
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
|
|
1813
|
+
await resetRuntimeSession(session);
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
if (!session.running && (type !== 'stop_and_drop' || session.queue.length === 0)) {
|
|
1817
|
+
// Nothing to interrupt or drop — just consume the signal.
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
|
|
1821
|
+
if (type === 'stop_and_drop') {
|
|
1822
|
+
const droppedPrompts = session.queue.splice(0);
|
|
1823
|
+
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
1824
|
+
}
|
|
1825
|
+
if (session.running) {
|
|
1826
|
+
await session.adapter.interrupt();
|
|
1827
|
+
}
|
|
1828
|
+
session.turnState = 'interrupted';
|
|
1829
|
+
writeTurn(session);
|
|
1830
|
+
clearStreaming(conversationId);
|
|
1831
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
1832
|
+
}
|
|
1792
1833
|
async function handleControlPrimitive(event) {
|
|
1793
1834
|
const { conversationId, value } = event;
|
|
1794
1835
|
const primitiveId = typeof value.id === 'string' ? value.id : '';
|
|
@@ -1801,30 +1842,39 @@ export async function main() {
|
|
|
1801
1842
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: no live Codex session`);
|
|
1802
1843
|
return;
|
|
1803
1844
|
}
|
|
1845
|
+
if (!(session.adapter instanceof CodexAppServerAdapter)) {
|
|
1846
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Cannot compact context: compact is only available for Codex app-server sessions`);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1804
1849
|
try {
|
|
1805
1850
|
await session.adapter.compactThread();
|
|
1806
1851
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Compact requested`);
|
|
1807
|
-
|
|
1852
|
+
writeState(session);
|
|
1808
1853
|
}
|
|
1809
1854
|
catch (error) {
|
|
1810
1855
|
const message = error instanceof Error ? error.message : String(error);
|
|
1811
1856
|
session.state.lastError = `Could not compact Codex context: ${message}`;
|
|
1812
1857
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${session.state.lastError}`);
|
|
1813
|
-
|
|
1858
|
+
writeState(session);
|
|
1814
1859
|
}
|
|
1815
1860
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1861
|
+
const controlPoller = createCodexControlPoller({
|
|
1862
|
+
rtdb,
|
|
1863
|
+
agentId,
|
|
1864
|
+
conversationIds: () => sessions.keys(),
|
|
1865
|
+
hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
|
|
1866
|
+
&& (session.running || session.queue.length > 0 || session.turnState === 'waiting_input')),
|
|
1821
1867
|
onSessionControl: ({ conversationId, control }) => {
|
|
1822
1868
|
applySessionControl(conversationId, control);
|
|
1823
1869
|
},
|
|
1824
1870
|
onSignal: handleControlSignal,
|
|
1825
1871
|
onPrimitive: handleControlPrimitive,
|
|
1826
|
-
onError: (error
|
|
1827
|
-
|
|
1872
|
+
onError: (error) => {
|
|
1873
|
+
// The legacy loop ignored transient RTDB failures; keep read/consume
|
|
1874
|
+
// errors quiet but surface handler failures.
|
|
1875
|
+
if (error.scope !== 'handler')
|
|
1876
|
+
return;
|
|
1877
|
+
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
1878
|
},
|
|
1829
1879
|
});
|
|
1830
1880
|
let publishRuntimeDetailsInFlight = false;
|
|
@@ -1837,7 +1887,7 @@ export async function main() {
|
|
|
1837
1887
|
});
|
|
1838
1888
|
if (!streamConnected)
|
|
1839
1889
|
return;
|
|
1840
|
-
await publishAgentRuntime(
|
|
1890
|
+
await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
|
|
1841
1891
|
console.error('[canon-codex] Failed to publish agent runtime:', error);
|
|
1842
1892
|
});
|
|
1843
1893
|
if (publishRuntimeDetailsInFlight)
|
|
@@ -1847,14 +1897,15 @@ export async function main() {
|
|
|
1847
1897
|
await refreshKnownConversationIds().catch((error) => {
|
|
1848
1898
|
console.error('[canon-codex] Failed to refresh known conversations:', error);
|
|
1849
1899
|
});
|
|
1850
|
-
await
|
|
1900
|
+
await publishHostSessionSnapshots({
|
|
1851
1901
|
conversationIds: Array.from(knownConversationIds),
|
|
1902
|
+
agentId,
|
|
1852
1903
|
clientType: 'codex',
|
|
1853
1904
|
runtime: runtimeDescriptor,
|
|
1854
|
-
workspaceOptions
|
|
1905
|
+
workspaceOptions,
|
|
1855
1906
|
defaultCwd: workingDir,
|
|
1856
|
-
extraSessionConfigFields:
|
|
1857
|
-
liveSessionConfigByConversation:
|
|
1907
|
+
extraSessionConfigFields: CODEX_SESSION_CONFIG_FIELDS,
|
|
1908
|
+
liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
|
|
1858
1909
|
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
1859
1910
|
return [
|
|
1860
1911
|
session.conversationId,
|
|
@@ -1883,16 +1934,24 @@ export async function main() {
|
|
|
1883
1934
|
const payload = {
|
|
1884
1935
|
descriptor,
|
|
1885
1936
|
surfaceMode: 'host',
|
|
1937
|
+
// The exec --json transport cannot block on approvals — without a
|
|
1938
|
+
// strip-level warning a user can believe they have an approval
|
|
1939
|
+
// gate they do not have.
|
|
1940
|
+
...(useAppServer
|
|
1941
|
+
? {}
|
|
1942
|
+
: { warning: "Approvals can't block on this Codex CLI — update Codex to enable the app-server transport and approval gates." }),
|
|
1886
1943
|
statusItems: [
|
|
1887
1944
|
{
|
|
1888
1945
|
id: 'transport',
|
|
1889
1946
|
label: 'Transport',
|
|
1890
|
-
value: 'app-server',
|
|
1947
|
+
value: useAppServer ? 'app-server' : 'exec --json',
|
|
1891
1948
|
},
|
|
1892
1949
|
{
|
|
1893
1950
|
id: 'streaming',
|
|
1894
1951
|
label: 'Live output',
|
|
1895
|
-
value:
|
|
1952
|
+
value: useAppServer
|
|
1953
|
+
? 'Plans, questions, approvals, tools, and message deltas'
|
|
1954
|
+
: 'Thinking, tools, and completed-message previews',
|
|
1896
1955
|
},
|
|
1897
1956
|
{
|
|
1898
1957
|
id: 'codex-cli',
|
|
@@ -1903,7 +1962,8 @@ export async function main() {
|
|
|
1903
1962
|
{
|
|
1904
1963
|
id: 'nativeActions',
|
|
1905
1964
|
label: 'Native actions',
|
|
1906
|
-
value: 'Enabled',
|
|
1965
|
+
value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
|
|
1966
|
+
...(useAppServer ? {} : { tone: 'warning' }),
|
|
1907
1967
|
},
|
|
1908
1968
|
{
|
|
1909
1969
|
id: 'mediaOut',
|
|
@@ -1922,13 +1982,12 @@ export async function main() {
|
|
|
1922
1982
|
fallbackReason: resolveExecutionFallbackReason(session?.environment),
|
|
1923
1983
|
},
|
|
1924
1984
|
notes: [
|
|
1925
|
-
|
|
1985
|
+
useAppServer
|
|
1986
|
+
? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
|
|
1987
|
+
: '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
1988
|
],
|
|
1927
1989
|
};
|
|
1928
|
-
await
|
|
1929
|
-
conversationId,
|
|
1930
|
-
presentation: payload,
|
|
1931
|
-
});
|
|
1990
|
+
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
1932
1991
|
})).catch((error) => {
|
|
1933
1992
|
console.error('[canon-codex] Failed to publish runtime info:', error);
|
|
1934
1993
|
});
|
|
@@ -1937,157 +1996,122 @@ export async function main() {
|
|
|
1937
1996
|
publishRuntimeDetailsInFlight = false;
|
|
1938
1997
|
}
|
|
1939
1998
|
};
|
|
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') {
|
|
1999
|
+
const stream = new CanonStream({
|
|
2000
|
+
apiKey,
|
|
2001
|
+
agentId,
|
|
2002
|
+
handler: {
|
|
2003
|
+
onMessage: (payload) => {
|
|
2004
|
+
const message = payload.message;
|
|
2005
|
+
if (message.senderId === agentId)
|
|
2006
|
+
return;
|
|
2007
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2008
|
+
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
void enqueueInboundMessage({
|
|
2012
|
+
conversationId: payload.conversationId,
|
|
2013
|
+
message,
|
|
2014
|
+
senderName: message.senderName || message.senderId,
|
|
2015
|
+
isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
|
|
2016
|
+
behavior: payload.behavior,
|
|
2017
|
+
activeSelfContextId: payload.activeSelfContextId,
|
|
2018
|
+
selfContexts: payload.selfContexts,
|
|
2019
|
+
provenance: payload.provenance,
|
|
2020
|
+
turnDispatch: payload.turnDispatch,
|
|
2021
|
+
});
|
|
2022
|
+
if (message.id) {
|
|
2023
|
+
saveRuntimeSessionState(runtimeId, {
|
|
2024
|
+
conversationId: payload.conversationId,
|
|
2025
|
+
baseCwd: workingDir,
|
|
2026
|
+
lastInboundMessageId: message.id,
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
},
|
|
2030
|
+
onMessageDeleted: (payload) => {
|
|
2031
|
+
removeQueuedPrompt(payload.conversationId, payload.messageId);
|
|
2032
|
+
},
|
|
2033
|
+
onConversationUpdated: (payload) => {
|
|
2034
|
+
handleConversationUpdated(payload);
|
|
2035
|
+
},
|
|
2036
|
+
onConnected: () => {
|
|
2006
2037
|
streamConnected = true;
|
|
2007
2038
|
void publishRuntimeHeartbeat();
|
|
2008
|
-
console.error('[canon-codex]
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2039
|
+
console.error('[canon-codex] SSE connected');
|
|
2040
|
+
},
|
|
2041
|
+
onDisconnected: () => {
|
|
2011
2042
|
streamConnected = false;
|
|
2012
|
-
|
|
2013
|
-
console.error(
|
|
2014
|
-
}
|
|
2043
|
+
runtimeState.clearAgentRuntime().catch(() => { });
|
|
2044
|
+
console.error('[canon-codex] SSE disconnected');
|
|
2045
|
+
},
|
|
2046
|
+
onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
|
|
2015
2047
|
},
|
|
2016
2048
|
});
|
|
2017
2049
|
await refreshCodexSkillInventory();
|
|
2018
2050
|
try {
|
|
2019
|
-
const conversations = await
|
|
2020
|
-
|
|
2021
|
-
for (const conversation of conversations) {
|
|
2022
|
-
port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
|
|
2023
|
-
port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
|
|
2024
|
-
port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
|
|
2025
|
-
}
|
|
2051
|
+
const conversations = await client.getConversations();
|
|
2052
|
+
lastKnownConversationRefreshAt = Date.now();
|
|
2026
2053
|
for (const conversation of conversations) {
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
})
|
|
2031
|
-
|
|
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
|
-
}
|
|
2054
|
+
knownConversationIds.add(conversation.id);
|
|
2055
|
+
conversationCache.set(conversation.id, conversation);
|
|
2056
|
+
clearStreaming(conversation.id);
|
|
2057
|
+
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
2058
|
+
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
2075
2059
|
}
|
|
2076
2060
|
}
|
|
2077
2061
|
catch (error) {
|
|
2078
2062
|
console.error('[canon-codex] Failed to load startup conversations:', error);
|
|
2079
2063
|
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2064
|
+
startCodexStreamInBackground(stream, (error) => {
|
|
2065
|
+
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
2066
|
+
});
|
|
2067
|
+
controlPoller.start();
|
|
2068
|
+
const heartbeat = setInterval(() => {
|
|
2069
|
+
for (const session of sessions.values()) {
|
|
2070
|
+
writeState(session);
|
|
2071
|
+
if (!session.running) {
|
|
2072
|
+
writeTurn(session);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
void publishRuntimeHeartbeat();
|
|
2076
|
+
}, HEARTBEAT_MS);
|
|
2077
|
+
const idleCheck = setInterval(() => {
|
|
2078
|
+
const now = Date.now();
|
|
2079
|
+
for (const conversationId of [...sessions.keys()]) {
|
|
2080
|
+
const session = sessions.get(conversationId);
|
|
2081
|
+
if (!session || session.running)
|
|
2082
|
+
continue;
|
|
2083
|
+
if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
|
|
2084
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Idle timeout`);
|
|
2085
|
+
closeSession(conversationId);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}, IDLE_CHECK_MS);
|
|
2089
|
+
const shutdown = async () => {
|
|
2090
|
+
console.error('[canon-codex] Shutting down...');
|
|
2091
|
+
controlPoller.stop();
|
|
2092
|
+
clearInterval(heartbeat);
|
|
2093
|
+
clearInterval(idleCheck);
|
|
2094
|
+
runtimeRequests.dispose();
|
|
2095
|
+
stream.stop();
|
|
2096
|
+
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
2097
|
+
for (const session of [...sessions.values()]) {
|
|
2098
|
+
await session.adapter.interrupt().catch(() => { });
|
|
2099
|
+
closeSession(session.conversationId);
|
|
2100
|
+
}
|
|
2101
|
+
markLocalRuntimeStopped(runtimeId);
|
|
2102
|
+
(lockHandle ?? getActiveProfileLock())?.release();
|
|
2103
|
+
process.exit(0);
|
|
2104
|
+
};
|
|
2105
|
+
process.on('SIGINT', shutdown);
|
|
2106
|
+
process.on('SIGTERM', shutdown);
|
|
2107
|
+
process.on('SIGHUP', shutdown);
|
|
2084
2108
|
console.error('[canon-codex] Ready — sessions created on demand');
|
|
2085
2109
|
await new Promise(() => { });
|
|
2086
2110
|
}
|
|
2087
2111
|
runCli(import.meta.url, main, (error) => {
|
|
2088
2112
|
const message = error instanceof Error ? error.message : String(error);
|
|
2089
2113
|
console.error(`[canon-codex] ${message}`);
|
|
2090
|
-
|
|
2114
|
+
getActiveProfileLock()?.release();
|
|
2091
2115
|
process.exit(1);
|
|
2092
2116
|
}, {
|
|
2093
2117
|
name: 'canon-codex',
|