@canonmsg/codex-plugin 0.18.2 → 0.18.4
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 +33 -25
- package/package.json +3 -2
- package/dist/outbox.d.ts +0 -83
- package/dist/outbox.js +0 -132
package/dist/host.d.ts
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
|
|
3
|
+
export declare const CODEX_EFFORT_OPTIONS: readonly [{
|
|
4
|
+
readonly value: "minimal";
|
|
5
|
+
readonly label: "Minimal";
|
|
6
|
+
}, {
|
|
7
|
+
readonly value: "low";
|
|
8
|
+
readonly label: "Low";
|
|
9
|
+
}, {
|
|
10
|
+
readonly value: "medium";
|
|
11
|
+
readonly label: "Medium";
|
|
12
|
+
}, {
|
|
13
|
+
readonly value: "high";
|
|
14
|
+
readonly label: "High";
|
|
15
|
+
}, {
|
|
16
|
+
readonly value: "xhigh";
|
|
17
|
+
readonly label: "Extra high";
|
|
18
|
+
}];
|
|
2
19
|
export declare function main(): Promise<void>;
|
package/dist/host.js
CHANGED
|
@@ -5,6 +5,7 @@ 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 { buildTurnArtifactContextLine, ensureTurnArtifactDir, flushTurnArtifacts, } from '@canonmsg/coding-agent-host';
|
|
8
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, 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
10
|
import { decideAutoReply, } from './inbound-policy.js';
|
|
10
11
|
import { CodexConversationAdapter, } from './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';
|
|
@@ -69,11 +69,12 @@ let workspaceOptions = [];
|
|
|
69
69
|
let workspaceRoots = [];
|
|
70
70
|
let workspaceRootMetadata = [];
|
|
71
71
|
/** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
|
|
72
|
-
const CODEX_EFFORT_OPTIONS = [
|
|
72
|
+
export const CODEX_EFFORT_OPTIONS = [
|
|
73
73
|
{ value: 'minimal', label: 'Minimal' },
|
|
74
74
|
{ value: 'low', label: 'Low' },
|
|
75
75
|
{ value: 'medium', label: 'Medium' },
|
|
76
76
|
{ value: 'high', label: 'High' },
|
|
77
|
+
{ value: 'xhigh', label: 'Extra high' },
|
|
77
78
|
];
|
|
78
79
|
const CODEX_EFFORT_VALUES = new Set(CODEX_EFFORT_OPTIONS.map((option) => option.value));
|
|
79
80
|
function buildCodexRuntimeDescriptor(input) {
|
|
@@ -888,7 +889,6 @@ export async function main() {
|
|
|
888
889
|
};
|
|
889
890
|
sessions.set(conversationId, session);
|
|
890
891
|
await controlPoller.baseline([conversationId]);
|
|
891
|
-
ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
|
|
892
892
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
893
893
|
writeState(session);
|
|
894
894
|
writeTurn(session);
|
|
@@ -1288,7 +1288,6 @@ export async function main() {
|
|
|
1288
1288
|
activeSelfContextId,
|
|
1289
1289
|
provenance: hydrated.provenance,
|
|
1290
1290
|
replyContext,
|
|
1291
|
-
sessionContextLines: [buildOutboxContextLine(session.cwd)],
|
|
1292
1291
|
message: input.message,
|
|
1293
1292
|
});
|
|
1294
1293
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
@@ -1301,16 +1300,13 @@ export async function main() {
|
|
|
1301
1300
|
}
|
|
1302
1301
|
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
1303
1302
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
* later turn.
|
|
1308
|
-
*/
|
|
1309
|
-
async function flushSessionOutbox(session) {
|
|
1303
|
+
async function routeSessionArtifacts(session, artifactDir) {
|
|
1304
|
+
if (!artifactDir)
|
|
1305
|
+
return;
|
|
1310
1306
|
const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
|
|
1311
1307
|
try {
|
|
1312
|
-
const result = await
|
|
1313
|
-
|
|
1308
|
+
const result = await flushTurnArtifacts({
|
|
1309
|
+
artifactDir,
|
|
1314
1310
|
send: (file) => sendMediaFileMessage(client, session.conversationId, file.path, '', {
|
|
1315
1311
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1316
1312
|
metadata: {
|
|
@@ -1324,20 +1320,20 @@ export async function main() {
|
|
|
1324
1320
|
}),
|
|
1325
1321
|
});
|
|
1326
1322
|
for (const entry of result.sent) {
|
|
1327
|
-
console.error(`${logPrefix}
|
|
1328
|
-
+ (entry.removeFailed ? '
|
|
1323
|
+
console.error(`${logPrefix} Routed artifact ${entry.file.fileName} (${entry.messageId})`
|
|
1324
|
+
+ (entry.removeFailed ? '; removal failed, so it may resend later' : ''));
|
|
1329
1325
|
}
|
|
1330
1326
|
for (const failure of result.failed) {
|
|
1331
|
-
console.error(`${logPrefix}
|
|
1327
|
+
console.error(`${logPrefix} Artifact upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
|
|
1332
1328
|
}
|
|
1333
1329
|
for (const skipped of result.skipped) {
|
|
1334
|
-
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
|
|
1335
|
-
console.error(`${logPrefix}
|
|
1330
|
+
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'unsupported') {
|
|
1331
|
+
console.error(`${logPrefix} Artifact skipped ${skipped.fileName} (${skipped.reason})`);
|
|
1336
1332
|
}
|
|
1337
1333
|
}
|
|
1338
1334
|
}
|
|
1339
1335
|
catch (error) {
|
|
1340
|
-
console.error(`${logPrefix}
|
|
1336
|
+
console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
|
|
1341
1337
|
}
|
|
1342
1338
|
}
|
|
1343
1339
|
async function runNextTurn(session) {
|
|
@@ -1365,7 +1361,19 @@ export async function main() {
|
|
|
1365
1361
|
// Status-only seed: 'thinking' renders as a working filament row on the
|
|
1366
1362
|
// clients; text here would be bubbled as speech (v4 register rule).
|
|
1367
1363
|
writeCodexStreaming(session, '', 'thinking');
|
|
1364
|
+
let artifactDir = null;
|
|
1365
|
+
let artifactsRouted = false;
|
|
1366
|
+
const routeArtifactsOnce = async () => {
|
|
1367
|
+
if (artifactsRouted)
|
|
1368
|
+
return;
|
|
1369
|
+
artifactsRouted = true;
|
|
1370
|
+
await routeSessionArtifacts(session, artifactDir);
|
|
1371
|
+
};
|
|
1368
1372
|
try {
|
|
1373
|
+
const turnId = session.currentTurnId ?? randomUUID();
|
|
1374
|
+
session.currentTurnId = turnId;
|
|
1375
|
+
artifactDir = await ensureTurnArtifactDir(session.cwd, turnId);
|
|
1376
|
+
const turnPrompt = `${nextTurn.prompt}\n\n${buildTurnArtifactContextLine(artifactDir)}`;
|
|
1369
1377
|
const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
|
|
1370
1378
|
if (modelGuard) {
|
|
1371
1379
|
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
@@ -1469,7 +1477,7 @@ export async function main() {
|
|
|
1469
1477
|
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
1470
1478
|
session.adapter.clearThreadId();
|
|
1471
1479
|
};
|
|
1472
|
-
const runTurnOnce = () => session.adapter.runTurn(
|
|
1480
|
+
const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
|
|
1473
1481
|
planMode: nextTurn.planMode,
|
|
1474
1482
|
onServerRequest: (request) => handleCodexServerRequest(session, request),
|
|
1475
1483
|
});
|
|
@@ -1486,12 +1494,8 @@ export async function main() {
|
|
|
1486
1494
|
if (result.threadId && !session.resetRequested) {
|
|
1487
1495
|
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
1488
1496
|
}
|
|
1489
|
-
// Turn-end outbox flush — media lands before the final text reply.
|
|
1490
|
-
// Interrupted turns keep their files for the next completed turn.
|
|
1491
|
-
if (!result.interrupted) {
|
|
1492
|
-
await flushSessionOutbox(session);
|
|
1493
|
-
}
|
|
1494
1497
|
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1498
|
+
await routeArtifactsOnce();
|
|
1495
1499
|
const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
|
|
1496
1500
|
responseUserId: ownerId ?? undefined,
|
|
1497
1501
|
title: 'Codex Plan',
|
|
@@ -1513,6 +1517,7 @@ export async function main() {
|
|
|
1513
1517
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
1514
1518
|
clearStoredThread();
|
|
1515
1519
|
}
|
|
1520
|
+
await routeArtifactsOnce();
|
|
1516
1521
|
const turnTrail = buildFinalTurnTrail(session);
|
|
1517
1522
|
await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
|
|
1518
1523
|
messageId: buildCodexMessageId(session, 'final'),
|
|
@@ -1530,6 +1535,7 @@ export async function main() {
|
|
|
1530
1535
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
|
|
1531
1536
|
}
|
|
1532
1537
|
else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
|
|
1538
|
+
await routeArtifactsOnce();
|
|
1533
1539
|
const userVisibleError = formatCodexTurnFailure(result.errorText);
|
|
1534
1540
|
session.state.lastError = userVisibleError;
|
|
1535
1541
|
writeState(session);
|
|
@@ -1552,6 +1558,7 @@ export async function main() {
|
|
|
1552
1558
|
await handoffFinalMessage(session.conversationId);
|
|
1553
1559
|
}
|
|
1554
1560
|
else if (!result.interrupted) {
|
|
1561
|
+
await routeArtifactsOnce();
|
|
1555
1562
|
await handoffFinalMessage(session.conversationId);
|
|
1556
1563
|
}
|
|
1557
1564
|
else if (result.interrupted) {
|
|
@@ -1571,6 +1578,7 @@ export async function main() {
|
|
|
1571
1578
|
: `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
|
|
1572
1579
|
session.state.lastError = message;
|
|
1573
1580
|
writeState(session);
|
|
1581
|
+
await routeArtifactsOnce();
|
|
1574
1582
|
await sendMessageWithRetryChunked(client, session.conversationId, message, {
|
|
1575
1583
|
messageId: buildCodexMessageId(session, 'failure'),
|
|
1576
1584
|
...(session.activeSelfContextId
|
|
@@ -1816,7 +1824,7 @@ export async function main() {
|
|
|
1816
1824
|
{
|
|
1817
1825
|
id: 'mediaOut',
|
|
1818
1826
|
label: 'Media out',
|
|
1819
|
-
value: '
|
|
1827
|
+
value: 'Explicit turn artifact directory',
|
|
1820
1828
|
},
|
|
1821
1829
|
],
|
|
1822
1830
|
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.4",
|
|
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,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@canonmsg/agent-sdk": "^3.2.3",
|
|
33
|
+
"@canonmsg/coding-agent-host": "^0.2.0",
|
|
33
34
|
"@canonmsg/core": "^2.6.0"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
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
|
-
}
|