@minhspark/codex-mcp-bridge 1.21.5 → 1.21.6

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.5",
3
+ "version": "1.21.6",
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,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.21.6] - 2026-10-12
8
+
9
+ ### Fixed
10
+
11
+ - Continue observing a delivered Desktop task through bounded `read_thread` snapshots when the `wait_threads` renderer stops returning results after relay recovery. Preserve the original deadline, access checks, and authoritative reply validation without resending the prompt (#122, #123).
12
+ - Keep native requests capped at 128 KiB while accepting responses up to 8 MiB on both relay hops. Reject larger responses explicitly, trim internal metadata reads, omit tool output from default transcript reads, and bound the serialized MCP read result (#123).
13
+ - Identify failed rollout validation checks and record byte offsets in bounded diagnostics without exposing reply contents. Validation requirements remain unchanged; unsupported rollout sequences still fail closed pending concrete evidence (#123).
14
+
15
+ ### Upgrade notes
16
+
17
+ - Update both the bridge and native relay companion. Default Desktop transcript reads omit tool outputs and cap each included item at 2,000 characters; use an exact `turnId` for authoritative assistant response inspection.
18
+ - Native frames above 8 MiB, rollout files above 16 MiB, and authoritative replies above 1 MiB remain explicit limits.
19
+
7
20
  ## [1.21.5] - 2026-10-11
8
21
 
9
22
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">codex-mcp-bridge</h1>
6
6
 
7
- Local Desktop handoff guides: [daily workflow and directory verification](docs/daily-handoffs.md), [new-machine setup and permissions](docs/new-machine-setup.md), and [project onboarding](docs/project-onboarding.md).
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
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
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)
@@ -3,7 +3,7 @@
3
3
  Install the messaging MCP entries first. Local Card Bridge Desktop is optional
4
4
  and is needed only for automatic task-card startup and its settings UI. Neither
5
5
  component uploads an entire local project. For initial installation see
6
- [new-machine setup](new-machine-setup.md); for grants see
6
+ [Windows setup](new-windows-setup.md) or [macOS setup](new-macos-setup.md); for grants see
7
7
  [project onboarding](project-onboarding.md).
8
8
 
9
9
  Workspace trust, project authorization and host tool permission are separate.
@@ -0,0 +1,67 @@
1
+ # New macOS setup
2
+
3
+ Connect Claude Desktop's **Code tab** and Codex Desktop through local MCP
4
+ processes and sockets. Both apps still authenticate with their providers and
5
+ use hosted models; local bridge transport does not mean offline inference.
6
+
7
+ ## Local installation: required steps
8
+
9
+ The [ChatGPT plugin setup](../README.md#chatgpt-plugin) uses a hosted Sites
10
+ connector. For a local Desktop installation, use the following path instead:
11
+
12
+ 1. Install and sign in to Codex Desktop and Claude Desktop under the same macOS
13
+ user. Have Node.js 22+, npm, the Codex CLI, and the Claude Code CLI available.
14
+ Save the intended local project in Codex Desktop and open it in Claude's
15
+ Code tab.
16
+ 2. Follow [macOS installation](../README.md#macos-terminal) to install the bridge
17
+ and register the native relay and reverse bridge. Keep bootstrap enabled on
18
+ the first relay install. Do not use the ChatGPT plugin pairing instructions,
19
+ run `codex-sites-connector`, or configure a hosted endpoint.
20
+ 3. Follow [Claude Code registration](../README.md#register-claude-code) for the
21
+ forward bridge. Use the intended project directories for
22
+ `CODEX_BRIDGE_ALLOWED_ROOTS`, rather than the example's entire home directory;
23
+ macOS separates multiple roots with `:`. Preserve an existing entry's
24
+ custom settings.
25
+ 4. Add [native relay pipe forwarding](../README.md#native-relay-pipe-forwarding)
26
+ to the existing relay entry. Forward `CODEX_APP_TOOLS_PIPE_PATH` by name,
27
+ not its temporary value. The relay needs the trusted app-bundled Node runtime
28
+ selected by the installer; do not substitute an arbitrary Homebrew/nvm Node.
29
+ 5. Reconnect the affected MCP servers in the actual Desktop Code conversations,
30
+ or restart the affected app after active work finishes. Complete
31
+ [installation verification](../README.md#verify-the-installation), including
32
+ a message and reply in each direction.
33
+
34
+ There are three registrations: `codex-bridge` in Claude Code, and
35
+ `claude-bridge` plus `codex-native-relay` in Codex Desktop. The relay is transport
36
+ plumbing, not a third agent. Ordinary Claude chat has a separate MCP registry;
37
+ adding a server there does not register it in the Code tab.
38
+
39
+ ## Restricted local configuration
40
+
41
+ Local transport does not require Full Access. Keep the intended sandbox,
42
+ approval reviewer, and project restrictions. Before running installers, check
43
+ for `$CODEX_HOME/bridge-full-access.enabled` (normally
44
+ `~/.codex/bridge-full-access.enabled`): a saved opt-in makes them enable Full
45
+ Access even without `--full-access`. Resolve that existing opt-in deliberately
46
+ before installing; `CODEX_BRIDGE_ENFORCE_FULL_ACCESS=0` does not disable it.
47
+
48
+ For a restricted Codex setup, these are top-level configuration settings,
49
+ before any `[table]`:
50
+
51
+ ```toml
52
+ approval_policy = "on-request"
53
+ approvals_reviewer = "auto_review"
54
+ sandbox_mode = "workspace-write"
55
+ ```
56
+
57
+ Verify the active Desktop task uses the intended restrictions; defaults alone
58
+ are not evidence of its effective permissions. To suppress optional reporting,
59
+ set `CODEX_BRIDGE_TELEMETRY=0` in all three MCP registrations' environments;
60
+ see [telemetry controls](../README.md#optional-active-install-statistics).
61
+
62
+ For workspace trust, shared project grants, and optional messaging automation,
63
+ use [project onboarding](project-onboarding.md). For target selection,
64
+ permission mismatches, and uncertain deliveries, use
65
+ [daily Desktop handoffs](daily-handoffs.md) and the
66
+ [technical reference](https://github.com/buidangminh23/codex-mcp-bridge/blob/main/REFERENCE.md). Tool discovery or a connected socket
67
+ alone does not establish a completed exchange.
@@ -1,4 +1,4 @@
1
- # New-machine setup and initial permissions
1
+ # New Windows setup and initial permissions
2
2
 
3
3
  This is a Windows local Desktop source-install guide, not evidence of testing
4
4
  on a second machine. Use a pinned source revision containing these scripts;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.21.5",
3
+ "version": "1.21.6",
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",
@@ -48,7 +48,8 @@
48
48
  "LICENSE",
49
49
  "CHANGELOG.md",
50
50
  "docs/daily-handoffs.md",
51
- "docs/new-machine-setup.md",
51
+ "docs/new-windows-setup.md",
52
+ "docs/new-macos-setup.md",
52
53
  "docs/project-onboarding.md",
53
54
  "docs/desktop-handoff-design.md"
54
55
  ],
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.5",
4
+ "version": "1.21.6",
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.5";
26
+ const VERSION = "1.21.6";
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"];
@@ -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.5";
32
+ const VERSION = "1.21.6";
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;
@@ -21,6 +21,15 @@ function unavailable(reason) {
21
21
  return { status: "unavailable", reason: reason?.message ?? String(reason) };
22
22
  }
23
23
 
24
+ function validationError(message, check, fields = {}) {
25
+ const details = Object.entries({ check, ...fields }).map(([key, value]) => `${key}=${value}`).join("; ");
26
+ return new Error(`${message} [${details}]`);
27
+ }
28
+
29
+ function recordOffsets(entries) {
30
+ return entries.slice(0, 3).map(({ start }) => start).join(",") || "none";
31
+ }
32
+
24
33
  function configuredSessions(env) {
25
34
  const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
26
35
  if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
@@ -165,23 +174,29 @@ function recordBelongsToTurn(entry, threadId, turnId) {
165
174
  const metadata = object(payload.internal_chat_message_metadata_passthrough) ? payload.internal_chat_message_metadata_passthrough : {};
166
175
  const turnFields = [payload.turn_id, payload.root_turn_id, metadata.turn_id, metadata.root_turn_id].filter((value) => value !== undefined);
167
176
  if (!turnFields.includes(turnId)) return false;
168
- if (turnFields.some((value) => value !== turnId)) throw new Error("The native response contains contradictory turn identity");
177
+ if (turnFields.some((value) => value !== turnId)) throw validationError("The native response contains contradictory turn identity", "turn_identity", { recordByte: entry.start });
169
178
  const threadFields = [payload.thread_id, metadata.thread_id].filter((value) => value !== undefined);
170
- if (threadFields.some((value) => value !== threadId)) throw new Error("The native response contains contradictory task identity");
179
+ if (threadFields.some((value) => value !== threadId)) throw validationError("The native response contains contradictory task identity", "task_identity", { recordByte: entry.start });
171
180
  return true;
172
181
  }
173
182
 
174
- function responseItemText(payload) {
175
- if (!Array.isArray(payload.content) || payload.content.some((item) => !object(item) || item.type !== "output_text" || typeof item.text !== "string")) {
176
- throw new Error("The native final response contains unsupported content");
183
+ function responseItemText(entry) {
184
+ const { payload } = entry.record;
185
+ const invalidPart = Array.isArray(payload.content)
186
+ ? payload.content.findIndex((item) => !object(item) || item.type !== "output_text" || typeof item.text !== "string") : "not_array";
187
+ if (invalidPart !== -1) {
188
+ throw validationError("The native final response contains unsupported content", "response_content", { responseItemByte: entry.start, contentPart: invalidPart });
177
189
  }
178
190
  return payload.content.map((item) => item.text).join("");
179
191
  }
180
192
 
181
- function completedEventText(item) {
193
+ function completedEventText(entry) {
194
+ const item = entry.record.payload.item;
182
195
  if (!object(item) || item.type !== "AgentMessage" || item.phase !== "final_answer") return null;
183
- if (!Array.isArray(item.content) || item.content.some((part) => !object(part) || part.type !== "Text" || typeof part.text !== "string")) {
184
- throw new Error("The native completed assistant item contains unsupported content");
196
+ const invalidPart = Array.isArray(item.content)
197
+ ? item.content.findIndex((part) => !object(part) || part.type !== "Text" || typeof part.text !== "string") : "not_array";
198
+ if (invalidPart !== -1) {
199
+ throw validationError("The native completed assistant item contains unsupported content", "completed_content", { completedEventByte: entry.start, contentPart: invalidPart });
185
200
  }
186
201
  return item.content.map((part) => part.text).join("");
187
202
  }
@@ -193,10 +208,16 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
193
208
  const completions = turns.filter(({ record }) => record.type === "event_msg" && COMPLETED.has(record.payload.type));
194
209
  const failures = turns.filter(({ record }) => record.type === "event_msg" &&
195
210
  (FAILED.has(record.payload.type) || record.payload.error || ["failed", "aborted", "interrupted"].includes(record.payload.status)));
196
- if (failures.length || starts.length !== 1 || contexts.length !== 1 || completions.length !== 1 ||
197
- canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd ||
198
- starts[0].start >= contexts[0].start || contexts[0].start >= completions[0].start) {
199
- throw new Error("The native turn lifecycle is missing, ambiguous, failed, or out of order");
211
+ const lifecycleError = (check, fields) => validationError("The native turn lifecycle is missing, ambiguous, failed, or out of order", check, fields);
212
+ if (failures.length) throw lifecycleError("lifecycle_failure", { count: failures.length, eventBytes: recordOffsets(failures) });
213
+ for (const [kind, entries] of [["start", starts], ["context", contexts], ["completion", completions]]) {
214
+ if (entries.length !== 1) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
215
+ }
216
+ if (canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd) {
217
+ throw lifecycleError("lifecycle_workspace", { contextByte: contexts[0].start });
218
+ }
219
+ if (starts[0].start >= contexts[0].start || contexts[0].start >= completions[0].start) {
220
+ throw lifecycleError("lifecycle_order", { startByte: starts[0].start, contextByte: contexts[0].start, completionByte: completions[0].start });
200
221
  }
201
222
 
202
223
  let dispatch = null;
@@ -205,47 +226,66 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
205
226
  record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread"));
206
227
  if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
207
228
  contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
208
- throw new Error("The newly observed turn is not correlated to the exact native dispatch");
229
+ throw validationError("The newly observed turn is not correlated to the exact native dispatch", "dispatch_correlation", {
230
+ count: dispatches.length, dispatchBytes: recordOffsets(dispatches), contextByte: contexts[0].start, completionByte: completions[0].start,
231
+ });
209
232
  }
210
233
  dispatch = dispatches[0];
211
234
  }
212
235
 
213
236
  const allAssistantEntries = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "message" && record.payload.role === "assistant");
214
237
  const finalEntries = allAssistantEntries.filter(({ record }) => record.payload.phase === "final_answer");
215
- if (!finalEntries.length && allAssistantEntries.length) throw new Error("The completed native turn has assistant items but no final assistant reply");
238
+ if (!finalEntries.length && allAssistantEntries.length) throw validationError("The completed native turn has assistant items but no final assistant reply", "missing_final_phase", {
239
+ count: allAssistantEntries.length, responseItemBytes: recordOffsets(allAssistantEntries),
240
+ });
216
241
  const assistantItems = [];
217
- const ids = new Set();
242
+ const ids = new Map();
218
243
  for (const entry of finalEntries) {
219
244
  const { payload } = entry.record;
220
245
  if (typeof payload.id !== "string" || !payload.id.trim() || ids.has(payload.id) ||
221
246
  entry.start <= (dispatch?.start ?? contexts[0].start) || entry.start >= completions[0].start) {
222
- throw new Error("The native final response item identity is missing, duplicated, or out of order");
247
+ const check = typeof payload.id !== "string" || !payload.id.trim() ? "response_item_id_missing" : ids.has(payload.id) ? "response_item_id_duplicate" : "response_item_order";
248
+ throw validationError("The native final response item identity is missing, duplicated, or out of order", check, {
249
+ responseItemByte: entry.start, previousItemByte: ids.get(payload.id)?.start ?? "none",
250
+ afterByte: dispatch?.start ?? contexts[0].start, completionByte: completions[0].start,
251
+ });
223
252
  }
224
- ids.add(payload.id);
225
- const text = responseItemText(payload);
226
- if (!text.trim()) throw new Error("The native final response item is empty");
253
+ ids.set(payload.id, entry);
254
+ const text = responseItemText(entry);
255
+ if (!text.trim()) throw validationError("The native final response item is empty", "response_item_empty", { responseItemByte: entry.start });
227
256
  assistantItems.push({ id: payload.id, text });
228
257
  }
229
258
 
230
259
  const eventItems = new Map();
231
- for (const { record, start } of turns) {
260
+ for (const entry of turns) {
261
+ const { record, start } = entry;
232
262
  if (record.type !== "event_msg" || record.payload.type !== "item_completed") continue;
233
263
  const eventItem = record.payload.item;
234
264
  if (ids.has(eventItem?.id) && (eventItem.type !== "AgentMessage" || eventItem.phase !== "final_answer")) {
235
- throw new Error("The native assistant item representations conflict in type or phase");
265
+ throw validationError("The native assistant item representations conflict in type or phase", "assistant_representation_type_phase", {
266
+ responseItemByte: ids.get(eventItem.id).start, completedEventByte: start,
267
+ });
236
268
  }
237
- const text = completedEventText(record.payload.item);
269
+ const text = completedEventText(entry);
238
270
  if (text === null) continue;
239
271
  const id = record.payload.item.id;
240
272
  if (typeof id !== "string" || !id.trim() || eventItems.has(id) || start <= contexts[0].start || start >= completions[0].start) {
241
- throw new Error("The native completed assistant item identity is missing, duplicated, or out of order");
273
+ const check = typeof id !== "string" || !id.trim() ? "completed_item_id_missing" : eventItems.has(id) ? "completed_item_id_duplicate" : "completed_item_order";
274
+ throw validationError("The native completed assistant item identity is missing, duplicated, or out of order", check, {
275
+ completedEventByte: start, previousEventByte: eventItems.get(id)?.start ?? "none",
276
+ contextByte: contexts[0].start, completionByte: completions[0].start,
277
+ });
242
278
  }
243
- eventItems.set(id, text);
279
+ eventItems.set(id, { text, start });
244
280
  }
245
281
  for (const item of assistantItems) {
246
- if (eventItems.has(item.id) && eventItems.get(item.id) !== item.text) throw new Error("The native assistant item representations conflict");
282
+ if (eventItems.has(item.id) && eventItems.get(item.id).text !== item.text) throw validationError("The native assistant item representations conflict", "assistant_representation_text", {
283
+ responseItemByte: ids.get(item.id).start, completedEventByte: eventItems.get(item.id).start,
284
+ });
285
+ }
286
+ for (const [id, event] of eventItems) {
287
+ if (!ids.has(id)) throw validationError("The native completed assistant item has no matching authoritative response item", "authoritative_response_missing", { completedEventByte: event.start });
247
288
  }
248
- if ([...eventItems.keys()].some((id) => !ids.has(id))) throw new Error("The native completed assistant item has no matching authoritative response item");
249
289
 
250
290
  const text = assistantItems.map((item) => item.text).join("\n\n");
251
291
  if (Buffer.byteLength(text, "utf8") > MAX_REPLY_BYTES) throw new Error("The native final response exceeds the bounded reply limit");
package/src/index.mjs CHANGED
@@ -39,7 +39,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
39
39
 
40
40
  exitForVersionRequest(import.meta.url);
41
41
 
42
- const VERSION = "1.21.5";
42
+ const VERSION = "1.21.6";
43
43
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
44
44
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
45
45
 
@@ -875,8 +875,12 @@ registerTool(
875
875
  return textResult(JSON.stringify(response, null, 2), response.status === "unavailable");
876
876
  }
877
877
  await desktopTasks.inspect(threadId, undefined, { deadline });
878
- const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) }, { deadline });
879
- return textResult(JSON.stringify(response, null, 2));
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;
880
884
  }
881
885
  await assertThreadAccess(threadId);
882
886
  const res = await client.call("thread/read", { threadId, includeTurns: true });
@@ -9,6 +9,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
9
 
10
10
  import {
11
11
  MAX_FRAME_BYTES,
12
+ MAX_RESPONSE_FRAME_BYTES,
12
13
  NATIVE_DISPATCH_METHOD,
13
14
  NativeToolsClient,
14
15
  NativeRelayError,
@@ -36,7 +37,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
36
37
 
37
38
  exitForVersionRequest(import.meta.url);
38
39
 
39
- const VERSION = "1.21.5";
40
+ const VERSION = "1.21.6";
40
41
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
41
42
 
42
43
  function errorResponse(code, message, sent) {
@@ -102,7 +103,7 @@ export function createNativeScopeAuthorizer({ dispatchDesktop, env = process.env
102
103
  };
103
104
  const threadFor = async (executorThreadId, threadId, accountContext, projects) => {
104
105
  if (typeof threadId !== "string" || !threadId) fail("Operation has no target task");
105
- const result = await call(executorThreadId, "read_thread", { threadId, hostId: "local", turnLimit: 1 }, accountContext);
106
+ const result = await call(executorThreadId, "read_thread", { threadId, hostId: "local", turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 1 }, accountContext);
106
107
  return validateThread(result?.thread, threadId, projects);
107
108
  };
108
109
  const targetIds = (operation, args, targetThreadId) => {
@@ -613,7 +614,12 @@ export class RelaySocketServer {
613
614
 
614
615
  #reply(socket, response) {
615
616
  if (socket.destroyed) return;
616
- socket.end(`${JSON.stringify(response)}\n`);
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;
621
+ }
622
+ socket.end(frame);
617
623
  }
618
624
 
619
625
  stop() {
@@ -39,13 +39,8 @@ 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
- /**
43
- * A relay frame carries one chat message, so a megabyte-scale line is either a
44
- * bug or something trying to make the companion buffer without limit. The cap
45
- * is applied on both halves: the client refuses to send an oversized message,
46
- * and the companion refuses to accumulate one.
47
- */
48
42
  export const MAX_FRAME_BYTES = 128 * 1024;
43
+ export const MAX_RESPONSE_FRAME_BYTES = 8 * 1024 * 1024;
49
44
 
50
45
  const DEFAULT_TIMEOUT_MS = 30000;
51
46
  const RELAY_SOCKET_NAME = "native-relay.sock";
@@ -617,7 +612,11 @@ export class NativeToolsClient {
617
612
  buffer = Buffer.concat([buffer, chunk]);
618
613
  while (buffer.length >= 4) {
619
614
  const length = buffer.readUInt32LE(0);
620
- if (!length || length > MAX_FRAME_BYTES) {
615
+ 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"));
617
+ return;
618
+ }
619
+ if (!length) {
621
620
  fail(new NativeRelayError("Invalid Codex Desktop native frame length", "NATIVE_BAD_RESPONSE"));
622
621
  return;
623
622
  }
@@ -885,16 +884,19 @@ export class NativeDesktopRelay {
885
884
  })();
886
885
  });
887
886
  socket.on("data", (chunk) => {
888
- buffer = Buffer.concat([buffer, chunk]);
889
- if (buffer.length > MAX_FRAME_BYTES) {
887
+ if (settled) return;
888
+ const newline = chunk.indexOf(10);
889
+ const frameChunk = newline < 0 ? chunk : chunk.subarray(0, newline + 1);
890
+ if (buffer.length + frameChunk.length > MAX_RESPONSE_FRAME_BYTES) {
890
891
  finish(
891
892
  reject,
892
- new NativeRelayError("The Codex Desktop relay answered with an oversized frame", "RELAY_BAD_RESPONSE", {
893
+ new NativeRelayError(`The Codex Desktop relay response exceeds ${MAX_RESPONSE_FRAME_BYTES} bytes; reduce the requested turns or outputs`, "RELAY_RESPONSE_TOO_LARGE", {
893
894
  reachedCompanion: true,
894
895
  }),
895
896
  );
896
897
  return;
897
898
  }
899
+ buffer = Buffer.concat([buffer, frameChunk]);
898
900
  const index = buffer.indexOf(10);
899
901
  if (index < 0) return;
900
902
  try {
@@ -37,7 +37,7 @@ export function findRolloutSegments(sessions, threadId) {
37
37
  }
38
38
  }
39
39
  }
40
- if (!matches.length) throw new Error("No rollout matches the calling Codex task");
40
+ if (!matches.length) throw new Error(`No rollout matches the calling Codex task [check=rollout_discovery; directoriesScanned=${directories}; entriesScanned=${entries}; layout=sessions/YYYY/MM/DD/rollout-*-taskId[_continuationId].jsonl]`);
41
41
  return matches.sort();
42
42
  }
43
43
 
@@ -27,6 +27,13 @@ export const NATIVE_BACKEND = "codex-desktop-native";
27
27
  export const APP_SERVER_BACKEND = "app-server";
28
28
  export const DESKTOP_TOOL_BUDGET_MS = 40000;
29
29
  const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
30
+ const METADATA_READ_OPTIONS = { includeOutputs: false, maxOutputCharsPerItem: 1 };
31
+
32
+ function unavailableWaitRenderer(error) {
33
+ const cause = error.cause ?? error;
34
+ return cause.code === "NATIVE_DISPATCH_FAILED" &&
35
+ /^(Codex app tool request failed\.?|The app tool call ended before producing a result\.?)$/.test(cause.message);
36
+ }
30
37
 
31
38
  function sameAccountContext(expected, current) {
32
39
  return Boolean(expected && current && ["claude", "codex"].every((provider) =>
@@ -194,7 +201,7 @@ export class DesktopTaskDelivery {
194
201
  let response;
195
202
  try {
196
203
  this.security.assertThread(receipt.threadId, cwd);
197
- response = await this.request("read_thread", { threadId: receipt.threadId, hostId: "local", turnLimit: 1 }, { deadline });
204
+ response = await this.request("read_thread", { threadId: receipt.threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline });
198
205
  } catch (err) {
199
206
  throw new Error(`Existing Desktop task ${receipt.threadId} could not be verified. No prompt was resent and no duplicate task was created: ${err.message}`, { cause: err });
200
207
  }
@@ -364,7 +371,7 @@ export class DesktopTaskDelivery {
364
371
  }
365
372
 
366
373
  async #inspectMetadata(threadId, cwd, { deadline } = {}) {
367
- const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 }, { deadline, includeRelayContext: true });
374
+ const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline, includeRelayContext: true });
368
375
  const response = envelope.result;
369
376
  const thread = response?.thread;
370
377
  if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
@@ -471,23 +478,41 @@ export class DesktopTaskDelivery {
471
478
  const startedAt = this.now();
472
479
  let cursor;
473
480
  let turnId = null;
481
+ let readSnapshots = false;
482
+ const readSnapshot = async () => {
483
+ const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
484
+ const thread = result?.thread;
485
+ if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the observed task's local workspace.");
486
+ this.security.assertCwd(thread.cwd);
487
+ this.security.assertThread(threadId, thread.cwd);
488
+ this.senderProjectScope(thread.cwd);
489
+ if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
490
+ return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
491
+ };
474
492
  const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
475
493
  for (;;) {
476
494
  if (this.now() - startedAt >= timeoutMs) return expired();
477
495
  let response;
478
496
  try {
479
- response = await this.request("wait_threads", {
497
+ response = readSnapshots ? await readSnapshot() : await this.request("wait_threads", {
480
498
  targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
481
499
  }, { deadline: startedAt + timeoutMs });
482
500
  } catch (err) {
483
501
  if (this.now() - startedAt >= timeoutMs) return expired();
484
- throw err;
502
+ if (readSnapshots || !unavailableWaitRenderer(err)) throw err;
503
+ readSnapshots = true;
504
+ try {
505
+ response = await readSnapshot();
506
+ } catch (readError) {
507
+ if (this.now() - startedAt >= timeoutMs) return expired();
508
+ throw readError;
509
+ }
485
510
  }
486
511
  const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
487
512
  if (response?.errors?.length || !poll) throw new Error(`Could not observe task ${threadId}; it may still be running. Read it before retrying: ${JSON.stringify(response)}`);
488
513
  cursor = poll.cursor;
489
514
  const turn = poll.latestTurn;
490
- const threadStatus = poll.thread.status?.type;
515
+ const threadStatus = typeof poll.thread.status === "string" ? poll.thread.status : poll.thread.status?.type;
491
516
  if (["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus)) {
492
517
  return { threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt };
493
518
  }