@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/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,57 +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) }, { deadline });
|
|
879
|
-
return textResult(JSON.stringify(response, null, 2));
|
|
880
|
-
}
|
|
881
|
-
await assertThreadAccess(threadId);
|
|
882
|
-
const res = await client.call("thread/read", { threadId, includeTurns: true });
|
|
883
|
-
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
884
|
-
security.assertCwd(thread.cwd);
|
|
885
|
-
const items = (thread.turns ?? []).flatMap((t) => t.items ?? []);
|
|
886
|
-
const msgs = items
|
|
887
|
-
.filter((i) => i?.type === "agentMessage" || i?.type === "userMessage")
|
|
888
|
-
.slice(-(limit ?? 10))
|
|
889
|
-
.map((i) => {
|
|
890
|
-
const body =
|
|
891
|
-
i.type === "userMessage"
|
|
892
|
-
? (i.content ?? [])
|
|
893
|
-
.map((c) => (c.type === "text" ? c.text : `<${c.type}>`))
|
|
894
|
-
.join(" ")
|
|
895
|
-
: (i.text ?? "");
|
|
896
|
-
return `[${i.type === "userMessage" ? "user" : "codex"}] ${body.trim()}`;
|
|
897
|
-
});
|
|
898
|
-
const header = `thread ${threadId}\n title: ${thread.name ?? "(unnamed)"}\n cwd: ${thread.cwd ?? "?"}\n status: ${thread.status?.type ?? "?"}`;
|
|
899
|
-
return textResult(msgs.length ? `${header}\n\n${msgs.join("\n\n")}` : `${header}\n\n(no messages found)`);
|
|
900
|
-
} catch (err) {
|
|
901
|
-
return failure(err);
|
|
902
|
-
}
|
|
903
|
-
},
|
|
904
|
-
);
|
|
905
841
|
|
|
906
842
|
registerTool(
|
|
907
843
|
"interrupt_codex_turn",
|
|
@@ -8,7 +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
|
+
MAX_RESPONSE_FRAME_BYTES,
|
|
13
|
+
responseTooLargeError,
|
|
12
14
|
NATIVE_DISPATCH_METHOD,
|
|
13
15
|
NativeToolsClient,
|
|
14
16
|
NativeRelayError,
|
|
@@ -36,11 +38,12 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
|
|
|
36
38
|
|
|
37
39
|
exitForVersionRequest(import.meta.url);
|
|
38
40
|
|
|
39
|
-
const VERSION = "1.
|
|
41
|
+
const VERSION = "1.22.0";
|
|
40
42
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
41
43
|
|
|
42
|
-
function errorResponse(code, message, sent) {
|
|
43
|
-
|
|
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 } : {}) } };
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
function boundRequest(payload) {
|
|
@@ -102,7 +105,7 @@ export function createNativeScopeAuthorizer({ dispatchDesktop, env = process.env
|
|
|
102
105
|
};
|
|
103
106
|
const threadFor = async (executorThreadId, threadId, accountContext, projects) => {
|
|
104
107
|
if (typeof threadId !== "string" || !threadId) fail("Operation has no target task");
|
|
105
|
-
const result = await call(executorThreadId, "read_thread", { threadId, hostId: "local", turnLimit: 1 }, accountContext);
|
|
108
|
+
const result = await call(executorThreadId, "read_thread", { threadId, hostId: "local", turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 1 }, accountContext);
|
|
106
109
|
return validateThread(result?.thread, threadId, projects);
|
|
107
110
|
};
|
|
108
111
|
const targetIds = (operation, args, targetThreadId) => {
|
|
@@ -265,7 +268,7 @@ export async function handleRelayRequest(
|
|
|
265
268
|
var authorization;
|
|
266
269
|
if (strict) authorization = await authorize({ executorThreadId, targetThreadId, operation: "send_message_to_thread", accountContext });
|
|
267
270
|
} catch (err) {
|
|
268
|
-
return errorResponse(errorCode(err), err.message, false);
|
|
271
|
+
return errorResponse(errorCode(err), err.message, false, err);
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
/**
|
|
@@ -293,7 +296,7 @@ export async function handleRelayRequest(
|
|
|
293
296
|
}
|
|
294
297
|
return { ok: true, v: boundRequest(payload) ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, targetThreadId, executorThreadId, result: result ?? null };
|
|
295
298
|
} catch (err) {
|
|
296
|
-
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);
|
|
297
300
|
}
|
|
298
301
|
}
|
|
299
302
|
|
|
@@ -310,7 +313,7 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
|
|
|
310
313
|
try {
|
|
311
314
|
accountContext = await checkAccountContext(payload, assertAccount);
|
|
312
315
|
} catch (error) {
|
|
313
|
-
return errorResponse(errorCode(error), error.message, false);
|
|
316
|
+
return errorResponse(errorCode(error), error.message, false, error);
|
|
314
317
|
}
|
|
315
318
|
if (typeof dispatchDesktop !== "function") {
|
|
316
319
|
return errorResponse("NATIVE_OPERATION_UNAVAILABLE", "This companion does not support Desktop operations; reload the native relay");
|
|
@@ -364,7 +367,7 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
|
|
|
364
367
|
throw error;
|
|
365
368
|
}
|
|
366
369
|
} catch (err) {
|
|
367
|
-
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);
|
|
368
371
|
}
|
|
369
372
|
}
|
|
370
373
|
|
|
@@ -567,9 +570,9 @@ export class RelaySocketServer {
|
|
|
567
570
|
socket.on("data", (chunk) => {
|
|
568
571
|
if (handled) return;
|
|
569
572
|
buffer = Buffer.concat([buffer, chunk]);
|
|
570
|
-
if (buffer.length >
|
|
573
|
+
if (buffer.length > MAX_REQUEST_FRAME_BYTES) {
|
|
571
574
|
handled = true;
|
|
572
|
-
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`));
|
|
573
576
|
return;
|
|
574
577
|
}
|
|
575
578
|
const index = buffer.indexOf(10);
|
|
@@ -613,7 +616,13 @@ export class RelaySocketServer {
|
|
|
613
616
|
|
|
614
617
|
#reply(socket, response) {
|
|
615
618
|
if (socket.destroyed) return;
|
|
616
|
-
|
|
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`;
|
|
624
|
+
}
|
|
625
|
+
socket.end(line);
|
|
617
626
|
}
|
|
618
627
|
|
|
619
628
|
stop() {
|
package/src/native-relay.mjs
CHANGED
|
@@ -39,13 +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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* and the companion refuses to accumulate one.
|
|
47
|
-
*/
|
|
48
|
-
export const MAX_FRAME_BYTES = 128 * 1024;
|
|
42
|
+
// Requests carry commands; responses may contain a long Desktop history.
|
|
43
|
+
export const MAX_REQUEST_FRAME_BYTES = 128 * 1024;
|
|
44
|
+
export const MAX_RESPONSE_FRAME_BYTES = 8 * 1024 * 1024;
|
|
45
|
+
export const MAX_FRAME_BYTES = MAX_REQUEST_FRAME_BYTES;
|
|
49
46
|
|
|
50
47
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
51
48
|
const RELAY_SOCKET_NAME = "native-relay.sock";
|
|
@@ -53,14 +50,21 @@ const WINDOWS_RELAY_SOCKET = "\\\\.\\pipe\\LOCAL\\codex-native-relay";
|
|
|
53
50
|
const RELAY_CONFIG_NAME = "native-relay.json";
|
|
54
51
|
|
|
55
52
|
export class NativeRelayError extends Error {
|
|
56
|
-
constructor(message, code, { reachedCompanion = false } = {}) {
|
|
53
|
+
constructor(message, code, { reachedCompanion = false, boundary, bytes, limit } = {}) {
|
|
57
54
|
super(message);
|
|
58
55
|
this.name = "NativeRelayError";
|
|
59
56
|
this.code = code;
|
|
60
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;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
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
|
+
|
|
64
68
|
export function codexHome(env = process.env) {
|
|
65
69
|
return env.CODEX_HOME ?? path.join(homeDir(), ".codex");
|
|
66
70
|
}
|
|
@@ -86,7 +90,7 @@ export function relayAccountContext(value) {
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function beforeWriteError(error) {
|
|
89
|
-
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 });
|
|
90
94
|
failure.sent = false;
|
|
91
95
|
return failure;
|
|
92
96
|
}
|
|
@@ -617,7 +621,11 @@ export class NativeToolsClient {
|
|
|
617
621
|
buffer = Buffer.concat([buffer, chunk]);
|
|
618
622
|
while (buffer.length >= 4) {
|
|
619
623
|
const length = buffer.readUInt32LE(0);
|
|
620
|
-
if (
|
|
624
|
+
if (length > MAX_RESPONSE_FRAME_BYTES) {
|
|
625
|
+
fail(responseTooLargeError("native-pipe", length));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (!length) {
|
|
621
629
|
fail(new NativeRelayError("Invalid Codex Desktop native frame length", "NATIVE_BAD_RESPONSE"));
|
|
622
630
|
return;
|
|
623
631
|
}
|
|
@@ -675,7 +683,7 @@ export class NativeToolsClient {
|
|
|
675
683
|
method: this.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
|
|
676
684
|
params,
|
|
677
685
|
}));
|
|
678
|
-
if (payload.length >
|
|
686
|
+
if (payload.length > MAX_REQUEST_FRAME_BYTES) {
|
|
679
687
|
throw new NativeRelayError("Native dispatch exceeds the frame limit", "RELAY_MESSAGE_TOO_LARGE");
|
|
680
688
|
}
|
|
681
689
|
try {
|
|
@@ -820,9 +828,9 @@ export class NativeDesktopRelay {
|
|
|
820
828
|
async #request(request, timeoutMs, beforeSend) {
|
|
821
829
|
validateBeforeSend(beforeSend);
|
|
822
830
|
const line = `${JSON.stringify(request)}\n`;
|
|
823
|
-
if (Buffer.byteLength(line, "utf8") >
|
|
831
|
+
if (Buffer.byteLength(line, "utf8") > MAX_REQUEST_FRAME_BYTES) {
|
|
824
832
|
throw new NativeRelayError(
|
|
825
|
-
`Relay message is larger than ${
|
|
833
|
+
`Relay message is larger than ${MAX_REQUEST_FRAME_BYTES} bytes; shorten it before relaying.`,
|
|
826
834
|
"RELAY_MESSAGE_TOO_LARGE",
|
|
827
835
|
);
|
|
828
836
|
}
|
|
@@ -835,7 +843,7 @@ export class NativeDesktopRelay {
|
|
|
835
843
|
const error = new NativeRelayError(
|
|
836
844
|
response?.error?.message ?? "the Codex Desktop relay refused the message",
|
|
837
845
|
response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
|
|
838
|
-
{ reachedCompanion: response?.error?.sent !== false },
|
|
846
|
+
{ boundary: response?.error?.boundary, bytes: response?.error?.bytes, limit: response?.error?.limit, reachedCompanion: response?.error?.sent !== false },
|
|
839
847
|
);
|
|
840
848
|
if (response?.error?.sent === false) error.sent = false;
|
|
841
849
|
throw error;
|
|
@@ -885,16 +893,14 @@ export class NativeDesktopRelay {
|
|
|
885
893
|
})();
|
|
886
894
|
});
|
|
887
895
|
socket.on("data", (chunk) => {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
reachedCompanion: true,
|
|
894
|
-
}),
|
|
895
|
-
);
|
|
896
|
+
if (settled) return;
|
|
897
|
+
const newline = chunk.indexOf(10);
|
|
898
|
+
const frameChunk = newline < 0 ? chunk : chunk.subarray(0, newline + 1);
|
|
899
|
+
if (buffer.length + frameChunk.length > MAX_RESPONSE_FRAME_BYTES) {
|
|
900
|
+
finish(reject, responseTooLargeError("companion-to-bridge", buffer.length + frameChunk.length));
|
|
896
901
|
return;
|
|
897
902
|
}
|
|
903
|
+
buffer = Buffer.concat([buffer, frameChunk]);
|
|
898
904
|
const index = buffer.indexOf(10);
|
|
899
905
|
if (index < 0) return;
|
|
900
906
|
try {
|
|
@@ -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/rollout-segments.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function findRolloutSegments(sessions, threadId) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
if (!matches.length) throw new Error(
|
|
40
|
+
if (!matches.length) throw new Error(`No rollout matches the calling Codex task [check=rollout_discovery; directoriesScanned=${directories}; entriesScanned=${entries}; layout=sessions/YYYY/MM/DD/rollout-*-taskId[_continuationId].jsonl]`);
|
|
41
41
|
return matches.sort();
|
|
42
42
|
}
|
|
43
43
|
|