@canonmsg/codex-plugin 0.11.12 → 0.12.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/dist/adapter.d.ts +16 -1
- package/dist/adapter.js +1 -1
- package/dist/app-server-adapter.d.ts +62 -0
- package/dist/app-server-adapter.js +502 -0
- package/dist/host.js +394 -70
- package/package.json +3 -3
package/dist/host.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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
7
|
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
|
|
7
|
-
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata,
|
|
8
|
+
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildPlanApprovalRequest, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
8
9
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
9
10
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
11
|
+
import { CodexAppServerAdapter } from './app-server-adapter.js';
|
|
12
|
+
import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|
|
10
13
|
import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
11
14
|
import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
|
|
12
15
|
import { detectCodexCliVersion } from './codex-cli-version.js';
|
|
@@ -64,6 +67,19 @@ let workspaceRoots = [];
|
|
|
64
67
|
let workspaceRootMetadata = [];
|
|
65
68
|
function buildCodexRuntimeDescriptor(input) {
|
|
66
69
|
const commands = [
|
|
70
|
+
...(input.supportsPlanMode
|
|
71
|
+
? [{
|
|
72
|
+
id: 'plan',
|
|
73
|
+
label: 'Plan first',
|
|
74
|
+
description: 'Ask Codex to plan before implementing. Text after /plan becomes the planning prompt.',
|
|
75
|
+
aliases: ['plan'],
|
|
76
|
+
category: 'plan',
|
|
77
|
+
placements: ['composer_slash', 'command_palette'],
|
|
78
|
+
availability: ['always'],
|
|
79
|
+
trailingTextBehavior: 'send_as_prompt',
|
|
80
|
+
dispatch: { kind: 'text_passthrough', template: '/plan {argument}' },
|
|
81
|
+
}]
|
|
82
|
+
: []),
|
|
67
83
|
{
|
|
68
84
|
id: 'runtime-status',
|
|
69
85
|
label: 'Runtime status',
|
|
@@ -79,6 +95,8 @@ function buildCodexRuntimeDescriptor(input) {
|
|
|
79
95
|
...RUNTIME_NEW_SESSION_ACTION,
|
|
80
96
|
primitive: 'session.new',
|
|
81
97
|
},
|
|
98
|
+
RUNTIME_STOP_ACTION,
|
|
99
|
+
RUNTIME_STOP_AND_DROP_ACTION,
|
|
82
100
|
];
|
|
83
101
|
const descriptor = buildFirstPartyCodingRuntimeDescriptor({
|
|
84
102
|
clientType: 'codex',
|
|
@@ -91,11 +109,6 @@ function buildCodexRuntimeDescriptor(input) {
|
|
|
91
109
|
presentation: input.presentation,
|
|
92
110
|
streamingTextMode: 'snapshot',
|
|
93
111
|
commands,
|
|
94
|
-
actions: [
|
|
95
|
-
RUNTIME_STOP_ACTION,
|
|
96
|
-
RUNTIME_STOP_AND_DROP_ACTION,
|
|
97
|
-
RUNTIME_NEW_SESSION_ACTION,
|
|
98
|
-
],
|
|
99
112
|
});
|
|
100
113
|
if (input.models.length > 0) {
|
|
101
114
|
return descriptor;
|
|
@@ -119,18 +132,6 @@ function buildCodexModelOptions(model) {
|
|
|
119
132
|
? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
|
|
120
133
|
: [];
|
|
121
134
|
}
|
|
122
|
-
function normalizeRuntimeTurnState(value) {
|
|
123
|
-
const normalizedTurn = normalizeTurnState(value);
|
|
124
|
-
if (normalizedTurn) {
|
|
125
|
-
return {
|
|
126
|
-
state: normalizedTurn.state,
|
|
127
|
-
...(normalizedTurn.openedAt !== undefined ? { openedAt: normalizedTurn.openedAt } : {}),
|
|
128
|
-
...(normalizedTurn.updatedAt !== undefined ? { updatedAt: normalizedTurn.updatedAt } : {}),
|
|
129
|
-
...(normalizedTurn.turnUpdatedAt !== undefined ? { turnUpdatedAt: normalizedTurn.turnUpdatedAt } : {}),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
135
|
async function publishAgentRuntime(agentId, runtime) {
|
|
135
136
|
await publishHostAgentRuntime(agentId, 'codex', runtime);
|
|
136
137
|
}
|
|
@@ -249,6 +250,78 @@ function stringArgs(value) {
|
|
|
249
250
|
? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
|
250
251
|
: undefined;
|
|
251
252
|
}
|
|
253
|
+
function supportsCodexAppServer(codexBin) {
|
|
254
|
+
if (process.env.CANON_CODEX_TRANSPORT === 'exec')
|
|
255
|
+
return false;
|
|
256
|
+
if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
|
|
257
|
+
return true;
|
|
258
|
+
const result = spawnSync(codexBin, ['app-server', '--help'], {
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
261
|
+
});
|
|
262
|
+
return result.status === 0;
|
|
263
|
+
}
|
|
264
|
+
function parsePlanCommand(content) {
|
|
265
|
+
const trimmed = content.trimStart();
|
|
266
|
+
if (!trimmed.startsWith('/plan'))
|
|
267
|
+
return { planMode: false, content };
|
|
268
|
+
const rest = trimmed.replace(/^\/plan(?:\s+)?/i, '').trim();
|
|
269
|
+
return {
|
|
270
|
+
planMode: true,
|
|
271
|
+
content: rest || 'Please inspect the request and propose a plan before making changes.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function mapCodexQuestions(value) {
|
|
275
|
+
if (!Array.isArray(value))
|
|
276
|
+
return undefined;
|
|
277
|
+
const questions = value.slice(0, 12).flatMap((entry) => {
|
|
278
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
279
|
+
return [];
|
|
280
|
+
const record = entry;
|
|
281
|
+
const id = typeof record.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 120) : null;
|
|
282
|
+
const question = typeof record.question === 'string' && record.question.trim()
|
|
283
|
+
? record.question.trim().slice(0, 1000)
|
|
284
|
+
: null;
|
|
285
|
+
if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
|
|
286
|
+
return [];
|
|
287
|
+
const header = typeof record.header === 'string' && record.header.trim()
|
|
288
|
+
? record.header.trim().slice(0, 120)
|
|
289
|
+
: undefined;
|
|
290
|
+
const rawOptions = Array.isArray(record.options) ? record.options : [];
|
|
291
|
+
const choices = rawOptions.slice(0, 12).flatMap((option) => {
|
|
292
|
+
if (!option || typeof option !== 'object' || Array.isArray(option))
|
|
293
|
+
return [];
|
|
294
|
+
const optionRecord = option;
|
|
295
|
+
const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
|
|
296
|
+
? optionRecord.label.trim().slice(0, 120)
|
|
297
|
+
: null;
|
|
298
|
+
if (!label)
|
|
299
|
+
return [];
|
|
300
|
+
const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
|
|
301
|
+
? optionRecord.description.trim().slice(0, 300)
|
|
302
|
+
: undefined;
|
|
303
|
+
return [{ label, value: label, ...(description ? { description } : {}) }];
|
|
304
|
+
});
|
|
305
|
+
return [{
|
|
306
|
+
id,
|
|
307
|
+
question,
|
|
308
|
+
...(header ? { header } : {}),
|
|
309
|
+
...(choices.length > 0 ? { choices } : {}),
|
|
310
|
+
...(choices.length > 0 && record.allowOther !== false ? { allowOther: true } : {}),
|
|
311
|
+
...(record.allowOther === true || record.isOther === true ? { allowOther: true } : {}),
|
|
312
|
+
...(record.isSecret === true ? { isSecret: true } : {}),
|
|
313
|
+
...(record.multiSelect === true ? { multiSelect: true } : {}),
|
|
314
|
+
}];
|
|
315
|
+
});
|
|
316
|
+
return questions.length > 0 ? questions : undefined;
|
|
317
|
+
}
|
|
318
|
+
function isRecord(value) {
|
|
319
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
320
|
+
}
|
|
321
|
+
function readString(record, key) {
|
|
322
|
+
const value = record[key];
|
|
323
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
324
|
+
}
|
|
252
325
|
export async function main() {
|
|
253
326
|
setDefaultResultOrder('ipv4first');
|
|
254
327
|
const { values: args } = parseArgs({
|
|
@@ -290,12 +363,14 @@ export async function main() {
|
|
|
290
363
|
}
|
|
291
364
|
const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
|
|
292
365
|
const codexCliStatus = detectCodexCliVersion(codexBin);
|
|
366
|
+
const useAppServer = supportsCodexAppServer(codexBin);
|
|
293
367
|
if (codexCliStatus.version) {
|
|
294
368
|
console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
|
|
295
369
|
}
|
|
296
370
|
else {
|
|
297
371
|
console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
|
|
298
372
|
}
|
|
373
|
+
console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
|
|
299
374
|
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
|
|
300
375
|
console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
301
376
|
const client = new CanonClient(apiKey, baseUrl);
|
|
@@ -418,14 +493,6 @@ export async function main() {
|
|
|
418
493
|
pendingMembershipChanges.delete(conversationId);
|
|
419
494
|
}
|
|
420
495
|
}
|
|
421
|
-
async function loadSenderRuntimeState(conversationId, senderId) {
|
|
422
|
-
try {
|
|
423
|
-
return normalizeRuntimeTurnState(await rtdbRead(`/turn-state/${conversationId}/${senderId}`));
|
|
424
|
-
}
|
|
425
|
-
catch {
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
496
|
async function loadHydratedInboundContext(input) {
|
|
430
497
|
const [conversation, page] = await Promise.all([
|
|
431
498
|
getConversationMeta(input.conversationId),
|
|
@@ -523,6 +590,61 @@ export async function main() {
|
|
|
523
590
|
function clearStreaming(conversationId) {
|
|
524
591
|
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
525
592
|
}
|
|
593
|
+
function writeCodexStreaming(session, text, status) {
|
|
594
|
+
if (text !== null) {
|
|
595
|
+
session.turnLiveText = text;
|
|
596
|
+
}
|
|
597
|
+
else if (status !== 'thinking' && session.turnLiveText === 'Thinking…') {
|
|
598
|
+
session.turnLiveText = '';
|
|
599
|
+
}
|
|
600
|
+
runtimeState.writeStreaming(session.conversationId, {
|
|
601
|
+
text: session.turnLiveText,
|
|
602
|
+
status,
|
|
603
|
+
messageId: session.currentTurnId ?? undefined,
|
|
604
|
+
turnId: session.currentTurnId,
|
|
605
|
+
blocks: session.turnBlocks,
|
|
606
|
+
}).catch(() => { });
|
|
607
|
+
}
|
|
608
|
+
function upsertTurnBlock(session, block) {
|
|
609
|
+
const now = Date.now();
|
|
610
|
+
const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
|
|
611
|
+
const existing = index >= 0 ? session.turnBlocks[index] : null;
|
|
612
|
+
const next = {
|
|
613
|
+
...(existing ?? {
|
|
614
|
+
sequence: session.turnBlocks.length + 1,
|
|
615
|
+
createdAt: now,
|
|
616
|
+
}),
|
|
617
|
+
...block,
|
|
618
|
+
turnId: session.currentTurnId ?? block.id,
|
|
619
|
+
updatedAt: now,
|
|
620
|
+
};
|
|
621
|
+
session.turnBlocks = index >= 0
|
|
622
|
+
? [
|
|
623
|
+
...session.turnBlocks.slice(0, index),
|
|
624
|
+
next,
|
|
625
|
+
...session.turnBlocks.slice(index + 1),
|
|
626
|
+
]
|
|
627
|
+
: [...session.turnBlocks, next];
|
|
628
|
+
}
|
|
629
|
+
function completeTurnBlock(session, id, summary) {
|
|
630
|
+
const existing = session.turnBlocks.find((block) => block.id === id);
|
|
631
|
+
if (!existing)
|
|
632
|
+
return;
|
|
633
|
+
upsertTurnBlock(session, {
|
|
634
|
+
id,
|
|
635
|
+
kind: existing.kind,
|
|
636
|
+
status: 'completed',
|
|
637
|
+
title: existing.title,
|
|
638
|
+
text: existing.text,
|
|
639
|
+
summary: summary ?? existing.summary,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
function buildFinalTurnTrail(session) {
|
|
643
|
+
return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
|
|
644
|
+
...block,
|
|
645
|
+
turnId: session.currentTurnId ?? block.turnId,
|
|
646
|
+
})));
|
|
647
|
+
}
|
|
526
648
|
async function handoffFinalMessage(conversationId) {
|
|
527
649
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
528
650
|
clearStreaming(conversationId);
|
|
@@ -555,6 +677,9 @@ export async function main() {
|
|
|
555
677
|
return;
|
|
556
678
|
session.closed = true;
|
|
557
679
|
stopVisibleWorkSignal(session);
|
|
680
|
+
if ('close' in session.adapter && typeof session.adapter.close === 'function') {
|
|
681
|
+
session.adapter.close();
|
|
682
|
+
}
|
|
558
683
|
releaseConversationEnvironment(session.environment);
|
|
559
684
|
clearStreaming(conversationId);
|
|
560
685
|
runtimeState.clearSessionState(conversationId).catch(() => { });
|
|
@@ -638,11 +763,20 @@ export async function main() {
|
|
|
638
763
|
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
639
764
|
}
|
|
640
765
|
const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
766
|
+
const adapter = useAppServer
|
|
767
|
+
? new CodexAppServerAdapter({
|
|
768
|
+
cwd: sessionCwd,
|
|
769
|
+
threadId: storedThreadId,
|
|
770
|
+
codexBin,
|
|
771
|
+
model: policy.model ?? null,
|
|
772
|
+
sandbox: policy.sandbox,
|
|
773
|
+
approvalPolicy: policy.approvalPolicy,
|
|
774
|
+
addDirs: args['add-dir'] ?? [],
|
|
775
|
+
configOverrides: args.config ?? [],
|
|
776
|
+
fullAuto: policy.fullAuto,
|
|
777
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
778
|
+
})
|
|
779
|
+
: new CodexConversationAdapter({
|
|
646
780
|
cwd: sessionCwd,
|
|
647
781
|
threadId: storedThreadId,
|
|
648
782
|
codexBin,
|
|
@@ -654,7 +788,12 @@ export async function main() {
|
|
|
654
788
|
configOverrides: args.config ?? [],
|
|
655
789
|
fullAuto: policy.fullAuto,
|
|
656
790
|
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
657
|
-
})
|
|
791
|
+
});
|
|
792
|
+
const session = {
|
|
793
|
+
conversationId,
|
|
794
|
+
cwd: sessionCwd,
|
|
795
|
+
environment,
|
|
796
|
+
adapter,
|
|
658
797
|
queue: [],
|
|
659
798
|
running: false,
|
|
660
799
|
state: {
|
|
@@ -673,6 +812,8 @@ export async function main() {
|
|
|
673
812
|
lastActivity: Date.now(),
|
|
674
813
|
typingKeepaliveTimer: null,
|
|
675
814
|
closed: false,
|
|
815
|
+
turnLiveText: '',
|
|
816
|
+
turnBlocks: [],
|
|
676
817
|
};
|
|
677
818
|
sessions.set(conversationId, session);
|
|
678
819
|
await Promise.all([
|
|
@@ -697,8 +838,8 @@ export async function main() {
|
|
|
697
838
|
pendingSessionCreations.delete(conversationId);
|
|
698
839
|
}
|
|
699
840
|
}
|
|
700
|
-
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = []) {
|
|
701
|
-
const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs };
|
|
841
|
+
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false) {
|
|
842
|
+
const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs, planMode };
|
|
702
843
|
if (toFront) {
|
|
703
844
|
session.queue.unshift(nextPrompt);
|
|
704
845
|
}
|
|
@@ -709,8 +850,145 @@ export async function main() {
|
|
|
709
850
|
writeTurn(session);
|
|
710
851
|
void runNextTurn(session);
|
|
711
852
|
}
|
|
853
|
+
async function waitForRuntimeInputResponse(input) {
|
|
854
|
+
while (Date.now() < input.expiresAt) {
|
|
855
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
856
|
+
conversationId: input.conversationId,
|
|
857
|
+
inputId: input.inputId,
|
|
858
|
+
}).catch(() => null);
|
|
859
|
+
if (response?.status === 'submitted') {
|
|
860
|
+
return { status: 'submitted', value: response.value, answers: response.answers };
|
|
861
|
+
}
|
|
862
|
+
if (response?.status === 'cancelled' || response?.status === 'timeout') {
|
|
863
|
+
return { status: response.status };
|
|
864
|
+
}
|
|
865
|
+
await sleep(1_000);
|
|
866
|
+
}
|
|
867
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
868
|
+
conversationId: input.conversationId,
|
|
869
|
+
inputId: input.inputId,
|
|
870
|
+
}).catch(() => null);
|
|
871
|
+
if (response?.status === 'submitted') {
|
|
872
|
+
return { status: 'submitted', value: response.value, answers: response.answers };
|
|
873
|
+
}
|
|
874
|
+
return { status: 'timeout' };
|
|
875
|
+
}
|
|
876
|
+
async function waitForRuntimeApprovalResponse(input) {
|
|
877
|
+
while (Date.now() < input.expiresAt) {
|
|
878
|
+
const response = await client.consumeRuntimeApprovalResponse({
|
|
879
|
+
conversationId: input.conversationId,
|
|
880
|
+
approvalId: input.approvalId,
|
|
881
|
+
}).catch(() => null);
|
|
882
|
+
if (response?.status === 'allow') {
|
|
883
|
+
return { decision: 'allow', sessionRule: response.sessionRule };
|
|
884
|
+
}
|
|
885
|
+
if (response?.status === 'deny' || response?.status === 'timeout') {
|
|
886
|
+
return { decision: 'deny' };
|
|
887
|
+
}
|
|
888
|
+
await sleep(1_000);
|
|
889
|
+
}
|
|
890
|
+
await client.consumeRuntimeApprovalResponse({
|
|
891
|
+
conversationId: input.conversationId,
|
|
892
|
+
approvalId: input.approvalId,
|
|
893
|
+
}).catch(() => null);
|
|
894
|
+
return { decision: 'deny' };
|
|
895
|
+
}
|
|
896
|
+
async function handleCodexServerRequest(session, request) {
|
|
897
|
+
const requestId = String(request.id);
|
|
898
|
+
const params = request.params;
|
|
899
|
+
const expiresAt = Date.now() + 30 * 60_000;
|
|
900
|
+
if (request.method === 'item/tool/requestUserInput') {
|
|
901
|
+
const paramsInput = isRecord(params.input) ? params.input : null;
|
|
902
|
+
const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
|
|
903
|
+
const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
|
|
904
|
+
const inputId = readString(params, 'itemId') ?? requestId;
|
|
905
|
+
await client.createRuntimeInputRequest({
|
|
906
|
+
conversationId: session.conversationId,
|
|
907
|
+
inputId,
|
|
908
|
+
kind: 'clarify',
|
|
909
|
+
expiresAt,
|
|
910
|
+
responseUserId: ownerId ?? undefined,
|
|
911
|
+
title: 'Codex needs input',
|
|
912
|
+
prompt: questions?.length
|
|
913
|
+
? 'Codex needs your input to continue.'
|
|
914
|
+
: 'Codex needs input.',
|
|
915
|
+
...(questions ? { questions } : {}),
|
|
916
|
+
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
917
|
+
native: {
|
|
918
|
+
runtime: 'codex',
|
|
919
|
+
method: request.method,
|
|
920
|
+
requestId,
|
|
921
|
+
turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
|
|
922
|
+
handles: {
|
|
923
|
+
itemId: readString(params, 'itemId') ?? '',
|
|
924
|
+
threadId: readString(params, 'threadId') ?? '',
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
turnId: session.currentTurnId ?? undefined,
|
|
928
|
+
});
|
|
929
|
+
const response = await waitForRuntimeInputResponse({
|
|
930
|
+
conversationId: session.conversationId,
|
|
931
|
+
inputId,
|
|
932
|
+
expiresAt,
|
|
933
|
+
});
|
|
934
|
+
return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
|
|
935
|
+
}
|
|
936
|
+
const mappedApproval = mapCodexAppServerApprovalRequest({
|
|
937
|
+
method: request.method,
|
|
938
|
+
params,
|
|
939
|
+
});
|
|
940
|
+
if (mappedApproval) {
|
|
941
|
+
const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
|
|
942
|
+
await client.createRuntimeApprovalRequest({
|
|
943
|
+
conversationId: session.conversationId,
|
|
944
|
+
approvalId,
|
|
945
|
+
toolName: mappedApproval.toolName,
|
|
946
|
+
toolSummary: mappedApproval.toolSummary,
|
|
947
|
+
category: mappedApproval.category,
|
|
948
|
+
risk: mappedApproval.risk,
|
|
949
|
+
riskLevel: mappedApproval.riskLevel,
|
|
950
|
+
native: {
|
|
951
|
+
...mappedApproval.native,
|
|
952
|
+
requestId,
|
|
953
|
+
method: request.method,
|
|
954
|
+
},
|
|
955
|
+
details: mappedApproval.details,
|
|
956
|
+
responseUserId: ownerId ?? undefined,
|
|
957
|
+
allowSessionRule: true,
|
|
958
|
+
expiresAt,
|
|
959
|
+
turnId: session.currentTurnId ?? undefined,
|
|
960
|
+
});
|
|
961
|
+
const response = await waitForRuntimeApprovalResponse({
|
|
962
|
+
conversationId: session.conversationId,
|
|
963
|
+
approvalId,
|
|
964
|
+
expiresAt,
|
|
965
|
+
});
|
|
966
|
+
if (request.method === 'item/permissions/requestApproval') {
|
|
967
|
+
return response.decision === 'allow'
|
|
968
|
+
? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
|
|
969
|
+
: { permissions: {}, scope: 'turn' };
|
|
970
|
+
}
|
|
971
|
+
return mapCanonApprovalResultToCodexDecision({
|
|
972
|
+
decision: response.decision,
|
|
973
|
+
...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
return {};
|
|
977
|
+
}
|
|
712
978
|
async function enqueueInboundMessage(input) {
|
|
713
979
|
knownConversationIds.add(input.conversationId);
|
|
980
|
+
if (isRecord(input.message.metadata)
|
|
981
|
+
&& input.message.metadata.type === 'plan_approval_reply'
|
|
982
|
+
&& typeof input.message.metadata.decision === 'string') {
|
|
983
|
+
const session = await getOrCreateSession(input.conversationId);
|
|
984
|
+
const feedback = readString(input.message.metadata, 'feedback');
|
|
985
|
+
const decision = input.message.metadata.decision;
|
|
986
|
+
const prompt = decision === 'approve'
|
|
987
|
+
? 'The plan was approved. Implement the approved plan now.'
|
|
988
|
+
: `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
|
|
989
|
+
enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
714
992
|
let materialized = [];
|
|
715
993
|
if (input.message.id) {
|
|
716
994
|
try {
|
|
@@ -723,7 +1001,11 @@ export async function main() {
|
|
|
723
1001
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
724
1002
|
}
|
|
725
1003
|
}
|
|
726
|
-
const
|
|
1004
|
+
const renderedContent = renderInboundContent(input.message, materialized);
|
|
1005
|
+
const planCommand = useAppServer
|
|
1006
|
+
? parsePlanCommand(renderedContent)
|
|
1007
|
+
: { planMode: false, content: renderedContent };
|
|
1008
|
+
const content = planCommand.content;
|
|
727
1009
|
const hydrated = await loadHydratedInboundContext({
|
|
728
1010
|
conversationId: input.conversationId,
|
|
729
1011
|
message: input.message,
|
|
@@ -773,7 +1055,6 @@ export async function main() {
|
|
|
773
1055
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
774
1056
|
metadata: {
|
|
775
1057
|
turnSemantics: 'turn_complete',
|
|
776
|
-
turnComplete: true,
|
|
777
1058
|
replyBehavior: 'suppress_auto_reply',
|
|
778
1059
|
},
|
|
779
1060
|
}).catch(() => { });
|
|
@@ -789,14 +1070,14 @@ export async function main() {
|
|
|
789
1070
|
replyContext,
|
|
790
1071
|
});
|
|
791
1072
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
792
|
-
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
|
|
1073
|
+
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
793
1074
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
794
1075
|
await session.adapter.interrupt().catch(() => { });
|
|
795
1076
|
clearStreaming(input.conversationId);
|
|
796
1077
|
client.setTyping(input.conversationId, false).catch(() => { });
|
|
797
1078
|
return;
|
|
798
1079
|
}
|
|
799
|
-
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
|
|
1080
|
+
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
800
1081
|
}
|
|
801
1082
|
async function runNextTurn(session) {
|
|
802
1083
|
if (session.running || session.closed)
|
|
@@ -808,6 +1089,8 @@ export async function main() {
|
|
|
808
1089
|
session.state.lastError = undefined;
|
|
809
1090
|
session.state.state = 'running';
|
|
810
1091
|
session.currentTurnId = randomUUID();
|
|
1092
|
+
session.turnLiveText = '';
|
|
1093
|
+
session.turnBlocks = [];
|
|
811
1094
|
session.currentTurnOpenedAt = Date.now();
|
|
812
1095
|
session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
|
|
813
1096
|
session.lastAcceptedIntent = nextTurn.intent;
|
|
@@ -817,12 +1100,7 @@ export async function main() {
|
|
|
817
1100
|
writeState(session);
|
|
818
1101
|
writeTurn(session);
|
|
819
1102
|
startVisibleWorkSignal(session);
|
|
820
|
-
|
|
821
|
-
text: 'Thinking…',
|
|
822
|
-
status: 'thinking',
|
|
823
|
-
messageId: session.currentTurnId ?? undefined,
|
|
824
|
-
turnId: session.currentTurnId,
|
|
825
|
-
}).catch(() => { });
|
|
1103
|
+
writeCodexStreaming(session, 'Thinking…', 'thinking');
|
|
826
1104
|
try {
|
|
827
1105
|
const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
|
|
828
1106
|
if (modelGuard) {
|
|
@@ -846,12 +1124,31 @@ export async function main() {
|
|
|
846
1124
|
writeTurn(session);
|
|
847
1125
|
stopVisibleWorkSignal(session);
|
|
848
1126
|
client.setTyping(session.conversationId, false).catch(() => { });
|
|
849
|
-
|
|
1127
|
+
writeCodexStreaming(session, event.text, 'streaming');
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (event.type === 'plan.updated') {
|
|
1131
|
+
session.turnState = 'streaming';
|
|
1132
|
+
markTurnProgress(session);
|
|
1133
|
+
writeTurn(session);
|
|
1134
|
+
stopVisibleWorkSignal(session);
|
|
1135
|
+
client.setTyping(session.conversationId, false).catch(() => { });
|
|
1136
|
+
upsertTurnBlock(session, {
|
|
1137
|
+
id: `plan:${session.currentTurnId}`,
|
|
1138
|
+
kind: 'plan',
|
|
1139
|
+
status: 'running',
|
|
1140
|
+
title: 'Plan',
|
|
850
1141
|
text: event.text,
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1142
|
+
});
|
|
1143
|
+
writeCodexStreaming(session, event.text, 'streaming');
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (event.type === 'waiting') {
|
|
1147
|
+
session.turnState = 'waiting_input';
|
|
1148
|
+
markTurnProgress(session);
|
|
1149
|
+
writeTurn(session);
|
|
1150
|
+
stopVisibleWorkSignal(session);
|
|
1151
|
+
writeCodexStreaming(session, null, 'waiting_input');
|
|
855
1152
|
return;
|
|
856
1153
|
}
|
|
857
1154
|
if (event.type === 'command.started') {
|
|
@@ -859,20 +1156,24 @@ export async function main() {
|
|
|
859
1156
|
markTurnProgress(session);
|
|
860
1157
|
writeTurn(session);
|
|
861
1158
|
startVisibleWorkSignal(session);
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1159
|
+
upsertTurnBlock(session, {
|
|
1160
|
+
id: `command:${session.currentTurnId}`,
|
|
1161
|
+
kind: 'tool',
|
|
1162
|
+
status: 'running',
|
|
1163
|
+
title: summarizeCommand(event.command),
|
|
1164
|
+
summary: 'Command running',
|
|
1165
|
+
});
|
|
1166
|
+
writeCodexStreaming(session, null, 'tool');
|
|
868
1167
|
return;
|
|
869
1168
|
}
|
|
870
1169
|
if (event.type === 'command.completed') {
|
|
1170
|
+
completeTurnBlock(session, `command:${session.currentTurnId}`, 'Command completed');
|
|
871
1171
|
if (session.turnState === 'tool') {
|
|
872
1172
|
session.turnState = 'thinking';
|
|
873
1173
|
markTurnProgress(session);
|
|
874
1174
|
writeTurn(session);
|
|
875
1175
|
startVisibleWorkSignal(session);
|
|
1176
|
+
writeCodexStreaming(session, null, 'thinking');
|
|
876
1177
|
}
|
|
877
1178
|
return;
|
|
878
1179
|
}
|
|
@@ -887,7 +1188,10 @@ export async function main() {
|
|
|
887
1188
|
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
888
1189
|
session.adapter.clearThreadId();
|
|
889
1190
|
};
|
|
890
|
-
const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs
|
|
1191
|
+
const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
|
|
1192
|
+
planMode: nextTurn.planMode,
|
|
1193
|
+
onServerRequest: (request) => handleCodexServerRequest(session, request),
|
|
1194
|
+
});
|
|
891
1195
|
let result = await runTurnOnce();
|
|
892
1196
|
if (!result.interrupted
|
|
893
1197
|
&& !result.finalMessage
|
|
@@ -901,10 +1205,28 @@ export async function main() {
|
|
|
901
1205
|
if (result.threadId && !session.resetRequested) {
|
|
902
1206
|
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
903
1207
|
}
|
|
904
|
-
if (!result.interrupted && result.finalMessage) {
|
|
1208
|
+
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1209
|
+
const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
|
|
1210
|
+
responseUserId: ownerId ?? undefined,
|
|
1211
|
+
title: 'Codex Plan',
|
|
1212
|
+
body: result.finalMessage,
|
|
1213
|
+
});
|
|
1214
|
+
await client.sendMessage(session.conversationId, planApproval.text, {
|
|
1215
|
+
metadata: {
|
|
1216
|
+
...planApproval.metadata,
|
|
1217
|
+
turnId: session.currentTurnId,
|
|
1218
|
+
turnSemantics: 'control',
|
|
1219
|
+
replyBehavior: 'suppress_auto_reply',
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
await handoffFinalMessage(session.conversationId);
|
|
1223
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
|
|
1224
|
+
}
|
|
1225
|
+
else if (!result.interrupted && result.finalMessage) {
|
|
905
1226
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
906
1227
|
clearStoredThread();
|
|
907
1228
|
}
|
|
1229
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
908
1230
|
await client.sendMessage(session.conversationId, result.finalMessage, {
|
|
909
1231
|
...(session.activeSelfContextId
|
|
910
1232
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -912,8 +1234,8 @@ export async function main() {
|
|
|
912
1234
|
metadata: {
|
|
913
1235
|
turnId: session.currentTurnId,
|
|
914
1236
|
turnSemantics: 'turn_complete',
|
|
915
|
-
turnComplete: true,
|
|
916
1237
|
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1238
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
917
1239
|
},
|
|
918
1240
|
});
|
|
919
1241
|
await handoffFinalMessage(session.conversationId);
|
|
@@ -926,6 +1248,7 @@ export async function main() {
|
|
|
926
1248
|
if (result.errorText) {
|
|
927
1249
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
928
1250
|
}
|
|
1251
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
929
1252
|
await client.sendMessage(session.conversationId, userVisibleError, {
|
|
930
1253
|
...(session.activeSelfContextId
|
|
931
1254
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -933,8 +1256,8 @@ export async function main() {
|
|
|
933
1256
|
metadata: {
|
|
934
1257
|
turnId: session.currentTurnId,
|
|
935
1258
|
turnSemantics: 'turn_complete',
|
|
936
|
-
turnComplete: true,
|
|
937
1259
|
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1260
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
938
1261
|
},
|
|
939
1262
|
});
|
|
940
1263
|
await handoffFinalMessage(session.conversationId);
|
|
@@ -964,7 +1287,6 @@ export async function main() {
|
|
|
964
1287
|
metadata: {
|
|
965
1288
|
turnId: session.currentTurnId,
|
|
966
1289
|
turnSemantics: 'turn_complete',
|
|
967
|
-
turnComplete: true,
|
|
968
1290
|
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
969
1291
|
},
|
|
970
1292
|
}).catch(() => { });
|
|
@@ -1024,6 +1346,7 @@ export async function main() {
|
|
|
1024
1346
|
permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
1025
1347
|
defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
1026
1348
|
presentation: runtimePresentation,
|
|
1349
|
+
supportsPlanMode: useAppServer,
|
|
1027
1350
|
}),
|
|
1028
1351
|
};
|
|
1029
1352
|
async function baselineControlSignal(conversationId) {
|
|
@@ -1108,12 +1431,14 @@ export async function main() {
|
|
|
1108
1431
|
{
|
|
1109
1432
|
id: 'transport',
|
|
1110
1433
|
label: 'Transport',
|
|
1111
|
-
value: 'exec --json',
|
|
1434
|
+
value: useAppServer ? 'app-server' : 'exec --json',
|
|
1112
1435
|
},
|
|
1113
1436
|
{
|
|
1114
1437
|
id: 'streaming',
|
|
1115
1438
|
label: 'Live output',
|
|
1116
|
-
value:
|
|
1439
|
+
value: useAppServer
|
|
1440
|
+
? 'Plans, questions, approvals, tools, and message deltas'
|
|
1441
|
+
: 'Thinking, tools, and completed-message previews',
|
|
1117
1442
|
},
|
|
1118
1443
|
{
|
|
1119
1444
|
id: 'codex-cli',
|
|
@@ -1124,8 +1449,8 @@ export async function main() {
|
|
|
1124
1449
|
{
|
|
1125
1450
|
id: 'nativeActions',
|
|
1126
1451
|
label: 'Native actions',
|
|
1127
|
-
value: 'Limited until app-server transport',
|
|
1128
|
-
tone: 'warning',
|
|
1452
|
+
value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
|
|
1453
|
+
...(useAppServer ? {} : { tone: 'warning' }),
|
|
1129
1454
|
},
|
|
1130
1455
|
],
|
|
1131
1456
|
execution: {
|
|
@@ -1139,8 +1464,9 @@ export async function main() {
|
|
|
1139
1464
|
fallbackReason: resolveExecutionFallbackReason(session?.environment),
|
|
1140
1465
|
},
|
|
1141
1466
|
notes: [
|
|
1142
|
-
|
|
1143
|
-
|
|
1467
|
+
useAppServer
|
|
1468
|
+
? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
|
|
1469
|
+
: '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.',
|
|
1144
1470
|
],
|
|
1145
1471
|
};
|
|
1146
1472
|
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
@@ -1215,6 +1541,7 @@ export async function main() {
|
|
|
1215
1541
|
permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
1216
1542
|
defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
1217
1543
|
presentation: runtimePresentation,
|
|
1544
|
+
supportsPlanMode: useAppServer,
|
|
1218
1545
|
}),
|
|
1219
1546
|
};
|
|
1220
1547
|
}
|
|
@@ -1235,6 +1562,7 @@ export async function main() {
|
|
|
1235
1562
|
permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
|
|
1236
1563
|
defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
|
|
1237
1564
|
presentation: runtimePresentation,
|
|
1565
|
+
supportsPlanMode: useAppServer,
|
|
1238
1566
|
}),
|
|
1239
1567
|
};
|
|
1240
1568
|
}
|
|
@@ -1264,13 +1592,9 @@ export async function main() {
|
|
|
1264
1592
|
? inboundMessages.slice(cursorIndex + 1)
|
|
1265
1593
|
: inboundMessages.slice(-1);
|
|
1266
1594
|
for (const latestMessage of messagesToRecover) {
|
|
1267
|
-
const senderTurnState = latestMessage.senderType === 'ai_agent'
|
|
1268
|
-
? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
|
|
1269
|
-
: null;
|
|
1270
1595
|
const triggerDecision = shouldTriggerAgentTurn({
|
|
1271
1596
|
senderType: latestMessage.senderType ?? 'human',
|
|
1272
1597
|
metadata: latestMessage.metadata,
|
|
1273
|
-
senderTurnState,
|
|
1274
1598
|
});
|
|
1275
1599
|
if (!triggerDecision.allow) {
|
|
1276
1600
|
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
|