@canonmsg/claude-code-plugin 0.15.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { SendContextualMessageOptions } from '@canonmsg/core';
1
+ import type { DirectSessionSelection, SendContextualMessageOptions } from '@canonmsg/core';
2
2
  export type McpParseResult<T> = {
3
3
  ok: true;
4
4
  value: T;
@@ -32,6 +32,7 @@ export interface SendContextualMessageArgs {
32
32
  targetUserId?: string;
33
33
  text: string;
34
34
  selfContext: SendContextualMessageOptions['selfContext'];
35
+ sessionSelection?: DirectSessionSelection;
35
36
  messageOptions?: SendContextualMessageOptions['messageOptions'];
36
37
  }
37
38
  export declare function parseSendContextualMessageArgs(value: unknown): McpParseResult<SendContextualMessageArgs>;
package/dist/mcp-args.js CHANGED
@@ -87,6 +87,28 @@ export function parseSendMessageArgs(value) {
87
87
  },
88
88
  };
89
89
  }
90
+ function parseSessionSelection(value) {
91
+ if (value == null)
92
+ return { ok: true, value: undefined };
93
+ const raw = typeof value === 'string'
94
+ ? value
95
+ : isRecord(value) && typeof value.mode === 'string'
96
+ ? value.mode
97
+ : null;
98
+ const mode = raw?.trim().toLowerCase().replace(/-/g, '_');
99
+ if (!mode)
100
+ return { ok: false, error: 'Invalid session_selection' };
101
+ if (mode === 'new' || mode === 'fresh' || mode === 'start_new') {
102
+ return { ok: true, value: { mode: 'new' } };
103
+ }
104
+ if (mode === 'continue_latest' || mode === 'latest' || mode === 'continue') {
105
+ return { ok: true, value: { mode: 'continue_latest' } };
106
+ }
107
+ if (mode === 'continue_or_create' || mode === 'auto' || mode === 'reuse_or_create') {
108
+ return { ok: true, value: { mode: 'continue_or_create' } };
109
+ }
110
+ return { ok: false, error: 'session_selection must be new, continue_latest, or continue_or_create' };
111
+ }
90
112
  export function parseSendContextualMessageArgs(value) {
91
113
  if (!isRecord(value))
92
114
  return { ok: false, error: 'Arguments must be an object' };
@@ -111,6 +133,9 @@ export function parseSendContextualMessageArgs(value) {
111
133
  if (type !== 'cross_session') {
112
134
  return { ok: false, error: 'self_context.type must be cross_session' };
113
135
  }
136
+ const sessionSelection = parseSessionSelection(value.session_selection);
137
+ if (!sessionSelection.ok)
138
+ return sessionSelection;
114
139
  const messageOptions = isRecord(value.message_options)
115
140
  ? value.message_options
116
141
  : undefined;
@@ -125,6 +150,7 @@ export function parseSendContextualMessageArgs(value) {
125
150
  type: 'cross_session',
126
151
  context: context.value,
127
152
  },
153
+ ...(sessionSelection.value ? { sessionSelection: sessionSelection.value } : {}),
128
154
  ...(messageOptions ? { messageOptions } : {}),
129
155
  },
130
156
  };
@@ -0,0 +1,83 @@
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 ADDED
@@ -0,0 +1,132 @@
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
+ }
package/dist/server.js CHANGED
@@ -163,6 +163,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
163
163
  type: 'object',
164
164
  description: 'Required { type: "cross_session", context: "why I am saying this here" }',
165
165
  },
166
+ session_selection: {
167
+ type: 'string',
168
+ enum: ['new', 'continue_latest', 'continue_or_create'],
169
+ description: 'Optional direct-agent session behavior when target_user_id is used',
170
+ },
166
171
  message_options: {
167
172
  type: 'object',
168
173
  description: 'Optional Canon send options for the contextual outbound message',
@@ -321,7 +326,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
321
326
  const parsed = parseSendContextualMessageArgs(args);
322
327
  if (!parsed.ok)
323
328
  return toolArgumentError(parsed.error);
324
- const { sourceConversationId, targetConversationId, targetUserId, text, selfContext, messageOptions, } = parsed.value;
329
+ const { sourceConversationId, targetConversationId, targetUserId, text, selfContext, sessionSelection, messageOptions, } = parsed.value;
325
330
  const contextualMessageOptions = messageOptions;
326
331
  const result = await client.sendContextualMessage({
327
332
  sourceConversationId,
@@ -329,6 +334,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
329
334
  ...(targetUserId ? { targetUserId } : {}),
330
335
  text,
331
336
  selfContext,
337
+ ...(sessionSelection ? { sessionSelection } : {}),
332
338
  ...(contextualMessageOptions
333
339
  ? {
334
340
  messageOptions: {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Startup recovery for inbound messages missed while the host was offline.
3
+ *
4
+ * The host persists a `lastInboundMessageId` cursor per conversation. On
5
+ * startup we paginate `getMessagesPage` (newest-first pages, older pages via
6
+ * its `before` message-id parameter) until the cursor is found or a hard
7
+ * per-conversation bound is hit, then replay everything after the cursor.
8
+ *
9
+ * This module is intentionally identical in packages/claude-code-plugin and
10
+ * packages/codex-plugin — keep both copies in sync (future consolidation
11
+ * candidate).
12
+ */
13
+ export declare const STARTUP_RECOVERY_PAGE_SIZE = 25;
14
+ export declare const STARTUP_RECOVERY_MAX_MESSAGES = 500;
15
+ export interface StartupRecoveryMessage {
16
+ id: string;
17
+ senderId: string;
18
+ createdAt?: string;
19
+ }
20
+ export interface StartupRecoveryPage {
21
+ messages: StartupRecoveryMessage[];
22
+ }
23
+ export type StartupRecoveryMode =
24
+ /** Cursor found — `messages` is everything strictly after it. */
25
+ 'after-cursor'
26
+ /** Cursor present but not found within the bound — `messages` is the bounded recent window. */
27
+ | 'truncated-window'
28
+ /**
29
+ * No usable cursor (fresh runtime file, or the cursor message no longer
30
+ * exists in history) — only the newest inbound message is recovered, since
31
+ * a full-history replay could fire mass duplicate turns.
32
+ */
33
+ | 'latest-only';
34
+ export interface StartupRecoveryResult<TPage extends StartupRecoveryPage> {
35
+ mode: StartupRecoveryMode;
36
+ /** Missed inbound messages (own messages excluded), oldest first. */
37
+ messages: TPage['messages'];
38
+ /** First page fetched — reusable as hydration context for recovered turns. */
39
+ newestPage: TPage;
40
+ }
41
+ export declare function collectMissedInboundMessages<TPage extends StartupRecoveryPage>(input: {
42
+ fetchPage: (before?: string) => Promise<TPage>;
43
+ cursor: string | null | undefined;
44
+ agentId: string;
45
+ maxMessages?: number;
46
+ }): Promise<StartupRecoveryResult<TPage>>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Startup recovery for inbound messages missed while the host was offline.
3
+ *
4
+ * The host persists a `lastInboundMessageId` cursor per conversation. On
5
+ * startup we paginate `getMessagesPage` (newest-first pages, older pages via
6
+ * its `before` message-id parameter) until the cursor is found or a hard
7
+ * per-conversation bound is hit, then replay everything after the cursor.
8
+ *
9
+ * This module is intentionally identical in packages/claude-code-plugin and
10
+ * packages/codex-plugin — keep both copies in sync (future consolidation
11
+ * candidate).
12
+ */
13
+ export const STARTUP_RECOVERY_PAGE_SIZE = 25;
14
+ export const STARTUP_RECOVERY_MAX_MESSAGES = 500;
15
+ export async function collectMissedInboundMessages(input) {
16
+ const maxMessages = input.maxMessages ?? STARTUP_RECOVERY_MAX_MESSAGES;
17
+ const newestPage = await input.fetchPage();
18
+ const collected = [...newestPage.messages];
19
+ const seenIds = new Set(collected.map((message) => message.id));
20
+ const hasCursor = (messages) => input.cursor != null && messages.some((message) => message.id === input.cursor);
21
+ let cursorFound = hasCursor(collected);
22
+ if (input.cursor != null) {
23
+ while (!cursorFound && collected.length < maxMessages) {
24
+ // Pages are newest-first, so the last collected message is the oldest.
25
+ const before = collected[collected.length - 1]?.id;
26
+ if (!before)
27
+ break;
28
+ const page = await input.fetchPage(before);
29
+ const fresh = page.messages.filter((message) => !seenIds.has(message.id));
30
+ // No pagination progress (history exhausted, or the server ignored the
31
+ // `before` cursor because that message was hard-deleted) — stop here.
32
+ if (fresh.length === 0)
33
+ break;
34
+ for (const message of fresh)
35
+ seenIds.add(message.id);
36
+ collected.push(...fresh);
37
+ cursorFound = hasCursor(fresh);
38
+ }
39
+ }
40
+ const ascending = [...collected].sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
41
+ const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId);
42
+ let mode;
43
+ let missed;
44
+ if (cursorFound) {
45
+ const cursorIndex = ascending.findIndex((message) => message.id === input.cursor);
46
+ mode = 'after-cursor';
47
+ missed = inboundOnly(ascending.slice(cursorIndex + 1));
48
+ }
49
+ else if (input.cursor != null && collected.length >= maxMessages) {
50
+ mode = 'truncated-window';
51
+ missed = inboundOnly(ascending.slice(-maxMessages));
52
+ }
53
+ else {
54
+ mode = 'latest-only';
55
+ missed = inboundOnly(ascending).slice(-1);
56
+ }
57
+ // Safe: `missed` only holds elements of pages returned by `fetchPage`.
58
+ return { mode, messages: missed, newestPage };
59
+ }
@@ -1,4 +1,4 @@
1
- import type { ApprovalManager, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRisk } from '@canonmsg/core';
1
+ import type { ApprovalManager, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRisk, CanonUnifiedDiff } from '@canonmsg/core';
2
2
  export type ClaudeToolPolicyDecision = {
3
3
  behavior: 'allow';
4
4
  } | {
@@ -27,4 +27,10 @@ export declare function decideClaudeToolPermissionForMode(input: {
27
27
  approvalManager?: ApprovalManager | null;
28
28
  canRequestApproval?: () => Promise<boolean>;
29
29
  allowedNonOwnerTools?: Iterable<string>;
30
+ /**
31
+ * Lazy file-change preview for the approval card (Edit/Write-class tools).
32
+ * Invoked only once the call is headed for an owner approval; best-effort —
33
+ * a throw or undefined simply omits the diff, never blocks the approval.
34
+ */
35
+ buildApprovalDiff?: () => Promise<CanonUnifiedDiff | undefined>;
30
36
  }): Promise<ClaudeToolPolicyDecision>;
@@ -174,6 +174,15 @@ export async function decideClaudeToolPermissionForMode(input) {
174
174
  message: NATIVE_APPROVAL_UNAVAILABLE_MESSAGE,
175
175
  };
176
176
  }
177
+ let diff;
178
+ if (input.buildApprovalDiff) {
179
+ try {
180
+ diff = await input.buildApprovalDiff();
181
+ }
182
+ catch {
183
+ diff = undefined;
184
+ }
185
+ }
177
186
  let result;
178
187
  try {
179
188
  const risk = classifyClaudeApprovalRisk(input.toolName, input.toolInput);
@@ -188,6 +197,7 @@ export async function decideClaudeToolPermissionForMode(input) {
188
197
  method: 'canUseTool',
189
198
  },
190
199
  details: buildClaudeApprovalDetails(input.toolName, input.toolInput),
200
+ ...(diff ? { diff } : {}),
191
201
  ignoreSessionRules: !input.isOwnerTurn,
192
202
  allowSessionRule: input.isOwnerTurn,
193
203
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.15.1",
3
+ "version": "0.19.0",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,9 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
34
- "@canonmsg/agent-sdk": "^3.1.0",
35
- "@canonmsg/core": "^2.1.0",
36
- "@modelcontextprotocol/sdk": "^1.29.0"
34
+ "@canonmsg/agent-sdk": "^3.2.1",
35
+ "@canonmsg/core": "^2.5.0",
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
+ "zod": "^4.0.0"
37
38
  },
38
39
  "engines": {
39
40
  "node": ">=18.0.0"