@minhspark/codex-mcp-bridge 1.21.5 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.21.5",
3
+ "version": "1.22.0",
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,35 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.0] - 2026-10-12
8
+
9
+ ### Added
10
+
11
+ - 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).
12
+
13
+ ### Fixed
14
+
15
+ - 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).
16
+ - Preserve bounded default history reads while allowing explicit tool output selection. Return structured oversize errors across native transport and serialized MCP responses.
17
+
18
+ ### Upgrade notes
19
+
20
+ - 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.
21
+ - 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.
22
+
23
+ ## [1.21.6] - 2026-10-12
24
+
25
+ ### Fixed
26
+
27
+ - 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).
28
+ - 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).
29
+ - 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).
30
+
31
+ ### Upgrade notes
32
+
33
+ - 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.
34
+ - Native frames above 8 MiB, rollout files above 16 MiB, and authoritative replies above 1 MiB remain explicit limits.
35
+
7
36
  ## [1.21.5] - 2026-10-11
8
37
 
9
38
  ### 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.22.0",
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.22.0",
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.22.0";
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.22.0";
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,27 +174,44 @@ 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
  }
188
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
+
189
215
  function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch } = {}) {
190
216
  const turns = records.filter((entry) => recordBelongsToTurn(entry, threadId, turnId));
191
217
  const starts = turns.filter(({ record }) => record.type === "event_msg" && STARTED.has(record.payload.type));
@@ -193,10 +219,16 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
193
219
  const completions = turns.filter(({ record }) => record.type === "event_msg" && COMPLETED.has(record.payload.type));
194
220
  const failures = turns.filter(({ record }) => record.type === "event_msg" &&
195
221
  (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");
222
+ const lifecycleError = (check, fields) => validationError("The native turn lifecycle is missing, ambiguous, failed, or out of order", check, fields);
223
+ if (failures.length) throw lifecycleError("lifecycle_failure", { count: failures.length, eventBytes: recordOffsets(failures) });
224
+ for (const [kind, entries] of [["start", starts], ["context", contexts], ["completion", completions]]) {
225
+ if (entries.length !== 1) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
226
+ }
227
+ if (canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd) {
228
+ throw lifecycleError("lifecycle_workspace", { contextByte: contexts[0].start });
229
+ }
230
+ if (starts[0].start >= contexts[0].start || contexts[0].start >= completions[0].start) {
231
+ throw lifecycleError("lifecycle_order", { startByte: starts[0].start, contextByte: contexts[0].start, completionByte: completions[0].start });
200
232
  }
201
233
 
202
234
  let dispatch = null;
@@ -205,47 +237,67 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
205
237
  record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread"));
206
238
  if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
207
239
  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");
240
+ throw validationError("The newly observed turn is not correlated to the exact native dispatch", "dispatch_correlation", {
241
+ count: dispatches.length, dispatchBytes: recordOffsets(dispatches), contextByte: contexts[0].start, completionByte: completions[0].start,
242
+ });
209
243
  }
210
244
  dispatch = dispatches[0];
211
245
  }
212
246
 
213
247
  const allAssistantEntries = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "message" && record.payload.role === "assistant");
214
248
  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");
249
+ if (!finalEntries.length && allAssistantEntries.length) throw validationError("The completed native turn has assistant items but no final assistant reply", "missing_final_phase", {
250
+ count: allAssistantEntries.length, responseItemBytes: recordOffsets(allAssistantEntries),
251
+ });
216
252
  const assistantItems = [];
217
- const ids = new Set();
253
+ const ids = new Map();
218
254
  for (const entry of finalEntries) {
219
255
  const { payload } = entry.record;
220
256
  if (typeof payload.id !== "string" || !payload.id.trim() || ids.has(payload.id) ||
221
257
  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");
258
+ const check = typeof payload.id !== "string" || !payload.id.trim() ? "response_item_id_missing" : ids.has(payload.id) ? "response_item_id_duplicate" : "response_item_order";
259
+ throw validationError("The native final response item identity is missing, duplicated, or out of order", check, {
260
+ responseItemByte: entry.start, previousItemByte: ids.get(payload.id)?.start ?? "none",
261
+ afterByte: dispatch?.start ?? contexts[0].start, completionByte: completions[0].start,
262
+ });
223
263
  }
224
- ids.add(payload.id);
225
- const text = responseItemText(payload);
226
- if (!text.trim()) throw new Error("The native final response item is empty");
264
+ ids.set(payload.id, entry);
265
+ const text = responseItemText(entry);
266
+ if (!text.trim()) throw validationError("The native final response item is empty", "response_item_empty", { responseItemByte: entry.start });
227
267
  assistantItems.push({ id: payload.id, text });
228
268
  }
229
269
 
230
270
  const eventItems = new Map();
231
- for (const { record, start } of turns) {
271
+ for (const entry of turns) {
272
+ const { record, start } = entry;
232
273
  if (record.type !== "event_msg" || record.payload.type !== "item_completed") continue;
233
274
  const eventItem = record.payload.item;
234
275
  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");
276
+ throw validationError("The native assistant item representations conflict in type or phase", "assistant_representation_type_phase", {
277
+ responseItemByte: ids.get(eventItem.id).start, completedEventByte: start,
278
+ });
236
279
  }
237
- const text = completedEventText(record.payload.item);
280
+ const text = completedEventText(entry);
238
281
  if (text === null) continue;
239
282
  const id = record.payload.item.id;
240
283
  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");
284
+ const check = typeof id !== "string" || !id.trim() ? "completed_item_id_missing" : eventItems.has(id) ? "completed_item_id_duplicate" : "completed_item_order";
285
+ throw validationError("The native completed assistant item identity is missing, duplicated, or out of order", check, {
286
+ completedEventByte: start, previousEventByte: eventItems.get(id)?.start ?? "none",
287
+ contextByte: contexts[0].start, completionByte: completions[0].start,
288
+ });
242
289
  }
243
- eventItems.set(id, text);
290
+ eventItems.set(id, { text, start, memoryCitation: completedMemoryCitation(eventItem) });
244
291
  }
245
292
  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");
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", {
295
+ responseItemByte: ids.get(item.id).start, completedEventByte: eventItems.get(item.id).start,
296
+ });
297
+ }
298
+ for (const [id, event] of eventItems) {
299
+ if (!ids.has(id)) throw validationError("The native completed assistant item has no matching authoritative response item", "authoritative_response_missing", { completedEventByte: event.start });
247
300
  }
248
- if ([...eventItems.keys()].some((id) => !ids.has(id))) throw new Error("The native completed assistant item has no matching authoritative response item");
249
301
 
250
302
  const text = assistantItems.map((item) => item.text).join("\n\n");
251
303
  if (Buffer.byteLength(text, "utf8") > MAX_REPLY_BYTES) throw new Error("The native final response exceeds the bounded reply limit");
@@ -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
+ }