@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.
- package/dist/approval-diff.d.ts +15 -0
- package/dist/approval-diff.js +285 -0
- package/dist/host.d.ts +9 -0
- package/dist/host.js +400 -191
- package/dist/mcp-args.d.ts +2 -1
- package/dist/mcp-args.js +26 -0
- package/dist/outbox.d.ts +83 -0
- package/dist/outbox.js +132 -0
- package/dist/server.js +7 -1
- package/dist/startup-recovery.d.ts +46 -0
- package/dist/startup-recovery.js +59 -0
- package/dist/tool-policy.d.ts +7 -1
- package/dist/tool-policy.js +10 -0
- package/package.json +5 -4
package/dist/mcp-args.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/outbox.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/tool-policy.d.ts
CHANGED
|
@@ -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>;
|
package/dist/tool-policy.js
CHANGED
|
@@ -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.
|
|
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
|
|
35
|
-
"@canonmsg/core": "^2.
|
|
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"
|