@canonmsg/claude-code-plugin 0.19.1 → 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.
- package/dist/host.js +45 -85
- package/dist/session-state.d.ts +2 -0
- package/dist/session-state.js +1 -0
- package/package.json +5 -5
- 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';
|
|
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: '
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1305
|
-
|
|
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
|
|
1311
|
-
|
|
1312
|
-
send: (file) =>
|
|
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)}]
|
|
1316
|
-
+ (entry.removeFailed ? '
|
|
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)}]
|
|
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)}]
|
|
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)}]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/dist/session-state.d.ts
CHANGED
|
@@ -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: {
|
package/dist/session-state.js
CHANGED
|
@@ -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,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.2",
|
|
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/coding-agent-host": "^0.2.0",
|
|
35
36
|
"@canonmsg/core": "^2.6.0",
|
|
36
|
-
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
37
|
-
"zod": "^4.0.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
|
-
}
|