@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 +75 -115
- package/dist/session-state.d.ts +6 -0
- package/dist/session-state.js +2 -0
- package/dist/tool-policy.js +2 -2
- package/package.json +6 -6
- package/dist/outbox.d.ts +0 -83
- package/dist/outbox.js +0 -132
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
|
|
28
|
-
import {
|
|
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 {
|
|
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: '
|
|
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
|
-
|
|
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
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
|
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
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
|
1311
|
-
|
|
1312
|
-
|
|
1245
|
+
const result = await collectTurnArtifacts({
|
|
1246
|
+
cwd: session.cwd,
|
|
1247
|
+
baseline: input.artifactBaseline,
|
|
1313
1248
|
});
|
|
1314
|
-
for (const
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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)}]
|
|
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)}]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/dist/session-state.d.ts
CHANGED
|
@@ -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: {
|
package/dist/session-state.js
CHANGED
|
@@ -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,
|
package/dist/tool-policy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const NON_OWNER_DENY_MESSAGE = 'Only the agent owner can authorize
|
|
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
|
|
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.
|
|
4
|
-
"description": "Canon channel plugin for Claude Code
|
|
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/
|
|
36
|
-
"@
|
|
37
|
-
"
|
|
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
|
-
}
|