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