@minhspark/codex-mcp-bridge 1.18.2 → 1.19.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.19.1] - 2026-09-29
8
+
9
+ ### Fixed
10
+
11
+ - Recognize `codex_work_desktop` as a local Desktop sender while retaining exact host task/turn, workspace, and lifecycle checks (#84). Its permission profile is classified like that of any other Codex Desktop task, including the managed sandboxes supported since 1.19.0 (#83). Claude's inbound policy and permission parity checks are unchanged.
12
+ - Read completed native replies from `codex_work_desktop` tasks and correlate both raw and XML-escaped Desktop dispatch envelopes to the exact sent prompt (#84). Task, workspace, turn, executor, and pre-send watermark checks remain required for automatic reply observation.
13
+ - Preserve confirmed native send acceptance when the overall response deadline expires (#84). The timeout does not release the underlying operation's thread lock, retry a prompt, or turn an unconfirmed send into a successful delivery.
14
+ - Avoid a Windows Node 24.13.0 native crash when preparing supervisor and MCP contract test fixtures from a Unicode checkout path by awaiting asynchronous directory copies (#84).
15
+ - Stop refusing Claude → Codex callers on Windows because Windows PowerShell started slowly (#42). The caller check reads the process ancestry in a fresh Windows PowerShell that first compiles its Toolhelp helper, and the five-second fail-closed deadline covered that startup as well as the read. On a cold or contended machine the startup alone has taken 5 to 15 seconds: CI refused callers with `INSPECTION_TIMEOUT` before the script had begun (5,036 ms) and while the helper was still compiling (5,017 ms), although the read itself takes about 60 ms. The helper now reports when it is ready, and the five-second deadline starts there. Getting ready has its own 25-second ceiling, so a whole read still ends within the 30 seconds the bridge gives its other PowerShell identity reads. Either expiry still refuses the caller, and a slow start is reported as `INSPECTOR_START_TIMEOUT`. The helper also writes its JSON itself, so the timed read no longer loads PowerShell's JSON serializer.
16
+ - Lock `ip-address` at 10.7.2 for development installs (#87). 10.5.0 misclassified IPv6 link-local and NAT64 local-use addresses. The bridge's stdio servers never load the rate-limiting code that uses it, and the published package ships no lockfile, so installs from npm resolve their own versions.
17
+
18
+ ## [1.19.0] - 2026-09-29
19
+
20
+ ### Added
21
+
22
+ - Give each independent Codex Desktop task its own creation identity with an optional `requestId` UUID on `delegate_to_codex` and `start_codex_thread` (#82). Creation receipts were keyed by directory and title, or by the exact prompt when no title was given, so a second independent task with the same title returned the earlier task and its brief was never sent. A fresh `requestId` creates a separate task even with the same directory, title and prompt, and retrying with the same ID recovers the original receipt after a bridge restart without resending the prompt. Calls without `requestId` keep the previous deduplication. Legacy app-server mode rejects `requestId` before creating anything, because it cannot deduplicate creations durably.
23
+
24
+ ### Changed
25
+
26
+ - Guide agents to open a new Codex conversation for each independent task when the user asks for one or has given standing instructions to, and to continue unfinished work, fixes and results in the task's original thread (#82). The server instructions, tool descriptions and README previously told agents to prefer an existing task and to create one only on an explicit request.
27
+
28
+ ### Fixed
29
+
30
+ - Verify Codex → Claude Desktop senders that run inside a Codex-managed sandbox (#83). Codex Desktop 26.924 starts ordinary tasks with a `managed` permission profile, a `workspace-write` sandbox, and a `granular` approval policy, and its permission selector may offer only **Ask for approval** and **Approve for me**; the bridge accepted only the disabled full-access profile, so every send from such a task stopped with `CODEX_SENDER_CONTEXT_UNVERIFIED`. A managed profile whose file system is restricted now attests `prompting` with a `user` or `auto_review` reviewer and any approval policy, matching Claude, which counts `dontAsk` and `auto` as prompting; it can never attest `bypass`, which still requires Full access with `approval_policy: never`. The bridge still fails closed when the managed file system is unrestricted, grants write access to the file-system root, a literal root path, a glob, or an unrecognised special path, or has any unrecognised shape. A granular policy is reported as a stable `granular(<allowed categories>)` label, and `claude_bridge_status` shows the sender's permission profile and approval reviewer, which a queued send now also pins. A class mismatch against a Claude task in `bypassPermissions` names the Full access requirement.
31
+ - Retry removing an integration test's temporary home on Windows while the bridge and the PowerShell probes it started are still exiting (#86). They keep that directory as their working directory after the SDK's close() sends SIGKILL, so the removal failed a Windows CI run with `EPERM`.
32
+
7
33
  ## [1.18.2] - 2026-09-26
8
34
 
9
35
  ### Fixed
package/README.md CHANGED
@@ -220,17 +220,24 @@ The registered supervisor should report auto-reload enabled. Next, list the inte
220
220
 
221
221
  | Direction | Tools |
222
222
  |---|---|
223
- | Claude → Codex | `list_codex_threads`, then `send_to_codex_thread` |
223
+ | Continue unfinished Codex work | Verify the original `threadId`, then `send_to_codex_thread` |
224
224
  | Codex → Claude | `list_claude_sessions`, then `send_to_claude_session` |
225
- | Create a Codex task | `delegate_to_codex` with `cwd` and `prompt` |
225
+ | Start independent Codex work | `delegate_to_codex` or `start_codex_thread` with `cwd`, initial `prompt`, and a fresh UUID `requestId` in Desktop mode |
226
226
 
227
227
  Example requests:
228
228
 
229
+ - **In Claude:** “For each independent new task or feature, create a new Codex conversation and send the initial brief there. Continue unfinished work in its original conversation.”
229
230
  - **In Claude:** “Send this review request to my existing Codex task in this project and wait for its reply.”
230
231
  - **In Codex:** “Send this result to my Claude Desktop Code session in this project and confirm its reply.”
231
232
 
232
233
  Use the exact project directory and destination task. If several Claude sessions match, specify the task ID. A `reply_received` receipt confirms a reply; a timeout does not mean the task stopped, so inspect it before retrying.
233
234
 
235
+ An explicit request for a new conversation, or standing user instructions such as the example above, authorizes creation for independent work. Keep fixes, clarifications and results for unfinished work in its original conversation. A completed assistant turn does not by itself mean the whole task is finished; matching a project or finding a recent task does not make it the right destination for new work.
236
+
237
+ In Desktop mode, generate a fresh UUID `requestId` for each independent task and retain it on every retry of that creation. Different request IDs create separate tasks even with the same directory, title and prompt. The same ID recovers the original receipt after a bridge restart; an edited title or prompt is not resent. Continue that task with its returned `threadId`. Pending or uncertain creation remains blocked: inspect the original receipt/task instead of changing the ID to retry.
238
+
239
+ For backward compatibility, calls without `requestId` retain the previous deduplication by directory and explicit title (or exact prompt when no title is supplied). They can return an older task with the same title. Use a fresh request ID for independent new work; keep the old call shape when recovering an earlier legacy creation. Legacy app-server mode rejects `requestId` before creating anything because durable creation deduplication is only supported through Desktop native delivery.
240
+
234
241
  The sending tools ask agents to write each prompt in English with these sections, dropping any that do not apply: `Goal`, `Context`, `Task`, `Scope`, `Constraints`, `Done when`, `Reply format`. The first line names the sender, the project and the purpose. Text the user supplied is sent unchanged.
235
242
 
236
243
  ```text
@@ -384,4 +391,4 @@ npm test
384
391
 
385
392
  On Windows, use `node --test --test-concurrency=2` (also avoids npm versions that reject forwarded flags). CI tests Node 22 and 24 on Linux, macOS, and Windows. Tests use isolated fixtures and do not spend model quota. For the optional telemetry endpoint, also run `deno test --allow-env supabase/functions/bridge-usage/contract-check.ts`.
386
393
 
387
- [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
394
+ [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.18.2",
3
+ "version": "1.19.1",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -22,7 +22,7 @@ import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs
22
22
 
23
23
  exitForVersionRequest(import.meta.url);
24
24
 
25
- const VERSION = "1.18.2";
25
+ const VERSION = "1.19.1";
26
26
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
27
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
28
28
  const entryArgs = ["native-relay-companion.mjs"];
@@ -26,7 +26,7 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
26
26
 
27
27
  exitForVersionRequest(import.meta.url);
28
28
 
29
- const VERSION = "1.18.2";
29
+ const VERSION = "1.19.1";
30
30
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
31
31
  const FORWARD_MIN_INTERVAL_MS = 5000;
32
32
  const FORWARD_MAX_PER_SESSION = 50;
@@ -482,7 +482,8 @@ registerTool(
482
482
  rootPolicy.recheck(scopeBindings.sender, "Codex sender working directory");
483
483
  rootPolicy.recheck(scopeBindings.recipient, "Claude recipient working directory");
484
484
  }
485
- if (active.threadId !== sender.threadId || active.turnId !== sender.turnId || active.cwd !== sender.cwd || active.mode !== sender.mode || active.approvalPolicy !== sender.approvalPolicy || JSON.stringify(active.review) !== JSON.stringify(sender.review)) {
485
+ if (active.threadId !== sender.threadId || active.turnId !== sender.turnId || active.cwd !== sender.cwd || active.mode !== sender.mode || active.approvalPolicy !== sender.approvalPolicy
486
+ || active.permissionProfile !== sender.permissionProfile || active.approvalsReviewer !== sender.approvalsReviewer || JSON.stringify(active.review) !== JSON.stringify(sender.review)) {
486
487
  throw preflightFailure("CODEX_SENDER_CONTEXT_CHANGED", "The sender's active turn or permissions changed while this message was queued.");
487
488
  }
488
489
  assertRecipientClass(refreshed, active);
@@ -690,6 +691,7 @@ registerTool(
690
691
  `sender mode: ${sender?.mode ?? (desktopOnly ? "unverified - Desktop sends blocked" : peer.permissionMode ?? "unknown")}`,
691
692
  ...(sender ? [`sender context: ${sender.status} (${sender.source ?? "unavailable"})`, `sender task: ${sender.threadId ?? "unknown"}`, `sender turn: ${sender.turnId ?? "unknown"}`, ...(sender.reason ? [`sender detail: ${sender.reason}`] : [])] : []),
692
693
  ...(sender?.approvalPolicy ? [`sender approval policy: ${sender.approvalPolicy}`] : []),
694
+ ...(sender?.permissionProfile ? [`sender permission profile: ${sender.permissionProfile}`, `sender approval reviewer: ${sender.approvalsReviewer}`] : []),
693
695
  ...(sender?.review ? [`sender auto review: ${sender.review.autoReview}`, `sender Node REPL review: ${sender.review.nodeReplReview}`] : []),
694
696
  `session policy: ${desktopOnly ? "desktop-only" : "all Claude Code entrypoints"}`,
695
697
  `Claude account: ${accounts.claude.status}${accounts.claude.fingerprint ? ` (${accounts.claude.fingerprint.slice(0, 12)})` : ` - ${accounts.claude.reason}`}`,
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { promisify } from "node:util";
4
4
  import { listClaudeSessions } from "./peer-protocol.mjs";
@@ -6,12 +6,35 @@ import { readClaudeDesktopContext } from "./claude-desktop-context.mjs";
6
6
 
7
7
  const execute = promisify(execFile);
8
8
  const MAX_ANCESTORS = 8;
9
+ const INSPECTION_OUTPUT_LIMIT = 16384;
10
+
11
+ /**
12
+ * The five-second deadline is what keeps the caller check fail-closed: a read
13
+ * that cannot answer in time leaves the caller unverified. On Windows the read
14
+ * runs in a fresh Windows PowerShell that must first compile its Toolhelp
15
+ * helper, and the deadline used to cover that startup too. Starting is the
16
+ * machine's cost, not the read's: CI refused callers with INSPECTION_TIMEOUT
17
+ * both before the script had begun (5,036ms) and while the helper was still
18
+ * compiling (5,017ms), a cold start here has been measured between 5s and 15s,
19
+ * and the read itself takes milliseconds once the helper exists.
20
+ *
21
+ * The helper therefore reports on stderr when it is ready, and the five-second
22
+ * deadline starts there. Getting ready has its own ceiling, so a whole read
23
+ * still ends within the 30s the bridge gives its other PowerShell identity
24
+ * reads (peer-protocol.mjs). Either expiry kills the process and refuses the
25
+ * caller; nothing is read before the helper is ready, so the wait adds no
26
+ * staleness.
27
+ */
28
+ const INSPECTION_TIMEOUT_MS = 5000;
29
+ const INSPECTOR_START_TIMEOUT_MS = 25000;
30
+ const INSPECTOR_READY_MARKER = "BRIDGE_ANCESTRY_READY";
9
31
  const WINDOWS_ANCESTRY_SOURCE = `
10
32
  using System;
11
33
  using System.Collections.Generic;
12
34
  using System.ComponentModel;
13
35
  using System.Globalization;
14
36
  using System.Runtime.InteropServices;
37
+ using System.Text;
15
38
 
16
39
  public static class ClaudeProcessAncestry {
17
40
  [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
@@ -83,6 +106,17 @@ public static class ClaudeProcessAncestry {
83
106
  }
84
107
  return rows.ToArray();
85
108
  }
109
+
110
+ public static string ReadJson(uint firstPid, int limit) {
111
+ var json = new StringBuilder("[");
112
+ foreach (var row in Read(firstPid, limit)) {
113
+ if (json.Length > 1) json.Append(',');
114
+ json.Append(@"{""pid"":").Append(row.pid.ToString(CultureInfo.InvariantCulture))
115
+ .Append(@",""parentPid"":").Append(row.parentPid.ToString(CultureInfo.InvariantCulture))
116
+ .Append(@",""processStart"":""").Append(row.processStart).Append(@"""}");
117
+ }
118
+ return json.Append(']').ToString();
119
+ }
86
120
  }`;
87
121
 
88
122
  function parseWindowsAncestry(stdout, parentPid, limit) {
@@ -105,26 +139,77 @@ function parseWindowsAncestry(stdout, parentPid, limit) {
105
139
  return rows;
106
140
  }
107
141
 
108
- export async function readProcessAncestry({ parentPid = process.ppid, platform = process.platform, maxDepth = MAX_ANCESTORS, run = execute } = {}) {
142
+ /**
143
+ * Runs a process the way execFile does - the same stdout and stderr strings,
144
+ * maxBuffer limit, and exit-code and spawn errors - except that `timeout`
145
+ * starts once `readyMarker` appears on stderr, and `startupTimeout` bounds the
146
+ * time before it. An expiry kills the process and rejects with ETIMEDOUT and
147
+ * the phase that ran out. The result also carries the pid, readyAfterMs and
148
+ * elapsedMs so tests can report where the time went.
149
+ */
150
+ export function runProcessInspector(command, args, { timeout, startupTimeout, readyMarker, maxBuffer = Infinity, windowsHide = true } = {}) {
151
+ return new Promise((resolve, reject) => {
152
+ const started = performance.now();
153
+ const child = spawn(command, args, { windowsHide, stdio: ["ignore", "pipe", "pipe"] });
154
+ const output = { stdout: [], stderr: [] };
155
+ const size = { stdout: 0, stderr: 0 };
156
+ let readyAfterMs = null;
157
+ let settled = false;
158
+ let timer;
159
+ const text = (stream) => Buffer.concat(output[stream]).toString("utf8");
160
+ const settle = (error) => {
161
+ if (settled) return;
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ if (error && child.pid !== undefined && child.exitCode === null && child.signalCode === null) child.kill();
165
+ const result = { stdout: text("stdout"), stderr: text("stderr"), pid: child.pid, readyAfterMs, elapsedMs: Math.round(performance.now() - started) };
166
+ if (error) reject(Object.assign(error, result));
167
+ else resolve(result);
168
+ };
169
+ const arm = (phase, ms) => {
170
+ clearTimeout(timer);
171
+ timer = setTimeout(() => settle(Object.assign(new Error(`Process inspection ${phase} deadline elapsed`), { code: "ETIMEDOUT", phase, killed: true, signal: "SIGTERM" })), ms);
172
+ };
173
+ if (readyMarker) arm("startup", startupTimeout);
174
+ else arm("inspection", timeout);
175
+ for (const stream of ["stdout", "stderr"]) {
176
+ child[stream].on("data", (chunk) => {
177
+ if (settled) return;
178
+ output[stream].push(chunk);
179
+ size[stream] += chunk.length;
180
+ if (size[stream] > maxBuffer) return settle(Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
181
+ if (stream === "stderr" && readyMarker && readyAfterMs === null && text("stderr").includes(readyMarker)) {
182
+ readyAfterMs = Math.round(performance.now() - started);
183
+ arm("inspection", timeout);
184
+ }
185
+ });
186
+ }
187
+ child.on("error", settle);
188
+ child.on("close", (code, signal) => settle(code === 0 ? null : Object.assign(new Error(`Process inspection exited with ${signal ?? code}`), { code, signal })));
189
+ });
190
+ }
191
+
192
+ export async function readProcessAncestry({ parentPid = process.ppid, platform = process.platform, maxDepth = MAX_ANCESTORS, run } = {}) {
109
193
  if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || parentPid > 0xffffffff) return [];
110
194
  const limit = Number.isInteger(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, MAX_ANCESTORS) : MAX_ANCESTORS;
111
- const options = { timeout: 5000, maxBuffer: 16384, windowsHide: true };
112
195
  if (platform === "win32") {
113
196
  const shell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
114
- const script = `$ErrorActionPreference='Stop'; $PSModuleAutoLoadingPreference='None'; Import-Module -Name ([IO.Path]::Combine($PSHOME,'Modules','Microsoft.PowerShell.Utility','Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop; Add-Type -TypeDefinition @'\n${WINDOWS_ANCESTRY_SOURCE}\n'@\nConvertTo-Json -InputObject @([ClaudeProcessAncestry]::Read(${parentPid},${limit})) -Compress`;
115
- const { stdout } = await run(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], options);
197
+ const script = `$ErrorActionPreference='Stop'; $PSModuleAutoLoadingPreference='None'; Import-Module -Name ([IO.Path]::Combine($PSHOME,'Modules','Microsoft.PowerShell.Utility','Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop; Add-Type -TypeDefinition @'\n${WINDOWS_ANCESTRY_SOURCE}\n'@\n[Console]::Error.WriteLine('${INSPECTOR_READY_MARKER}')\n[Console]::Out.Write([ClaudeProcessAncestry]::ReadJson(${parentPid},${limit}))`;
198
+ const { stdout } = await (run ?? runProcessInspector)(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
199
+ timeout: INSPECTION_TIMEOUT_MS, startupTimeout: INSPECTOR_START_TIMEOUT_MS, readyMarker: INSPECTOR_READY_MARKER, maxBuffer: INSPECTION_OUTPUT_LIMIT, windowsHide: true,
200
+ });
116
201
  return parseWindowsAncestry(stdout, parentPid, limit);
117
202
  }
118
203
  const rows = [];
119
204
  const seen = new Set();
120
205
  let next = parentPid;
121
- const deadline = Date.now() + 5000;
206
+ const deadline = Date.now() + INSPECTION_TIMEOUT_MS;
122
207
  while (next > 0 && !seen.has(next) && rows.length < limit) {
123
208
  seen.add(next);
124
209
  const remaining = deadline - Date.now();
125
210
  if (remaining <= 0) throw new Error("process inspection deadline elapsed");
126
- const { stdout } = await run("/bin/ps", ["-p", String(next), "-o", "pid=,ppid=,lstart="], {
127
- ...options, timeout: remaining, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
211
+ const { stdout } = await (run ?? execute)("/bin/ps", ["-p", String(next), "-o", "pid=,ppid=,lstart="], {
212
+ timeout: remaining, maxBuffer: INSPECTION_OUTPUT_LIMIT, windowsHide: true, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
128
213
  });
129
214
  const match = stdout.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
130
215
  if (!match) break;
@@ -140,6 +225,7 @@ function senderResult(status, reason, values = {}) {
140
225
  }
141
226
 
142
227
  function senderFailureCode(error) {
228
+ if (error?.code === "ETIMEDOUT" && error.phase === "startup") return "INSPECTOR_START_TIMEOUT";
143
229
  if (error?.code === "ETIMEDOUT" || (error?.killed && ["SIGTERM", "SIGKILL"].includes(error.signal))) return "INSPECTION_TIMEOUT";
144
230
  if (error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "OUTPUT_LIMIT";
145
231
  if (["ENOENT", "EACCES", "EPERM"].includes(error?.code)) return error.code;
@@ -8,6 +8,7 @@ const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024;
8
8
  const MAX_ENTRIES = 100000;
9
9
  const MAX_DIRECTORIES = 4096;
10
10
  const MAX_REPLY_BYTES = 1024 * 1024;
11
+ const DESKTOP_ORIGINATORS = new Set(["Codex Desktop", "codex_work_desktop"]);
11
12
  const COMPLETED = new Set(["task_complete", "task_completed", "turn_complete", "turn_completed"]);
12
13
  const STARTED = new Set(["task_started", "turn_started"]);
13
14
  const FAILED = new Set(["task_aborted", "turn_aborted", "task_failed", "turn_failed"]);
@@ -155,12 +156,14 @@ function validateSession(records, threadId, expectedCwd) {
155
156
  const sessions = records.filter(({ record }) => record.type === "session_meta").map(({ record }) => record.payload);
156
157
  if (sessions.length !== 1) throw new Error("The Codex rollout has ambiguous session identity");
157
158
  const session = sessions[0];
158
- if (session.id !== threadId || session.originator !== "Codex Desktop" || session.source !== "vscode") throw new Error("The rollout does not confirm the exact native Codex Desktop task");
159
+ if (session.id !== threadId || !DESKTOP_ORIGINATORS.has(session.originator) || session.source !== "vscode") throw new Error("The rollout does not confirm the exact native Codex Desktop task");
159
160
  if (canonicalDirectory(session.cwd, "The rollout workspace") !== expectedCwd) throw new Error("The rollout workspace does not match the selected native task");
160
161
  }
161
162
 
162
- function delegationOutput(executorThreadId, prompt) {
163
- return `<codex_delegation>\n <source_thread_id>${executorThreadId}</source_thread_id>\n <input>${prompt}</input>\n</codex_delegation>`;
163
+ function delegationOutputs(executorThreadId, prompt) {
164
+ const escaped = prompt.replace(/[&<>]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[character]);
165
+ // Older Desktop rollouts stored raw text; current builds escape XML text content.
166
+ return [prompt, escaped].map((text) => `<codex_delegation>\n <source_thread_id>${executorThreadId}</source_thread_id>\n <input>${text}</input>\n</codex_delegation>`);
164
167
  }
165
168
 
166
169
  function normalizedText(value) {
@@ -214,7 +217,7 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
214
217
  if (requireDispatch) {
215
218
  const dispatches = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "function_call_output" &&
216
219
  record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread");
217
- if (dispatches.length !== 1 || dispatches[0].record.payload.output !== requireDispatch.output ||
220
+ if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
218
221
  contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
219
222
  throw new Error("The newly observed turn is not correlated to the exact native dispatch");
220
223
  }
@@ -324,7 +327,7 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
324
327
  validateSession(records, threadId, cwd);
325
328
  const tail = records.filter(({ start }) => start >= watermark.size);
326
329
  const inspected = inspectTurnRecords(tail, threadId, turnId, cwd, {
327
- requireDispatch: { output: delegationOutput(executorThreadId, prompt) },
330
+ requireDispatch: { outputs: delegationOutputs(executorThreadId, prompt) },
328
331
  });
329
332
  return { ...inspected, observationStatus: inspected.status };
330
333
  } catch (error) {
@@ -9,6 +9,13 @@ const STARTED = new Set(["task_started", "turn_started"]);
9
9
  const MAX_ROLLOUT_BYTES = 64 * 1024 * 1024;
10
10
  const MAX_ENTRIES = 100000;
11
11
  const MAX_DIRECTORIES = 4096;
12
+ const DESKTOP_ORIGINATORS = new Set(["Codex Desktop", "codex_work_desktop"]);
13
+ const PROMPTING_POLICIES = new Set(["on-request", "on-failure", "untrusted"]);
14
+ const GRANULAR_CATEGORIES = ["sandbox_approval", "rules", "mcp_elicitations"];
15
+ const MANAGED_REVIEWERS = new Set(["user", "auto_review"]);
16
+ const MANAGED_SANDBOXES = new Set(["read-only", "workspace-write"]);
17
+ const ENTRY_ACCESS = new Set(["read", "write", "deny", "none"]);
18
+ const WRITABLE_SPECIAL_PATHS = new Set(["project_roots", "current_working_directory", "tmpdir", "slash_tmp"]);
12
19
 
13
20
  function object(value) {
14
21
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -97,14 +104,79 @@ export function readState(file, maxBytes) {
97
104
  }
98
105
 
99
106
  /**
100
- * Claude's inbound parity gate groups sessions by one question: does a human
101
- * still prompt the sender? Codex answers it with approval_policy. Its two host
102
- * review flags describe automated review instead - node_repl_auto_review_required
103
- * is a per-model catalog attribute (Codex Desktop derives it from
104
- * autoReview.requiredOnModels; the live gpt-6-astra task carried it with a
105
- * user reviewer and auto review disabled) and auto_review_enabled names a
106
- * Guardian reviewer - so they are required as evidence and reported, but
107
- * never move a sender between classes.
107
+ * Codex writes AskForApproval as a string, or as {granular: {...}} whose
108
+ * boolean categories choose which approval requests reach the reviewer. The
109
+ * label is a plain string so receipts and the queued-send comparison can
110
+ * compare it directly.
111
+ */
112
+ function approvalPolicyLabel(policy) {
113
+ if (policy === "never" || PROMPTING_POLICIES.has(policy)) return policy;
114
+ if (!exactObject(policy, ["granular"]) || !object(policy.granular)) return null;
115
+ const categories = Object.entries(policy.granular);
116
+ if (!GRANULAR_CATEGORIES.every((category) => Object.hasOwn(policy.granular, category)) || categories.some(([, allowed]) => typeof allowed !== "boolean")) return null;
117
+ return `granular(${categories.filter(([, allowed]) => allowed).map(([category]) => category).sort().join(",")})`;
118
+ }
119
+
120
+ function rootPath(value) {
121
+ let local = value.replace(/^file:/i, "");
122
+ if (local.startsWith("///")) local = local.slice(2);
123
+ local = local.replace(/^\/(?=[a-z]:)/i, "").replace(/^\\\\\?\\UNC\\/i, "\\\\");
124
+ const { base } = path.win32.parse(path.win32.normalize(local || "/"));
125
+ return !base || base === ".";
126
+ }
127
+
128
+ /**
129
+ * A write grant on the file-system root, on a literal root path, or on a
130
+ * target whose breadth cannot be judged (a glob or an unrecognised special
131
+ * path) would leave the sandbox as open as full access, so such a profile
132
+ * gets no class. Read and deny entries only narrow what the sandbox exposes,
133
+ * so an unrecognised special path among them is ignored, as Codex ignores it.
134
+ */
135
+ function assertSandboxEntry(entry) {
136
+ const target = object(entry) ? entry.path : undefined;
137
+ const known = object(target) && (
138
+ (target.type === "path" && exactObject(target, ["type", "path"]) && typeof target.path === "string")
139
+ || (target.type === "glob_pattern" && exactObject(target, ["type", "pattern"]) && typeof target.pattern === "string")
140
+ || (target.type === "special" && exactObject(target, ["type", "value"]) && object(target.value) && typeof target.value.kind === "string"));
141
+ if (!known || !ENTRY_ACCESS.has(entry.access) || Object.keys(entry).some((key) => !["path", "access", "missing_path_behavior"].includes(key))
142
+ || (Object.hasOwn(entry, "missing_path_behavior") && entry.missing_path_behavior !== "skip")) throw new Error("The caller's managed sandbox has an unsupported file-system entry; no permission class was inferred");
143
+ if (entry.access === "write" && (target.type === "glob_pattern" || (target.type === "path" ? rootPath(target.path) : !WRITABLE_SPECIAL_PATHS.has(target.value.kind)))) {
144
+ throw new Error("The caller's managed sandbox grants write access beyond specific directories; no permission class was inferred");
145
+ }
146
+ }
147
+
148
+ function assertManagedSandbox(context) {
149
+ const { permission_profile: profile, sandbox_policy: sandbox } = context;
150
+ if (!exactObject(profile, ["type", "file_system", "network"]) || !["restricted", "enabled"].includes(profile.network)) throw new Error("The caller's managed permission profile is unsupported; no permission class was inferred");
151
+ const fileSystem = profile.file_system;
152
+ if (object(fileSystem) && fileSystem.type === "unrestricted") throw new Error("The caller's managed sandbox leaves the file system unrestricted; no permission class was inferred");
153
+ if (!object(fileSystem) || fileSystem.type !== "restricted" || !Array.isArray(fileSystem.entries)
154
+ || Object.keys(fileSystem).some((key) => !["type", "entries", "glob_scan_max_depth"].includes(key))
155
+ || (Object.hasOwn(fileSystem, "glob_scan_max_depth") && !(Number.isSafeInteger(fileSystem.glob_scan_max_depth) && fileSystem.glob_scan_max_depth > 0))) throw new Error("The caller's managed file-system sandbox is unsupported; no permission class was inferred");
156
+ for (const entry of fileSystem.entries) assertSandboxEntry(entry);
157
+ if (!object(sandbox) || !MANAGED_SANDBOXES.has(sandbox.type)) throw new Error("The caller's effective sandbox policy does not match its managed permission profile");
158
+ }
159
+
160
+ /**
161
+ * Claude's inbound parity gate asks one question: does the sender bypass
162
+ * permission checks? Claude puts only bypassPermissions in that class and
163
+ * counts dontAsk (deny without asking) and auto (a classifier approves) as
164
+ * prompting.
165
+ *
166
+ * For a Codex task with no sandbox, approval_policy decides: never is bypass,
167
+ * and a policy that routes requests for approval is prompting. A task inside
168
+ * a Codex-managed sandbox that restricts the file system never bypasses the
169
+ * checks, whatever its approval policy or reviewer: a sandbox that never asks
170
+ * is dontAsk, and escalations decided by the auto_review subagent are auto.
171
+ * Such a sender is always prompting, never bypass. Codex Desktop's "Ask for
172
+ * approval" and "Approve for me" run this way; only "Full access" is bypass.
173
+ *
174
+ * The two host review flags describe automated review instead -
175
+ * node_repl_auto_review_required is a per-model catalog attribute (Codex
176
+ * Desktop derives it from autoReview.requiredOnModels; the live gpt-6-astra
177
+ * task carried it with a user reviewer and auto review disabled) and
178
+ * auto_review_enabled names a Guardian reviewer - so they are required as
179
+ * evidence and reported, but never move a sender between classes.
108
180
  * Downgrading them held every send from such a model behind an approval
109
181
  * dialog that Claude Desktop does not render.
110
182
  */
@@ -112,11 +184,18 @@ function permissionClass(context, metadata) {
112
184
  for (const field of ["auto_review_enabled", "node_repl_auto_review_required"]) {
113
185
  if (!Object.hasOwn(metadata, field) || typeof metadata[field] !== "boolean") throw new Error(`The caller's ${field} review evidence is ${reviewFlag(metadata, field)}; the host must supply an explicit boolean`);
114
186
  }
187
+ const approvalPolicy = approvalPolicyLabel(context.approval_policy);
188
+ if (object(context.permission_profile) && context.permission_profile.type === "managed") {
189
+ if (!MANAGED_REVIEWERS.has(context.approvals_reviewer)) throw new Error("The caller's effective approval reviewer is unverified");
190
+ assertManagedSandbox(context);
191
+ if (!approvalPolicy) throw new Error("The caller's effective approval policy is unsupported");
192
+ return { mode: "prompting", approvalPolicy, permissionProfile: "managed" };
193
+ }
115
194
  if (context.approvals_reviewer !== "user") throw new Error("The caller's effective approval reviewer is unverified");
116
- if (!exactObject(context.permission_profile, ["type"]) || context.permission_profile.type !== "disabled") throw new Error("The caller's permission profile is restricted or unsupported; no permission class was inferred");
195
+ if (!exactObject(context.permission_profile, ["type"]) || context.permission_profile.type !== "disabled") throw new Error("The caller's permission profile is unsupported; no permission class was inferred");
117
196
  if (!exactObject(context.sandbox_policy, ["type"]) || context.sandbox_policy.type !== "danger-full-access") throw new Error("The caller's effective sandbox policy does not match its disabled permission profile");
118
- if (context.approval_policy === "never") return "bypass";
119
- if (["on-request", "on-failure", "untrusted"].includes(context.approval_policy)) return "prompting";
197
+ if (approvalPolicy === "never") return { mode: "bypass", approvalPolicy, permissionProfile: "disabled" };
198
+ if (PROMPTING_POLICIES.has(approvalPolicy)) return { mode: "prompting", approvalPolicy, permissionProfile: "disabled" };
120
199
  throw new Error("The caller's effective approval policy is unsupported");
121
200
  }
122
201
 
@@ -135,13 +214,15 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
135
214
  const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
136
215
  const { session, context, lifecycle } = state;
137
216
  if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
138
- if (session?.id !== identity.threadId || session.originator !== originator || session.source !== "vscode") throw new Error(`The caller rollout does not confirm a root ${originator} task`);
217
+ const desktop = originator === "Codex Desktop";
218
+ const matchesOriginator = desktop ? DESKTOP_ORIGINATORS.has(session?.originator) : session?.originator === originator;
219
+ if (session?.id !== identity.threadId || !matchesOriginator || session.source !== "vscode") throw new Error(`The caller rollout does not confirm a root ${originator} task`);
139
220
  if (context?.turn_id !== identity.turnId || lifecycle?.turn_id !== identity.turnId || !STARTED.has(lifecycle?.type)) throw new Error("The calling turn is no longer the latest active Codex turn");
140
221
  if (typeof context.cwd !== "string" || !path.isAbsolute(context.cwd) || typeof session.cwd !== "string" || !path.isAbsolute(session.cwd)) throw new Error("The caller's workspace is missing or invalid");
141
222
  const cwd = fs.realpathSync.native(context.cwd);
142
223
  if (!fs.statSync(cwd).isDirectory() || path.relative(fs.realpathSync.native(session.cwd), cwd)) throw new Error("The caller's workspace changed from its Desktop session identity");
143
- const mode = permissionClass(context, metadata);
144
- return { status: "verified", ...identity, mode, cwd, source: file, approvalPolicy: context.approval_policy, reason: "Host-supplied calling task and active turn match the Desktop rollout's effective permission settings" };
224
+ const { mode, approvalPolicy, permissionProfile } = permissionClass(context, metadata);
225
+ return { status: "verified", ...identity, mode, cwd, source: file, approvalPolicy, permissionProfile, approvalsReviewer: context.approvals_reviewer, reason: "Host-supplied calling task and active turn match the Desktop rollout's effective permission settings" };
145
226
  } catch (error) {
146
227
  return unavailable(error?.code ? `Caller evidence could not be read (${error.code}); no sender permission class was inferred` : error.message, identity);
147
228
  }
@@ -5,11 +5,15 @@ import path from "node:path";
5
5
  import { homeDir } from "./platform.mjs";
6
6
 
7
7
  const HASH = /^[a-f0-9]{64}$/;
8
- const FIELDS = new Set(["version", "key", "cwd", "promptHash", "state", "startedAt", "threadId", "projectId", "projectName", "name", "accountContext"]);
8
+ const REQUEST_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
9
+ const FIELDS = new Set(["version", "key", "cwd", "promptHash", "state", "startedAt", "threadId", "projectId", "projectName", "name", "accountContext", "requestId"]);
9
10
  const digest = (value) => createHash("sha256").update(value).digest("hex");
10
11
  const normalizedName = (name) => name?.normalize("NFC").trim().replace(/\s+/g, " ");
11
12
  const canonicalCwd = (cwd) => process.platform === "win32" ? path.normalize(cwd).toLowerCase() : path.normalize(cwd);
12
13
  const nonemptyString = (value) => typeof value === "string" && value.trim().length > 0 && value.length <= 8192 && !/[\u0000-\u001f]/.test(value);
14
+ const receiptKey = ({ cwd, name, promptHash, requestId }) => digest(JSON.stringify(requestId
15
+ ? [canonicalCwd(cwd), "request", requestId.toLowerCase()]
16
+ : [canonicalCwd(cwd), normalizedName(name) || promptHash]));
13
17
 
14
18
  function assertKey(key) {
15
19
  if (typeof key !== "string" || !HASH.test(key)) throw new Error("Invalid Desktop task receipt key; refusing unsafe filesystem access.");
@@ -23,6 +27,7 @@ function validateReceipt(key, receipt) {
23
27
  const valid = receipt && typeof receipt === "object" && !Array.isArray(receipt)
24
28
  && Object.keys(receipt).every((field) => FIELDS.has(field))
25
29
  && receipt.version === 1 && receipt.key === key && HASH.test(receipt.promptHash)
30
+ && (receipt.requestId === undefined || typeof receipt.requestId === "string" && REQUEST_ID.test(receipt.requestId))
26
31
  && nonemptyString(receipt.cwd) && path.isAbsolute(receipt.cwd)
27
32
  && ["pending", "unknown", "known"].includes(receipt.state)
28
33
  && Number.isSafeInteger(receipt.startedAt) && receipt.startedAt >= 0
@@ -31,7 +36,7 @@ function validateReceipt(key, receipt) {
31
36
  && ["threadId", "projectId", "projectName", "name"].every((field) => receipt[field] === undefined || nonemptyString(receipt[field]))
32
37
  && (receipt.state !== "known" || nonemptyString(receipt.threadId));
33
38
  if (!valid) throw unsafeReceipt(key);
34
- const expected = digest(JSON.stringify([canonicalCwd(receipt.cwd), normalizedName(receipt.name) || receipt.promptHash]));
39
+ const expected = receiptKey(receipt);
35
40
  if (expected !== key) throw unsafeReceipt(key);
36
41
  return receipt;
37
42
  }
@@ -41,12 +46,15 @@ export class DesktopTaskReceipts {
41
46
  this.directory = path.resolve(directory);
42
47
  }
43
48
 
44
- key({ cwd, prompt, name }) {
49
+ key({ cwd, prompt, name, requestId }) {
45
50
  if (!nonemptyString(cwd) || !path.isAbsolute(cwd) || typeof prompt !== "string" || (name !== undefined && typeof name !== "string")) {
46
51
  throw new Error("Desktop task receipts require an absolute validated cwd, a prompt string, and an optional name string.");
47
52
  }
53
+ if (requestId !== undefined && (typeof requestId !== "string" || !REQUEST_ID.test(requestId))) {
54
+ throw new Error("Desktop task creation requestId must be a stable UUID; use a fresh UUID only for independent new work.");
55
+ }
48
56
  const promptHash = digest(prompt);
49
- return { key: digest(JSON.stringify([canonicalCwd(cwd), normalizedName(name) || promptHash])), promptHash };
57
+ return { key: receiptKey({ cwd, name, promptHash, requestId }), promptHash, ...(requestId ? { requestId: requestId.toLowerCase() } : {}) };
50
58
  }
51
59
 
52
60
  async ensureDirectory(create = false) {
package/src/index.mjs CHANGED
@@ -34,7 +34,7 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
34
34
 
35
35
  exitForVersionRequest(import.meta.url);
36
36
 
37
- const VERSION = "1.18.2";
37
+ const VERSION = "1.19.1";
38
38
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
39
39
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
40
40
 
@@ -169,12 +169,12 @@ function threadNameFor({ cwd, prompt, name }) {
169
169
  );
170
170
  }
171
171
 
172
- async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
172
+ async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp, waitForReply = true }) {
173
173
  const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
174
174
  const workspace = resolveWorkspacePath(cwd);
175
175
  const created = await desktopTasks.create({
176
176
  cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
177
- dedupeName: name ?? "", model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, deadline,
177
+ dedupeName: name ?? "", requestId, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, deadline,
178
178
  });
179
179
  const notes = [];
180
180
  if (workspace.note) notes.push(workspace.note);
@@ -188,6 +188,7 @@ async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSe
188
188
  }
189
189
  const lines = [
190
190
  created.reused ? "Reused the existing Codex Desktop task; the prompt was not resent" : "Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
191
+ ...(requestId ? [`requestId: ${requestId}`] : []),
191
192
  `cwd: ${created.cwd}`,
192
193
  ...(created.projectAssignmentStatus === "unverified"
193
194
  ? [`expected projectId: ${created.expectedProjectId}`, `expected project: ${created.expectedProjectName ?? "(unnamed)"}`, "project assignment: unverified", created.projectAssignmentNote]
@@ -195,7 +196,7 @@ async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSe
195
196
  "permissions: Codex Desktop settings; no external app-server writer",
196
197
  ...(created.promptChanged ? [created.projectAssignmentStatus === "unverified"
197
198
  ? "The edited brief was not sent. Inspect this task's project assignment before continuing it."
198
- : "The edited brief was not sent. Use send_to_codex_thread with this threadId for a continuation, or a distinct title for separate work."] : []), ...notes,
199
+ : "The edited brief was not sent. Continue the same unfinished work with send_to_codex_thread and this threadId. For independent new work, create a task with a fresh requestId; never change requestId merely to retry uncertain delivery."] : []), ...notes,
199
200
  ];
200
201
  if (created.projectAssignmentStatus === "unverified") return textResult([...lines, "status: existing task retained; project assignment needs inspection"].join("\n"));
201
202
  if (!waitForReply) return textResult([...lines, created.reused ? "status: existing task; read it to check its current progress" : "status: accepted; the task is running in Desktop"].join("\n"));
@@ -341,9 +342,12 @@ const server = new McpServer(
341
342
  { name: "codex-bridge", version: VERSION },
342
343
  {
343
344
  instructions:
344
- "Bridge Claude work into Codex. Prefer an existing task: inspect its exact threadId and project, then use send_to_codex_thread. " +
345
- "Only use delegate_to_codex or start_codex_thread when the user explicitly requests a new task at the " +
346
- "requested cwd. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
345
+ "Bridge Claude work into Codex. When the user requests a new conversation or has given standing authorization to create one for each independent task, " +
346
+ "use delegate_to_codex or start_codex_thread with the initial prompt and a fresh requestId for each independent task or feature in Desktop mode, even in the same project. " +
347
+ "In legacy app-server mode, use delegate_to_codex with the initial prompt and omit requestId; durable creation deduplication is unavailable there. " +
348
+ "Reuse that requestId only for retries of that creation. Continue unfinished work, fixes, clarifications, and results in its verified original threadId with send_to_codex_thread. " +
349
+ "A completed turn alone does not mean the task is finished. Do not choose an old task merely because its project matches or it was recently active. " +
350
+ "Without authorization to create a new conversation, ask before creating it. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
347
351
  "Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
348
352
  "Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
349
353
  "list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring. " +
@@ -399,9 +403,12 @@ registerTool(
399
403
  title: "Delegate work to a new Codex session",
400
404
  description:
401
405
  "Create a named Codex session at the requested project directory, send Claude's prompt into it, " +
402
- "return Codex's reply, and hand the session to Codex Desktop without leaving the bridge writer lock behind.",
406
+ "return Codex's reply, and hand the session to Codex Desktop without leaving the bridge writer lock behind. " +
407
+ "Use for independent new work when the user explicitly or through standing instructions authorizes new conversations. " +
408
+ "In Desktop mode, supply a fresh requestId per independent task and keep it on retries; omit requestId in legacy app-server mode. Use send_to_codex_thread for unfinished work.",
403
409
  inputSchema: {
404
410
  cwd: z.string().describe("Absolute project directory where Codex must work"),
411
+ requestId: z.string().uuid().optional().describe("Desktop creation identity: fresh UUID for each independent new task; retain exactly on retries. Omitting it preserves legacy title/prompt deduplication and can return an older task. Not supported in legacy app-server mode."),
405
412
  prompt: z.string().describe(`The complete task Claude is delegating to Codex. ${PROMPT_FIELD_HINT}`),
406
413
  name: z.string().min(1).max(200).optional().describe("Optional Codex session title; otherwise one is derived from the prompt"),
407
414
  timeoutSec: z
@@ -432,12 +439,13 @@ registerTool(
432
439
  openWorldHint: true,
433
440
  },
434
441
  },
435
- async ({ cwd, prompt, name, timeoutSec, model, effort, openInApp, releaseAfterTurn }) => {
442
+ async ({ cwd, prompt, name, requestId, timeoutSec, model, effort, openInApp, releaseAfterTurn }) => {
436
443
  const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
437
444
  const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
438
445
  const notes = [];
439
446
  try {
440
- if (desktopTasksEnabled) return await delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp });
447
+ if (desktopTasksEnabled) return await delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp });
448
+ if (requestId !== undefined) throw new Error("Creation requestId requires Desktop native delivery; legacy app-server mode cannot safely deduplicate this request. No task was created.");
441
449
  const created = await createCodexThread({ cwd, prompt, name, model });
442
450
  if (created.workspace.note) notes.push(created.workspace.note);
443
451
  if (shouldOpen && !shouldRelease) {
@@ -490,6 +498,7 @@ registerTool(
490
498
  title: "Send a prompt to a Codex thread",
491
499
  description:
492
500
  "Send a prompt as a new user turn inside an existing Codex thread and wait for Codex to answer. " +
501
+ "Use only for the same unfinished task, including follow-up fixes, clarifications, or results; independent new work belongs in a new conversation when authorized. " +
493
502
  "The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId. " +
494
503
  "Desktop-owned tasks must use Desktop native delivery; an open task is a valid destination. " +
495
504
  "If legacy delivery reports an active writer, inspect codex_bridge_status and repair the native relay/configuration. " +
@@ -529,6 +538,7 @@ registerTool(
529
538
  },
530
539
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
531
540
  const deadline = desktopTasksEnabled ? desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
541
+ let acceptedDelivery;
532
542
  return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
533
543
  const notes = [];
534
544
  const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
@@ -538,6 +548,7 @@ registerTool(
538
548
  const workspace = cwd ? resolveWorkspacePath(cwd) : null;
539
549
  if (workspace) security.assertCwd(workspace.path);
540
550
  const delivered = await desktopTasks.send({ threadId, prompt, cwd: workspace?.path, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, name, deadline });
551
+ acceptedDelivery = delivered;
541
552
  notes.push("sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
542
553
  if (shouldOpen) {
543
554
  try {
@@ -608,7 +619,16 @@ registerTool(
608
619
  } catch (err) {
609
620
  return failure(err);
610
621
  }
611
- }, { deadline }).catch(failure);
622
+ }, {
623
+ deadline,
624
+ onDeadline(error) {
625
+ if (!acceptedDelivery) throw error;
626
+ return {
627
+ ...textResult(`sent through Codex Desktop; no external app-server writer\ncwd: ${acceptedDelivery.cwd}\nthreadId: ${threadId}\nstatus: timeout\nTask was accepted; the response deadline elapsed before a completed reply was observed. Inspect the existing task; do not resend.`),
628
+ structuredContent: { threadId, deliveryStatus: "accepted", status: "timeout", responseStatus: "unavailable" },
629
+ };
630
+ },
631
+ }).catch(failure);
612
632
  },
613
633
  );
614
634
 
@@ -702,9 +722,10 @@ registerTool(
702
722
  "start_codex_thread",
703
723
  {
704
724
  title: "Start a new Codex thread",
705
- description: "Start a Codex task. In Desktop mode include the initial prompt to create and assign a visible task atomically; use delegate_to_codex to also wait for its reply.",
725
+ description: "Start a new Codex task when the user explicitly or through standing instructions authorizes a new conversation for independent work. In Desktop mode include the initial prompt and a fresh requestId to create and assign a visible task atomically; keep requestId unchanged on retries. Continue unfinished work with send_to_codex_thread and its original threadId. Use delegate_to_codex to also wait for its reply.",
706
726
  inputSchema: {
707
727
  cwd: z.string().describe("Absolute working directory for the new Codex session"),
728
+ requestId: z.string().uuid().optional().describe("Desktop creation identity: fresh UUID per independent task, same UUID on retries. Omit only for legacy title/prompt deduplication. Not supported in legacy app-server mode."),
708
729
  prompt: z.string().min(1).optional().describe(`Initial task; required with CODEX_BRIDGE_DESKTOP_TASKS=1, starts immediately. ${PROMPT_FIELD_HINT}`),
709
730
  model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
710
731
  name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
@@ -716,12 +737,13 @@ registerTool(
716
737
  openWorldHint: true,
717
738
  },
718
739
  },
719
- async ({ cwd, model, name, prompt }) => {
740
+ async ({ cwd, model, name, prompt, requestId }) => {
720
741
  try {
721
742
  if (desktopTasksEnabled) {
722
743
  if (!prompt?.trim()) throw new Error("Desktop task creation requires the initial prompt. Use delegate_to_codex, or pass prompt to start_codex_thread. No task was created.");
723
- return await delegateDesktopTask({ cwd, model, name, prompt, waitForReply: false });
744
+ return await delegateDesktopTask({ cwd, model, name, prompt, requestId, waitForReply: false });
724
745
  }
746
+ if (requestId !== undefined) throw new Error("Creation requestId requires Desktop native delivery; legacy app-server mode cannot safely deduplicate this request. No task was created.");
725
747
  if (prompt) throw new Error("Use delegate_to_codex to send an initial prompt in app-server mode.");
726
748
  const created = await createCodexThread({ cwd, model, name });
727
749
  return textResult(
@@ -34,7 +34,7 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
34
34
 
35
35
  exitForVersionRequest(import.meta.url);
36
36
 
37
- const VERSION = "1.18.2";
37
+ const VERSION = "1.19.1";
38
38
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
39
39
 
40
40
  function errorResponse(code, message, sent) {
@@ -22,5 +22,5 @@ export function assertRecipientClass(session, sender) {
22
22
  if (inbound.value === "accept") return;
23
23
  const recipient = session.desktop?.permissionClass ?? null;
24
24
  if (!recipient || recipient === sender.mode) return;
25
- throw preflightFailure("CLAUDE_RECIPIENT_CLASS_MISMATCH", `The Claude Desktop task "${session.desktop.title}" runs in ${session.desktop.permissionMode} (${recipient} class) while this sender attests ${sender.mode}. With no explicit crossSessionInbound setting, Claude holds a cross-session message whose sender class differs from the recipient's, and Claude Desktop declares no peer approval dialog, so the message would expire unapproved. Report this to the user: they can run that task in the ${sender.mode} class, change this Codex task's approval policy so its class matches, or set crossSessionInbound to accept in the recipient session's own settings. Do not change the attested sender class or recipient settings yourself.`);
25
+ throw preflightFailure("CLAUDE_RECIPIENT_CLASS_MISMATCH", `The Claude Desktop task "${session.desktop.title}" runs in ${session.desktop.permissionMode} (${recipient} class) while this sender attests ${sender.mode}. With no explicit crossSessionInbound setting, Claude holds a cross-session message whose sender class differs from the recipient's, and Claude Desktop declares no peer approval dialog, so the message would expire unapproved. Report this to the user: they can run that task in the ${sender.mode} class, change this Codex task's permissions so its class matches (a Codex task attests bypass only with Full access and approval policy never), or set crossSessionInbound to accept in the recipient session's own settings. Do not change the attested sender class or recipient settings yourself.`);
26
26
  }
@@ -79,7 +79,7 @@ export class DesktopTaskDelivery {
79
79
  }
80
80
  }
81
81
 
82
- async withThread(threadId, operation, { deadline } = {}) {
82
+ async withThread(threadId, operation, { deadline, onDeadline } = {}) {
83
83
  const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
84
84
  let expired = false;
85
85
  const current = previous.catch(() => {}).then(() => {
@@ -98,6 +98,10 @@ export class DesktopTaskDelivery {
98
98
  reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again."));
99
99
  }, Math.max(1, deadline - this.now()));
100
100
  })]);
101
+ } catch (error) {
102
+ // Preserve confirmed dispatch state while the underlying operation retains its lock.
103
+ if (expired && onDeadline) return onDeadline(error);
104
+ throw error;
101
105
  } finally {
102
106
  clearTimeout(timer);
103
107
  }
@@ -197,12 +201,12 @@ export class DesktopTaskDelivery {
197
201
  return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
198
202
  }
199
203
 
200
- async create({ cwd, prompt, name, dedupeName = name, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
204
+ async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
201
205
  this.security.assertCwd(cwd);
202
206
  cwd = realpathSync.native(cwd);
203
207
  this.security.assertCwd(cwd);
204
208
  dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
205
- const identity = this.receipts.key({ cwd, prompt, name: dedupeName });
209
+ const identity = this.receipts.key({ cwd, prompt, name: dedupeName, requestId });
206
210
  const accountContext = this.accountContext?.();
207
211
  if (this.accountContext && !accountContext) throw new Error("Desktop creation requires the original verified account context.");
208
212
  const options = { cwd, promptHash: identity.promptHash, deadline, accountContext };