@minhspark/codex-mcp-bridge 1.21.6 → 1.22.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.21.6",
3
+ "version": "1.22.1",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.1] - 2026-10-13
8
+
9
+ ### Fixed
10
+
11
+ - Align the README badges in two centered rows. Render the three custom analytics badges at an intrinsic height of 20 pixels to match the standard flat badges even when GitHub overrides HTML height attributes. Scale both SVG dimensions together to preserve the artwork proportions.
12
+
13
+ ### Verification
14
+
15
+ - Regression coverage checks the intrinsic height and aspect ratio of all three generated analytics badges. The existing analytics tests retain coverage for source dates, stale values, missing data, and aggregate privacy.
16
+ - `npm test` passes from a clean archived checkout: 1,267 passed, 23 platform skips, and no failures. The Repository analytics workflow publishes the updated SVGs from the existing source data.
17
+
18
+ ### Upgrade notes
19
+
20
+ - This release updates README presentation and the bundled analytics publisher. MCP behavior is unchanged; it does not add or validate a Desktop handoff feature. GitHub may cache badge images until its image proxy refreshes them.
21
+
22
+ ## [1.22.0] - 2026-10-12
23
+
24
+ ### Added
25
+
26
+ - Optional response preferences for Desktop reads and reply observation, with tool payload filtering and bounded, replayable cursor pages. Snapshots remain bound to the caller, accounts, workspace, selected task, and delivery receipt (#125).
27
+
28
+ ### Fixed
29
+
30
+ - Accept completed Desktop replies whose memory citation is represented separately in the completion event, but appended to the authoritative response item. Require an exact reconstruction from validated citation metadata and preserve reply IDs, hashes, lifecycle checks, and conflict rejection (#123).
31
+ - Preserve bounded default history reads while allowing explicit tool output selection. Return structured oversize errors across native transport and serialized MCP responses.
32
+
33
+ ### Upgrade notes
34
+
35
+ - Update both bridge and native relay companion. Calls without response preferences retain existing response formats and the 2,000-character default item budget. Preferred history reads use a 16,000-character item budget; exact-turn reads retain authoritative assistant text.
36
+ - Cursor pages default to 64 KiB and support 8–96 KiB budgets. Native responses above 8 MiB and authoritative replies above 1 MiB remain explicit limits.
37
+
7
38
  ## [1.21.6] - 2026-10-12
8
39
 
9
40
  ### Fixed
package/README.md CHANGED
@@ -6,13 +6,16 @@
6
6
 
7
7
  Local Desktop handoff guides: [daily workflow and directory verification](docs/daily-handoffs.md), [Windows setup](docs/new-windows-setup.md), [macOS setup](docs/new-macos-setup.md), and [project onboarding](docs/project-onboarding.md).
8
8
 
9
- [![npm](https://img.shields.io/npm/v/@minhspark/codex-mcp-bridge?logo=npm&color=CB3837)](https://www.npmjs.com/package/@minhspark/codex-mcp-bridge)
10
- [![npm downloads](https://raw.githubusercontent.com/buidangminh23/codex-mcp-bridge/analytics/stats-npm-downloads.svg)](https://www.npmjs.com/package/@minhspark/codex-mcp-bridge)
11
- [![npm downloads / month](https://raw.githubusercontent.com/buidangminh23/codex-mcp-bridge/analytics/stats-npm-downloads-month.svg)](https://www.npmcharts.com/compare/%40minhspark%2Fcodex-mcp-bridge?minimal=true)
12
- [![Used by repositories (Libraries.io)](https://img.shields.io/librariesio/dependent-repos/npm/@minhspark/codex-mcp-bridge?label=Used%20by&logo=github&color=0969da)](https://github.com/buidangminh23/codex-mcp-bridge/network/dependents)
13
- [![skills.sh installs](https://skills.sh/b/buidangminh23/codex-mcp-bridge?label=skills.sh)](https://skills.sh/buidangminh23/codex-mcp-bridge/codex-bridge)
14
- [![GitHub views / source window](https://raw.githubusercontent.com/buidangminh23/codex-mcp-bridge/analytics/stats-github-views.svg)](https://buidangminh23.github.io/codex-mcp-bridge/)
15
- [![license](https://img.shields.io/npm/l/@minhspark/codex-mcp-bridge)](LICENSE)
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/@minhspark/codex-mcp-bridge"><img src="https://img.shields.io/npm/v/@minhspark/codex-mcp-bridge?logo=npm&amp;color=CB3837" height="20" alt="npm" /></a>
11
+ <a href="https://www.npmjs.com/package/@minhspark/codex-mcp-bridge"><img src="https://raw.githubusercontent.com/buidangminh23/codex-mcp-bridge/analytics/stats-npm-downloads.svg" height="20" alt="npm downloads" /></a>
12
+ <a href="https://www.npmcharts.com/compare/%40minhspark%2Fcodex-mcp-bridge?minimal=true"><img src="https://raw.githubusercontent.com/buidangminh23/codex-mcp-bridge/analytics/stats-npm-downloads-month.svg" height="20" alt="npm downloads / month" /></a>
13
+ <br />
14
+ <a href="https://github.com/buidangminh23/codex-mcp-bridge/network/dependents"><img src="https://img.shields.io/librariesio/dependent-repos/npm/@minhspark/codex-mcp-bridge?label=Used%20by&amp;logo=github&amp;color=0969da" height="20" alt="Used by repositories (Libraries.io)" /></a>
15
+ <a href="https://skills.sh/buidangminh23/codex-mcp-bridge/codex-bridge"><img src="https://skills.sh/b/buidangminh23/codex-mcp-bridge?label=skills.sh" height="20" alt="skills.sh installs" /></a>
16
+ <a href="https://buidangminh23.github.io/codex-mcp-bridge/"><img src="https://raw.githubusercontent.com/buidangminh23/codex-mcp-bridge/analytics/stats-github-views.svg" height="20" alt="GitHub views / source window" /></a>
17
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/@minhspark/codex-mcp-bridge" height="20" alt="license" /></a>
18
+ </p>
16
19
 
17
20
  Send prompts and replies between **Claude and Codex**, keeping each conversation in its own app. Supports Windows, macOS, and Linux; native Desktop integration supports Windows and macOS.
18
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.21.6",
3
+ "version": "1.22.1",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.21.6",
4
+ "version": "1.22.1",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -23,7 +23,7 @@ import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-acce
23
23
 
24
24
  exitForVersionRequest(import.meta.url);
25
25
 
26
- const VERSION = "1.21.6";
26
+ const VERSION = "1.22.1";
27
27
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
28
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
29
29
  const entryArgs = ["native-relay-companion.mjs"];
@@ -129,7 +129,7 @@ function renderBadge(label, message, color, detail) {
129
129
  const messageWidth = Math.ceil([...message].length * 7.2 + 16);
130
130
  const width = labelWidth + messageWidth;
131
131
  const accessible = `${label}: ${message}`;
132
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="24" viewBox="0 0 ${width} 24" role="img" aria-label="${escapeXml(accessible)}"><title>${escapeXml(`${accessible}. ${detail}`)}</title><rect width="${width}" height="24" rx="4" fill="#555"/><path d="M${labelWidth} 0H${width - 4}Q${width} 0 ${width} 4V20Q${width} 24 ${width - 4} 24H${labelWidth}Z" fill="${color}"/><g fill="#fff" font-family="DejaVu Sans Mono,monospace" font-size="12" text-anchor="middle"><text x="${labelWidth / 2}" y="16">${escapeXml(label)}</text><text x="${labelWidth + messageWidth / 2}" y="16">${escapeXml(message)}</text></g></svg>
132
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width * 20 / 24}" height="20" viewBox="0 0 ${width} 24" role="img" aria-label="${escapeXml(accessible)}"><title>${escapeXml(`${accessible}. ${detail}`)}</title><rect width="${width}" height="24" rx="4" fill="#555"/><path d="M${labelWidth} 0H${width - 4}Q${width} 0 ${width} 4V20Q${width} 24 ${width - 4} 24H${labelWidth}Z" fill="${color}"/><g fill="#fff" font-family="DejaVu Sans Mono,monospace" font-size="12" text-anchor="middle"><text x="${labelWidth / 2}" y="16">${escapeXml(label)}</text><text x="${labelWidth + messageWidth / 2}" y="16">${escapeXml(message)}</text></g></svg>
133
133
  `;
134
134
  }
135
135
 
@@ -29,7 +29,7 @@ import { registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
29
29
 
30
30
  exitForVersionRequest(import.meta.url);
31
31
 
32
- const VERSION = "1.21.6";
32
+ const VERSION = "1.22.1";
33
33
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
34
34
  const FORWARD_MIN_INTERVAL_MS = 5000;
35
35
  const FORWARD_MAX_PER_SESSION = 50;
@@ -201,6 +201,17 @@ function completedEventText(entry) {
201
201
  return item.content.map((part) => part.text).join("");
202
202
  }
203
203
 
204
+ function completedMemoryCitation(item) {
205
+ const citation = item.memory_citation;
206
+ if (!object(citation) || !Array.isArray(citation.entries) || !Array.isArray(citation.rolloutIds)) return null;
207
+ const singleLine = (value) => typeof value === "string" && !/[\r\n<>]/.test(value);
208
+ if (citation.entries.some((entry) => !object(entry) || !singleLine(entry.path) || !entry.path || !singleLine(entry.note) ||
209
+ !Number.isSafeInteger(entry.lineStart) || entry.lineStart < 1 || !Number.isSafeInteger(entry.lineEnd) || entry.lineEnd < entry.lineStart) ||
210
+ citation.rolloutIds.some((id) => typeof id !== "string" || !UUID.test(id))) return null;
211
+ const entries = citation.entries.map((entry) => `${entry.path}:${entry.lineStart}-${entry.lineEnd}|note=[${entry.note}]`).join("\n");
212
+ return `<oai-mem-citation>\n<citation_entries>\n${entries}\n</citation_entries>\n<rollout_ids>\n${citation.rolloutIds.join("\n")}\n</rollout_ids>\n</oai-mem-citation>`;
213
+ }
214
+
204
215
  function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch } = {}) {
205
216
  const turns = records.filter((entry) => recordBelongsToTurn(entry, threadId, turnId));
206
217
  const starts = turns.filter(({ record }) => record.type === "event_msg" && STARTED.has(record.payload.type));
@@ -276,10 +287,11 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
276
287
  contextByte: contexts[0].start, completionByte: completions[0].start,
277
288
  });
278
289
  }
279
- eventItems.set(id, { text, start });
290
+ eventItems.set(id, { text, start, memoryCitation: completedMemoryCitation(eventItem) });
280
291
  }
281
292
  for (const item of assistantItems) {
282
- if (eventItems.has(item.id) && eventItems.get(item.id).text !== item.text) throw validationError("The native assistant item representations conflict", "assistant_representation_text", {
293
+ const event = eventItems.get(item.id);
294
+ if (event && event.text !== item.text && (!event.memoryCitation || event.text + event.memoryCitation !== item.text)) throw validationError("The native assistant item representations conflict", "assistant_representation_text", {
283
295
  responseItemByte: ids.get(item.id).start, completedEventByte: eventItems.get(item.id).start,
284
296
  });
285
297
  }
@@ -0,0 +1,208 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.mjs";
4
+ import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
5
+ import { ResponseSnapshots, responsePreferencesSchema, normalizeResponsePreferences, projectHistory, projectReply, assertResponseResultSize } from "./response-preferences.mjs";
6
+
7
+ export function registerDesktopReadTools(registerTool, {
8
+ desktopTasksEnabled, desktopTasks, replyReceipts, security, client,
9
+ context: operationContext, assertOperation,
10
+ assertThreadAccess, normalizeThreadCwd, textResult, replyResult, failure,
11
+ }) {
12
+ const snapshots = new ResponseSnapshots();
13
+ const invalid = (message) => Object.assign(new Error(message), { code: "INVALID_RESPONSE_PREFERENCES" });
14
+ const receiptHash = (delivered) => createHash("sha256").update(JSON.stringify(delivered)).digest("hex");
15
+ const binding = (operation) => {
16
+ const { accounts, caller } = operationContext();
17
+ return { operation, accounts, caller: { sessionId: caller.sessionId, taskId: caller.taskId, cwd: caller.cwd } };
18
+ };
19
+ const selector = (values) => Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
20
+
21
+ async function authorize(auth, deliveryId) {
22
+ const context = operationContext();
23
+ await assertOperation(context);
24
+ if (deliveryId && receiptHash(replyReceipts.read(deliveryId, context)) !== auth.receiptHash) {
25
+ throw new Error("Reply observation binding changed; cached content was withheld");
26
+ }
27
+ if (auth.projectScope) recheckProjectScope(auth.projectScope);
28
+ if (auth.scopeBinding) security.hardenedRoots.recheck(auth.scopeBinding);
29
+ if (auth.threadId) {
30
+ const current = await desktopTasks.inspect(auth.threadId, auth.cwd, { deadline: context.deadline });
31
+ if (auth.projectScope) recheckProjectScope(auth.projectScope, current.projectScope);
32
+ if (auth.scopeBinding) security.hardenedRoots.recheck(auth.scopeBinding);
33
+ }
34
+ await assertOperation(context);
35
+ }
36
+
37
+ async function authorization(threadId, cwd, deliveryId, originalReceipt) {
38
+ const inspected = threadId ? await desktopTasks.inspect(threadId, cwd, { deadline: operationContext().deadline }) : null;
39
+ return { threadId, cwd: inspected?.thread.cwd ?? cwd, projectScope: inspected?.projectScope ?? captureProjectScope(operationContext().caller.cwd, cwd),
40
+ scopeBinding: inspected?.scopeBinding ?? (security.hardenedRoots?.mode === "project-policy" ? security.hardenedRoots.capture(cwd) : undefined), ...(deliveryId ? { receiptHash: receiptHash(originalReceipt) } : {}) };
41
+ }
42
+
43
+ async function continuation(preferences, owner, selection, deliveryId) {
44
+ const cached = snapshots.inspect(preferences, owner, selection);
45
+ await authorize(cached.authorization, deliveryId);
46
+ const result = snapshots.continue({ preferences, binding: owner, selection });
47
+ await authorize(cached.authorization, deliveryId);
48
+ return result;
49
+ }
50
+
51
+ async function respond({ preferences, owner, selection, metadata, items, auth, deliveryId }) {
52
+ await authorize(auth, deliveryId);
53
+ const result = snapshots.respond({ preferences, binding: owner, selection, metadata, items, authorization: auth });
54
+ await authorize(auth, deliveryId);
55
+ return result;
56
+ }
57
+
58
+ async function preferredRead({ threadId, turnId, limit, responsePreferences }) {
59
+ const owner = binding("read_codex_thread");
60
+ const selection = selector({ threadId, turnId, limit });
61
+ if (responsePreferences.delivery?.options?.cursor) return continuation(responsePreferences, owner, selection);
62
+ const preferences = normalizeResponsePreferences(responsePreferences);
63
+ const auth = await authorization(threadId);
64
+ const deadline = operationContext().deadline;
65
+ if (turnId) {
66
+ const response = await desktopTasks.inspectNativeTurn(threadId, turnId, auth.cwd, { deadline });
67
+ const { assistantItems, text, ...metadata } = response;
68
+ return respond({ preferences, owner, selection: { ...selection, limit: limit ?? 10 }, auth,
69
+ metadata: { ...metadata, threadId, turnId, turnStatus: response.turnStatus ?? (["completed", "completed_no_reply"].includes(response.status) ? "completed" : null) },
70
+ items: projectReply(assistantItems ?? [], turnId) });
71
+ }
72
+ const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10), includeOutputs: preferences.filter.includeToolPayloads, maxOutputCharsPerItem: 16000 }, { deadline });
73
+ const projected = projectHistory(response.turns ?? [], preferences.filter);
74
+ return respond({ preferences, owner, selection: { ...selection, limit: limit ?? 10 }, auth,
75
+ metadata: { threadId, turnId: response.turns?.[0]?.id ?? null, turnStatus: response.turns?.[0]?.status ?? null,
76
+ turns: projected.turns, sourceHasMore: response.page?.hasMore ?? Boolean(response.page?.nextCursor), omissions: projected.omissions },
77
+ items: projected.items });
78
+ }
79
+
80
+ async function preferredWait({ deliveryId, timeoutSec, responsePreferences }) {
81
+ const owner = binding("wait_codex_reply");
82
+ const selection = { deliveryId };
83
+ if (responsePreferences.delivery?.options?.cursor) {
84
+ if (timeoutSec !== undefined) throw invalid("A cursor reads a snapshot; omit timeoutSec on continuation");
85
+ return continuation(responsePreferences, owner, selection, deliveryId);
86
+ }
87
+ const preferences = normalizeResponsePreferences(responsePreferences);
88
+ const context = operationContext();
89
+ const originalReceipt = replyReceipts.read(deliveryId, context);
90
+ let delivered = originalReceipt;
91
+ // Leave time for the live authorization checks after observation.
92
+ const remaining = Math.max(0, context.deadline - Date.now());
93
+ const observationDeadline = context.deadline - Math.min(1000, Math.floor(remaining / 4));
94
+ const timeout = () => ({ threadId: delivered.threadId, status: "timeout", responseStatus: "unavailable", text: "" });
95
+ if (delivered.creationObservation) {
96
+ delivered = await desktopTasks.resolveCreation(delivered, { deadline: observationDeadline }) ?? delivered;
97
+ }
98
+ const auth = await authorization(delivered.threadId, delivered.cwd, deliveryId, originalReceipt);
99
+ const finish = async (result) => {
100
+ const { text, assistantItems, activity, ...metadata } = result;
101
+ return respond({ preferences, owner, selection, auth, deliveryId,
102
+ metadata: { ...metadata, threadId: delivered.threadId, turnId: result.turnId ?? null,
103
+ turnStatus: ["completed", "interrupted", "failed"].includes(result.status) ? result.status : null, deliveryId,
104
+ deliveryStatus: ["completed", "completed_no_reply"].includes(result.responseStatus) ? "accepted" : delivered.deliveryStatus ?? "accepted",
105
+ nextAction: result.status === "timeout" ? "wait_codex_reply" : "none" },
106
+ items: projectReply(assistantItems ?? [], result.turnId) });
107
+ };
108
+ if (!delivered.threadId || Date.now() >= observationDeadline) return finish(timeout());
109
+ const result = await desktopTasks.withThread(delivered.threadId, () => desktopTasks.wait(delivered.threadId, {
110
+ timeoutMs: Math.max(0, observationDeadline - Date.now()), previousTurnId: delivered.previousTurnId,
111
+ responseObservation: delivered.responseObservation,
112
+ }), { deadline: observationDeadline, onDeadline: timeout });
113
+ return finish(result);
114
+ }
115
+ registerTool(
116
+ "wait_codex_reply",
117
+ {
118
+ title: "Continue waiting for a Codex reply",
119
+ description: "Continue a Desktop creation or send using its deliveryId; an unconfirmed acknowledgement does not prove delivery, including after MCP reconnect. Never sends a message or creates a task. On timeout, call again with the same deliveryId; do not ask the user to fetch the answer or resend. Stops for approval, user input or a verification error. Only the original verified Claude session and accounts can use the receipt.",
120
+ inputSchema: {
121
+ responsePreferences: responsePreferencesSchema.optional().describe("Opt in to versioned filtered responses and cursor delivery; omitted preserves existing behavior. Inspect the returned envelope and follow nextCursor with the same tool and selector."),
122
+ deliveryId: z.string().uuid().describe("Exact deliveryId returned by send_to_codex_thread, delegate_to_codex or start_codex_thread"),
123
+ timeoutSec: z.number().int().min(1).max(40).optional().describe("Bounded observation time, default 40 seconds; repeat with the same receipt if still running"),
124
+ },
125
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
126
+ },
127
+ async ({ deliveryId, timeoutSec, responsePreferences }) => {
128
+ if (responsePreferences !== undefined) {
129
+ try {
130
+ if (!desktopTasksEnabled) throw invalid("responsePreferences requires Desktop native delivery");
131
+ return await preferredWait({ deliveryId, timeoutSec, responsePreferences });
132
+ } catch (error) { return failure(error); }
133
+ }
134
+ if (!desktopTasksEnabled) return failure(new Error("Reply continuations require Desktop native delivery"));
135
+ const context = operationContext();
136
+ let delivered = replyReceipts.read(deliveryId, context);
137
+ if (delivered.creationObservation) {
138
+ const resolved = await desktopTasks.resolveCreation(delivered, { deadline: context.deadline });
139
+ if (!resolved) return replyResult(delivered, deliveryId, { threadId: null, status: "timeout", responseStatus: "unavailable", text: "" });
140
+ delivered = resolved;
141
+ }
142
+ const { threadId } = delivered;
143
+ const expired = () => replyResult(delivered, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" });
144
+ return desktopTasks.withThread(threadId, async () => {
145
+ await desktopTasks.inspect(threadId, delivered.cwd, { deadline: context.deadline });
146
+ const result = await desktopTasks.wait(threadId, { timeoutMs: Math.max(0, context.deadline - Date.now()), previousTurnId: delivered.previousTurnId, responseObservation: delivered.responseObservation });
147
+ return replyResult(delivered, deliveryId, result);
148
+ }, { deadline: context.deadline, onDeadline: expired });
149
+ },
150
+ );
151
+
152
+ registerTool(
153
+ "read_codex_thread",
154
+ {
155
+ title: "Read a Codex thread",
156
+ description: "Read a Codex thread without sending anything. In Desktop mode, pass the exact turnId returned by send_to_codex_thread for authoritative assistant item IDs, text, and reply hash; without turnId the native recent-history view may omit items.",
157
+ inputSchema: {
158
+ responsePreferences: responsePreferencesSchema.optional().describe("Opt in to versioned filtered responses and cursor delivery; omitted preserves existing behavior. Inspect the returned envelope and follow nextCursor with the same tool and selector."),
159
+ threadId: z.string().describe("Codex thread id"),
160
+ turnId: z.string().optional().describe("Exact turn id to inspect authoritatively in Codex Desktop mode"),
161
+ limit: z.number().int().min(1).max(50).optional().describe("How many recent messages to show (default 10)"),
162
+ },
163
+ annotations: {
164
+ readOnlyHint: true,
165
+ openWorldHint: true,
166
+ },
167
+ },
168
+ async ({ threadId, turnId, limit, responsePreferences }) => {
169
+ try {
170
+ if (responsePreferences !== undefined) {
171
+ if (!desktopTasksEnabled) throw invalid("responsePreferences requires Desktop native delivery");
172
+ return await preferredRead({ threadId, turnId, limit, responsePreferences });
173
+ }
174
+ if (desktopTasksEnabled) {
175
+ const deadline = operationContext()?.deadline ?? Date.now() + DESKTOP_TOOL_BUDGET_MS;
176
+ if (turnId) {
177
+ const response = await desktopTasks.inspectNativeTurn(threadId, turnId, undefined, { deadline });
178
+ return assertResponseResultSize(textResult(JSON.stringify(response, null, 2), response.status === "unavailable"));
179
+ }
180
+ await desktopTasks.inspect(threadId, undefined, { deadline });
181
+ const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10), includeOutputs: false, maxOutputCharsPerItem: 2000 }, { deadline });
182
+ return assertResponseResultSize(textResult(JSON.stringify(response, null, 2)));
183
+ }
184
+ await assertThreadAccess(threadId);
185
+ const res = await client.call("thread/read", { threadId, includeTurns: true });
186
+ const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
187
+ security.assertCwd(thread.cwd);
188
+ const items = (thread.turns ?? []).flatMap((t) => t.items ?? []);
189
+ const msgs = items
190
+ .filter((i) => i?.type === "agentMessage" || i?.type === "userMessage")
191
+ .slice(-(limit ?? 10))
192
+ .map((i) => {
193
+ const body =
194
+ i.type === "userMessage"
195
+ ? (i.content ?? [])
196
+ .map((c) => (c.type === "text" ? c.text : `<${c.type}>`))
197
+ .join(" ")
198
+ : (i.text ?? "");
199
+ return `[${i.type === "userMessage" ? "user" : "codex"}] ${body.trim()}`;
200
+ });
201
+ const header = `thread ${threadId}\n title: ${thread.name ?? "(unnamed)"}\n cwd: ${thread.cwd ?? "?"}\n status: ${thread.status?.type ?? "?"}`;
202
+ return textResult(msgs.length ? `${header}\n\n${msgs.join("\n\n")}` : `${header}\n\n(no messages found)`);
203
+ } catch (err) {
204
+ return failure(err);
205
+ }
206
+ },
207
+ );
208
+ }
package/src/index.mjs CHANGED
@@ -35,11 +35,12 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
35
35
  import { bridgeReadiness } from "./bridge-readiness.mjs";
36
36
  import { captureProjectScope } from "./project-scope.mjs";
37
37
  import { DesktopReplyReceipts } from "./desktop-reply-receipts.mjs";
38
+ import { registerDesktopReadTools } from "./desktop-read-tools.mjs";
38
39
  import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
39
40
 
40
41
  exitForVersionRequest(import.meta.url);
41
42
 
42
- const VERSION = "1.21.6";
43
+ const VERSION = "1.22.1";
43
44
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
44
45
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
45
46
 
@@ -109,7 +110,7 @@ function withheldDesktopResult(error, context) {
109
110
  const reason = (error?.message ?? "The calling session or accounts could not be reverified.").replace(/\s*No message was sent\./g, "");
110
111
  return {
111
112
  ...textResult(`${reason} ${context.dispatched ? "A Desktop operation may already have been dispatched. Its original destination and any creation receipt were preserved." : "No Desktop mutation was dispatched."} Reply content was withheld. Inspect the original task and do not resend automatically.`, true),
112
- structuredContent: { accountContext: context.accounts, operation: { state: context.dispatched ? "dispatched_outcome_unverified" : "blocked", threadId: context.threadId ?? null } },
113
+ structuredContent: { ...failure(error).structuredContent, accountContext: context.accounts, operation: { state: context.dispatched ? "dispatched_outcome_unverified" : "blocked", threadId: context.threadId ?? null } },
113
114
  };
114
115
  }
115
116
 
@@ -137,7 +138,16 @@ const textResult = (text, isError = false) => ({
137
138
  ...(isError ? { isError: true } : {}),
138
139
  });
139
140
 
140
- const failure = (err) => textResult(`Codex bridge error: ${err?.message ?? String(err)}`, true);
141
+ const failure = (err) => {
142
+ const result = textResult(`Codex bridge error: ${err?.message ?? String(err)}`, true);
143
+ for (let cause = err; cause; cause = cause.cause) {
144
+ if (["RESPONSE_TOO_LARGE", "INVALID_RESPONSE_PREFERENCES", "INVALID_CURSOR", "CURSOR_EXPIRED"].includes(cause.code)) {
145
+ result.structuredContent = { error: Object.fromEntries(["code", "boundary", "bytes", "limit"].filter((key) => cause[key] !== undefined).map((key) => [key, cause[key]])) };
146
+ break;
147
+ }
148
+ }
149
+ return result;
150
+ };
141
151
 
142
152
  function replyResult(delivered, deliveryId, result, notes = []) {
143
153
  result = { activity: [], errors: [], ...result };
@@ -430,7 +440,7 @@ function registerTool(name, definition, handler) {
430
440
  await assertDesktopOperation(context);
431
441
  const result = await handler(...args);
432
442
  await assertDesktopOperation(context);
433
- if (result.isError && !context.dispatched) {
443
+ if (result.isError && !context.dispatched && result.structuredContent?.responseVersion !== 1) {
434
444
  result.structuredContent = { ...result.structuredContent, accountContext: context.accounts, operation: { state: "blocked", threadId: context.threadId ?? null } };
435
445
  }
436
446
  return result;
@@ -447,6 +457,12 @@ function registerTool(name, definition, handler) {
447
457
  });
448
458
  }
449
459
 
460
+ registerDesktopReadTools(registerTool, {
461
+ desktopTasksEnabled, desktopTasks, replyReceipts, security, client,
462
+ context: () => desktopOperation.getStore(), assertOperation: assertDesktopOperation,
463
+ assertThreadAccess, normalizeThreadCwd, textResult, replyResult, failure,
464
+ });
465
+
450
466
  registerProjectOnboardingTools(registerTool, { beforePrepare: async () => {
451
467
  if (!desktopTasksEnabled || !desktopOperation.getStore()?.caller) throw new Error("Project onboarding requires a verified Desktop caller");
452
468
  await assertDesktopOperation(desktopOperation.getStore(), { verifyProcess: true });
@@ -691,35 +707,6 @@ registerTool(
691
707
  },
692
708
  );
693
709
 
694
- registerTool(
695
- "wait_codex_reply",
696
- {
697
- title: "Continue waiting for a Codex reply",
698
- description: "Continue a Desktop creation or send using its deliveryId; an unconfirmed acknowledgement does not prove delivery, including after MCP reconnect. Never sends a message or creates a task. On timeout, call again with the same deliveryId; do not ask the user to fetch the answer or resend. Stops for approval, user input or a verification error. Only the original verified Claude session and accounts can use the receipt.",
699
- inputSchema: {
700
- deliveryId: z.string().uuid().describe("Exact deliveryId returned by send_to_codex_thread, delegate_to_codex or start_codex_thread"),
701
- timeoutSec: z.number().int().min(1).max(40).optional().describe("Bounded observation time, default 40 seconds; repeat with the same receipt if still running"),
702
- },
703
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
704
- },
705
- async ({ deliveryId }) => {
706
- if (!desktopTasksEnabled) return failure(new Error("Reply continuations require Desktop native delivery"));
707
- const context = desktopOperation.getStore();
708
- let delivered = replyReceipts.read(deliveryId, context);
709
- if (delivered.creationObservation) {
710
- const resolved = await desktopTasks.resolveCreation(delivered, { deadline: context.deadline });
711
- if (!resolved) return replyResult(delivered, deliveryId, { threadId: null, status: "timeout", responseStatus: "unavailable", text: "" });
712
- delivered = resolved;
713
- }
714
- const { threadId } = delivered;
715
- const expired = () => replyResult(delivered, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" });
716
- return desktopTasks.withThread(threadId, async () => {
717
- await desktopTasks.inspect(threadId, delivered.cwd, { deadline: context.deadline });
718
- const result = await desktopTasks.wait(threadId, { timeoutMs: Math.max(0, context.deadline - Date.now()), previousTurnId: delivered.previousTurnId, responseObservation: delivered.responseObservation });
719
- return replyResult(delivered, deliveryId, result);
720
- }, { deadline: context.deadline, onDeadline: expired });
721
- },
722
- );
723
710
 
724
711
  registerTool(
725
712
  "list_codex_threads",
@@ -851,61 +838,6 @@ registerTool(
851
838
  },
852
839
  );
853
840
 
854
- registerTool(
855
- "read_codex_thread",
856
- {
857
- title: "Read a Codex thread",
858
- description: "Read a Codex thread without sending anything. In Desktop mode, pass the exact turnId returned by send_to_codex_thread for authoritative assistant item IDs, text, and reply hash; without turnId the native recent-history view may omit items.",
859
- inputSchema: {
860
- threadId: z.string().describe("Codex thread id"),
861
- turnId: z.string().optional().describe("Exact turn id to inspect authoritatively in Codex Desktop mode"),
862
- limit: z.number().int().min(1).max(50).optional().describe("How many recent messages to show (default 10)"),
863
- },
864
- annotations: {
865
- readOnlyHint: true,
866
- openWorldHint: true,
867
- },
868
- },
869
- async ({ threadId, turnId, limit }) => {
870
- try {
871
- if (desktopTasksEnabled) {
872
- const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + DESKTOP_TOOL_BUDGET_MS;
873
- if (turnId) {
874
- const response = await desktopTasks.inspectNativeTurn(threadId, turnId, undefined, { deadline });
875
- return textResult(JSON.stringify(response, null, 2), response.status === "unavailable");
876
- }
877
- await desktopTasks.inspect(threadId, undefined, { deadline });
878
- const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10), includeOutputs: false, maxOutputCharsPerItem: 2000 }, { deadline });
879
- const result = textResult(JSON.stringify(response, null, 2));
880
- if (Buffer.byteLength(JSON.stringify(result), "utf8") > 8 * 1024 * 1024) {
881
- return textResult("DESKTOP_READ_RESPONSE_TOO_LARGE: The formatted Desktop read exceeds 8 MiB. Read fewer turns with limit: 1 or select a specific turnId.", true);
882
- }
883
- return result;
884
- }
885
- await assertThreadAccess(threadId);
886
- const res = await client.call("thread/read", { threadId, includeTurns: true });
887
- const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
888
- security.assertCwd(thread.cwd);
889
- const items = (thread.turns ?? []).flatMap((t) => t.items ?? []);
890
- const msgs = items
891
- .filter((i) => i?.type === "agentMessage" || i?.type === "userMessage")
892
- .slice(-(limit ?? 10))
893
- .map((i) => {
894
- const body =
895
- i.type === "userMessage"
896
- ? (i.content ?? [])
897
- .map((c) => (c.type === "text" ? c.text : `<${c.type}>`))
898
- .join(" ")
899
- : (i.text ?? "");
900
- return `[${i.type === "userMessage" ? "user" : "codex"}] ${body.trim()}`;
901
- });
902
- const header = `thread ${threadId}\n title: ${thread.name ?? "(unnamed)"}\n cwd: ${thread.cwd ?? "?"}\n status: ${thread.status?.type ?? "?"}`;
903
- return textResult(msgs.length ? `${header}\n\n${msgs.join("\n\n")}` : `${header}\n\n(no messages found)`);
904
- } catch (err) {
905
- return failure(err);
906
- }
907
- },
908
- );
909
841
 
910
842
  registerTool(
911
843
  "interrupt_codex_turn",
@@ -8,8 +8,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
 
10
10
  import {
11
- MAX_FRAME_BYTES,
11
+ MAX_REQUEST_FRAME_BYTES,
12
12
  MAX_RESPONSE_FRAME_BYTES,
13
+ responseTooLargeError,
13
14
  NATIVE_DISPATCH_METHOD,
14
15
  NativeToolsClient,
15
16
  NativeRelayError,
@@ -37,11 +38,12 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
37
38
 
38
39
  exitForVersionRequest(import.meta.url);
39
40
 
40
- const VERSION = "1.21.6";
41
+ const VERSION = "1.22.1";
41
42
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
42
43
 
43
- function errorResponse(code, message, sent) {
44
- return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message, ...(sent === false ? { sent: false } : {}) } };
44
+ function errorResponse(code, message, sent, details) {
45
+ const size = code === "RESPONSE_TOO_LARGE" && details ? { boundary: details.boundary, bytes: details.bytes, limit: details.limit } : {};
46
+ return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message, ...size, ...(sent === false ? { sent: false } : {}) } };
45
47
  }
46
48
 
47
49
  function boundRequest(payload) {
@@ -266,7 +268,7 @@ export async function handleRelayRequest(
266
268
  var authorization;
267
269
  if (strict) authorization = await authorize({ executorThreadId, targetThreadId, operation: "send_message_to_thread", accountContext });
268
270
  } catch (err) {
269
- return errorResponse(errorCode(err), err.message, false);
271
+ return errorResponse(errorCode(err), err.message, false, err);
270
272
  }
271
273
 
272
274
  /**
@@ -294,7 +296,7 @@ export async function handleRelayRequest(
294
296
  }
295
297
  return { ok: true, v: boundRequest(payload) ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, targetThreadId, executorThreadId, result: result ?? null };
296
298
  } catch (err) {
297
- return errorResponse(errorCode(err), err?.message ?? String(err), err?.sent === false && err?.reachedCompanion !== true ? false : undefined);
299
+ return errorResponse(errorCode(err), err?.message ?? String(err), err?.sent === false && err?.reachedCompanion !== true ? false : undefined, err);
298
300
  }
299
301
  }
300
302
 
@@ -311,7 +313,7 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
311
313
  try {
312
314
  accountContext = await checkAccountContext(payload, assertAccount);
313
315
  } catch (error) {
314
- return errorResponse(errorCode(error), error.message, false);
316
+ return errorResponse(errorCode(error), error.message, false, error);
315
317
  }
316
318
  if (typeof dispatchDesktop !== "function") {
317
319
  return errorResponse("NATIVE_OPERATION_UNAVAILABLE", "This companion does not support Desktop operations; reload the native relay");
@@ -365,7 +367,7 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
365
367
  throw error;
366
368
  }
367
369
  } catch (err) {
368
- return errorResponse(errorCode(err), err?.message ?? String(err), err?.sent === false && err?.reachedCompanion !== true ? false : undefined);
370
+ return errorResponse(errorCode(err), err?.message ?? String(err), err?.sent === false && err?.reachedCompanion !== true ? false : undefined, err);
369
371
  }
370
372
  }
371
373
 
@@ -568,9 +570,9 @@ export class RelaySocketServer {
568
570
  socket.on("data", (chunk) => {
569
571
  if (handled) return;
570
572
  buffer = Buffer.concat([buffer, chunk]);
571
- if (buffer.length > MAX_FRAME_BYTES) {
573
+ if (buffer.length > MAX_REQUEST_FRAME_BYTES) {
572
574
  handled = true;
573
- this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
575
+ this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_REQUEST_FRAME_BYTES} bytes`));
574
576
  return;
575
577
  }
576
578
  const index = buffer.indexOf(10);
@@ -614,12 +616,13 @@ export class RelaySocketServer {
614
616
 
615
617
  #reply(socket, response) {
616
618
  if (socket.destroyed) return;
617
- const frame = `${JSON.stringify(response)}\n`;
618
- if (Buffer.byteLength(frame, "utf8") > MAX_RESPONSE_FRAME_BYTES) {
619
- socket.end(`${JSON.stringify(errorResponse("RELAY_RESPONSE_TOO_LARGE", `The native relay response exceeds ${MAX_RESPONSE_FRAME_BYTES} bytes; reduce the requested turns or outputs`))}\n`);
620
- return;
619
+ let line = `${JSON.stringify(response)}\n`;
620
+ const bytes = Buffer.byteLength(line, "utf8");
621
+ if (bytes > MAX_RESPONSE_FRAME_BYTES) {
622
+ const error = responseTooLargeError("companion-to-bridge", bytes);
623
+ line = `${JSON.stringify({ ...errorResponse(error.code, error.message, undefined, error), v: response.v })}\n`;
621
624
  }
622
- socket.end(frame);
625
+ socket.end(line);
623
626
  }
624
627
 
625
628
  stop() {
@@ -39,8 +39,10 @@ export const NATIVE_DISPATCH_METHOD = "tools/call";
39
39
  export const RELAY_PROTOCOL_VERSION = 1;
40
40
  export const ACCOUNT_RELAY_PROTOCOL_VERSION = 2;
41
41
 
42
- export const MAX_FRAME_BYTES = 128 * 1024;
42
+ // Requests carry commands; responses may contain a long Desktop history.
43
+ export const MAX_REQUEST_FRAME_BYTES = 128 * 1024;
43
44
  export const MAX_RESPONSE_FRAME_BYTES = 8 * 1024 * 1024;
45
+ export const MAX_FRAME_BYTES = MAX_REQUEST_FRAME_BYTES;
44
46
 
45
47
  const DEFAULT_TIMEOUT_MS = 30000;
46
48
  const RELAY_SOCKET_NAME = "native-relay.sock";
@@ -48,14 +50,21 @@ const WINDOWS_RELAY_SOCKET = "\\\\.\\pipe\\LOCAL\\codex-native-relay";
48
50
  const RELAY_CONFIG_NAME = "native-relay.json";
49
51
 
50
52
  export class NativeRelayError extends Error {
51
- constructor(message, code, { reachedCompanion = false } = {}) {
53
+ constructor(message, code, { reachedCompanion = false, boundary, bytes, limit } = {}) {
52
54
  super(message);
53
55
  this.name = "NativeRelayError";
54
56
  this.code = code;
55
57
  this.reachedCompanion = reachedCompanion;
58
+ if (boundary !== undefined) this.boundary = boundary;
59
+ if (bytes !== undefined) this.bytes = bytes;
60
+ if (limit !== undefined) this.limit = limit;
56
61
  }
57
62
  }
58
63
 
64
+ export function responseTooLargeError(boundary, bytes, limit = MAX_RESPONSE_FRAME_BYTES) {
65
+ return new NativeRelayError(`Response at ${boundary} exceeds ${limit} bytes (${bytes} bytes received or required); reduce the requested turns or outputs`, "RESPONSE_TOO_LARGE", { boundary, bytes, limit, reachedCompanion: true });
66
+ }
67
+
59
68
  export function codexHome(env = process.env) {
60
69
  return env.CODEX_HOME ?? path.join(homeDir(), ".codex");
61
70
  }
@@ -81,7 +90,7 @@ export function relayAccountContext(value) {
81
90
  }
82
91
 
83
92
  function beforeWriteError(error) {
84
- const failure = new NativeRelayError(error?.message ?? "Relay preflight failed before dispatch", typeof error?.code === "string" ? error.code : "RELAY_PREFLIGHT_FAILED", { reachedCompanion: false });
93
+ const failure = new NativeRelayError(error?.message ?? "Relay preflight failed before dispatch", typeof error?.code === "string" ? error.code : "RELAY_PREFLIGHT_FAILED", { ...error, reachedCompanion: false });
85
94
  failure.sent = false;
86
95
  return failure;
87
96
  }
@@ -613,7 +622,7 @@ export class NativeToolsClient {
613
622
  while (buffer.length >= 4) {
614
623
  const length = buffer.readUInt32LE(0);
615
624
  if (length > MAX_RESPONSE_FRAME_BYTES) {
616
- fail(new NativeRelayError(`Codex Desktop native response exceeds ${MAX_RESPONSE_FRAME_BYTES} bytes; reduce the requested turns or outputs`, "NATIVE_RESPONSE_TOO_LARGE"));
625
+ fail(responseTooLargeError("native-pipe", length));
617
626
  return;
618
627
  }
619
628
  if (!length) {
@@ -674,7 +683,7 @@ export class NativeToolsClient {
674
683
  method: this.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
675
684
  params,
676
685
  }));
677
- if (payload.length > MAX_FRAME_BYTES) {
686
+ if (payload.length > MAX_REQUEST_FRAME_BYTES) {
678
687
  throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
679
688
  }
680
689
  try {
@@ -819,9 +828,9 @@ export class NativeDesktopRelay {
819
828
  async #request(request, timeoutMs, beforeSend) {
820
829
  validateBeforeSend(beforeSend);
821
830
  const line = `${JSON.stringify(request)}\n`;
822
- if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
831
+ if (Buffer.byteLength(line, "utf8") > MAX_REQUEST_FRAME_BYTES) {
823
832
  throw new NativeRelayError(
824
- `Relay message is larger than ${MAX_FRAME_BYTES} bytes; shorten it before relaying.`,
833
+ `Relay message is larger than ${MAX_REQUEST_FRAME_BYTES} bytes; shorten it before relaying.`,
825
834
  "RELAY_MESSAGE_TOO_LARGE",
826
835
  );
827
836
  }
@@ -834,7 +843,7 @@ export class NativeDesktopRelay {
834
843
  const error = new NativeRelayError(
835
844
  response?.error?.message ?? "the Codex Desktop relay refused the message",
836
845
  response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
837
- { reachedCompanion: response?.error?.sent !== false },
846
+ { boundary: response?.error?.boundary, bytes: response?.error?.bytes, limit: response?.error?.limit, reachedCompanion: response?.error?.sent !== false },
838
847
  );
839
848
  if (response?.error?.sent === false) error.sent = false;
840
849
  throw error;
@@ -888,12 +897,7 @@ export class NativeDesktopRelay {
888
897
  const newline = chunk.indexOf(10);
889
898
  const frameChunk = newline < 0 ? chunk : chunk.subarray(0, newline + 1);
890
899
  if (buffer.length + frameChunk.length > MAX_RESPONSE_FRAME_BYTES) {
891
- finish(
892
- reject,
893
- new NativeRelayError(`The Codex Desktop relay response exceeds ${MAX_RESPONSE_FRAME_BYTES} bytes; reduce the requested turns or outputs`, "RELAY_RESPONSE_TOO_LARGE", {
894
- reachedCompanion: true,
895
- }),
896
- );
900
+ finish(reject, responseTooLargeError("companion-to-bridge", buffer.length + frameChunk.length));
897
901
  return;
898
902
  }
899
903
  buffer = Buffer.concat([buffer, frameChunk]);
@@ -0,0 +1,233 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { z } from 'zod';
3
+
4
+ export const DEFAULT_PAGE_BYTES = 64 * 1024;
5
+ export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
6
+ export const responsePreferencesSchema = z.object({
7
+ delivery: z.object({
8
+ type: z.literal('cursorPagination'),
9
+ options: z.object({
10
+ maxBytes: z.number().int().min(8 * 1024).max(96 * 1024).optional(),
11
+ cursor: z.string().min(1).max(512).optional(),
12
+ }).strict().optional(),
13
+ }).strict().optional(),
14
+ filter: z.object({
15
+ includeReasoning: z.boolean().optional(),
16
+ includeToolPayloads: z.boolean().optional(),
17
+ }).strict().optional(),
18
+ }).strict();
19
+
20
+ function failure(code, message, details = {}) {
21
+ return Object.assign(new Error(message), { code, ...details });
22
+ }
23
+ function parsePreferences(input) {
24
+ const result = responsePreferencesSchema.safeParse(input);
25
+ if (!result.success) throw failure('INVALID_RESPONSE_PREFERENCES', result.error.message);
26
+ return result.data;
27
+ }
28
+ export function normalizeResponsePreferences(input) {
29
+ if (input === undefined) return undefined;
30
+ const value = parsePreferences(input);
31
+ return {
32
+ ...(value.delivery ? { delivery: { type: 'cursorPagination', options: { maxBytes: value.delivery.options?.maxBytes ?? DEFAULT_PAGE_BYTES } } } : {}),
33
+ filter: { includeReasoning: value.filter?.includeReasoning ?? true, includeToolPayloads: value.filter?.includeToolPayloads ?? false },
34
+ };
35
+ }
36
+ const scalar = value => value === null || ['string', 'number', 'boolean'].includes(typeof value);
37
+ function pick(value, keys) {
38
+ return Object.fromEntries(keys.filter(key => value?.[key] !== undefined && scalar(value[key])).map(key => [key, value[key]]));
39
+ }
40
+ const IDENTITY = ['id', 'type', 'name', 'status', 'startedAt', 'completedAt', 'createdAt', 'updatedAt', 'timestamp', 'durationMs', 'exitCode'];
41
+ const TOOL_TYPES = new Set(['commandExecution', 'mcpToolCall', 'dynamicToolCall', 'fileChange', 'collabAgentToolCall', 'webSearch', 'imageView', 'imageGeneration', 'function_call', 'function_call_output', 'tool_call', 'tool_result', 'functionCall', 'functionCallOutput', 'subAgentActivity', 'contextCompaction', 'sleep']);
42
+ const MESSAGE_TYPES = new Set(['agentMessage', 'userMessage', 'assistantMessage', 'message', 'commentary']);
43
+
44
+ export function projectHistory(turns, filter = { includeReasoning: true, includeToolPayloads: false }) {
45
+ const omissions = { reasoningRecords: 0, toolPayloadRecords: 0, unknownRecords: 0 };
46
+ const headers = [], items = [];
47
+ for (const turn of turns) {
48
+ const turnId = typeof turn.id === 'string' ? turn.id : typeof turn.turnId === 'string' ? turn.turnId : null;
49
+ headers.push({ ...pick(turn, ['id', 'turnId', 'status', 'startedAt', 'completedAt', 'createdAt', 'updatedAt', 'timestamp']), turnId });
50
+ const source = Array.isArray(turn.items) ? turn.items : [];
51
+ for (let index = source.length - 1; index >= 0; index--) {
52
+ const raw = source[index];
53
+ if (!raw || typeof raw !== 'object') { omissions.unknownRecords++; continue; }
54
+ let item;
55
+ if (raw.type === 'reasoning') {
56
+ if (!filter.includeReasoning) { omissions.reasoningRecords++; continue; }
57
+ item = raw;
58
+ } else if (MESSAGE_TYPES.has(raw.type)) item = raw;
59
+ else if (TOOL_TYPES.has(raw.type)) {
60
+ if (filter.includeToolPayloads) item = raw;
61
+ else {
62
+ omissions.toolPayloadRecords++;
63
+ item = pick(raw, [...IDENTITY, 'tool', 'server', 'callId', 'processId', 'cwd', 'path', 'kind', 'agentId', 'agentThreadId', 'agentPath', 'namespace', 'receiverThreadId', 'senderThreadId']);
64
+ if (Array.isArray(raw.changes)) item.changes = raw.changes.map(change => ({
65
+ ...pick(change, ['path', 'kind']),
66
+ ...(change?.kind && typeof change.kind === 'object' ? { kind: pick(change.kind, ['type', 'movePath']) } : {}),
67
+ }));
68
+ }
69
+ } else { omissions.unknownRecords++; item = pick(raw, IDENTITY); }
70
+ items.push({ turnId, itemId: typeof raw.id === 'string' ? raw.id : `${turnId}:${index}`, sourceIndex: index, item });
71
+ }
72
+ }
73
+ return { turns: headers, items, omissions };
74
+ }
75
+ export function projectReply(assistantItems, turnId) {
76
+ return assistantItems.map((item, sourceIndex) => ({ turnId, itemId: item.id ?? `${turnId}:${sourceIndex}`, sourceIndex, item: { type: 'agentMessage', text: item.text } }));
77
+ }
78
+ function stable(value) {
79
+ if (Array.isArray(value)) return value.map(stable);
80
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, stable(value[key])]));
81
+ return value;
82
+ }
83
+ const encode = value => JSON.stringify(stable(value));
84
+ export function responseResult(envelope) {
85
+ return { content: [{ type: 'text', text: JSON.stringify(envelope) }], structuredContent: envelope, ...(['unavailable', 'failed', 'systemError'].includes(envelope.status) ? { isError: true } : {}) };
86
+ }
87
+ const size = value => Buffer.byteLength(JSON.stringify(value));
88
+ function oversize(bytes, limit) {
89
+ return failure('RESPONSE_TOO_LARGE', `Response requires ${bytes} bytes; limit is ${limit}`, { boundary: 'mcp_tool_result', bytes, limit });
90
+ }
91
+ export function assertResponseResultSize(result) {
92
+ const bytes = size(result);
93
+ if (bytes > MAX_RESPONSE_BYTES) throw oversize(bytes, MAX_RESPONSE_BYTES);
94
+ return result;
95
+ }
96
+
97
+ // The caller checks live scope and receipt ownership before and after each store operation.
98
+ export class ResponseSnapshots {
99
+ constructor({ now = Date.now, maxCacheBytes = 32 * 1024 * 1024, ttlMs = 30 * 60 * 1000 } = {}) {
100
+ this.now = now;
101
+ this.maxCacheBytes = maxCacheBytes;
102
+ this.ttlMs = ttlMs;
103
+ this.snapshots = new Map();
104
+ this.cacheBytes = 0;
105
+ }
106
+ clear() { this.snapshots.clear(); this.cacheBytes = 0; }
107
+ remove(id) {
108
+ const entry = this.snapshots.get(id);
109
+ if (entry) { this.cacheBytes -= entry.bytes; this.snapshots.delete(id); }
110
+ }
111
+ prune() {
112
+ for (const [id, entry] of this.snapshots) if (entry.expires <= this.now()) this.remove(id);
113
+ }
114
+ token(entry, index, offset) {
115
+ const value = `${entry.id}.${index}.${offset}`;
116
+ return `${value}.${createHmac('sha256', entry.secret).update(value).digest('base64url')}`;
117
+ }
118
+ lookup(preferences, binding, selection) {
119
+ const requested = parsePreferences(preferences);
120
+ const cursor = requested.delivery?.options?.cursor;
121
+ const match = typeof cursor === 'string' && /^([A-Za-z0-9_-]{32})\.(\d+)\.(\d+)\.([A-Za-z0-9_-]{43})$/.exec(cursor);
122
+ if (!match) throw failure('INVALID_CURSOR', 'Invalid response cursor');
123
+ this.prune();
124
+ const entry = this.snapshots.get(match[1]);
125
+ if (!entry) throw failure('CURSOR_EXPIRED', 'Response snapshot expired or is no longer available');
126
+ if (entry.binding !== encode(binding)) throw failure('INVALID_CURSOR', 'Cursor does not belong to this caller');
127
+ const index = Number(match[2]), offset = Number(match[3]);
128
+ const expected = this.token(entry, index, offset);
129
+ if (cursor.length !== expected.length || !timingSafeEqual(Buffer.from(cursor), Buffer.from(expected))) throw failure('INVALID_CURSOR', 'Invalid response cursor');
130
+ const originalSelection = JSON.parse(entry.selection);
131
+ for (const [key, value] of Object.entries(selection ?? {})) {
132
+ if (value !== undefined && encode(value) !== encode(originalSelection[key])) throw failure('INVALID_CURSOR', `Cursor conflicts with selector ${key}`);
133
+ }
134
+ const original = JSON.parse(entry.preferences);
135
+ if (requested.delivery.options?.maxBytes !== undefined && requested.delivery.options.maxBytes !== original.delivery.options.maxBytes) throw failure('INVALID_CURSOR', 'Cursor conflicts with page budget');
136
+ for (const [key, value] of Object.entries(requested.filter ?? {})) {
137
+ if (value !== original.filter[key]) throw failure('INVALID_CURSOR', `Cursor conflicts with filter ${key}`);
138
+ }
139
+ return { entry, index, offset };
140
+ }
141
+ inspect(preferences, binding, selection) {
142
+ const { entry } = this.lookup(preferences, binding, selection);
143
+ return { selection: JSON.parse(entry.selection), metadata: JSON.parse(entry.metadata), authorization: JSON.parse(entry.authorization) };
144
+ }
145
+ continue({ preferences, binding, selection }) {
146
+ const { entry, index, offset } = this.lookup(preferences, binding, selection);
147
+ const result = this.render(entry, index, offset);
148
+ this.snapshots.delete(entry.id);
149
+ this.snapshots.set(entry.id, entry);
150
+ return result;
151
+ }
152
+ respond({ preferences, binding, selection, metadata = {}, items, authorization = null }) {
153
+ const supplied = parsePreferences(preferences);
154
+ if (supplied.delivery?.options?.cursor) throw failure('INVALID_CURSOR', 'Use continuation for a response cursor');
155
+ const applied = normalizeResponsePreferences(supplied);
156
+ const now = this.now();
157
+ const entry = {
158
+ id: randomBytes(24).toString('base64url'), secret: randomBytes(32),
159
+ binding: encode(binding), selection: encode(selection ?? {}), authorization: encode(authorization),
160
+ metadata: JSON.stringify({ ...metadata, responseVersion: 1, snapshotAt: new Date(now).toISOString(), appliedResponsePreferences: applied }),
161
+ preferences: JSON.stringify(applied), expires: now + this.ttlMs,
162
+ records: items.map(({ item, ...header }) => ({ header: JSON.stringify(header), body: Buffer.from(JSON.stringify(item), 'utf8') })),
163
+ };
164
+ entry.bytes = Buffer.byteLength(entry.binding + entry.selection + entry.authorization + entry.metadata + entry.preferences + entry.id) + entry.secret.length + entry.records.reduce((total, record) => total + Buffer.byteLength(record.header) + record.body.length + 32, 0);
165
+ if (!applied.delivery) {
166
+ const result = responseResult({ ...JSON.parse(entry.metadata), items: entry.records.map(record => ({ ...JSON.parse(record.header), item: JSON.parse(record.body.toString('utf8')) })), page: { complete: true, nextCursor: null, remainingItems: 0, expiresAt: null } });
167
+ return assertResponseResultSize(result);
168
+ }
169
+ const result = this.render(entry, 0, 0);
170
+ if (result.structuredContent.page.complete) return result;
171
+ const limit = applied.delivery.options.maxBytes;
172
+ const maximumOffset = entry.records.reduce((maximum, record) => Math.max(maximum, record.body.length), 0);
173
+ const page = { ...result.structuredContent.page, nextCursor: this.token(entry, entry.records.length, maximumOffset), remainingItems: entry.records.length };
174
+ for (const record of entry.records) {
175
+ const header = JSON.parse(record.header);
176
+ const measured = item => size(responseResult({ ...result.structuredContent, items: [item], page }));
177
+ if (record.body.length * 2 <= limit && measured({ ...header, item: JSON.parse(record.body.toString('utf8')) }) <= limit) continue;
178
+ // Reserve maximum offset/token widths and one four-byte code point before issuing any cursor.
179
+ const required = measured({ ...header, fragment: { encoding: 'json', offsetBytes: record.body.length, totalBytes: record.body.length, text: '🧑' } });
180
+ if (required > limit) throw oversize(required, limit);
181
+ }
182
+ if (entry.bytes > this.maxCacheBytes) throw oversize(entry.bytes, this.maxCacheBytes);
183
+ this.prune();
184
+ while (this.cacheBytes + entry.bytes > this.maxCacheBytes) this.remove(this.snapshots.keys().next().value);
185
+ this.snapshots.set(entry.id, entry);
186
+ this.cacheBytes += entry.bytes;
187
+ return result;
188
+ }
189
+ render(entry, startIndex, startOffset) {
190
+ const metadata = JSON.parse(entry.metadata);
191
+ const limit = metadata.appliedResponsePreferences.delivery.options.maxBytes;
192
+ const envelope = (items, index, offset) => {
193
+ const complete = index === entry.records.length;
194
+ return responseResult({ ...metadata, ...(metadata.deliveryId && !complete ? { nextAction: 'wait_codex_reply' } : {}), items, page: {
195
+ complete, nextCursor: complete ? null : this.token(entry, index, offset), remainingItems: entry.records.length - index,
196
+ expiresAt: complete ? null : new Date(entry.expires).toISOString(),
197
+ } });
198
+ };
199
+ let index = startIndex, offset = startOffset;
200
+ const items = [];
201
+ let result = envelope(items, index, offset);
202
+ if (index === entry.records.length && size(result) > limit) throw oversize(size(result), limit);
203
+ while (index < entry.records.length) {
204
+ const record = entry.records[index], header = JSON.parse(record.header);
205
+ if (!offset) {
206
+ const complete = { ...header, item: JSON.parse(record.body.toString('utf8')) };
207
+ const candidate = envelope([...items, complete], index + 1, 0);
208
+ if (size(candidate) <= limit) { items.push(complete); index++; result = candidate; continue; }
209
+ if (items.length && size(envelope([complete], index + 1, 0)) <= limit) break;
210
+ }
211
+ const fragmentAt = end => ({ ...header, fragment: { encoding: 'json', offsetBytes: offset, totalBytes: record.body.length, text: record.body.subarray(offset, end).toString('utf8') } });
212
+ const candidateAt = end => envelope([...items, fragmentAt(end)], end === record.body.length ? index + 1 : index, end === record.body.length ? 0 : end);
213
+ let low = offset + 1, high = record.body.length, best = offset;
214
+ while (low <= high) {
215
+ const middle = Math.floor((low + high) / 2);
216
+ let end = middle;
217
+ while (end > offset && end < record.body.length && (record.body[end] & 0xc0) === 0x80) end--;
218
+ if (end > offset && size(candidateAt(end)) <= limit) { best = Math.max(best, end); low = middle + 1; }
219
+ else if (end === offset) low = middle + 1;
220
+ else high = middle - 1;
221
+ }
222
+ if (best === offset) {
223
+ if (!items.length) throw oversize(size(candidateAt(Math.min(record.body.length, offset + 4))), limit);
224
+ break;
225
+ }
226
+ items.push(fragmentAt(best));
227
+ if (best === record.body.length) { index++; offset = 0; } else offset = best;
228
+ result = envelope(items, index, offset);
229
+ if (offset) break;
230
+ }
231
+ return result;
232
+ }
233
+ }
@@ -119,6 +119,7 @@ export class DesktopTaskDelivery {
119
119
  });
120
120
  return includeRelayContext ? { result: response.result, executorThreadId: response.executorThreadId } : response.result;
121
121
  } catch (err) {
122
+ if (err.code === "RESPONSE_TOO_LARGE") throw err;
122
123
  if (err.code?.startsWith("PROJECT_SCOPE_") || err.code === "CLAUDE_SENDER_CONTEXT_UNVERIFIED") throw err;
123
124
  throw new Error(`Codex Desktop operation ${operation} failed: ${err.message}. Desktop-only mode will not start or use an external app-server. Open Codex Desktop and reconnect its native relay, then inspect the existing task before retrying a send.`, { cause: err });
124
125
  }