@canonmsg/claude-code-plugin 0.19.0 → 0.19.2

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.
@@ -1,5 +1,5 @@
1
1
  import { isAbsolute, join, relative } from 'node:path';
2
- import { truncateUnifiedDiff, } from '@canonmsg/core';
2
+ import { sanitizeUnifiedDiffForSharing, truncateUnifiedDiff, } from '@canonmsg/core';
3
3
  /** Unified-hunk context lines on either side of a change (git default). */
4
4
  const HUNK_CONTEXT_LINES = 3;
5
5
  /**
@@ -68,7 +68,9 @@ export async function synthesizeClaudeApprovalDiff(toolName, toolInput, options)
68
68
  const hunks = buildUnifiedHunks(oldText, postImage);
69
69
  if (!hunks)
70
70
  return undefined;
71
- return truncateUnifiedDiff({
71
+ // Sanitize before truncating so byte budgets are spent on hunks that ship —
72
+ // secret-bearing paths and private-key blocks lose their hunk text here.
73
+ return truncateUnifiedDiff(sanitizeUnifiedDiffForSharing({
72
74
  files: [{
73
75
  path: toDisplayPath(resolvedPath, options.cwd),
74
76
  status,
@@ -76,7 +78,7 @@ export async function synthesizeClaudeApprovalDiff(toolName, toolInput, options)
76
78
  additions: hunks.additions,
77
79
  deletions: hunks.deletions,
78
80
  }],
79
- });
81
+ }));
80
82
  }
81
83
  // ── Edit splicing ────────────────────────────────────────────────────
82
84
  function parseSingleEdit(toolInput) {
package/dist/host.js CHANGED
@@ -24,15 +24,13 @@ import { parseArgs } from 'node:util';
24
24
  import { randomUUID } from 'node:crypto';
25
25
  import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
- import { readFile, stat } from 'node:fs/promises';
28
- import { basename, isAbsolute, join } from 'node:path';
29
- import { createSdkMcpServer, query, tool, } from '@anthropic-ai/claude-agent-sdk';
30
- import { z } from 'zod';
27
+ import { readFile } from 'node:fs/promises';
28
+ import { query, } from '@anthropic-ai/claude-agent-sdk';
31
29
  import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
30
+ import { buildTurnArtifactContextLine, ensureTurnArtifactDir, flushTurnArtifacts, } from '@canonmsg/coding-agent-host';
32
31
  import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
33
32
  import { decideAutoReply, } from './inbound-policy.js';
34
33
  import { runCli } from './cli-entry.js';
35
- import { OUTBOX_MAX_FILE_BYTES, buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
36
34
  import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
37
35
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
38
36
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
@@ -582,7 +580,7 @@ function buildClaudeRuntimeInfo(input) {
582
580
  {
583
581
  id: 'mediaOut',
584
582
  label: 'Media out',
585
- value: 'Turn-end outbox + canon_send_file',
583
+ value: 'Explicit turn artifact directory',
586
584
  tier: 'detail',
587
585
  },
588
586
  ].filter((item) => Boolean(item));
@@ -672,7 +670,10 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
672
670
  supportsNonFinalPermanentMessages: false,
673
671
  };
674
672
  function buildCanonPrompt(input) {
675
- const sessionContextLines = input.session ? buildClaudeSessionContextLines(input.session) : undefined;
673
+ const sessionContextLines = [
674
+ ...(input.session ? buildClaudeSessionContextLines(input.session) : []),
675
+ ...(input.artifactDir ? [buildTurnArtifactContextLine(input.artifactDir)] : []),
676
+ ];
676
677
  return renderCanonTurnBriefPrompt(buildCanonTurnContextV2({
677
678
  content: input.content,
678
679
  conversationId: input.conversationId,
@@ -682,7 +683,7 @@ function buildCanonPrompt(input) {
682
683
  activeSelfContextId: input.activeSelfContextId,
683
684
  provenance: input.provenance,
684
685
  replyContext: input.replyContext,
685
- sessionContextLines,
686
+ sessionContextLines: sessionContextLines.length ? sessionContextLines : undefined,
686
687
  message: input.message,
687
688
  }));
688
689
  }
@@ -706,7 +707,6 @@ function buildClaudeSessionContextLines(session) {
706
707
  `Working directory: ${session.cwd}`,
707
708
  session.environment.branch ? `Git branch: ${session.environment.branch}` : null,
708
709
  session.environment.worktreePath ? `Worktree: ${session.environment.worktreePath}` : null,
709
- `${buildOutboxContextLine(session.cwd)} To send one file immediately mid-turn, use the canon_send_file tool instead.`,
710
710
  ].filter((line) => Boolean(line));
711
711
  }
712
712
  function renderInboundContent(message, materialized) {
@@ -767,13 +767,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
767
767
  messageQueue.push(input);
768
768
  }
769
769
  };
770
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => {
770
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactDir = null) => {
771
771
  sendInput(createClaudeInputEnvelope({
772
772
  kind: 'canon',
773
773
  msg,
774
774
  intent,
775
775
  sourceMessageId,
776
776
  markAccepted,
777
+ artifactDir,
777
778
  isOwnerTurn,
778
779
  }));
779
780
  };
@@ -849,7 +850,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
849
850
  }
850
851
  return { status: 'timeout' };
851
852
  }
852
- function buildOutboxMediaMetadata() {
853
+ function buildArtifactMediaMetadata() {
853
854
  return {
854
855
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
855
856
  // Media is permanent conversation content (promoted like a final reply)
@@ -859,57 +860,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
859
860
  replyBehavior: 'suppress_auto_reply',
860
861
  };
861
862
  }
862
- function sendOutboxMediaFile(filePath, caption = '') {
863
- return sendMediaFileMessage(client, conversationId, filePath, caption, {
864
- ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
865
- metadata: buildOutboxMediaMetadata(),
866
- });
867
- }
868
- // Immediate explicit media-out channel: the agent calls this tool with a
869
- // path, the host uploads + sends right away. Permission flows through
870
- // canUseTool like every other MCP tool (owner approval in default mode).
871
- const canonMediaServer = createSdkMcpServer({
872
- name: 'canon',
873
- tools: [
874
- tool('canon_send_file', 'Send a local file (screenshot, plot, artifact) to the current Canon conversation immediately as a media attachment.', {
875
- path: z.string().describe('Path of the file to send; relative paths resolve against the session working directory.'),
876
- caption: z.string().optional().describe('Optional caption shown with the attachment.'),
877
- }, async (args) => {
878
- try {
879
- const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
880
- const info = await stat(filePath);
881
- if (!info.isFile()) {
882
- throw new Error(`${filePath} is not a regular file`);
883
- }
884
- if (info.size > OUTBOX_MAX_FILE_BYTES) {
885
- throw new Error(`File is ${info.size} bytes; the Canon media limit is ${OUTBOX_MAX_FILE_BYTES} bytes`);
886
- }
887
- const { messageId } = await sendOutboxMediaFile(filePath, args.caption ?? '');
888
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] canon_send_file sent ${basename(filePath)} (${messageId})`);
889
- return {
890
- content: [{
891
- type: 'text',
892
- text: `Sent ${basename(filePath)} to the Canon conversation (message ${messageId}).`,
893
- }],
894
- };
895
- }
896
- catch (error) {
897
- return {
898
- content: [{
899
- type: 'text',
900
- text: `canon_send_file failed: ${error instanceof Error ? error.message : String(error)}`,
901
- }],
902
- isError: true,
903
- };
904
- }
905
- }),
906
- ],
907
- });
908
863
  const claudeCliPath = resolveClaudeCliPath();
909
864
  const queryOptions = {
910
865
  systemPrompt: { type: 'preset', preset: 'claude_code' },
911
866
  tools: { type: 'preset', preset: 'claude_code' },
912
- mcpServers: { canon: canonMediaServer },
913
867
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
914
868
  allowDangerouslySkipPermissions: true,
915
869
  cwd,
@@ -1119,7 +1073,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1119
1073
  environment,
1120
1074
  query: q,
1121
1075
  sendInput,
1122
- enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn),
1076
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactDir = null) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactDir),
1123
1077
  state: {
1124
1078
  model: undefined,
1125
1079
  permissionMode: config.permissionMode,
@@ -1300,32 +1254,32 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1300
1254
  return false;
1301
1255
  }
1302
1256
  }
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() {
1257
+ async function routeSessionArtifacts(artifactDir) {
1258
+ if (!artifactDir)
1259
+ return;
1309
1260
  try {
1310
- const result = await flushOutbox({
1311
- outboxDir: resolveOutboxDir(cwd),
1312
- send: (file) => sendOutboxMediaFile(file.path),
1261
+ const result = await flushTurnArtifacts({
1262
+ artifactDir,
1263
+ send: (file) => sendMediaFileMessage(client, conversationId, file.path, '', {
1264
+ ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1265
+ metadata: buildArtifactMediaMetadata(),
1266
+ }),
1313
1267
  });
1314
1268
  for (const entry of result.sent) {
1315
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent outbox file ${entry.file.fileName} (${entry.messageId})`
1316
- + (entry.removeFailed ? ' — removal failed; it may resend next turn' : ''));
1269
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Routed artifact ${entry.file.fileName} (${entry.messageId})`
1270
+ + (entry.removeFailed ? '; removal failed, so it may resend later' : ''));
1317
1271
  }
1318
1272
  for (const failure of result.failed) {
1319
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
1273
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact upload failed for ${failure.file.fileName}; left in place:`, failure.error);
1320
1274
  }
1321
1275
  for (const skipped of result.skipped) {
1322
- if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
1323
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox skipped ${skipped.fileName} (${skipped.reason}); left in place`);
1276
+ if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'unsupported') {
1277
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact skipped ${skipped.fileName} (${skipped.reason})`);
1324
1278
  }
1325
1279
  }
1326
1280
  }
1327
1281
  catch (error) {
1328
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox flush failed:`, error instanceof Error ? error.message : error);
1282
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact routing failed:`, error instanceof Error ? error.message : error);
1329
1283
  }
1330
1284
  }
1331
1285
  function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
@@ -1460,6 +1414,11 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1460
1414
  return;
1461
1415
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1462
1416
  }
1417
+ async function startCanonInput(input) {
1418
+ await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
1419
+ session.state.state = 'running';
1420
+ sendInput(input);
1421
+ }
1463
1422
  function drainPendingInput() {
1464
1423
  if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
1465
1424
  return;
@@ -1468,10 +1427,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1468
1427
  return;
1469
1428
  session.lastAcceptedIntent = next.intent;
1470
1429
  writeTurn();
1471
- void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
1472
- .finally(() => sendInput(next));
1430
+ void startCanonInput(next);
1473
1431
  }
1474
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false) => {
1432
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactDir = null) => {
1475
1433
  session.lastActivity = Date.now();
1476
1434
  const input = createClaudeInputEnvelope({
1477
1435
  kind: 'canon',
@@ -1479,6 +1437,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1479
1437
  intent,
1480
1438
  sourceMessageId,
1481
1439
  markAccepted,
1440
+ artifactDir,
1482
1441
  isOwnerTurn,
1483
1442
  });
1484
1443
  if (session.pendingFinalDelivery) {
@@ -1505,8 +1464,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1505
1464
  }
1506
1465
  session.lastAcceptedIntent = intent;
1507
1466
  writeTurn();
1508
- void markQueuedMessageAccepted(sourceMessageId, markAccepted)
1509
- .finally(() => sendInput(input));
1467
+ void startCanonInput(input);
1510
1468
  };
1511
1469
  writeTurn();
1512
1470
  // ── Process SDK output messages ──
@@ -1614,11 +1572,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1614
1572
  session.sdkSessionId = resultSessionId;
1615
1573
  const completedInput = session.activeInput;
1616
1574
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
1617
- // Turn-end outbox flush — media lands before the final text reply.
1618
- // Interrupted turns keep their files for the next completed turn.
1575
+ // Turn artifacts land before the final text reply.
1619
1576
  if (completedInput?.kind === 'canon'
1620
1577
  && !session.interruptedTurnKeys.has(completedInput.turnKey)) {
1621
- await flushSessionOutbox();
1578
+ await routeSessionArtifacts(completedInput.artifactDir);
1622
1579
  }
1623
1580
  const resultText = typeof msg.result === 'string'
1624
1581
  ? msg.result.trim()
@@ -1681,7 +1638,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1681
1638
  // Start processing loop and initialize session
1682
1639
  const init = async () => {
1683
1640
  processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
1684
- ensureOutboxDir(cwd).catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, err));
1685
1641
  await q.initializationResult();
1686
1642
  if (config.model) {
1687
1643
  try {
@@ -2419,6 +2375,7 @@ export async function main() {
2419
2375
  return;
2420
2376
  }
2421
2377
  session.activeSelfContextId = activeSelfContextId;
2378
+ const artifactDir = await ensureTurnArtifactDir(session.cwd, `claude-${m.id ?? randomUUID()}`);
2422
2379
  const promptText = buildCanonPrompt({
2423
2380
  content,
2424
2381
  conversationId: input.conversationId,
@@ -2430,6 +2387,7 @@ export async function main() {
2430
2387
  replyContext,
2431
2388
  message: m,
2432
2389
  session,
2390
+ artifactDir,
2433
2391
  });
2434
2392
  const messageContent = await buildCanonUserContent({
2435
2393
  promptText,
@@ -2442,7 +2400,7 @@ export async function main() {
2442
2400
  content: messageContent,
2443
2401
  },
2444
2402
  parent_tool_use_id: null,
2445
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
2403
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactDir);
2446
2404
  if (m.id) {
2447
2405
  saveRuntimeSessionState(runtimeId, {
2448
2406
  conversationId: input.conversationId,
@@ -2610,6 +2568,7 @@ export async function main() {
2610
2568
  return;
2611
2569
  }
2612
2570
  session.activeSelfContextId = activeSelfContextId;
2571
+ const artifactDir = await ensureTurnArtifactDir(session.cwd, `claude-${m.id ?? randomUUID()}`);
2613
2572
  const promptText = buildCanonPrompt({
2614
2573
  content,
2615
2574
  conversationId: convoId,
@@ -2621,6 +2580,7 @@ export async function main() {
2621
2580
  replyContext,
2622
2581
  message: m,
2623
2582
  session,
2583
+ artifactDir,
2624
2584
  });
2625
2585
  const messageContent = await buildCanonUserContent({
2626
2586
  promptText,
@@ -2633,7 +2593,7 @@ export async function main() {
2633
2593
  content: messageContent,
2634
2594
  },
2635
2595
  parent_tool_use_id: null,
2636
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
2596
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactDir);
2637
2597
  if (m.id) {
2638
2598
  saveRuntimeSessionState(runtimeId, {
2639
2599
  conversationId: convoId,
@@ -7,6 +7,7 @@ export interface ClaudeInputEnvelope {
7
7
  intent: DeliveryIntent;
8
8
  sourceMessageId: string | null;
9
9
  markAccepted: boolean;
10
+ artifactDir: string | null;
10
11
  turnKey: string;
11
12
  isOwnerTurn: boolean;
12
13
  }
@@ -41,6 +42,7 @@ export declare function createClaudeInputEnvelope(input: {
41
42
  intent?: DeliveryIntent;
42
43
  sourceMessageId?: string | null;
43
44
  markAccepted?: boolean;
45
+ artifactDir?: string | null;
44
46
  isOwnerTurn?: boolean;
45
47
  }): ClaudeInputEnvelope;
46
48
  export declare function buildClaudeFinalMessageId(input: {
@@ -8,6 +8,7 @@ export function createClaudeInputEnvelope(input) {
8
8
  intent: input.intent ?? 'queue',
9
9
  sourceMessageId,
10
10
  markAccepted: Boolean(input.markAccepted),
11
+ artifactDir: input.artifactDir ?? null,
11
12
  isOwnerTurn: input.kind === 'canon'
12
13
  ? input.isOwnerTurn === true
13
14
  : input.isOwnerTurn !== false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  ".claude-plugin"
24
24
  ],
25
25
  "scripts": {
26
- "prepare:workspace-deps": "npm --prefix ../core run build && npm --prefix ../agent-sdk run build",
26
+ "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../coding-agent-host",
27
27
  "build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
28
28
  "dev": "npm run prepare:workspace-deps && tsc --watch",
29
29
  "prepack": "npm run build",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
34
- "@canonmsg/agent-sdk": "^3.2.1",
35
- "@canonmsg/core": "^2.5.0",
36
- "@modelcontextprotocol/sdk": "^1.29.0",
37
- "zod": "^4.0.0"
34
+ "@canonmsg/agent-sdk": "^3.2.3",
35
+ "@canonmsg/coding-agent-host": "^0.2.0",
36
+ "@canonmsg/core": "^2.6.0",
37
+ "@modelcontextprotocol/sdk": "^1.29.0"
38
38
  },
39
39
  "engines": {
40
40
  "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
- }