@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/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.21.5";
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) => textResult(`Codex bridge error: ${err?.message ?? String(err)}`, true);
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
- MAX_FRAME_BYTES,
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.21.5";
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
- return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message, ...(sent === false ? { sent: false } : {}) } };
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 > MAX_FRAME_BYTES) {
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 ${MAX_FRAME_BYTES} bytes`));
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
- socket.end(`${JSON.stringify(response)}\n`);
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() {
@@ -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
- * A relay frame carries one chat message, so a megabyte-scale line is either a
44
- * bug or something trying to make the companion buffer without limit. The cap
45
- * is applied on both halves: the client refuses to send an oversized message,
46
- * and the companion refuses to accumulate one.
47
- */
48
- 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 (!length || length > MAX_FRAME_BYTES) {
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 > MAX_FRAME_BYTES) {
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") > MAX_FRAME_BYTES) {
831
+ if (Buffer.byteLength(line, "utf8") > MAX_REQUEST_FRAME_BYTES) {
824
832
  throw new NativeRelayError(
825
- `Relay message is larger than ${MAX_FRAME_BYTES} bytes; shorten it before relaying.`,
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
- buffer = Buffer.concat([buffer, chunk]);
889
- if (buffer.length > MAX_FRAME_BYTES) {
890
- finish(
891
- reject,
892
- new NativeRelayError("The Codex Desktop relay answered with an oversized frame", "RELAY_BAD_RESPONSE", {
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
+ }
@@ -37,7 +37,7 @@ export function findRolloutSegments(sessions, threadId) {
37
37
  }
38
38
  }
39
39
  }
40
- if (!matches.length) throw new Error("No rollout matches the calling Codex task");
40
+ if (!matches.length) throw new Error(`No rollout matches the calling Codex task [check=rollout_discovery; directoriesScanned=${directories}; entriesScanned=${entries}; layout=sessions/YYYY/MM/DD/rollout-*-taskId[_continuationId].jsonl]`);
41
41
  return matches.sort();
42
42
  }
43
43