@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/docs/daily-handoffs.md +1 -1
- package/docs/new-macos-setup.md +67 -0
- package/docs/{new-machine-setup.md → new-windows-setup.md} +1 -1
- package/package.json +3 -2
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/codex-native-response.mjs +78 -26
- package/src/desktop-read-tools.mjs +208 -0
- package/src/index.mjs +20 -84
- package/src/native-relay-companion.mjs +21 -12
- package/src/native-relay.mjs +28 -22
- package/src/response-preferences.mjs +233 -0
- package/src/rollout-segments.mjs +1 -1
- package/src/thread-delivery.mjs +31 -5
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-
|
|
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
|
[](https://www.npmjs.com/package/@minhspark/codex-mcp-bridge)
|
|
10
10
|
[](https://www.npmjs.com/package/@minhspark/codex-mcp-bridge)
|
package/docs/daily-handoffs.md
CHANGED
|
@@ -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-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minhspark/codex-mcp-bridge",
|
|
3
|
-
"version": "1.
|
|
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-
|
|
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.
|
|
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.
|
|
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"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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(
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
225
|
-
const text = responseItemText(
|
|
226
|
-
if (!text.trim()) throw
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|