@canonmsg/codex-plugin 0.18.3 → 0.18.5
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/host.d.ts +17 -0
- package/dist/host.js +91 -49
- package/package.json +4 -3
- package/dist/outbox.d.ts +0 -83
- package/dist/outbox.js +0 -132
package/dist/host.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
interface HostSessionState {
|
|
3
|
+
lastError?: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
permissionMode?: string;
|
|
6
|
+
effort?: string;
|
|
7
|
+
/** Tokens consumed by the latest completed turn; Codex reports no context window. */
|
|
8
|
+
contextUsage?: {
|
|
9
|
+
totalTokens: number;
|
|
10
|
+
};
|
|
11
|
+
state: 'idle' | 'running';
|
|
12
|
+
}
|
|
13
|
+
export declare function buildCodexInitialSessionState(input: {
|
|
14
|
+
model?: string;
|
|
15
|
+
permissionMode?: string;
|
|
16
|
+
effort?: string | null;
|
|
17
|
+
}): HostSessionState;
|
|
2
18
|
/** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
|
|
3
19
|
export declare const CODEX_EFFORT_OPTIONS: readonly [{
|
|
4
20
|
readonly value: "minimal";
|
|
@@ -17,3 +33,4 @@ export declare const CODEX_EFFORT_OPTIONS: readonly [{
|
|
|
17
33
|
readonly label: "Extra high";
|
|
18
34
|
}];
|
|
19
35
|
export declare function main(): Promise<void>;
|
|
36
|
+
export {};
|
package/dist/host.js
CHANGED
|
@@ -5,7 +5,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { dirname } from 'node:path';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
8
|
-
import {
|
|
8
|
+
import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
|
|
9
|
+
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, sendMessageWithRetry, sendMessageWithRetryChunked, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
9
10
|
import { decideAutoReply, } from './inbound-policy.js';
|
|
10
11
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
11
12
|
import { CodexAppServerAdapter } from './app-server-adapter.js';
|
|
@@ -16,7 +17,6 @@ import { detectCodexCliVersion } from './codex-cli-version.js';
|
|
|
16
17
|
import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
|
|
17
18
|
import { startCodexStreamInBackground } from './host-lifecycle.js';
|
|
18
19
|
import { createCodexControlPoller } from './control-channel.js';
|
|
19
|
-
import { buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
|
|
20
20
|
import { runCli } from './cli-entry.js';
|
|
21
21
|
import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
22
22
|
import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
|
|
@@ -53,6 +53,14 @@ Keep this terminal open while you want Canon to reach the agent. Closing it,
|
|
|
53
53
|
logging out, rebooting, or sleeping long enough to stop the process takes the
|
|
54
54
|
local agent offline until you revive it. Docs:
|
|
55
55
|
https://canonmail.com/agents/integrations`;
|
|
56
|
+
export function buildCodexInitialSessionState(input) {
|
|
57
|
+
return {
|
|
58
|
+
model: input.model,
|
|
59
|
+
permissionMode: input.permissionMode,
|
|
60
|
+
...(input.effort ? { effort: input.effort } : {}),
|
|
61
|
+
state: 'idle',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
56
64
|
const MAX_SESSIONS = 12;
|
|
57
65
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
58
66
|
const HEARTBEAT_MS = 30_000;
|
|
@@ -248,7 +256,7 @@ function resolveCodexEffectiveRuntimePolicy(input) {
|
|
|
248
256
|
};
|
|
249
257
|
}
|
|
250
258
|
function buildCanonPrompt(input) {
|
|
251
|
-
return
|
|
259
|
+
return renderCodingHostInboundPrompt(buildCanonInboundFrameV1(buildCanonTurnContextV2({
|
|
252
260
|
content: input.content,
|
|
253
261
|
conversationId: input.conversationId,
|
|
254
262
|
participantContext: input.participantContext,
|
|
@@ -257,9 +265,8 @@ function buildCanonPrompt(input) {
|
|
|
257
265
|
activeSelfContextId: input.activeSelfContextId,
|
|
258
266
|
provenance: input.provenance,
|
|
259
267
|
replyContext: input.replyContext,
|
|
260
|
-
sessionContextLines: input.sessionContextLines,
|
|
261
268
|
message: input.message,
|
|
262
|
-
}));
|
|
269
|
+
})));
|
|
263
270
|
}
|
|
264
271
|
function renderInboundContent(message, materialized) {
|
|
265
272
|
return renderCanonHostInboundContent(message, materialized);
|
|
@@ -867,11 +874,11 @@ export async function main() {
|
|
|
867
874
|
adapter,
|
|
868
875
|
queue: [],
|
|
869
876
|
running: false,
|
|
870
|
-
state: {
|
|
877
|
+
state: buildCodexInitialSessionState({
|
|
871
878
|
model: policy.model,
|
|
872
879
|
permissionMode: policy.permissionMode,
|
|
873
|
-
|
|
874
|
-
},
|
|
880
|
+
effort: initialEffort,
|
|
881
|
+
}),
|
|
875
882
|
policyFingerprint: policy.fingerprint,
|
|
876
883
|
turnState: 'idle',
|
|
877
884
|
currentTurnId: null,
|
|
@@ -889,7 +896,6 @@ export async function main() {
|
|
|
889
896
|
};
|
|
890
897
|
sessions.set(conversationId, session);
|
|
891
898
|
await controlPoller.baseline([conversationId]);
|
|
892
|
-
ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
|
|
893
899
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
894
900
|
writeState(session);
|
|
895
901
|
writeTurn(session);
|
|
@@ -908,8 +914,17 @@ export async function main() {
|
|
|
908
914
|
pendingSessionCreations.delete(conversationId);
|
|
909
915
|
}
|
|
910
916
|
}
|
|
911
|
-
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false) {
|
|
912
|
-
const nextPrompt = {
|
|
917
|
+
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false, artifactRoutingMode = 'disabled') {
|
|
918
|
+
const nextPrompt = {
|
|
919
|
+
prompt,
|
|
920
|
+
intent,
|
|
921
|
+
sourceMessageId,
|
|
922
|
+
markAccepted,
|
|
923
|
+
imagePaths,
|
|
924
|
+
mediaAddDirs,
|
|
925
|
+
planMode,
|
|
926
|
+
artifactRoutingMode,
|
|
927
|
+
};
|
|
913
928
|
if (toFront) {
|
|
914
929
|
session.queue.unshift(nextPrompt);
|
|
915
930
|
}
|
|
@@ -920,6 +935,11 @@ export async function main() {
|
|
|
920
935
|
writeTurn(session);
|
|
921
936
|
void runNextTurn(session);
|
|
922
937
|
}
|
|
938
|
+
function resolveArtifactRoutingMode(participantContext) {
|
|
939
|
+
return participantContext.conversationType === 'direct' && participantContext.isOwner
|
|
940
|
+
? 'workspace-generated'
|
|
941
|
+
: 'disabled';
|
|
942
|
+
}
|
|
923
943
|
async function waitForRuntimeInputResponse(input) {
|
|
924
944
|
while (Date.now() < input.expiresAt) {
|
|
925
945
|
const response = await client.consumeRuntimeInputResponse({
|
|
@@ -1289,56 +1309,59 @@ export async function main() {
|
|
|
1289
1309
|
activeSelfContextId,
|
|
1290
1310
|
provenance: hydrated.provenance,
|
|
1291
1311
|
replyContext,
|
|
1292
|
-
sessionContextLines: [buildOutboxContextLine(session.cwd)],
|
|
1293
1312
|
message: input.message,
|
|
1294
1313
|
});
|
|
1295
1314
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
1296
|
-
|
|
1315
|
+
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
1316
|
+
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode);
|
|
1297
1317
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
1298
1318
|
await session.adapter.interrupt().catch(() => { });
|
|
1299
1319
|
clearStreaming(input.conversationId);
|
|
1300
1320
|
typingSignals.clear(input.conversationId).catch(() => { });
|
|
1301
1321
|
return;
|
|
1302
1322
|
}
|
|
1303
|
-
|
|
1323
|
+
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
1324
|
+
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode);
|
|
1304
1325
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1326
|
+
function sendTurnArtifactFile(session, file) {
|
|
1327
|
+
return sendMediaFileMessage(client, session.conversationId, file.path, '', {
|
|
1328
|
+
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1329
|
+
metadata: {
|
|
1330
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
1331
|
+
// Media is permanent conversation content (promoted like a final
|
|
1332
|
+
// reply) but must never re-trigger other agents — the host's own
|
|
1333
|
+
// final text message stays the only turn-complete trigger.
|
|
1334
|
+
turnSemantics: 'turn_complete',
|
|
1335
|
+
replyBehavior: 'suppress_auto_reply',
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
async function routeWorkspaceGeneratedArtifacts(session, baseline) {
|
|
1340
|
+
if (!baseline)
|
|
1341
|
+
return;
|
|
1311
1342
|
const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
|
|
1312
1343
|
try {
|
|
1313
|
-
const result = await
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1317
|
-
metadata: {
|
|
1318
|
-
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
1319
|
-
// Media is permanent conversation content (promoted like a final
|
|
1320
|
-
// reply) but must never re-trigger other agents — the host's own
|
|
1321
|
-
// final text message stays the only turn-complete trigger.
|
|
1322
|
-
turnSemantics: 'turn_complete',
|
|
1323
|
-
replyBehavior: 'suppress_auto_reply',
|
|
1324
|
-
},
|
|
1325
|
-
}),
|
|
1344
|
+
const result = await collectTurnArtifacts({
|
|
1345
|
+
cwd: session.cwd,
|
|
1346
|
+
baseline,
|
|
1326
1347
|
});
|
|
1327
|
-
for (const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1348
|
+
for (const file of result.files) {
|
|
1349
|
+
try {
|
|
1350
|
+
const { messageId } = await sendTurnArtifactFile(session, file);
|
|
1351
|
+
console.error(`${logPrefix} Routed generated artifact ${file.relativePath ?? file.fileName} (${messageId})`);
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
console.error(`${logPrefix} Artifact upload failed for ${file.relativePath ?? file.fileName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1355
|
+
}
|
|
1333
1356
|
}
|
|
1334
1357
|
for (const skipped of result.skipped) {
|
|
1335
|
-
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
|
|
1336
|
-
console.error(`${logPrefix}
|
|
1358
|
+
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'scan-limit') {
|
|
1359
|
+
console.error(`${logPrefix} Artifact skipped ${skipped.fileName} (${skipped.reason})`);
|
|
1337
1360
|
}
|
|
1338
1361
|
}
|
|
1339
1362
|
}
|
|
1340
1363
|
catch (error) {
|
|
1341
|
-
console.error(`${logPrefix}
|
|
1364
|
+
console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
|
|
1342
1365
|
}
|
|
1343
1366
|
}
|
|
1344
1367
|
async function runNextTurn(session) {
|
|
@@ -1366,7 +1389,26 @@ export async function main() {
|
|
|
1366
1389
|
// Status-only seed: 'thinking' renders as a working filament row on the
|
|
1367
1390
|
// clients; text here would be bubbled as speech (v4 register rule).
|
|
1368
1391
|
writeCodexStreaming(session, '', 'thinking');
|
|
1392
|
+
let artifactBaseline = null;
|
|
1393
|
+
let artifactsRouted = false;
|
|
1394
|
+
const routeArtifactsOnce = async () => {
|
|
1395
|
+
if (artifactsRouted)
|
|
1396
|
+
return;
|
|
1397
|
+
artifactsRouted = true;
|
|
1398
|
+
if (nextTurn.artifactRoutingMode === 'workspace-generated') {
|
|
1399
|
+
await routeWorkspaceGeneratedArtifacts(session, artifactBaseline);
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1369
1402
|
try {
|
|
1403
|
+
const turnId = session.currentTurnId ?? randomUUID();
|
|
1404
|
+
session.currentTurnId = turnId;
|
|
1405
|
+
let turnPrompt = nextTurn.prompt;
|
|
1406
|
+
if (nextTurn.artifactRoutingMode === 'workspace-generated') {
|
|
1407
|
+
artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
|
|
1408
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
|
|
1409
|
+
return null;
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1370
1412
|
const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
|
|
1371
1413
|
if (modelGuard) {
|
|
1372
1414
|
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
@@ -1470,7 +1512,7 @@ export async function main() {
|
|
|
1470
1512
|
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
1471
1513
|
session.adapter.clearThreadId();
|
|
1472
1514
|
};
|
|
1473
|
-
const runTurnOnce = () => session.adapter.runTurn(
|
|
1515
|
+
const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
|
|
1474
1516
|
planMode: nextTurn.planMode,
|
|
1475
1517
|
onServerRequest: (request) => handleCodexServerRequest(session, request),
|
|
1476
1518
|
});
|
|
@@ -1487,12 +1529,8 @@ export async function main() {
|
|
|
1487
1529
|
if (result.threadId && !session.resetRequested) {
|
|
1488
1530
|
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
1489
1531
|
}
|
|
1490
|
-
// Turn-end outbox flush — media lands before the final text reply.
|
|
1491
|
-
// Interrupted turns keep their files for the next completed turn.
|
|
1492
|
-
if (!result.interrupted) {
|
|
1493
|
-
await flushSessionOutbox(session);
|
|
1494
|
-
}
|
|
1495
1532
|
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1533
|
+
await routeArtifactsOnce();
|
|
1496
1534
|
const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
|
|
1497
1535
|
responseUserId: ownerId ?? undefined,
|
|
1498
1536
|
title: 'Codex Plan',
|
|
@@ -1514,6 +1552,7 @@ export async function main() {
|
|
|
1514
1552
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
1515
1553
|
clearStoredThread();
|
|
1516
1554
|
}
|
|
1555
|
+
await routeArtifactsOnce();
|
|
1517
1556
|
const turnTrail = buildFinalTurnTrail(session);
|
|
1518
1557
|
await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
|
|
1519
1558
|
messageId: buildCodexMessageId(session, 'final'),
|
|
@@ -1531,6 +1570,7 @@ export async function main() {
|
|
|
1531
1570
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
|
|
1532
1571
|
}
|
|
1533
1572
|
else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
|
|
1573
|
+
await routeArtifactsOnce();
|
|
1534
1574
|
const userVisibleError = formatCodexTurnFailure(result.errorText);
|
|
1535
1575
|
session.state.lastError = userVisibleError;
|
|
1536
1576
|
writeState(session);
|
|
@@ -1553,6 +1593,7 @@ export async function main() {
|
|
|
1553
1593
|
await handoffFinalMessage(session.conversationId);
|
|
1554
1594
|
}
|
|
1555
1595
|
else if (!result.interrupted) {
|
|
1596
|
+
await routeArtifactsOnce();
|
|
1556
1597
|
await handoffFinalMessage(session.conversationId);
|
|
1557
1598
|
}
|
|
1558
1599
|
else if (result.interrupted) {
|
|
@@ -1572,6 +1613,7 @@ export async function main() {
|
|
|
1572
1613
|
: `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
|
|
1573
1614
|
session.state.lastError = message;
|
|
1574
1615
|
writeState(session);
|
|
1616
|
+
await routeArtifactsOnce();
|
|
1575
1617
|
await sendMessageWithRetryChunked(client, session.conversationId, message, {
|
|
1576
1618
|
messageId: buildCodexMessageId(session, 'failure'),
|
|
1577
1619
|
...(session.activeSelfContextId
|
|
@@ -1817,7 +1859,7 @@ export async function main() {
|
|
|
1817
1859
|
{
|
|
1818
1860
|
id: 'mediaOut',
|
|
1819
1861
|
label: 'Media out',
|
|
1820
|
-
value: '
|
|
1862
|
+
value: 'Generated media artifacts',
|
|
1821
1863
|
},
|
|
1822
1864
|
],
|
|
1823
1865
|
execution: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.5",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"scripts"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk",
|
|
24
|
+
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../coding-agent-host",
|
|
25
25
|
"build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
26
26
|
"dev": "npm run prepare:workspace-deps && tsc --watch",
|
|
27
27
|
"smoke": "node scripts/smoke-test.mjs",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@canonmsg/agent-sdk": "^3.2.3",
|
|
33
|
-
"@canonmsg/
|
|
33
|
+
"@canonmsg/coding-agent-host": "^0.2.2",
|
|
34
|
+
"@canonmsg/core": "^2.8.0"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
|
36
37
|
"node": ">=18.0.0"
|
package/dist/outbox.d.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
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>;
|
package/dist/outbox.js
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
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
|
-
import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
export const OUTBOX_MAX_FILES_PER_TURN = 8;
|
|
20
|
-
export const OUTBOX_MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
21
|
-
export function resolveOutboxDir(sessionCwd) {
|
|
22
|
-
return join(sessionCwd, '.canon', 'outbox');
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Create the outbox directory for a session and drop a `.gitignore` into the
|
|
26
|
-
* host-managed `.canon/` dir (only when absent) so outbox state never shows
|
|
27
|
-
* up as untracked dirt inside project checkouts or conversation worktrees.
|
|
28
|
-
*/
|
|
29
|
-
export async function ensureOutboxDir(sessionCwd) {
|
|
30
|
-
const dir = resolveOutboxDir(sessionCwd);
|
|
31
|
-
await mkdir(dir, { recursive: true });
|
|
32
|
-
try {
|
|
33
|
-
// The `*` pattern ignores everything under .canon, including this file.
|
|
34
|
-
await writeFile(join(sessionCwd, '.canon', '.gitignore'), '*\n', { flag: 'wx' });
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// Already present (or unwritable) — never block session startup on it.
|
|
38
|
-
}
|
|
39
|
-
return dir;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* The one terse paragraph injected into the runtime's Canon context so the
|
|
43
|
-
* agent knows the outbox exists. Hosts may append their own extra sentence
|
|
44
|
-
* (e.g. an immediate-send tool) but must not paraphrase the convention.
|
|
45
|
-
*/
|
|
46
|
-
export function buildOutboxContextLine(sessionCwd) {
|
|
47
|
-
const maxMb = Math.floor(OUTBOX_MAX_FILE_BYTES / (1024 * 1024));
|
|
48
|
-
return `Media outbox: to deliver a file (screenshot, plot, artifact) to this Canon conversation, write it into ${resolveOutboxDir(sessionCwd)} — when your turn ends the host uploads each regular file there as a chat attachment and then deletes it. Limits: ${OUTBOX_MAX_FILES_PER_TURN} files per turn and ${maxMb}MB per file; subdirectories, symlinks, and dotfiles are ignored.`;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Discover the outbox files eligible for upload this turn. A missing outbox
|
|
52
|
-
* directory is an empty result. Entries are ordered by file name so multi-file
|
|
53
|
-
* turns deliver deterministically; everything past the per-turn cap (or over
|
|
54
|
-
* the size cap) is left in place and reported as skipped.
|
|
55
|
-
*/
|
|
56
|
-
export async function scanOutbox(outboxDir, options) {
|
|
57
|
-
const maxFiles = options?.maxFiles ?? OUTBOX_MAX_FILES_PER_TURN;
|
|
58
|
-
const maxFileBytes = options?.maxFileBytes ?? OUTBOX_MAX_FILE_BYTES;
|
|
59
|
-
let entries;
|
|
60
|
-
try {
|
|
61
|
-
entries = await readdir(outboxDir, { withFileTypes: true });
|
|
62
|
-
}
|
|
63
|
-
catch (error) {
|
|
64
|
-
if (error.code === 'ENOENT') {
|
|
65
|
-
return { files: [], skipped: [] };
|
|
66
|
-
}
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
const files = [];
|
|
70
|
-
const skipped = [];
|
|
71
|
-
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
72
|
-
// `readdir` does not follow symlinks, so a symlinked file reports
|
|
73
|
-
// isSymbolicLink(), not isFile() — links and directories both land here.
|
|
74
|
-
if (!entry.isFile()) {
|
|
75
|
-
skipped.push({ fileName: entry.name, reason: 'not-regular-file' });
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (entry.name.startsWith('.')) {
|
|
79
|
-
skipped.push({ fileName: entry.name, reason: 'hidden' });
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
const path = join(outboxDir, entry.name);
|
|
83
|
-
const info = await stat(path);
|
|
84
|
-
if (info.size > maxFileBytes) {
|
|
85
|
-
skipped.push({ fileName: entry.name, reason: 'too-large' });
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (files.length >= maxFiles) {
|
|
89
|
-
skipped.push({ fileName: entry.name, reason: 'file-cap' });
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
files.push({ path, fileName: entry.name, sizeBytes: info.size });
|
|
93
|
-
}
|
|
94
|
-
return { files, skipped };
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Upload-and-consume pass over the outbox. Each eligible file is handed to
|
|
98
|
-
* `send`; on success the file is removed (consumed), on failure it is left in
|
|
99
|
-
* place for a later turn. A failed removal after a successful send is still
|
|
100
|
-
* reported as sent (flagged `removeFailed`) so callers can warn about a
|
|
101
|
-
* potential duplicate next turn instead of re-reporting a delivery failure.
|
|
102
|
-
*/
|
|
103
|
-
export async function flushOutbox(input) {
|
|
104
|
-
const { files, skipped } = await scanOutbox(input.outboxDir, {
|
|
105
|
-
...(input.maxFiles != null ? { maxFiles: input.maxFiles } : {}),
|
|
106
|
-
...(input.maxFileBytes != null ? { maxFileBytes: input.maxFileBytes } : {}),
|
|
107
|
-
});
|
|
108
|
-
const remove = input.remove ?? ((path) => unlink(path));
|
|
109
|
-
const sent = [];
|
|
110
|
-
const failed = [];
|
|
111
|
-
for (const file of files) {
|
|
112
|
-
let messageId;
|
|
113
|
-
try {
|
|
114
|
-
({ messageId } = await input.send(file));
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
failed.push({
|
|
118
|
-
file,
|
|
119
|
-
error: error instanceof Error ? error.message : String(error),
|
|
120
|
-
});
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
await remove(file.path);
|
|
125
|
-
sent.push({ file, messageId });
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
sent.push({ file, messageId, removeFailed: true });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return { sent, failed, skipped };
|
|
132
|
-
}
|