@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/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.14.1",
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": "npm --prefix ../core run build && npm --prefix ../agent-sdk run build",
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.1.0",
33
- "@canonmsg/core": "^2.1.0"
32
+ "@canonmsg/agent-sdk": "^3.2.3",
33
+ "@canonmsg/core": "^2.6.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"