@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 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 { 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';
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 renderCanonTurnBriefPrompt(buildCanonTurnContextV2({
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
- state: 'idle',
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 = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs, planMode };
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
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
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
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
1323
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
1324
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode, artifactRoutingMode);
1304
1325
  }
1305
- /**
1306
- * Turn-end media outbox flush (see ./outbox.ts for the convention). Never
1307
- * throws: upload failures are logged and the files stay in place for a
1308
- * later turn.
1309
- */
1310
- async function flushSessionOutbox(session) {
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 flushOutbox({
1314
- outboxDir: resolveOutboxDir(session.cwd),
1315
- send: (file) => sendMediaFileMessage(client, session.conversationId, file.path, '', {
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 entry of result.sent) {
1328
- console.error(`${logPrefix} Sent outbox file ${entry.file.fileName} (${entry.messageId})`
1329
- + (entry.removeFailed ? ' — removal failed; it may resend next turn' : ''));
1330
- }
1331
- for (const failure of result.failed) {
1332
- console.error(`${logPrefix} Outbox upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
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} Outbox skipped ${skipped.fileName} (${skipped.reason}); left in place`);
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} Outbox flush failed:`, error instanceof Error ? error.message : error);
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(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
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: 'Turn-end outbox',
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",
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/core": "^2.6.0"
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
- }