@canonmsg/codex-plugin 0.14.0 → 0.18.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 +4 -0
- package/dist/adapter.js +12 -0
- package/dist/app-server-adapter.d.ts +11 -0
- package/dist/app-server-adapter.js +31 -2
- package/dist/app-server-approval.d.ts +12 -1
- package/dist/app-server-approval.js +269 -0
- package/dist/control-channel.d.ts +45 -0
- package/dist/control-channel.js +48 -0
- package/dist/host.js +239 -148
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/outbox.d.ts +83 -0
- package/dist/outbox.js +132 -0
- package/dist/startup-recovery.d.ts +46 -0
- package/dist/startup-recovery.js +59 -0
- package/package.json +3 -3
package/dist/host.js
CHANGED
|
@@ -4,8 +4,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { dirname } from 'node:path';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
|
-
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
|
|
8
|
-
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, 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, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent,
|
|
7
|
+
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
8
|
+
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, 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, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, sendMessageWithRetry, sendMessageWithRetryChunked, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
9
9
|
import { decideAutoReply, } from './inbound-policy.js';
|
|
10
10
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
11
11
|
import { CodexAppServerAdapter } from './app-server-adapter.js';
|
|
@@ -15,7 +15,10 @@ import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './per
|
|
|
15
15
|
import { detectCodexCliVersion } from './codex-cli-version.js';
|
|
16
16
|
import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
|
|
17
17
|
import { startCodexStreamInBackground } from './host-lifecycle.js';
|
|
18
|
+
import { createCodexControlPoller } from './control-channel.js';
|
|
19
|
+
import { buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
|
|
18
20
|
import { runCli } from './cli-entry.js';
|
|
21
|
+
import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
19
22
|
import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
|
|
20
23
|
const HELP = `canon-codex — run a local Codex agent host for Canon
|
|
21
24
|
|
|
@@ -54,9 +57,6 @@ const MAX_SESSIONS = 12;
|
|
|
54
57
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
55
58
|
const HEARTBEAT_MS = 30_000;
|
|
56
59
|
const IDLE_CHECK_MS = 60_000;
|
|
57
|
-
const CONTROL_POLL_MS = 2_000;
|
|
58
|
-
const IDLE_CONTROL_POLL_MS = 10_000;
|
|
59
|
-
const CONTROL_POLL_JITTER_MS = 1_000;
|
|
60
60
|
const CODEX_RUNTIME_CAPABILITIES = {
|
|
61
61
|
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
62
62
|
supportsInterrupt: true,
|
|
@@ -64,29 +64,20 @@ const CODEX_RUNTIME_CAPABILITIES = {
|
|
|
64
64
|
supportsQueue: true,
|
|
65
65
|
supportsNonFinalPermanentMessages: false,
|
|
66
66
|
};
|
|
67
|
-
function controlPollDelayMs(hasActiveWork) {
|
|
68
|
-
const base = hasActiveWork ? CONTROL_POLL_MS : IDLE_CONTROL_POLL_MS;
|
|
69
|
-
return base + Math.floor(Math.random() * CONTROL_POLL_JITTER_MS);
|
|
70
|
-
}
|
|
71
67
|
let workingDir = process.cwd();
|
|
72
68
|
let workspaceOptions = [];
|
|
73
69
|
let workspaceRoots = [];
|
|
74
70
|
let workspaceRootMetadata = [];
|
|
71
|
+
/** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
|
|
72
|
+
const CODEX_EFFORT_OPTIONS = [
|
|
73
|
+
{ value: 'minimal', label: 'Minimal' },
|
|
74
|
+
{ value: 'low', label: 'Low' },
|
|
75
|
+
{ value: 'medium', label: 'Medium' },
|
|
76
|
+
{ value: 'high', label: 'High' },
|
|
77
|
+
];
|
|
78
|
+
const CODEX_EFFORT_VALUES = new Set(CODEX_EFFORT_OPTIONS.map((option) => option.value));
|
|
75
79
|
function buildCodexRuntimeDescriptor(input) {
|
|
76
80
|
const commands = [
|
|
77
|
-
...(input.supportsPlanMode
|
|
78
|
-
? [{
|
|
79
|
-
id: 'plan',
|
|
80
|
-
label: 'Plan first',
|
|
81
|
-
description: 'Ask Codex to plan before implementing. Text after /plan becomes the planning prompt.',
|
|
82
|
-
aliases: ['plan'],
|
|
83
|
-
category: 'plan',
|
|
84
|
-
placements: ['composer_slash', 'command_palette'],
|
|
85
|
-
availability: ['always'],
|
|
86
|
-
trailingTextBehavior: 'send_as_prompt',
|
|
87
|
-
dispatch: { kind: 'text_passthrough', template: '/plan {argument}' },
|
|
88
|
-
}]
|
|
89
|
-
: []),
|
|
90
81
|
{
|
|
91
82
|
id: 'runtime-status',
|
|
92
83
|
label: 'Runtime status',
|
|
@@ -113,8 +104,36 @@ function buildCodexRuntimeDescriptor(input) {
|
|
|
113
104
|
executionModes: input.executionModes,
|
|
114
105
|
permissionModes: input.permissionModes,
|
|
115
106
|
defaultPermissionMode: input.defaultPermissionMode,
|
|
107
|
+
permissionModeLabel: 'Execution policy',
|
|
108
|
+
modelLiveBehavior: 'next_turn',
|
|
109
|
+
effortOptions: [...CODEX_EFFORT_OPTIONS],
|
|
110
|
+
defaultEffort: 'medium',
|
|
111
|
+
effortLiveBehavior: 'next_turn',
|
|
116
112
|
presentation: input.presentation,
|
|
117
113
|
streamingTextMode: 'snapshot',
|
|
114
|
+
...(input.supportsPlanMode
|
|
115
|
+
? {
|
|
116
|
+
turnModes: [
|
|
117
|
+
{
|
|
118
|
+
id: 'normal',
|
|
119
|
+
label: 'Normal',
|
|
120
|
+
description: 'Let Codex answer or act normally.',
|
|
121
|
+
scope: 'next_turn',
|
|
122
|
+
default: true,
|
|
123
|
+
activation: { kind: 'message_metadata', value: 'normal' },
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'plan',
|
|
127
|
+
label: 'Plan',
|
|
128
|
+
description: 'Ask Codex to plan before implementing.',
|
|
129
|
+
scope: 'next_turn',
|
|
130
|
+
ownerOnly: true,
|
|
131
|
+
aliases: ['plan'],
|
|
132
|
+
activation: { kind: 'message_metadata', value: 'plan' },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
}
|
|
136
|
+
: {}),
|
|
118
137
|
commands,
|
|
119
138
|
...(input.supportsRichCards
|
|
120
139
|
? {
|
|
@@ -162,7 +181,7 @@ async function loadSessionConfig(conversationId, agentId) {
|
|
|
162
181
|
return loadHostSessionConfig({
|
|
163
182
|
conversationId,
|
|
164
183
|
agentId,
|
|
165
|
-
extraStringFields: ['permissionMode'],
|
|
184
|
+
extraStringFields: ['permissionMode', 'effort'],
|
|
166
185
|
});
|
|
167
186
|
}
|
|
168
187
|
function resolveSessionExecutionMode(config) {
|
|
@@ -237,6 +256,7 @@ function buildCanonPrompt(input) {
|
|
|
237
256
|
activeSelfContextId: input.activeSelfContextId,
|
|
238
257
|
provenance: input.provenance,
|
|
239
258
|
replyContext: input.replyContext,
|
|
259
|
+
sessionContextLines: input.sessionContextLines,
|
|
240
260
|
message: input.message,
|
|
241
261
|
}));
|
|
242
262
|
}
|
|
@@ -329,15 +349,21 @@ function mapCodexQuestions(value) {
|
|
|
329
349
|
const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
|
|
330
350
|
? optionRecord.description.trim().slice(0, 300)
|
|
331
351
|
: undefined;
|
|
332
|
-
|
|
352
|
+
const preview = typeof optionRecord.preview === 'string' && optionRecord.preview.trim()
|
|
353
|
+
? optionRecord.preview.trim().slice(0, 4000)
|
|
354
|
+
: undefined;
|
|
355
|
+
return [{ label, value: label, ...(description ? { description } : {}), ...(preview ? { preview } : {}) }];
|
|
333
356
|
});
|
|
334
357
|
return [{
|
|
335
358
|
id,
|
|
336
359
|
question,
|
|
337
360
|
...(header ? { header } : {}),
|
|
338
361
|
...(choices.length > 0 ? { choices } : {}),
|
|
339
|
-
|
|
340
|
-
|
|
362
|
+
// Shared automatic-"Other" convention (Mac-app parity): on for choice
|
|
363
|
+
// questions unless the agent explicitly opts out.
|
|
364
|
+
...(resolveQuestionAllowOther(choices.length > 0, record.allowOther ?? record.isOther)
|
|
365
|
+
? { allowOther: true }
|
|
366
|
+
: {}),
|
|
341
367
|
...(record.isSecret === true ? { isSecret: true } : {}),
|
|
342
368
|
...(record.multiSelect === true ? { multiSelect: true } : {}),
|
|
343
369
|
}];
|
|
@@ -403,7 +429,7 @@ export async function main() {
|
|
|
403
429
|
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
|
|
404
430
|
console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
405
431
|
const client = new CanonClient(apiKey, baseUrl);
|
|
406
|
-
initRTDBAuth(client);
|
|
432
|
+
const rtdb = initRTDBAuth(client);
|
|
407
433
|
const typingSignals = createTypingStatusPublisher({
|
|
408
434
|
setTyping: (conversationId, typing, status) => status
|
|
409
435
|
? client.setTyping(conversationId, typing, status)
|
|
@@ -575,6 +601,7 @@ export async function main() {
|
|
|
575
601
|
hostMode: true,
|
|
576
602
|
clientType: 'codex',
|
|
577
603
|
isActive: true,
|
|
604
|
+
...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
|
|
578
605
|
}).catch(() => { });
|
|
579
606
|
}
|
|
580
607
|
function writeTurn(session) {
|
|
@@ -800,12 +827,16 @@ export async function main() {
|
|
|
800
827
|
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
801
828
|
}
|
|
802
829
|
const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
|
|
830
|
+
const initialEffort = config?.effort && CODEX_EFFORT_VALUES.has(config.effort)
|
|
831
|
+
? config.effort
|
|
832
|
+
: null;
|
|
803
833
|
const adapter = useAppServer
|
|
804
834
|
? new CodexAppServerAdapter({
|
|
805
835
|
cwd: sessionCwd,
|
|
806
836
|
threadId: storedThreadId,
|
|
807
837
|
codexBin,
|
|
808
838
|
model: policy.model ?? null,
|
|
839
|
+
reasoningEffort: initialEffort,
|
|
809
840
|
sandbox: policy.sandbox,
|
|
810
841
|
approvalPolicy: policy.approvalPolicy,
|
|
811
842
|
addDirs: args['add-dir'] ?? [],
|
|
@@ -818,6 +849,7 @@ export async function main() {
|
|
|
818
849
|
threadId: storedThreadId,
|
|
819
850
|
codexBin,
|
|
820
851
|
model: policy.model ?? null,
|
|
852
|
+
reasoningEffort: initialEffort,
|
|
821
853
|
sandbox: policy.sandbox,
|
|
822
854
|
approvalPolicy: policy.approvalPolicy,
|
|
823
855
|
codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
|
|
@@ -854,10 +886,8 @@ export async function main() {
|
|
|
854
886
|
turnCommandBlocks: createCommandBlockTracker(),
|
|
855
887
|
};
|
|
856
888
|
sessions.set(conversationId, session);
|
|
857
|
-
await
|
|
858
|
-
|
|
859
|
-
baselineSessionControl(conversationId),
|
|
860
|
-
]);
|
|
889
|
+
await controlPoller.baseline([conversationId]);
|
|
890
|
+
ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
|
|
861
891
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
862
892
|
writeState(session);
|
|
863
893
|
writeTurn(session);
|
|
@@ -1124,6 +1154,7 @@ export async function main() {
|
|
|
1124
1154
|
method: request.method,
|
|
1125
1155
|
},
|
|
1126
1156
|
details: mappedApproval.details,
|
|
1157
|
+
...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
|
|
1127
1158
|
responseUserId: ownerId ?? undefined,
|
|
1128
1159
|
allowSessionRule: true,
|
|
1129
1160
|
expiresAt,
|
|
@@ -1148,6 +1179,10 @@ export async function main() {
|
|
|
1148
1179
|
}
|
|
1149
1180
|
async function enqueueInboundMessage(input) {
|
|
1150
1181
|
knownConversationIds.add(input.conversationId);
|
|
1182
|
+
if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
|
|
1183
|
+
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1151
1186
|
if (isRecord(input.message.metadata)
|
|
1152
1187
|
&& input.message.metadata.type === 'plan_approval_reply'
|
|
1153
1188
|
&& typeof input.message.metadata.decision === 'string') {
|
|
@@ -1156,7 +1191,9 @@ export async function main() {
|
|
|
1156
1191
|
const decision = input.message.metadata.decision;
|
|
1157
1192
|
const prompt = decision === 'approve'
|
|
1158
1193
|
? 'The plan was approved. Implement the approved plan now.'
|
|
1159
|
-
:
|
|
1194
|
+
: decision === 'reject'
|
|
1195
|
+
? `The plan was declined — keep planning and wait for guidance before implementing.${feedback ? `\n\nNotes:\n${feedback}` : ''}`
|
|
1196
|
+
: `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
|
|
1160
1197
|
enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
|
|
1161
1198
|
return;
|
|
1162
1199
|
}
|
|
@@ -1173,8 +1210,12 @@ export async function main() {
|
|
|
1173
1210
|
}
|
|
1174
1211
|
}
|
|
1175
1212
|
const renderedContent = renderInboundContent(input.message, materialized);
|
|
1213
|
+
const turnMetadata = normalizeTurnMetadata(input.message.metadata);
|
|
1214
|
+
const requestedPlanMode = turnMetadata?.requestedTurnMode === 'plan';
|
|
1176
1215
|
const planCommand = useAppServer
|
|
1177
|
-
?
|
|
1216
|
+
? requestedPlanMode
|
|
1217
|
+
? { planMode: true, content: renderedContent }
|
|
1218
|
+
: parsePlanCommand(renderedContent)
|
|
1178
1219
|
: { planMode: false, content: renderedContent };
|
|
1179
1220
|
const content = planCommand.content;
|
|
1180
1221
|
const hydrated = await loadHydratedInboundContext({
|
|
@@ -1203,14 +1244,18 @@ export async function main() {
|
|
|
1203
1244
|
.filter((path) => path !== null);
|
|
1204
1245
|
const mediaAddDirs = uniqueStrings(promptMaterialized.map((attachment) => dirname(attachment.path)));
|
|
1205
1246
|
const participantContext = hydrated.participantContext;
|
|
1206
|
-
const autoReply =
|
|
1247
|
+
const autoReply = input.turnDispatch?.kind === 'run_turn'
|
|
1248
|
+
? {
|
|
1249
|
+
allow: true,
|
|
1250
|
+
reason: input.turnDispatch.reason || 'server dispatch allowed this turn',
|
|
1251
|
+
}
|
|
1252
|
+
: decideAutoReply(participantContext, behavior);
|
|
1207
1253
|
if (!autoReply.allow) {
|
|
1208
1254
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
1209
1255
|
return;
|
|
1210
1256
|
}
|
|
1211
1257
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
1212
1258
|
markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
|
|
1213
|
-
const turnMetadata = normalizeTurnMetadata(input.message.metadata);
|
|
1214
1259
|
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
1215
1260
|
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
1216
1261
|
let session;
|
|
@@ -1242,6 +1287,7 @@ export async function main() {
|
|
|
1242
1287
|
activeSelfContextId,
|
|
1243
1288
|
provenance: hydrated.provenance,
|
|
1244
1289
|
replyContext,
|
|
1290
|
+
sessionContextLines: [buildOutboxContextLine(session.cwd)],
|
|
1245
1291
|
message: input.message,
|
|
1246
1292
|
});
|
|
1247
1293
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
@@ -1254,6 +1300,45 @@ export async function main() {
|
|
|
1254
1300
|
}
|
|
1255
1301
|
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
1256
1302
|
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Turn-end media outbox flush (see ./outbox.ts for the convention). Never
|
|
1305
|
+
* throws: upload failures are logged and the files stay in place for a
|
|
1306
|
+
* later turn.
|
|
1307
|
+
*/
|
|
1308
|
+
async function flushSessionOutbox(session) {
|
|
1309
|
+
const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
|
|
1310
|
+
try {
|
|
1311
|
+
const result = await flushOutbox({
|
|
1312
|
+
outboxDir: resolveOutboxDir(session.cwd),
|
|
1313
|
+
send: (file) => sendMediaFileMessage(client, session.conversationId, file.path, '', {
|
|
1314
|
+
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1315
|
+
metadata: {
|
|
1316
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
1317
|
+
// Media is permanent conversation content (promoted like a final
|
|
1318
|
+
// reply) but must never re-trigger other agents — the host's own
|
|
1319
|
+
// final text message stays the only turn-complete trigger.
|
|
1320
|
+
turnSemantics: 'turn_complete',
|
|
1321
|
+
replyBehavior: 'suppress_auto_reply',
|
|
1322
|
+
},
|
|
1323
|
+
}),
|
|
1324
|
+
});
|
|
1325
|
+
for (const entry of result.sent) {
|
|
1326
|
+
console.error(`${logPrefix} Sent outbox file ${entry.file.fileName} (${entry.messageId})`
|
|
1327
|
+
+ (entry.removeFailed ? ' — removal failed; it may resend next turn' : ''));
|
|
1328
|
+
}
|
|
1329
|
+
for (const failure of result.failed) {
|
|
1330
|
+
console.error(`${logPrefix} Outbox upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
|
|
1331
|
+
}
|
|
1332
|
+
for (const skipped of result.skipped) {
|
|
1333
|
+
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
|
|
1334
|
+
console.error(`${logPrefix} Outbox skipped ${skipped.fileName} (${skipped.reason}); left in place`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
catch (error) {
|
|
1339
|
+
console.error(`${logPrefix} Outbox flush failed:`, error instanceof Error ? error.message : error);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1257
1342
|
async function runNextTurn(session) {
|
|
1258
1343
|
if (session.running || session.closed)
|
|
1259
1344
|
return;
|
|
@@ -1362,6 +1447,15 @@ export async function main() {
|
|
|
1362
1447
|
return;
|
|
1363
1448
|
}
|
|
1364
1449
|
if (event.type === 'turn.completed') {
|
|
1450
|
+
// Codex reports per-turn token usage but no context window, so the
|
|
1451
|
+
// meter publishes tokens only (input + cached covers the full
|
|
1452
|
+
// prompt context of the completed turn).
|
|
1453
|
+
const totalTokens = (event.usage?.input_tokens ?? 0)
|
|
1454
|
+
+ (event.usage?.cached_input_tokens ?? 0)
|
|
1455
|
+
+ (event.usage?.output_tokens ?? 0);
|
|
1456
|
+
if (totalTokens > 0) {
|
|
1457
|
+
session.state.contextUsage = { totalTokens };
|
|
1458
|
+
}
|
|
1365
1459
|
writeState(session);
|
|
1366
1460
|
}
|
|
1367
1461
|
};
|
|
@@ -1389,6 +1483,11 @@ export async function main() {
|
|
|
1389
1483
|
if (result.threadId && !session.resetRequested) {
|
|
1390
1484
|
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
1391
1485
|
}
|
|
1486
|
+
// Turn-end outbox flush — media lands before the final text reply.
|
|
1487
|
+
// Interrupted turns keep their files for the next completed turn.
|
|
1488
|
+
if (!result.interrupted) {
|
|
1489
|
+
await flushSessionOutbox(session);
|
|
1490
|
+
}
|
|
1392
1491
|
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1393
1492
|
const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
|
|
1394
1493
|
responseUserId: ownerId ?? undefined,
|
|
@@ -1504,9 +1603,6 @@ export async function main() {
|
|
|
1504
1603
|
}
|
|
1505
1604
|
}
|
|
1506
1605
|
}
|
|
1507
|
-
let controlStopped = false;
|
|
1508
|
-
const lastSeenControl = new Map();
|
|
1509
|
-
const lastSeenSignal = new Map();
|
|
1510
1606
|
let streamConnected = false;
|
|
1511
1607
|
const hostAvailableExecutionModes = [
|
|
1512
1608
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1540,28 +1636,87 @@ export async function main() {
|
|
|
1540
1636
|
supportsRichCards: useAppServer,
|
|
1541
1637
|
}),
|
|
1542
1638
|
};
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`).catch(() => null);
|
|
1547
|
-
if (!raw || typeof raw !== 'object')
|
|
1639
|
+
function applySessionControl(conversationId, control) {
|
|
1640
|
+
const session = sessions.get(conversationId);
|
|
1641
|
+
if (!session || session.closed)
|
|
1548
1642
|
return;
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1643
|
+
if (control.model && control.model !== session.state.model) {
|
|
1644
|
+
const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
|
|
1645
|
+
if (modelGuard) {
|
|
1646
|
+
session.state.lastError = modelGuard;
|
|
1647
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
|
|
1648
|
+
writeState(session);
|
|
1649
|
+
// The poller consumes the node; skip effort handling for this pass,
|
|
1650
|
+
// matching the legacy loop.
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
session.adapter.setModel(control.model);
|
|
1654
|
+
session.state.model = control.model;
|
|
1655
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
|
|
1656
|
+
writeState(session);
|
|
1657
|
+
}
|
|
1658
|
+
if (control.permissionMode) {
|
|
1659
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
|
|
1660
|
+
}
|
|
1661
|
+
if (control.effort) {
|
|
1662
|
+
if (CODEX_EFFORT_VALUES.has(control.effort)) {
|
|
1663
|
+
session.adapter.setReasoningEffort(control.effort);
|
|
1664
|
+
session.state.effort = control.effort;
|
|
1665
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Reasoning effort set for next turn -> ${control.effort}`);
|
|
1666
|
+
writeState(session);
|
|
1667
|
+
}
|
|
1668
|
+
else {
|
|
1669
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Ignoring unknown effort level (${control.effort})`);
|
|
1670
|
+
}
|
|
1552
1671
|
}
|
|
1553
1672
|
}
|
|
1554
|
-
async function
|
|
1555
|
-
|
|
1673
|
+
async function handleControlSignal(event) {
|
|
1674
|
+
const { conversationId, type } = event;
|
|
1675
|
+
const session = sessions.get(conversationId);
|
|
1676
|
+
// No live session: dedupe already advanced, but the node stays in place,
|
|
1677
|
+
// matching the legacy loop.
|
|
1678
|
+
if (!session || session.closed)
|
|
1679
|
+
return { consume: false };
|
|
1680
|
+
if (type === 'new_session') {
|
|
1681
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
|
|
1682
|
+
await resetRuntimeSession(session);
|
|
1556
1683
|
return;
|
|
1557
|
-
|
|
1558
|
-
if (!
|
|
1684
|
+
}
|
|
1685
|
+
if (!session.running && (type !== 'stop_and_drop' || session.queue.length === 0)) {
|
|
1686
|
+
// Nothing to interrupt or drop — just consume the signal.
|
|
1559
1687
|
return;
|
|
1560
|
-
const timestamp = Number(raw.updatedAt ?? 0);
|
|
1561
|
-
if (timestamp > 0) {
|
|
1562
|
-
lastSeenControl.set(conversationId, timestamp);
|
|
1563
1688
|
}
|
|
1689
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${type} signal`);
|
|
1690
|
+
if (type === 'stop_and_drop') {
|
|
1691
|
+
const droppedPrompts = session.queue.splice(0);
|
|
1692
|
+
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
1693
|
+
}
|
|
1694
|
+
if (session.running) {
|
|
1695
|
+
await session.adapter.interrupt();
|
|
1696
|
+
}
|
|
1697
|
+
session.turnState = 'interrupted';
|
|
1698
|
+
writeTurn(session);
|
|
1699
|
+
clearStreaming(conversationId);
|
|
1700
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
1564
1701
|
}
|
|
1702
|
+
const controlPoller = createCodexControlPoller({
|
|
1703
|
+
rtdb,
|
|
1704
|
+
agentId,
|
|
1705
|
+
conversationIds: () => sessions.keys(),
|
|
1706
|
+
hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
|
|
1707
|
+
&& (session.running || session.queue.length > 0 || session.turnState === 'waiting_input')),
|
|
1708
|
+
onSessionControl: ({ conversationId, control }) => {
|
|
1709
|
+
applySessionControl(conversationId, control);
|
|
1710
|
+
},
|
|
1711
|
+
onSignal: handleControlSignal,
|
|
1712
|
+
onError: (error) => {
|
|
1713
|
+
// The legacy loop ignored transient RTDB failures; keep read/consume
|
|
1714
|
+
// errors quiet but surface handler failures.
|
|
1715
|
+
if (error.scope !== 'handler')
|
|
1716
|
+
return;
|
|
1717
|
+
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);
|
|
1718
|
+
},
|
|
1719
|
+
});
|
|
1565
1720
|
let publishRuntimeDetailsInFlight = false;
|
|
1566
1721
|
const publishRuntimeHeartbeat = async () => {
|
|
1567
1722
|
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
@@ -1618,6 +1773,12 @@ export async function main() {
|
|
|
1618
1773
|
const payload = {
|
|
1619
1774
|
descriptor,
|
|
1620
1775
|
surfaceMode: 'host',
|
|
1776
|
+
// The exec --json transport cannot block on approvals — without a
|
|
1777
|
+
// strip-level warning a user can believe they have an approval
|
|
1778
|
+
// gate they do not have.
|
|
1779
|
+
...(useAppServer
|
|
1780
|
+
? {}
|
|
1781
|
+
: { warning: "Approvals can't block on this Codex CLI — update Codex to enable the app-server transport and approval gates." }),
|
|
1621
1782
|
statusItems: [
|
|
1622
1783
|
{
|
|
1623
1784
|
id: 'transport',
|
|
@@ -1643,6 +1804,11 @@ export async function main() {
|
|
|
1643
1804
|
value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
|
|
1644
1805
|
...(useAppServer ? {} : { tone: 'warning' }),
|
|
1645
1806
|
},
|
|
1807
|
+
{
|
|
1808
|
+
id: 'mediaOut',
|
|
1809
|
+
label: 'Media out',
|
|
1810
|
+
value: 'Turn-end outbox',
|
|
1811
|
+
},
|
|
1646
1812
|
],
|
|
1647
1813
|
execution: {
|
|
1648
1814
|
resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
|
|
@@ -1677,6 +1843,10 @@ export async function main() {
|
|
|
1677
1843
|
const message = payload.message;
|
|
1678
1844
|
if (message.senderId === agentId)
|
|
1679
1845
|
return;
|
|
1846
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
1847
|
+
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1680
1850
|
void enqueueInboundMessage({
|
|
1681
1851
|
conversationId: payload.conversationId,
|
|
1682
1852
|
message,
|
|
@@ -1686,6 +1856,7 @@ export async function main() {
|
|
|
1686
1856
|
activeSelfContextId: payload.activeSelfContextId,
|
|
1687
1857
|
selfContexts: payload.selfContexts,
|
|
1688
1858
|
provenance: payload.provenance,
|
|
1859
|
+
turnDispatch: payload.turnDispatch,
|
|
1689
1860
|
});
|
|
1690
1861
|
if (message.id) {
|
|
1691
1862
|
saveRuntimeSessionState(runtimeId, {
|
|
@@ -1774,17 +1945,19 @@ export async function main() {
|
|
|
1774
1945
|
conversationId: conversation.id,
|
|
1775
1946
|
baseCwd: workingDir,
|
|
1776
1947
|
})?.lastInboundMessageId;
|
|
1777
|
-
const
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1948
|
+
const recovery = await collectMissedInboundMessages({
|
|
1949
|
+
fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
1950
|
+
cursor,
|
|
1951
|
+
agentId,
|
|
1952
|
+
});
|
|
1953
|
+
const latestPage = recovery.newestPage;
|
|
1954
|
+
if (recovery.mode === 'truncated-window') {
|
|
1955
|
+
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
|
|
1956
|
+
}
|
|
1957
|
+
if (recovery.messages.length > 1) {
|
|
1958
|
+
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
|
|
1959
|
+
}
|
|
1960
|
+
for (const latestMessage of recovery.messages) {
|
|
1788
1961
|
const triggerDecision = shouldTriggerAgentTurn({
|
|
1789
1962
|
senderType: latestMessage.senderType ?? 'human',
|
|
1790
1963
|
metadata: latestMessage.metadata,
|
|
@@ -1819,89 +1992,7 @@ export async function main() {
|
|
|
1819
1992
|
startCodexStreamInBackground(stream, (error) => {
|
|
1820
1993
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
1821
1994
|
});
|
|
1822
|
-
|
|
1823
|
-
while (!controlStopped) {
|
|
1824
|
-
const hadActiveWork = [...sessions.values()].some((session) => !session.closed
|
|
1825
|
-
&& (session.running || session.queue.length > 0 || session.turnState === 'waiting_input'));
|
|
1826
|
-
for (const conversationId of [...sessions.keys()]) {
|
|
1827
|
-
try {
|
|
1828
|
-
const controlRaw = await rtdbRead(`/control/${conversationId}/${agentId}/session`);
|
|
1829
|
-
if (controlRaw && typeof controlRaw === 'object') {
|
|
1830
|
-
const control = controlRaw;
|
|
1831
|
-
const timestamp = control.updatedAt ?? 0;
|
|
1832
|
-
if (timestamp > (lastSeenControl.get(conversationId) ?? 0)) {
|
|
1833
|
-
lastSeenControl.set(conversationId, timestamp);
|
|
1834
|
-
const session = sessions.get(conversationId);
|
|
1835
|
-
if (session && !session.closed) {
|
|
1836
|
-
if (control.model && control.model !== session.state.model) {
|
|
1837
|
-
const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
|
|
1838
|
-
if (modelGuard) {
|
|
1839
|
-
session.state.lastError = modelGuard;
|
|
1840
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
|
|
1841
|
-
writeState(session);
|
|
1842
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
|
|
1843
|
-
continue;
|
|
1844
|
-
}
|
|
1845
|
-
session.adapter.setModel(control.model);
|
|
1846
|
-
session.state.model = control.model;
|
|
1847
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
|
|
1848
|
-
writeState(session);
|
|
1849
|
-
}
|
|
1850
|
-
if (control.permissionMode) {
|
|
1851
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
|
|
1852
|
-
}
|
|
1853
|
-
if (control.effort) {
|
|
1854
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`);
|
|
1861
|
-
if (!raw || typeof raw !== 'object')
|
|
1862
|
-
continue;
|
|
1863
|
-
const signal = raw;
|
|
1864
|
-
const timestamp = signal.updatedAt ?? 0;
|
|
1865
|
-
if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop' && signal.type !== 'new_session')
|
|
1866
|
-
|| timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
|
|
1867
|
-
continue;
|
|
1868
|
-
}
|
|
1869
|
-
lastSeenSignal.set(conversationId, timestamp);
|
|
1870
|
-
const session = sessions.get(conversationId);
|
|
1871
|
-
if (!session || session.closed)
|
|
1872
|
-
continue;
|
|
1873
|
-
if (signal.type === 'new_session') {
|
|
1874
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
|
|
1875
|
-
await resetRuntimeSession(session);
|
|
1876
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
|
|
1877
|
-
continue;
|
|
1878
|
-
}
|
|
1879
|
-
if (!session.running && (signal.type !== 'stop_and_drop' || session.queue.length === 0)) {
|
|
1880
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
|
|
1881
|
-
continue;
|
|
1882
|
-
}
|
|
1883
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
|
|
1884
|
-
if (signal.type === 'stop_and_drop') {
|
|
1885
|
-
const droppedPrompts = session.queue.splice(0);
|
|
1886
|
-
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
1887
|
-
}
|
|
1888
|
-
if (session.running) {
|
|
1889
|
-
await session.adapter.interrupt();
|
|
1890
|
-
}
|
|
1891
|
-
session.turnState = 'interrupted';
|
|
1892
|
-
writeTurn(session);
|
|
1893
|
-
clearStreaming(conversationId);
|
|
1894
|
-
typingSignals.clear(conversationId).catch(() => { });
|
|
1895
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
|
|
1896
|
-
}
|
|
1897
|
-
catch {
|
|
1898
|
-
// Ignore transient RTDB failures.
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
await new Promise((resolve) => setTimeout(resolve, controlPollDelayMs(hadActiveWork)));
|
|
1902
|
-
}
|
|
1903
|
-
};
|
|
1904
|
-
void pollControl();
|
|
1995
|
+
controlPoller.start();
|
|
1905
1996
|
const heartbeat = setInterval(() => {
|
|
1906
1997
|
for (const session of sessions.values()) {
|
|
1907
1998
|
writeState(session);
|
|
@@ -1925,7 +2016,7 @@ export async function main() {
|
|
|
1925
2016
|
}, IDLE_CHECK_MS);
|
|
1926
2017
|
const shutdown = async () => {
|
|
1927
2018
|
console.error('[canon-codex] Shutting down...');
|
|
1928
|
-
|
|
2019
|
+
controlPoller.stop();
|
|
1929
2020
|
clearInterval(heartbeat);
|
|
1930
2021
|
clearInterval(idleCheck);
|
|
1931
2022
|
stream.stop();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { main as hostMain } from './host.js';
|
|
2
2
|
export { main as registerMain } from './register.js';
|
|
3
3
|
export { main as setupMain } from './setup.js';
|
|
4
|
-
export { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|
|
4
|
+
export { extractCodexApprovalDiff, mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|
|
5
5
|
export type { CanonCodexApprovalRequest, CodexAppServerApprovalMethod, CodexApprovalDecision, CodexNativeApprovalRequest, } from './app-server-approval.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { main as hostMain } from './host.js';
|
|
2
2
|
export { main as registerMain } from './register.js';
|
|
3
3
|
export { main as setupMain } from './setup.js';
|
|
4
|
-
export { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|
|
4
|
+
export { extractCodexApprovalDiff, mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
|