@canonmsg/codex-plugin 0.14.1 → 0.18.1
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/adapter.d.ts +4 -0
- package/dist/adapter.js +12 -0
- package/dist/app-server-adapter.d.ts +11 -0
- package/dist/app-server-adapter.js +31 -2
- package/dist/app-server-approval.d.ts +12 -1
- package/dist/app-server-approval.js +271 -0
- package/dist/control-channel.d.ts +45 -0
- package/dist/control-channel.js +48 -0
- package/dist/host.js +196 -132
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/outbox.d.ts +83 -0
- package/dist/outbox.js +132 -0
- package/dist/startup-recovery.d.ts +46 -0
- package/dist/startup-recovery.js +59 -0
- package/package.json +4 -4
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
|
+
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"scripts"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"prepare:workspace-deps": "
|
|
24
|
+
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk",
|
|
25
25
|
"build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
26
26
|
"dev": "npm run prepare:workspace-deps && tsc --watch",
|
|
27
27
|
"smoke": "node scripts/smoke-test.mjs",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"prepack": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@canonmsg/agent-sdk": "^3.
|
|
33
|
-
"@canonmsg/core": "^2.
|
|
32
|
+
"@canonmsg/agent-sdk": "^3.2.3",
|
|
33
|
+
"@canonmsg/core": "^2.6.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=18.0.0"
|