@canonmsg/claude-code-plugin 0.19.1 → 0.19.3

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.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';
32
- 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';
30
+ import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
31
+ import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, 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, renderCodingHostInboundPrompt, 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: 'Generated media artifacts',
586
584
  tier: 'detail',
587
585
  },
588
586
  ].filter((item) => Boolean(item));
@@ -672,8 +670,7 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
672
670
  supportsNonFinalPermanentMessages: false,
673
671
  };
674
672
  function buildCanonPrompt(input) {
675
- const sessionContextLines = input.session ? buildClaudeSessionContextLines(input.session) : undefined;
676
- return renderCanonTurnBriefPrompt(buildCanonTurnContextV2({
673
+ return renderCodingHostInboundPrompt(buildCanonInboundFrameV1(buildCanonTurnContextV2({
677
674
  content: input.content,
678
675
  conversationId: input.conversationId,
679
676
  participantContext: input.participantContext,
@@ -682,32 +679,13 @@ function buildCanonPrompt(input) {
682
679
  activeSelfContextId: input.activeSelfContextId,
683
680
  provenance: input.provenance,
684
681
  replyContext: input.replyContext,
685
- sessionContextLines,
686
682
  message: input.message,
687
- }));
683
+ })));
688
684
  }
689
- function optionLabel(value, options) {
690
- if (!value)
691
- return null;
692
- const label = options.find((option) => option.value === value)?.label ?? value;
693
- return label === value ? value : `${label} (${value})`;
694
- }
695
- function buildClaudeSessionContextLines(session) {
696
- const workspace = workspaceOptions.find((option) => option.cwd === session.environment.baseCwd);
697
- const permissionMode = optionLabel(session.state.permissionMode, CLAUDE_PERMISSION_MODE_OPTIONS);
698
- const effort = optionLabel(session.state.effort, CLAUDE_EFFORT_OPTIONS);
699
- const model = optionLabel(session.state.model, session.availableModels);
700
- return [
701
- model ? `Selected model: ${model}` : null,
702
- permissionMode ? `Permission mode: ${permissionMode}` : null,
703
- effort ? `Thinking level: ${effort}` : null,
704
- workspace ? `Project: ${workspace.label} (${workspace.id})` : null,
705
- `Execution mode: ${session.environment.mode}`,
706
- `Working directory: ${session.cwd}`,
707
- session.environment.branch ? `Git branch: ${session.environment.branch}` : null,
708
- 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
- ].filter((line) => Boolean(line));
685
+ function resolveArtifactRoutingMode(participantContext) {
686
+ return participantContext.conversationType === 'direct' && participantContext.isOwner
687
+ ? 'workspace-generated'
688
+ : 'disabled';
711
689
  }
712
690
  function renderInboundContent(message, materialized) {
713
691
  return renderCanonHostInboundContent(message, materialized);
@@ -767,7 +745,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
767
745
  messageQueue.push(input);
768
746
  }
769
747
  };
770
- let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => {
748
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
771
749
  sendInput(createClaudeInputEnvelope({
772
750
  kind: 'canon',
773
751
  msg,
@@ -775,6 +753,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
775
753
  sourceMessageId,
776
754
  markAccepted,
777
755
  isOwnerTurn,
756
+ artifactRoutingMode,
778
757
  }));
779
758
  };
780
759
  const inputStream = {
@@ -849,7 +828,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
849
828
  }
850
829
  return { status: 'timeout' };
851
830
  }
852
- function buildOutboxMediaMetadata() {
831
+ function buildArtifactMediaMetadata() {
853
832
  return {
854
833
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
855
834
  // Media is permanent conversation content (promoted like a final reply)
@@ -859,57 +838,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
859
838
  replyBehavior: 'suppress_auto_reply',
860
839
  };
861
840
  }
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
841
  const claudeCliPath = resolveClaudeCliPath();
909
842
  const queryOptions = {
910
843
  systemPrompt: { type: 'preset', preset: 'claude_code' },
911
844
  tools: { type: 'preset', preset: 'claude_code' },
912
- mcpServers: { canon: canonMediaServer },
913
845
  permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
914
846
  allowDangerouslySkipPermissions: true,
915
847
  cwd,
@@ -1119,7 +1051,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1119
1051
  environment,
1120
1052
  query: q,
1121
1053
  sendInput,
1122
- enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn),
1054
+ enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
1123
1055
  state: {
1124
1056
  model: undefined,
1125
1057
  permissionMode: config.permissionMode,
@@ -1300,32 +1232,42 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1300
1232
  return false;
1301
1233
  }
1302
1234
  }
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() {
1235
+ function sendTurnArtifactFile(file) {
1236
+ return sendMediaFileMessage(client, conversationId, file.path, '', {
1237
+ ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1238
+ metadata: buildArtifactMediaMetadata(),
1239
+ });
1240
+ }
1241
+ async function routeWorkspaceGeneratedArtifacts(input) {
1242
+ if (!input.artifactBaseline)
1243
+ return;
1309
1244
  try {
1310
- const result = await flushOutbox({
1311
- outboxDir: resolveOutboxDir(cwd),
1312
- send: (file) => sendOutboxMediaFile(file.path),
1245
+ const result = await collectTurnArtifacts({
1246
+ cwd: session.cwd,
1247
+ baseline: input.artifactBaseline,
1313
1248
  });
1314
- 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' : ''));
1317
- }
1318
- 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}`);
1249
+ for (const file of result.files) {
1250
+ try {
1251
+ const { messageId } = await sendTurnArtifactFile(file);
1252
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Routed generated artifact ${file.relativePath ?? file.fileName} (${messageId})`);
1253
+ }
1254
+ catch (error) {
1255
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact upload failed for ${file.relativePath ?? file.fileName}: ${error instanceof Error ? error.message : String(error)}`);
1256
+ }
1320
1257
  }
1321
1258
  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`);
1259
+ if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'scan-limit') {
1260
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact skipped ${skipped.fileName} (${skipped.reason})`);
1324
1261
  }
1325
1262
  }
1326
1263
  }
1327
1264
  catch (error) {
1328
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox flush failed:`, error instanceof Error ? error.message : error);
1265
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact routing failed:`, error instanceof Error ? error.message : error);
1266
+ }
1267
+ }
1268
+ async function routeArtifactsForInput(input) {
1269
+ if (input.artifactRoutingMode === 'workspace-generated') {
1270
+ await routeWorkspaceGeneratedArtifacts(input);
1329
1271
  }
1330
1272
  }
1331
1273
  function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
@@ -1460,6 +1402,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1460
1402
  return;
1461
1403
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
1462
1404
  }
1405
+ async function prepareArtifactsForInput(input) {
1406
+ if (input.kind !== 'canon')
1407
+ return;
1408
+ if (!session.currentTurnId) {
1409
+ session.currentTurnId = randomUUID();
1410
+ session.currentTurnOpenedAt = Date.now();
1411
+ }
1412
+ if (input.artifactRoutingMode === 'workspace-generated') {
1413
+ input.artifactBaseline = await captureTurnArtifactSnapshot({ cwd: session.cwd }).catch((error) => {
1414
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Artifact snapshot failed:`, error instanceof Error ? error.message : error);
1415
+ return null;
1416
+ });
1417
+ return;
1418
+ }
1419
+ }
1420
+ async function startCanonInput(input) {
1421
+ await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
1422
+ await prepareArtifactsForInput(input);
1423
+ session.state.state = 'running';
1424
+ sendInput(input);
1425
+ }
1463
1426
  function drainPendingInput() {
1464
1427
  if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
1465
1428
  return;
@@ -1468,10 +1431,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1468
1431
  return;
1469
1432
  session.lastAcceptedIntent = next.intent;
1470
1433
  writeTurn();
1471
- void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
1472
- .finally(() => sendInput(next));
1434
+ void startCanonInput(next);
1473
1435
  }
1474
- enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false) => {
1436
+ enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
1475
1437
  session.lastActivity = Date.now();
1476
1438
  const input = createClaudeInputEnvelope({
1477
1439
  kind: 'canon',
@@ -1480,6 +1442,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1480
1442
  sourceMessageId,
1481
1443
  markAccepted,
1482
1444
  isOwnerTurn,
1445
+ artifactRoutingMode,
1483
1446
  });
1484
1447
  if (session.pendingFinalDelivery) {
1485
1448
  session.pendingInputs.push(input);
@@ -1505,8 +1468,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1505
1468
  }
1506
1469
  session.lastAcceptedIntent = intent;
1507
1470
  writeTurn();
1508
- void markQueuedMessageAccepted(sourceMessageId, markAccepted)
1509
- .finally(() => sendInput(input));
1471
+ void startCanonInput(input);
1510
1472
  };
1511
1473
  writeTurn();
1512
1474
  // ── Process SDK output messages ──
@@ -1614,11 +1576,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1614
1576
  session.sdkSessionId = resultSessionId;
1615
1577
  const completedInput = session.activeInput;
1616
1578
  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.
1579
+ // Turn artifacts land before the final text reply.
1619
1580
  if (completedInput?.kind === 'canon'
1620
1581
  && !session.interruptedTurnKeys.has(completedInput.turnKey)) {
1621
- await flushSessionOutbox();
1582
+ await routeArtifactsForInput(completedInput);
1622
1583
  }
1623
1584
  const resultText = typeof msg.result === 'string'
1624
1585
  ? msg.result.trim()
@@ -1681,7 +1642,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1681
1642
  // Start processing loop and initialize session
1682
1643
  const init = async () => {
1683
1644
  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
1645
  await q.initializationResult();
1686
1646
  if (config.model) {
1687
1647
  try {
@@ -1709,7 +1669,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1709
1669
  type: 'user',
1710
1670
  message: {
1711
1671
  role: 'user',
1712
- content: 'You are connected to Canon messaging. Follow-up messages in this session come from Canon participants, and your text responses will be sent back to the conversation. You have full Claude Code capabilities (file read/write, bash, etc).',
1672
+ content: 'You are assisting in an ongoing chat. Reply naturally to the latest participant.',
1713
1673
  },
1714
1674
  parent_tool_use_id: null,
1715
1675
  },
@@ -2429,12 +2389,12 @@ export async function main() {
2429
2389
  provenance: hydrated.provenance,
2430
2390
  replyContext,
2431
2391
  message: m,
2432
- session,
2433
2392
  });
2434
2393
  const messageContent = await buildCanonUserContent({
2435
2394
  promptText,
2436
2395
  materialized: [...replyMedia.materialized, ...materialized],
2437
2396
  });
2397
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2438
2398
  session.enqueueInbound({
2439
2399
  type: 'user',
2440
2400
  message: {
@@ -2442,7 +2402,7 @@ export async function main() {
2442
2402
  content: messageContent,
2443
2403
  },
2444
2404
  parent_tool_use_id: null,
2445
- }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner);
2405
+ }, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
2446
2406
  if (m.id) {
2447
2407
  saveRuntimeSessionState(runtimeId, {
2448
2408
  conversationId: input.conversationId,
@@ -2620,12 +2580,12 @@ export async function main() {
2620
2580
  provenance: hydrated.provenance,
2621
2581
  replyContext,
2622
2582
  message: m,
2623
- session,
2624
2583
  });
2625
2584
  const messageContent = await buildCanonUserContent({
2626
2585
  promptText,
2627
2586
  materialized: [...replyMedia.materialized, ...materialized],
2628
2587
  });
2588
+ const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
2629
2589
  session.enqueueInbound({
2630
2590
  type: 'user',
2631
2591
  message: {
@@ -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, artifactRoutingMode);
2637
2597
  if (m.id) {
2638
2598
  saveRuntimeSessionState(runtimeId, {
2639
2599
  conversationId: convoId,
@@ -1,12 +1,16 @@
1
1
  import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { DeliveryIntent, ModelOption, TurnLifecycleState } from '@canonmsg/core';
3
+ import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
3
4
  export type ClaudeInputKind = 'seed' | 'canon';
5
+ export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
4
6
  export interface ClaudeInputEnvelope {
5
7
  kind: ClaudeInputKind;
6
8
  msg: SDKUserMessage;
7
9
  intent: DeliveryIntent;
8
10
  sourceMessageId: string | null;
9
11
  markAccepted: boolean;
12
+ artifactRoutingMode: ClaudeArtifactRoutingMode;
13
+ artifactBaseline: TurnArtifactSnapshot | null;
10
14
  turnKey: string;
11
15
  isOwnerTurn: boolean;
12
16
  }
@@ -41,6 +45,8 @@ export declare function createClaudeInputEnvelope(input: {
41
45
  intent?: DeliveryIntent;
42
46
  sourceMessageId?: string | null;
43
47
  markAccepted?: boolean;
48
+ artifactRoutingMode?: ClaudeArtifactRoutingMode;
49
+ artifactBaseline?: TurnArtifactSnapshot | null;
44
50
  isOwnerTurn?: boolean;
45
51
  }): ClaudeInputEnvelope;
46
52
  export declare function buildClaudeFinalMessageId(input: {
@@ -8,6 +8,8 @@ export function createClaudeInputEnvelope(input) {
8
8
  intent: input.intent ?? 'queue',
9
9
  sourceMessageId,
10
10
  markAccepted: Boolean(input.markAccepted),
11
+ artifactRoutingMode: input.artifactRoutingMode ?? 'disabled',
12
+ artifactBaseline: input.artifactBaseline ?? null,
11
13
  isOwnerTurn: input.kind === 'canon'
12
14
  ? input.isOwnerTurn === true
13
15
  : input.isOwnerTurn !== false,
@@ -1,6 +1,6 @@
1
- const NON_OWNER_DENY_MESSAGE = 'Only the agent owner can authorize Claude Code to use runtime tools from Canon chat.';
1
+ const NON_OWNER_DENY_MESSAGE = 'Only the agent owner can authorize runtime tool use in this chat.';
2
2
  const NATIVE_APPROVAL_UNAVAILABLE_MESSAGE = 'Runtime action approval is not available for this conversation.';
3
- const NATIVE_APPROVAL_DENIED_MESSAGE = 'The agent owner denied this action in Canon.';
3
+ const NATIVE_APPROVAL_DENIED_MESSAGE = 'The agent owner denied this action.';
4
4
  export const CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE = 'default';
5
5
  const SENSITIVE_CLAUDE_TOOLS = new Set([
6
6
  'agent',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.19.1",
4
- "description": "Canon channel plugin for Claude Code \u2014 messaging where AI agents are first-class citizens",
3
+ "version": "0.19.3",
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",
7
7
  "types": "dist/index.d.ts",
@@ -23,7 +23,7 @@
23
23
  ".claude-plugin"
24
24
  ],
25
25
  "scripts": {
26
- "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk",
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",
@@ -32,9 +32,9 @@
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
34
34
  "@canonmsg/agent-sdk": "^3.2.3",
35
- "@canonmsg/core": "^2.6.0",
36
- "@modelcontextprotocol/sdk": "^1.29.0",
37
- "zod": "^4.0.0"
35
+ "@canonmsg/coding-agent-host": "^0.2.2",
36
+ "@canonmsg/core": "^2.8.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
- }