@canonmsg/codex-plugin 0.14.1 → 0.18.1
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 +271 -0
- package/dist/control-channel.d.ts +45 -0
- package/dist/control-channel.js +48 -0
- package/dist/host.js +196 -132
- 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 +4 -4
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,14 +64,18 @@ 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
81
|
{
|
|
@@ -100,6 +104,11 @@ function buildCodexRuntimeDescriptor(input) {
|
|
|
100
104
|
executionModes: input.executionModes,
|
|
101
105
|
permissionModes: input.permissionModes,
|
|
102
106
|
defaultPermissionMode: input.defaultPermissionMode,
|
|
107
|
+
permissionModeLabel: 'Execution policy',
|
|
108
|
+
modelLiveBehavior: 'next_turn',
|
|
109
|
+
effortOptions: [...CODEX_EFFORT_OPTIONS],
|
|
110
|
+
defaultEffort: 'medium',
|
|
111
|
+
effortLiveBehavior: 'next_turn',
|
|
103
112
|
presentation: input.presentation,
|
|
104
113
|
streamingTextMode: 'snapshot',
|
|
105
114
|
...(input.supportsPlanMode
|
|
@@ -172,7 +181,7 @@ async function loadSessionConfig(conversationId, agentId) {
|
|
|
172
181
|
return loadHostSessionConfig({
|
|
173
182
|
conversationId,
|
|
174
183
|
agentId,
|
|
175
|
-
extraStringFields: ['permissionMode'],
|
|
184
|
+
extraStringFields: ['permissionMode', 'effort'],
|
|
176
185
|
});
|
|
177
186
|
}
|
|
178
187
|
function resolveSessionExecutionMode(config) {
|
|
@@ -247,6 +256,7 @@ function buildCanonPrompt(input) {
|
|
|
247
256
|
activeSelfContextId: input.activeSelfContextId,
|
|
248
257
|
provenance: input.provenance,
|
|
249
258
|
replyContext: input.replyContext,
|
|
259
|
+
sessionContextLines: input.sessionContextLines,
|
|
250
260
|
message: input.message,
|
|
251
261
|
}));
|
|
252
262
|
}
|
|
@@ -339,15 +349,21 @@ function mapCodexQuestions(value) {
|
|
|
339
349
|
const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
|
|
340
350
|
? optionRecord.description.trim().slice(0, 300)
|
|
341
351
|
: undefined;
|
|
342
|
-
|
|
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 } : {}) }];
|
|
343
356
|
});
|
|
344
357
|
return [{
|
|
345
358
|
id,
|
|
346
359
|
question,
|
|
347
360
|
...(header ? { header } : {}),
|
|
348
361
|
...(choices.length > 0 ? { choices } : {}),
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
: {}),
|
|
351
367
|
...(record.isSecret === true ? { isSecret: true } : {}),
|
|
352
368
|
...(record.multiSelect === true ? { multiSelect: true } : {}),
|
|
353
369
|
}];
|
|
@@ -413,7 +429,7 @@ export async function main() {
|
|
|
413
429
|
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
|
|
414
430
|
console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
415
431
|
const client = new CanonClient(apiKey, baseUrl);
|
|
416
|
-
initRTDBAuth(client);
|
|
432
|
+
const rtdb = initRTDBAuth(client);
|
|
417
433
|
const typingSignals = createTypingStatusPublisher({
|
|
418
434
|
setTyping: (conversationId, typing, status) => status
|
|
419
435
|
? client.setTyping(conversationId, typing, status)
|
|
@@ -585,6 +601,7 @@ export async function main() {
|
|
|
585
601
|
hostMode: true,
|
|
586
602
|
clientType: 'codex',
|
|
587
603
|
isActive: true,
|
|
604
|
+
...(session.state.contextUsage ? { contextUsage: session.state.contextUsage } : {}),
|
|
588
605
|
}).catch(() => { });
|
|
589
606
|
}
|
|
590
607
|
function writeTurn(session) {
|
|
@@ -810,12 +827,16 @@ export async function main() {
|
|
|
810
827
|
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
811
828
|
}
|
|
812
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;
|
|
813
833
|
const adapter = useAppServer
|
|
814
834
|
? new CodexAppServerAdapter({
|
|
815
835
|
cwd: sessionCwd,
|
|
816
836
|
threadId: storedThreadId,
|
|
817
837
|
codexBin,
|
|
818
838
|
model: policy.model ?? null,
|
|
839
|
+
reasoningEffort: initialEffort,
|
|
819
840
|
sandbox: policy.sandbox,
|
|
820
841
|
approvalPolicy: policy.approvalPolicy,
|
|
821
842
|
addDirs: args['add-dir'] ?? [],
|
|
@@ -828,6 +849,7 @@ export async function main() {
|
|
|
828
849
|
threadId: storedThreadId,
|
|
829
850
|
codexBin,
|
|
830
851
|
model: policy.model ?? null,
|
|
852
|
+
reasoningEffort: initialEffort,
|
|
831
853
|
sandbox: policy.sandbox,
|
|
832
854
|
approvalPolicy: policy.approvalPolicy,
|
|
833
855
|
codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
|
|
@@ -864,10 +886,8 @@ export async function main() {
|
|
|
864
886
|
turnCommandBlocks: createCommandBlockTracker(),
|
|
865
887
|
};
|
|
866
888
|
sessions.set(conversationId, session);
|
|
867
|
-
await
|
|
868
|
-
|
|
869
|
-
baselineSessionControl(conversationId),
|
|
870
|
-
]);
|
|
889
|
+
await controlPoller.baseline([conversationId]);
|
|
890
|
+
ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
|
|
871
891
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
872
892
|
writeState(session);
|
|
873
893
|
writeTurn(session);
|
|
@@ -1134,6 +1154,7 @@ export async function main() {
|
|
|
1134
1154
|
method: request.method,
|
|
1135
1155
|
},
|
|
1136
1156
|
details: mappedApproval.details,
|
|
1157
|
+
...(mappedApproval.diff ? { diff: mappedApproval.diff } : {}),
|
|
1137
1158
|
responseUserId: ownerId ?? undefined,
|
|
1138
1159
|
allowSessionRule: true,
|
|
1139
1160
|
expiresAt,
|
|
@@ -1170,7 +1191,9 @@ export async function main() {
|
|
|
1170
1191
|
const decision = input.message.metadata.decision;
|
|
1171
1192
|
const prompt = decision === 'approve'
|
|
1172
1193
|
? 'The plan was approved. Implement the approved plan now.'
|
|
1173
|
-
:
|
|
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}` : ''}`;
|
|
1174
1197
|
enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
|
|
1175
1198
|
return;
|
|
1176
1199
|
}
|
|
@@ -1264,6 +1287,7 @@ export async function main() {
|
|
|
1264
1287
|
activeSelfContextId,
|
|
1265
1288
|
provenance: hydrated.provenance,
|
|
1266
1289
|
replyContext,
|
|
1290
|
+
sessionContextLines: [buildOutboxContextLine(session.cwd)],
|
|
1267
1291
|
message: input.message,
|
|
1268
1292
|
});
|
|
1269
1293
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
@@ -1276,6 +1300,45 @@ export async function main() {
|
|
|
1276
1300
|
}
|
|
1277
1301
|
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
1278
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
|
+
}
|
|
1279
1342
|
async function runNextTurn(session) {
|
|
1280
1343
|
if (session.running || session.closed)
|
|
1281
1344
|
return;
|
|
@@ -1384,6 +1447,15 @@ export async function main() {
|
|
|
1384
1447
|
return;
|
|
1385
1448
|
}
|
|
1386
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
|
+
}
|
|
1387
1459
|
writeState(session);
|
|
1388
1460
|
}
|
|
1389
1461
|
};
|
|
@@ -1411,6 +1483,11 @@ export async function main() {
|
|
|
1411
1483
|
if (result.threadId && !session.resetRequested) {
|
|
1412
1484
|
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
1413
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
|
+
}
|
|
1414
1491
|
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1415
1492
|
const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
|
|
1416
1493
|
responseUserId: ownerId ?? undefined,
|
|
@@ -1526,9 +1603,6 @@ export async function main() {
|
|
|
1526
1603
|
}
|
|
1527
1604
|
}
|
|
1528
1605
|
}
|
|
1529
|
-
let controlStopped = false;
|
|
1530
|
-
const lastSeenControl = new Map();
|
|
1531
|
-
const lastSeenSignal = new Map();
|
|
1532
1606
|
let streamConnected = false;
|
|
1533
1607
|
const hostAvailableExecutionModes = [
|
|
1534
1608
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1562,28 +1636,87 @@ export async function main() {
|
|
|
1562
1636
|
supportsRichCards: useAppServer,
|
|
1563
1637
|
}),
|
|
1564
1638
|
};
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`).catch(() => null);
|
|
1569
|
-
if (!raw || typeof raw !== 'object')
|
|
1639
|
+
function applySessionControl(conversationId, control) {
|
|
1640
|
+
const session = sessions.get(conversationId);
|
|
1641
|
+
if (!session || session.closed)
|
|
1570
1642
|
return;
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
+
}
|
|
1574
1671
|
}
|
|
1575
1672
|
}
|
|
1576
|
-
async function
|
|
1577
|
-
|
|
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);
|
|
1578
1683
|
return;
|
|
1579
|
-
|
|
1580
|
-
if (!
|
|
1684
|
+
}
|
|
1685
|
+
if (!session.running && (type !== 'stop_and_drop' || session.queue.length === 0)) {
|
|
1686
|
+
// Nothing to interrupt or drop — just consume the signal.
|
|
1581
1687
|
return;
|
|
1582
|
-
const timestamp = Number(raw.updatedAt ?? 0);
|
|
1583
|
-
if (timestamp > 0) {
|
|
1584
|
-
lastSeenControl.set(conversationId, timestamp);
|
|
1585
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(() => { });
|
|
1586
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
|
+
});
|
|
1587
1720
|
let publishRuntimeDetailsInFlight = false;
|
|
1588
1721
|
const publishRuntimeHeartbeat = async () => {
|
|
1589
1722
|
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
@@ -1640,6 +1773,12 @@ export async function main() {
|
|
|
1640
1773
|
const payload = {
|
|
1641
1774
|
descriptor,
|
|
1642
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." }),
|
|
1643
1782
|
statusItems: [
|
|
1644
1783
|
{
|
|
1645
1784
|
id: 'transport',
|
|
@@ -1665,6 +1804,11 @@ export async function main() {
|
|
|
1665
1804
|
value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
|
|
1666
1805
|
...(useAppServer ? {} : { tone: 'warning' }),
|
|
1667
1806
|
},
|
|
1807
|
+
{
|
|
1808
|
+
id: 'mediaOut',
|
|
1809
|
+
label: 'Media out',
|
|
1810
|
+
value: 'Turn-end outbox',
|
|
1811
|
+
},
|
|
1668
1812
|
],
|
|
1669
1813
|
execution: {
|
|
1670
1814
|
resolvedWorkspaceLabel: workspace?.label ?? workspaceId ?? null,
|
|
@@ -1801,17 +1945,19 @@ export async function main() {
|
|
|
1801
1945
|
conversationId: conversation.id,
|
|
1802
1946
|
baseCwd: workingDir,
|
|
1803
1947
|
})?.lastInboundMessageId;
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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) {
|
|
1815
1961
|
const triggerDecision = shouldTriggerAgentTurn({
|
|
1816
1962
|
senderType: latestMessage.senderType ?? 'human',
|
|
1817
1963
|
metadata: latestMessage.metadata,
|
|
@@ -1846,89 +1992,7 @@ export async function main() {
|
|
|
1846
1992
|
startCodexStreamInBackground(stream, (error) => {
|
|
1847
1993
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
1848
1994
|
});
|
|
1849
|
-
|
|
1850
|
-
while (!controlStopped) {
|
|
1851
|
-
const hadActiveWork = [...sessions.values()].some((session) => !session.closed
|
|
1852
|
-
&& (session.running || session.queue.length > 0 || session.turnState === 'waiting_input'));
|
|
1853
|
-
for (const conversationId of [...sessions.keys()]) {
|
|
1854
|
-
try {
|
|
1855
|
-
const controlRaw = await rtdbRead(`/control/${conversationId}/${agentId}/session`);
|
|
1856
|
-
if (controlRaw && typeof controlRaw === 'object') {
|
|
1857
|
-
const control = controlRaw;
|
|
1858
|
-
const timestamp = control.updatedAt ?? 0;
|
|
1859
|
-
if (timestamp > (lastSeenControl.get(conversationId) ?? 0)) {
|
|
1860
|
-
lastSeenControl.set(conversationId, timestamp);
|
|
1861
|
-
const session = sessions.get(conversationId);
|
|
1862
|
-
if (session && !session.closed) {
|
|
1863
|
-
if (control.model && control.model !== session.state.model) {
|
|
1864
|
-
const modelGuard = buildCodexModelGuardMessage(control.model, codexCliStatus);
|
|
1865
|
-
if (modelGuard) {
|
|
1866
|
-
session.state.lastError = modelGuard;
|
|
1867
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${modelGuard}`);
|
|
1868
|
-
writeState(session);
|
|
1869
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
|
|
1870
|
-
continue;
|
|
1871
|
-
}
|
|
1872
|
-
session.adapter.setModel(control.model);
|
|
1873
|
-
session.state.model = control.model;
|
|
1874
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
|
|
1875
|
-
writeState(session);
|
|
1876
|
-
}
|
|
1877
|
-
if (control.permissionMode) {
|
|
1878
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] approval mode is session-creation-only; ignoring mid-session change request (${control.permissionMode})`);
|
|
1879
|
-
}
|
|
1880
|
-
if (control.effort) {
|
|
1881
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/session`, null).catch(() => { });
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`);
|
|
1888
|
-
if (!raw || typeof raw !== 'object')
|
|
1889
|
-
continue;
|
|
1890
|
-
const signal = raw;
|
|
1891
|
-
const timestamp = signal.updatedAt ?? 0;
|
|
1892
|
-
if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop' && signal.type !== 'new_session')
|
|
1893
|
-
|| timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
|
|
1894
|
-
continue;
|
|
1895
|
-
}
|
|
1896
|
-
lastSeenSignal.set(conversationId, timestamp);
|
|
1897
|
-
const session = sessions.get(conversationId);
|
|
1898
|
-
if (!session || session.closed)
|
|
1899
|
-
continue;
|
|
1900
|
-
if (signal.type === 'new_session') {
|
|
1901
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
|
|
1902
|
-
await resetRuntimeSession(session);
|
|
1903
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
|
|
1904
|
-
continue;
|
|
1905
|
-
}
|
|
1906
|
-
if (!session.running && (signal.type !== 'stop_and_drop' || session.queue.length === 0)) {
|
|
1907
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
|
|
1908
|
-
continue;
|
|
1909
|
-
}
|
|
1910
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
|
|
1911
|
-
if (signal.type === 'stop_and_drop') {
|
|
1912
|
-
const droppedPrompts = session.queue.splice(0);
|
|
1913
|
-
await markQueuedPromptsRejected(conversationId, droppedPrompts);
|
|
1914
|
-
}
|
|
1915
|
-
if (session.running) {
|
|
1916
|
-
await session.adapter.interrupt();
|
|
1917
|
-
}
|
|
1918
|
-
session.turnState = 'interrupted';
|
|
1919
|
-
writeTurn(session);
|
|
1920
|
-
clearStreaming(conversationId);
|
|
1921
|
-
typingSignals.clear(conversationId).catch(() => { });
|
|
1922
|
-
await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
|
|
1923
|
-
}
|
|
1924
|
-
catch {
|
|
1925
|
-
// Ignore transient RTDB failures.
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
await new Promise((resolve) => setTimeout(resolve, controlPollDelayMs(hadActiveWork)));
|
|
1929
|
-
}
|
|
1930
|
-
};
|
|
1931
|
-
void pollControl();
|
|
1995
|
+
controlPoller.start();
|
|
1932
1996
|
const heartbeat = setInterval(() => {
|
|
1933
1997
|
for (const session of sessions.values()) {
|
|
1934
1998
|
writeState(session);
|
|
@@ -1952,7 +2016,7 @@ export async function main() {
|
|
|
1952
2016
|
}, IDLE_CHECK_MS);
|
|
1953
2017
|
const shutdown = async () => {
|
|
1954
2018
|
console.error('[canon-codex] Shutting down...');
|
|
1955
|
-
|
|
2019
|
+
controlPoller.stop();
|
|
1956
2020
|
clearInterval(heartbeat);
|
|
1957
2021
|
clearInterval(idleCheck);
|
|
1958
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';
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-end media outbox for Canon coding hosts.
|
|
3
|
+
*
|
|
4
|
+
* The host advertises a per-conversation outbox directory inside the session
|
|
5
|
+
* working directory (`<cwd>/.canon/outbox/`). When the runtime wants a file
|
|
6
|
+
* (screenshot, plot, artifact) delivered to the Canon conversation it writes
|
|
7
|
+
* the file there — an explicit channel, never inferred from reply prose. At
|
|
8
|
+
* turn end the host scans the outbox, uploads each regular file as a Canon
|
|
9
|
+
* media attachment, and removes files that were delivered. Failed uploads
|
|
10
|
+
* stay in place for a later turn; subdirectories, symlinks, and dotfiles are
|
|
11
|
+
* ignored.
|
|
12
|
+
*
|
|
13
|
+
* This module is intentionally identical in packages/claude-code-plugin and
|
|
14
|
+
* packages/codex-plugin — keep both copies in sync (future consolidation
|
|
15
|
+
* candidate).
|
|
16
|
+
*/
|
|
17
|
+
export declare const OUTBOX_MAX_FILES_PER_TURN = 8;
|
|
18
|
+
export declare const OUTBOX_MAX_FILE_BYTES: number;
|
|
19
|
+
export interface OutboxFile {
|
|
20
|
+
path: string;
|
|
21
|
+
fileName: string;
|
|
22
|
+
sizeBytes: number;
|
|
23
|
+
}
|
|
24
|
+
export type OutboxSkipReason = 'not-regular-file' | 'hidden' | 'too-large' | 'file-cap';
|
|
25
|
+
export interface OutboxScanResult {
|
|
26
|
+
/** Regular files eligible for upload this turn, ordered by file name. */
|
|
27
|
+
files: OutboxFile[];
|
|
28
|
+
skipped: Array<{
|
|
29
|
+
fileName: string;
|
|
30
|
+
reason: OutboxSkipReason;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export interface OutboxFlushResult {
|
|
34
|
+
sent: Array<{
|
|
35
|
+
file: OutboxFile;
|
|
36
|
+
messageId: string;
|
|
37
|
+
removeFailed?: true;
|
|
38
|
+
}>;
|
|
39
|
+
failed: Array<{
|
|
40
|
+
file: OutboxFile;
|
|
41
|
+
error: string;
|
|
42
|
+
}>;
|
|
43
|
+
skipped: OutboxScanResult['skipped'];
|
|
44
|
+
}
|
|
45
|
+
export declare function resolveOutboxDir(sessionCwd: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Create the outbox directory for a session and drop a `.gitignore` into the
|
|
48
|
+
* host-managed `.canon/` dir (only when absent) so outbox state never shows
|
|
49
|
+
* up as untracked dirt inside project checkouts or conversation worktrees.
|
|
50
|
+
*/
|
|
51
|
+
export declare function ensureOutboxDir(sessionCwd: string): Promise<string>;
|
|
52
|
+
/**
|
|
53
|
+
* The one terse paragraph injected into the runtime's Canon context so the
|
|
54
|
+
* agent knows the outbox exists. Hosts may append their own extra sentence
|
|
55
|
+
* (e.g. an immediate-send tool) but must not paraphrase the convention.
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildOutboxContextLine(sessionCwd: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Discover the outbox files eligible for upload this turn. A missing outbox
|
|
60
|
+
* directory is an empty result. Entries are ordered by file name so multi-file
|
|
61
|
+
* turns deliver deterministically; everything past the per-turn cap (or over
|
|
62
|
+
* the size cap) is left in place and reported as skipped.
|
|
63
|
+
*/
|
|
64
|
+
export declare function scanOutbox(outboxDir: string, options?: {
|
|
65
|
+
maxFiles?: number;
|
|
66
|
+
maxFileBytes?: number;
|
|
67
|
+
}): Promise<OutboxScanResult>;
|
|
68
|
+
/**
|
|
69
|
+
* Upload-and-consume pass over the outbox. Each eligible file is handed to
|
|
70
|
+
* `send`; on success the file is removed (consumed), on failure it is left in
|
|
71
|
+
* place for a later turn. A failed removal after a successful send is still
|
|
72
|
+
* reported as sent (flagged `removeFailed`) so callers can warn about a
|
|
73
|
+
* potential duplicate next turn instead of re-reporting a delivery failure.
|
|
74
|
+
*/
|
|
75
|
+
export declare function flushOutbox(input: {
|
|
76
|
+
outboxDir: string;
|
|
77
|
+
send: (file: OutboxFile) => Promise<{
|
|
78
|
+
messageId: string;
|
|
79
|
+
}>;
|
|
80
|
+
maxFiles?: number;
|
|
81
|
+
maxFileBytes?: number;
|
|
82
|
+
remove?: (path: string) => Promise<void>;
|
|
83
|
+
}): Promise<OutboxFlushResult>;
|