@minhspark/codex-mcp-bridge 1.22.4 → 1.22.6

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.4",
3
+ "version": "1.22.6",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.6] - 2026-10-20
8
+
9
+ ### Fixed
10
+
11
+ - Keep a confirmed Desktop creation identifiable when post-creation verification fails. `delegate_to_codex` and `start_codex_thread` now return the task's threadId, its deliveryId and "Task was accepted; do not resend" instead of a bare allowed-roots error that read as if nothing had been created. The confirmed threadId is kept even when a later account, receipt or scope check fails after the bridge waited for the task's workspace.
12
+ - Treat a filesystem root that Codex Desktop briefly reports as a newly created task's workspace, observed as `/` right after creation following an account switch, as "workspace not yet assigned" when it is not the expected project. Creation keeps observing until its deadline and then returns a pending receipt that keeps the accepted threadId; reply observation keeps polling. The tolerance lasts at most 120 seconds from creation, or from the first such report during reply observation, and the bridge acts on nothing at that path and grants no task access from it during that time.
13
+ - Report a pending creation whose task Desktop already confirmed as `deliveryStatus: accepted` instead of `unconfirmed`.
14
+
15
+ ### Security
16
+
17
+ - A root report that outlasts the grace period, any other workspace outside the allowed roots, a different project, an account change or a sender mismatch still fails closed. Sends and plain thread inspection keep the normal allowed-roots refusal for a root workspace; nothing is sent.
18
+
19
+ ### Verification
20
+
21
+ - 10 regression tests reproduce the defect on v1.22.5 and pass with the fix: creation confirmation with a brief `/` report, a pending result at the deadline, grace expiry, an outside-root workspace, a later account change, reply observation in both the wait and snapshot paths, and two MCP-level `delegate_to_codex` tests through the relay companion.
22
+ - The full CI matrix passed on Windows, macOS and Linux with Node 22 and 24. A local run outside the sandbox passed 1406 tests with 0 failures.
23
+ - An independent adversarial review found no security regression; its four confirmed correctness findings were fixed before merge.
24
+
25
+ ### Upgrade notes
26
+
27
+ - Reconnect the MCP server after updating and confirm the loaded version from the actual runtime before relying on the fix.
28
+ - A creation that is pending because Desktop has not reported its workspace continues with `wait_codex_reply` and the returned deliveryId. Do not resend the prompt or create the task again.
29
+ - Not yet exercised against a live Desktop account switch. A placeholder workspace other than a filesystem root still fails closed as before.
30
+
31
+ ## [1.22.5] - 2026-10-19
32
+
33
+ ### Fixed
34
+
35
+ - Persist confirmed Desktop send acceptance in the original owner-bound receipt so a later wait or MCP restart does not revert it to unconfirmed.
36
+ - Resolve exact peer dispatches from their original rollout watermark, including messages injected into an active Codex turn and completed replies followed by newer turns. Preserve sender, account, project and response-integrity checks, mark same-turn finals and retain exact empty memory-citation formatting.
37
+ - Include the advertised peer name in Claude's supported message envelope so the native recipient can identify the sender instead of showing an unnamed session. Codex Desktop's fixed sender badge remains controlled by the host; author headers identify the actual collaborator without impersonating user input.
38
+ - Follow verified native Claude compaction continuations when correlating an outstanding peer reply, while retaining interruption and identity checks.
39
+ - Return an unavailable reply at the observation deadline without losing accepted delivery state; continue rejecting identity and permission failures even when they occur after that deadline.
40
+
41
+ ### Verification
42
+
43
+ - The receipt fixes passed the full CI matrix on Windows, macOS and Linux with Node 22 and 24, including restart, compaction and deadline-boundary regressions.
44
+ - Live Desktop peer exchanges confirmed both directions, durable acceptance and recovery of a historical final response. Project authorization remained unchanged.
45
+
46
+ ### Upgrade notes
47
+
48
+ - A worker with an unresolved peer receipt can defer automatic reload. Verify the loaded version from the actual MCP after reconnecting; retain the original receipt and inspect the existing conversation before any follow-up.
49
+ - Native sender labels remain host-controlled. Claude can display the advertised peer name; Codex retains its native cross-chat label alongside the actual author header. This release does not add Cowork or Dispatch connectivity.
50
+
7
51
  ## [1.22.4] - 2026-10-19
8
52
 
9
53
  ### Fixed
@@ -45,6 +45,14 @@ message and does not establish a native sender label. If a handoff requires the
45
45
  recipient's peer sender attribution, use `send_to_claude_session` with a verified
46
46
  live task. Do not substitute composer submission or a text header for that route.
47
47
 
48
+ Peer messages include the bridge's advertised name through Claude's supported
49
+ `from-name` envelope field. Claude determines the native wording and verifies
50
+ the peer; the bridge does not set trusted-origin flags. Codex Desktop currently
51
+ uses its own fixed `Sent by ... from another chat` label and exposes no sender-name
52
+ override. Keep the actual `[From Claude ...]` or `[From Codex ...]` author header
53
+ in the message. A body header does not rename a native badge, and applications'
54
+ private data must not be patched to make that claim.
55
+
48
56
  If a link was already opened, retain its requestId and inspect its receipt.
49
57
  `promptSubmitted=false` with `submissionStatus=not_observed` means submission
50
58
  was not verified, not that nothing was delivered. A wrong-directory task can
@@ -68,6 +76,12 @@ new task, account, process, prompt and directory to match.
68
76
  deliveryId. For Codex-to-Claude, retain the original msgId and inspect its
69
77
  receipt. An unconfirmed acknowledgement is not proof of delivery; observe
70
78
  the original receipt instead of resending an uncertain task.
79
+ Confirmed native acceptance is retained across reconnects. A message injected
80
+ into an active Codex turn can return that turn's final answer with
81
+ `responseScope: same_turn_final`; it may cover other peer messages in that
82
+ turn. The exact dispatch must occur after the saved watermark, and commentary
83
+ alone is never a final reply. Receipt observation resolves that exact dispatch
84
+ even after newer turns start; it does not substitute the latest turn's answer.
71
85
  5. An `interrupted` receipt ends observation only: task outcome is unknown and
72
86
  background work may continue. Inspect the existing task before a scoped
73
87
  follow-up; do not repeat card creation or file operations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.4",
3
+ "version": "1.22.6",
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",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.22.4",
4
+ "version": "1.22.6",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -23,7 +23,7 @@ import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-acce
23
23
 
24
24
  exitForVersionRequest(import.meta.url);
25
25
 
26
- const VERSION = "1.22.4";
26
+ const VERSION = "1.22.6";
27
27
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
28
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
29
29
  const entryArgs = ["native-relay-companion.mjs"];
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.22.4";
33
+ const VERSION = "1.22.6";
34
34
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
35
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
36
  const FORWARD_MAX_PER_SESSION = 50;
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
  import { scanRollout } from "./rollout-reader.mjs";
6
7
  import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
7
8
 
@@ -81,20 +82,22 @@ function samePathBoundary(expected, current) {
81
82
  entry.path === current.ancestors[index].path && entry.dev === current.ancestors[index].dev && entry.ino === current.ancestors[index].ino);
82
83
  }
83
84
 
84
- function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize } = {}) {
85
+ function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize, dispatchOutputs, dispatchAfterOffset = afterOffset } = {}) {
85
86
  for (let attempt = 0; ; attempt++) {
86
- try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }); }
87
+ try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }); }
87
88
  catch (error) {
88
89
  if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
89
90
  }
90
91
  }
91
92
  }
92
93
 
93
- function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }) {
94
+ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
94
95
  const { file, sessions } = found;
95
96
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ROLLOUT_BYTES) throw new Error("The Codex rollout read limit is invalid");
96
97
  const beforeBoundary = capturePathBoundary(file, sessions);
97
98
  const records = [];
99
+ const dispatches = [];
100
+ const otherTurnStarts = [];
98
101
  const digest = crypto.createHash("sha256");
99
102
  let session, latestContext, retainedBytes = 0, turnBeforeWatermark = false;
100
103
  const snapshot = scanRollout(file, {
@@ -111,6 +114,19 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
111
114
  }
112
115
  if (record.type === "turn_context") latestContext = record.payload;
113
116
  const entry = { record, start };
117
+ if (start >= dispatchAfterOffset && dispatchOutputs && record.type === "response_item" && record.payload.type === "function_call_output" &&
118
+ record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread" && dispatchOutputs.includes(record.payload.output)) {
119
+ const metadata = record.payload.internal_chat_message_metadata_passthrough;
120
+ const selectedTurnId = record.payload.turn_id ?? record.payload.root_turn_id ?? metadata?.turn_id ?? metadata?.root_turn_id;
121
+ if (!UUID.test(selectedTurnId) || !recordBelongsToTurn(entry, threadId, selectedTurnId)) throw new Error("The native dispatch has no consistent turn identity");
122
+ dispatches.push({ turnId: selectedTurnId, start });
123
+ if (dispatches.length > 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
124
+ }
125
+ if (turnId && start >= afterOffset && (record.type === "turn_context" || record.type === "event_msg" && STARTED.has(record.payload.type)) &&
126
+ !recordBelongsToTurn(entry, threadId, turnId)) {
127
+ otherTurnStarts.push(start);
128
+ if (otherTurnStarts.length > 65536) throw new Error("The native turn boundary count exceeds its read limit");
129
+ }
114
130
  if (turnId && recordBelongsToTurn(entry, threadId, turnId)) {
115
131
  if (start < afterOffset) { turnBeforeWatermark = true; return; }
116
132
  retainedBytes += bytes;
@@ -123,7 +139,7 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
123
139
  if (!samePathBoundary(beforeBoundary, afterBoundary) || snapshot.identity.dev !== beforeBoundary.file.dev || snapshot.identity.ino !== beforeBoundary.file.ino) {
124
140
  throw new Error("The Codex rollout changed while reading");
125
141
  }
126
- return { ...snapshot, file, sessions, records, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
142
+ return { ...snapshot, file, sessions, records, dispatches, otherTurnStarts, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
127
143
  }
128
144
 
129
145
  function canonicalDirectory(value, label) {
@@ -209,10 +225,11 @@ function completedMemoryCitation(item) {
209
225
  !Number.isSafeInteger(entry.lineStart) || entry.lineStart < 1 || !Number.isSafeInteger(entry.lineEnd) || entry.lineEnd < entry.lineStart) ||
210
226
  citation.rolloutIds.some((id) => typeof id !== "string" || !UUID.test(id))) return null;
211
227
  const entries = citation.entries.map((entry) => `${entry.path}:${entry.lineStart}-${entry.lineEnd}|note=[${entry.note}]`).join("\n");
212
- return `<oai-mem-citation>\n<citation_entries>\n${entries}\n</citation_entries>\n<rollout_ids>\n${citation.rolloutIds.join("\n")}\n</rollout_ids>\n</oai-mem-citation>`;
228
+ const rolloutIds = citation.rolloutIds.join("\n");
229
+ return `<oai-mem-citation>\n<citation_entries>\n${entries ? `${entries}\n` : ""}</citation_entries>\n<rollout_ids>\n${rolloutIds ? `${rolloutIds}\n` : ""}</rollout_ids>\n</oai-mem-citation>`;
213
230
  }
214
231
 
215
- function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch } = {}) {
232
+ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch, otherTurnStarts = [] } = {}) {
216
233
  const turns = records.filter((entry) => recordBelongsToTurn(entry, threadId, turnId));
217
234
  const starts = turns.filter(({ record }) => record.type === "event_msg" && STARTED.has(record.payload.type));
218
235
  const contexts = turns.filter(({ record }) => record.type === "turn_context");
@@ -222,26 +239,39 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
222
239
  const lifecycleError = (check, fields) => validationError("The native turn lifecycle is missing, ambiguous, failed, or out of order", check, fields);
223
240
  if (failures.length) throw lifecycleError("lifecycle_failure", { count: failures.length, eventBytes: recordOffsets(failures) });
224
241
  for (const [kind, entries] of [["start", starts], ["context", contexts], ["completion", completions]]) {
225
- if (entries.length !== 1) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
242
+ if (entries.length !== 1 && (kind !== "context" || !entries.length)) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
243
+ }
244
+ if (contexts.some((entry) => !isDeepStrictEqual(entry.record.payload, contexts[0].record.payload))) {
245
+ throw lifecycleError("lifecycle_context_changed", { count: contexts.length, recordBytes: recordOffsets(contexts) });
226
246
  }
227
247
  if (canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd) {
228
248
  throw lifecycleError("lifecycle_workspace", { contextByte: contexts[0].start });
229
249
  }
230
- if (starts[0].start >= contexts[0].start || contexts[0].start >= completions[0].start) {
250
+ if (contexts.some((entry) => starts[0].start >= entry.start || entry.start >= completions[0].start)) {
231
251
  throw lifecycleError("lifecycle_order", { startByte: starts[0].start, contextByte: contexts[0].start, completionByte: completions[0].start });
232
252
  }
233
253
 
234
254
  let dispatch = null;
235
255
  if (requireDispatch) {
236
256
  const dispatches = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "function_call_output" &&
237
- record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread"));
238
- if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
257
+ record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread") &&
258
+ (!requireDispatch.existingTurn || requireDispatch.outputs.includes(record.payload.output)));
259
+ if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) || dispatches[0].start < (requireDispatch.afterOffset ?? 0) ||
260
+ (requireDispatch.existingTurn && contexts[0].start >= requireDispatch.afterOffset) ||
239
261
  contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
240
262
  throw validationError("The newly observed turn is not correlated to the exact native dispatch", "dispatch_correlation", {
241
263
  count: dispatches.length, dispatchBytes: recordOffsets(dispatches), contextByte: contexts[0].start, completionByte: completions[0].start,
242
264
  });
243
265
  }
244
266
  dispatch = dispatches[0];
267
+ if (otherTurnStarts.some((start) => start > dispatch.start && start < completions[0].start)) {
268
+ throw validationError("Another native turn superseded the dispatch before completion", "dispatch_superseded");
269
+ }
270
+ if (requireDispatch.existingTurn && turns.some(({ record, start }) => start > dispatch.start &&
271
+ (record.type === "response_item" && record.payload.type === "message" && record.payload.role === "user" ||
272
+ record.type === "event_msg" && record.payload.type === "user_message"))) {
273
+ throw validationError("New user input superseded the native dispatch in the active turn", "dispatch_superseded");
274
+ }
245
275
  }
246
276
 
247
277
  const allAssistantEntries = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "message" && record.payload.role === "assistant");
@@ -378,10 +408,52 @@ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd, desktopE
378
408
  }
379
409
  }
380
410
 
411
+ export function resolveCodexNativeDispatchResponse(binding, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
412
+ const { threadId, expectedCwd, executorThreadId, prompt, watermark, desktopEvidence } = binding;
413
+ let turnId = null;
414
+ try {
415
+ if (!UUID.test(threadId) || !UUID.test(executorThreadId) || typeof prompt !== "string" || !prompt.length) throw new Error("The native dispatch identity is invalid");
416
+ if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
417
+ const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
418
+ if (watermark.cwd !== cwd) throw new Error("The selected native task workspace changed after dispatch");
419
+ const marks = watermark.segments ?? [watermark];
420
+ if (!Array.isArray(marks) || !marks.length || marks.length > 64 || new Set(marks.map((mark) => mark.file)).size !== marks.length ||
421
+ !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256) ||
422
+ marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
423
+ const found = findRollouts(threadId, env);
424
+ if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
425
+ const outputs = delegationOutputs(executorThreadId, prompt);
426
+ const snapshots = found.map((entry) => {
427
+ const mark = marks.find(({ file }) => file === entry.file);
428
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
429
+ if (mark) {
430
+ if (snapshot.identity.dev !== mark.identity?.dev || snapshot.identity.ino !== mark.identity?.ino || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
431
+ if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
432
+ }
433
+ validateIdentity(snapshot, threadId, cwd, desktopEvidence);
434
+ return snapshot;
435
+ });
436
+ assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
437
+ const matches = snapshots.flatMap((snapshot) => snapshot.dispatches.map((dispatch) => ({ snapshot, dispatch })));
438
+ if (!matches.length) return { status: "pending", threadId, turnId: null, text: "", assistantItems: [], replySha256: null };
439
+ if (matches.length !== 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
440
+ validateSession(matches[0].snapshot, threadId, cwd, desktopEvidence);
441
+ turnId = matches[0].dispatch.turnId;
442
+ const result = readCodexNativeTurnResponse({ ...binding, turnId }, { env, maxRolloutBytes });
443
+ if (result.status === "unavailable" && result.dispatchObserved && /check=lifecycle_completion_count; count=0;/.test(result.reason)) {
444
+ return { status: "pending", threadId, turnId, dispatchObserved: true, text: "", assistantItems: [], replySha256: null };
445
+ }
446
+ return result;
447
+ } catch (error) {
448
+ return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
449
+ }
450
+ }
451
+
381
452
  export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId, expectedCwd, executorThreadId, prompt, watermark, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
453
+ let dispatchObserved = false;
382
454
  try {
383
455
  if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native response identity is invalid");
384
- if (turnId === previousTurnId) throw new Error("The observed turn was already present before dispatch");
456
+ const existingTurn = turnId === previousTurnId;
385
457
  if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact dispatched prompt is unavailable");
386
458
  if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
387
459
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
@@ -393,10 +465,11 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
393
465
  for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
394
466
  const found = findRollouts(threadId, env);
395
467
  if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
468
+ const outputs = delegationOutputs(executorThreadId, prompt);
396
469
  let retainedBytes = 0;
397
470
  const snapshots = found.map((entry) => {
398
471
  const mark = marks.find(({ file }) => file === entry.file);
399
- const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size });
472
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
400
473
  retainedBytes += snapshot.retainedBytes;
401
474
  if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
402
475
  if (mark) {
@@ -409,12 +482,20 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
409
482
  });
410
483
  assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
411
484
  const selected = uniqueTurn(snapshots);
485
+ const matchedDispatches = snapshots.flatMap((snapshot) => snapshot.dispatches);
486
+ if (matchedDispatches.length !== 1 || matchedDispatches[0].turnId !== turnId) throw new Error("The observed turn is not uniquely correlated to the exact native dispatch");
412
487
  validateSession(selected, threadId, cwd, desktopEvidence);
488
+ const selectedMark = marks.find(({ file }) => file === selected.file);
489
+ if (existingTurn && !selectedMark) throw new Error("The active native turn has no pre-send rollout watermark");
490
+ dispatchObserved = selected.records.some(({ record, start }) => start >= (selectedMark?.size ?? 0) &&
491
+ record.type === "response_item" && record.payload.type === "function_call_output" &&
492
+ record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread" && outputs.includes(record.payload.output));
413
493
  const inspected = inspectTurnRecords(selected.records, threadId, turnId, cwd, {
414
- requireDispatch: { outputs: delegationOutputs(executorThreadId, prompt) },
494
+ requireDispatch: { outputs, afterOffset: selectedMark?.size ?? 0, existingTurn },
495
+ otherTurnStarts: selected.otherTurnStarts,
415
496
  });
416
- return { ...inspected, observationStatus: inspected.status };
497
+ return { ...inspected, observationStatus: inspected.status, ...(existingTurn ? { responseScope: "same_turn_final" } : {}) };
417
498
  } catch (error) {
418
- return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
499
+ return { ...unavailable(error), ...(dispatchObserved ? { dispatchObserved: true } : {}), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
419
500
  }
420
501
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { isDeepStrictEqual } from "node:util";
4
5
  import { creationRequestHash, creationIdValid } from "./native-creation-receipts.mjs";
5
6
  import { homeDir } from "./platform.mjs";
6
7
 
@@ -52,6 +53,33 @@ export class DesktopReplyReceipts {
52
53
  return id;
53
54
  }
54
55
 
56
+ acknowledge(id, delivered, context) {
57
+ const previous = this.read(id, context);
58
+ const acknowledgement = JSON.parse(JSON.stringify(delivered));
59
+ if (acknowledgement.deliveryStatus !== "accepted" || previous.creationObservation ||
60
+ !["unconfirmed", "accepted"].includes(previous.deliveryStatus) ||
61
+ !isDeepStrictEqual({ ...acknowledgement, deliveryStatus: previous.deliveryStatus }, previous)) {
62
+ throw new Error("Delivery acknowledgement does not match the original reply binding");
63
+ }
64
+ if (previous.deliveryStatus === "accepted") return;
65
+ const file = this.file(id);
66
+ const bytes = Buffer.from(JSON.stringify({ version: 1, deliveryId: id, owner: owner(context), delivered: acknowledgement }));
67
+ if (bytes.length > LIMIT) throw new Error("Reply observation binding exceeds the receipt limit");
68
+ const temporary = `${file}.${randomUUID()}.pending`;
69
+ const fd = fs.openSync(temporary, "wx", 0o600);
70
+ try {
71
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); }
72
+ finally { fs.closeSync(fd); }
73
+ if (!isDeepStrictEqual(this.read(id, context), previous) || this.file(id) !== file) {
74
+ throw new Error("Reply receipt changed before acknowledgement");
75
+ }
76
+ fs.renameSync(temporary, file);
77
+ } finally {
78
+ try { fs.unlinkSync(temporary); }
79
+ catch (error) { if (error.code !== "ENOENT") throw error; }
80
+ }
81
+ }
82
+
55
83
  read(id, context) {
56
84
  const file = this.file(id);
57
85
  const before = fs.lstatSync(file);
package/src/index.mjs CHANGED
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
40
40
 
41
41
  exitForVersionRequest(import.meta.url);
42
42
 
43
- const VERSION = "1.22.4";
43
+ const VERSION = "1.22.6";
44
44
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
45
45
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
46
46
 
@@ -151,11 +151,12 @@ const failure = (err) => {
151
151
 
152
152
  function replyResult(delivered, deliveryId, result, notes = []) {
153
153
  result = { activity: [], errors: [], ...result };
154
+ const deliveryStatus = ["completed", "completed_no_reply"].includes(result.responseStatus) ? "accepted" : delivered.deliveryStatus ?? "accepted";
154
155
  const pending = result.status === "timeout";
155
156
  const continuation = deliveryId ? `deliveryId: ${deliveryId}\n${pending ? "Continue automatically with wait_codex_reply and this deliveryId; it only observes the original send. Do not resend the prompt." : "Keep this deliveryId to inspect the same send after reconnecting."}` : "";
156
157
  return {
157
- ...textResult([...notes, formatTurn(result, { desktop: true }), continuation].filter(Boolean).join("\n"), result.status === "failed" || result.status === "systemError"),
158
- structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus: result.responseStatus === "completed" || result.responseStatus === "completed_no_reply" ? "accepted" : delivered.deliveryStatus ?? "accepted", ...(deliveryId ? { deliveryId } : {}), nextAction: pending && deliveryId ? "wait_codex_reply" : "none" },
158
+ ...textResult([...notes, `deliveryStatus: ${deliveryStatus}`, formatTurn(result, { desktop: true }), continuation].filter(Boolean).join("\n"), result.status === "failed" || result.status === "systemError"),
159
+ structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus, ...(deliveryId ? { deliveryId } : {}), nextAction: pending && deliveryId ? "wait_codex_reply" : "none" },
159
160
  };
160
161
  }
161
162
 
@@ -210,6 +211,25 @@ function threadNameFor({ cwd, prompt, name }) {
210
211
  );
211
212
  }
212
213
 
214
+ /**
215
+ * Once a Desktop creation was dispatched, a later verification failure must
216
+ * never read like a refusal: the caller keeps the task and receipt identity
217
+ * and is told not to resend. Verification still fails closed; only the report
218
+ * changes.
219
+ */
220
+ function creationVerificationFailure({ created, deliveryId, requestId, err }) {
221
+ const threadId = created.threadId ?? err?.threadId ?? null;
222
+ const lines = threadId
223
+ ? ["Codex Desktop accepted the task, but post-creation verification failed.", `threadId: ${threadId}`]
224
+ : ["Codex Desktop creation was dispatched, but its outcome is not yet confirmed."];
225
+ lines.push(...(requestId ? [`requestId: ${requestId}`] : []), `cwd: ${created.cwd}`, ...(deliveryId ? [`deliveryId: ${deliveryId}`] : []), `error: ${err?.message ?? String(err)}`,
226
+ threadId ? "Task was accepted; do not resend. Resolve the verification error, then continue observing with wait_codex_reply and this deliveryId." : "Do not resend or create again. Continue observing with wait_codex_reply and this deliveryId.");
227
+ return {
228
+ ...textResult(lines.join("\n"), true),
229
+ structuredContent: { ...failure(err).structuredContent, threadId, deliveryStatus: threadId ? "accepted" : "unconfirmed", ...(deliveryId ? { deliveryId } : {}), nextAction: deliveryId ? "wait_codex_reply" : "none" },
230
+ };
231
+ }
232
+
213
233
  async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp, waitForReply = true }) {
214
234
  const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
215
235
  const workspace = resolveWorkspacePath(cwd);
@@ -221,8 +241,17 @@ async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort
221
241
  const deliveryId = created.deliveryId;
222
242
  let delivered = deliveryId ? replyReceipts.read(deliveryId, desktopOperation.getStore()) : null;
223
243
  if (delivered?.creationObservation) {
224
- const resolved = await desktopTasks.resolveCreation(delivered, { deadline });
225
- if (!resolved) return replyResult(delivered, deliveryId, { threadId: null, status: "timeout", responseStatus: "unavailable", text: "" }, ["Creation acknowledgement pending; do not create again."]);
244
+ let resolved;
245
+ try {
246
+ resolved = await desktopTasks.resolveCreation(delivered, { deadline });
247
+ } catch (err) {
248
+ return creationVerificationFailure({ created, deliveryId, requestId, err });
249
+ }
250
+ if (!resolved) {
251
+ const threadId = created.threadId ?? null;
252
+ return replyResult({ ...delivered, threadId, ...(threadId ? { deliveryStatus: "accepted" } : {}) }, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" },
253
+ [...(threadId ? [`threadId: ${threadId}`, "Task was accepted; Codex Desktop has not yet confirmed its workspace or creation receipt."] : []), "Creation acknowledgement pending; do not create again."]);
254
+ }
226
255
  delivered = resolved;
227
256
  created.threadId = resolved.threadId;
228
257
  }
@@ -625,6 +654,7 @@ registerTool(
625
654
  acceptedDelivery = prepared;
626
655
  } });
627
656
  acceptedDelivery = delivered;
657
+ if (delivered.deliveryStatus === "accepted") replyReceipts.acknowledge(deliveryId, delivered, desktopOperation.getStore());
628
658
  notes.push(delivered.deliveryStatus === "unconfirmed" ? "Desktop delivery acknowledgement timed out; delivery is unconfirmed. Continue observing this receipt; do not resend." : "sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
629
659
  if (delivered.deliveryStatus === "unconfirmed") return replyResult(delivered, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" }, notes);
630
660
  if (shouldOpen) {
@@ -694,7 +724,7 @@ registerTool(
694
724
  const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
695
725
  return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
696
726
  } catch (err) {
697
- if (acceptedDelivery?.deliveryStatus === "accepted") return textResult(`threadId: ${threadId}\nTask was accepted, but its reply receipt could not be prepared: ${err.message}. Inspect the existing task; do not resend.`, true);
727
+ if (acceptedDelivery?.deliveryStatus === "accepted") return textResult(`threadId: ${threadId}\ndeliveryId: ${deliveryId}\nTask was accepted, but its reply receipt could not be updated: ${err.message}. Inspect the existing task; do not resend.`, true);
698
728
  return failure(err);
699
729
  }
700
730
  }, {
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
38
38
 
39
39
  exitForVersionRequest(import.meta.url);
40
40
 
41
- const VERSION = "1.22.4";
41
+ const VERSION = "1.22.6";
42
42
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
43
43
 
44
44
  function errorResponse(code, message, sent, details) {
@@ -38,9 +38,11 @@ const projectsDir = () => path.join(homeDir(), ".claude", "projects");
38
38
  * listens for peer messages on a local socket or Windows named pipe. Messages are newline-delimited
39
39
  * JSON; the wrapper element is what Claude renders in its chat surface.
40
40
  */
41
- export function buildFrame({ text, fromSocket, priority = "next", permissionMode }) {
41
+ export function buildFrame({ text, fromSocket, fromName, priority = "next", permissionMode }) {
42
42
  const msgId = crypto.randomUUID();
43
43
  const from = `uds:${fromSocket.replace(/[^A-Za-z0-9:_/.\-]/gu, (character) => Array.from(Buffer.from(character), (byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join(""))}`;
44
+ const displayName = typeof fromName === "string" ? fromName.replace(/["<>\p{Cf}\p{Cc}\p{Cs}\p{Zl}\p{Zp}]/gu, "").trim() : "";
45
+ const name = displayName ? ` from-name="${displayName}"` : "";
44
46
  const mode = ["bypass", "prompting"].includes(permissionMode) ? ` from-mode="${permissionMode}"` : "";
45
47
  return {
46
48
  msgV: PEER_PROTOCOL_VERSION,
@@ -49,7 +51,7 @@ export function buildFrame({ text, fromSocket, priority = "next", permissionMode
49
51
  type: "user",
50
52
  message: {
51
53
  role: "user",
52
- content: `<cross-session-message from="${from}"${mode}>\n${text}\n</cross-session-message>`,
54
+ content: `<cross-session-message from="${from}"${name}${mode}>\n${text}\n</cross-session-message>`,
53
55
  },
54
56
  priority,
55
57
  from,
@@ -413,6 +415,38 @@ function isImageToolCompanion(entry, entriesById) {
413
415
  && typeof tool.id === "string" && tool.id === part.tool_use_id));
414
416
  }
415
417
 
418
+ function compactContinuation(entry, root, entriesById, sessionId, cwd) {
419
+ if (entry.type !== "system" || entry.subtype !== "compact_boundary" || entry.parentUuid !== null
420
+ || typeof entry.logicalParentUuid !== "string") return null;
421
+ const sameIdentity = (record) => record?.entrypoint === "claude-desktop"
422
+ && record.sessionId === sessionId && record.cwd === root.cwd;
423
+ if (!sameIdentity(root) || !sameIdentity(entry) || typeof root.cwd !== "string"
424
+ || path.resolve(root.cwd) !== path.resolve(cwd)) return null;
425
+ const segment = entry.compactMetadata?.preservedSegment;
426
+ const preserved = entry.compactMetadata?.preservedMessages;
427
+ const ids = preserved?.uuids;
428
+ if (!Array.isArray(ids) || !ids.length || ids.length > 65536
429
+ || ids.some((id) => typeof id !== "string" || !id) || new Set(ids).size !== ids.length
430
+ || !Array.isArray(preserved.allUuids) || preserved.allUuids.length !== ids.length
431
+ || ids.some((id, index) => preserved.allUuids[index] !== id)
432
+ || !ids.includes(root.uuid) || segment?.headUuid !== ids[0]
433
+ || segment.tailUuid !== ids.at(-1) || segment.tailUuid !== entry.logicalParentUuid
434
+ || typeof segment.anchorUuid !== "string" || preserved.anchorUuid !== segment.anchorUuid) return null;
435
+ if (ids.some((id, index) => !sameIdentity(entriesById.get(id))
436
+ || index > 0 && entriesById.get(id).parentUuid !== ids[index - 1])
437
+ || ids.includes(entriesById.get(ids[0]).parentUuid)) return null;
438
+ const summary = entriesById.get(segment.anchorUuid);
439
+ if (!sameIdentity(summary) || summary.type !== "user" || summary.message?.role !== "user"
440
+ || summary.isCompactSummary !== true || summary.isVisibleInTranscriptOnly !== true
441
+ || summary.turnOrigin !== "peer" || summary.origin != null || summary.promptSource != null) return null;
442
+ let ancestor = entriesById.get(summary.parentUuid);
443
+ for (let depth = 0; depth < 64 && ancestor?.type === "attachment"; depth++) {
444
+ if (!sameIdentity(ancestor)) return null;
445
+ ancestor = entriesById.get(ancestor.parentUuid);
446
+ }
447
+ return ancestor === entry ? summary.uuid : null;
448
+ }
449
+
416
450
  // Interruption closes reply observation, not execution. Never attach a later
417
451
  // user turn's answer to the original request or claim its side effects stopped.
418
452
  export function readTranscriptOutcome(sessionId, cwd, msgId) {
@@ -445,6 +479,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
445
479
  const openLeaves = new Set();
446
480
  const passiveLeaves = new Set();
447
481
  const interruptions = [];
482
+ const compactSummaries = new Set();
448
483
  const interrupt = (entry, outstanding) => {
449
484
  if (typeof entry.uuid !== "string" || !entry.uuid.trim()) return;
450
485
  openLeaves.delete(entry.parentUuid);
@@ -452,7 +487,16 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
452
487
  };
453
488
  for (const entry of entries) {
454
489
  if (entry === root) { descendants.set(entry.uuid, new Map()); openLeaves.add(entry.uuid); continue; }
455
- if (!descendants.has(entry.parentUuid)) continue;
490
+ let parentUuid = entry.parentUuid;
491
+ if (!ambiguous && !interruptions.length && descendants.has(entry.logicalParentUuid)
492
+ && openLeaves.has(entry.logicalParentUuid)) {
493
+ const summaryId = compactContinuation(entry, root, entriesById, sessionId, cwd);
494
+ if (summaryId) {
495
+ parentUuid = entry.logicalParentUuid;
496
+ compactSummaries.add(summaryId);
497
+ }
498
+ }
499
+ if (!descendants.has(parentUuid)) continue;
456
500
  if (entry.type === "attachment" && entry.attachment?.type === "queued_command") {
457
501
  // Only a native command record is a boundary. Its text is never used as
458
502
  // proof of authorization, cancellation, or successful task completion.
@@ -469,10 +513,10 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
469
513
  }
470
514
  const role = entry.message?.role;
471
515
  const content = entry.message?.content;
472
- const outstanding = new Map(descendants.get(entry.parentUuid));
516
+ const outstanding = new Map(descendants.get(parentUuid));
473
517
  const toolResults = Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "tool_result");
474
518
  const imageCompanion = isImageToolCompanion(entry, entriesById);
475
- if (role === "user" && !toolResults && !imageCompanion) {
519
+ if (role === "user" && !toolResults && !imageCompanion && !compactSummaries.has(entry.uuid)) {
476
520
  const notificationText = typeof content === "string" ? content
477
521
  : Array.isArray(content) ? content.filter((part) => typeof part?.text === "string").map((part) => part.text).join("\n") : "";
478
522
  const nativeHuman = entry.origin?.kind === "human" && entry.promptSource === "sdk" && entry.turnOrigin === "human";
@@ -524,7 +568,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
524
568
  && ["hook_success", "total_tokens_reminder", "deferred_tools_record"].includes(entry.attachment?.type)) {
525
569
  passiveLeaves.add(entry.uuid);
526
570
  }
527
- openLeaves.delete(entry.parentUuid);
571
+ openLeaves.delete(parentUuid);
528
572
  openLeaves.add(entry.uuid);
529
573
  if (role !== "assistant" || !["end_turn", "stop_sequence"].includes(entry.message.stop_reason)) continue;
530
574
  if (outstanding.size > 0) continue;
@@ -858,7 +902,7 @@ export class PeerEndpoint {
858
902
 
859
903
  async send(targetSocket, text, { priority = "next", msgId, permissionMode = this.permissionMode, beforeSend } = {}) {
860
904
  if (this.reloadPaused) throw new Error("The peer endpoint is quiesced; no message was sent");
861
- const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode });
905
+ const frame = buildFrame({ text, fromSocket: this.socketPath, fromName: this.name, priority, permissionMode });
862
906
  if (msgId) frame.uuid = frame.msg_id = msgId;
863
907
  const token = readPeerToken(targetSocket);
864
908
  const line = (token ? JSON.stringify({ type: "auth", token }) + "\n" : "") + JSON.stringify(frame) + "\n";
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { creationRequestHash } from "./native-creation-receipts.mjs";
7
7
  import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
8
- import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse } from "./codex-native-response.mjs";
8
+ import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse, resolveCodexNativeDispatchResponse } from "./codex-native-response.mjs";
9
9
  import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
10
10
  import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
11
11
 
@@ -26,6 +26,8 @@ import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
26
26
  export const NATIVE_BACKEND = "codex-desktop-native";
27
27
  export const APP_SERVER_BACKEND = "app-server";
28
28
  export const DESKTOP_TOOL_BUDGET_MS = 40000;
29
+ export const WORKSPACE_UNASSIGNED = "DESKTOP_WORKSPACE_UNASSIGNED";
30
+ export const UNASSIGNED_WORKSPACE_GRACE_MS = 120000;
29
31
  const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
30
32
  const METADATA_READ_OPTIONS = { includeOutputs: false, maxOutputCharsPerItem: 1 };
31
33
 
@@ -35,6 +37,17 @@ function unavailableWaitRenderer(error) {
35
37
  /^(Codex app tool request failed\.?|The app tool call ended before producing a result\.?)$/.test(cause.message);
36
38
  }
37
39
 
40
+ function isFilesystemRoot(value) {
41
+ if (typeof value !== "string" || !path.isAbsolute(value)) return false;
42
+ const resolved = path.resolve(value);
43
+ return path.parse(resolved).root === resolved;
44
+ }
45
+
46
+ function acceptedCreationError(threadId, error) {
47
+ if (error.acceptedCreation) return error;
48
+ return Object.assign(new Error(`Codex Desktop created task ${threadId}, but its post-creation verification failed: ${error.message}. threadId: ${threadId}. Task was accepted; do not resend. Its reply was withheld until the verification error is resolved.`, { cause: error }), { code: "DESKTOP_CREATION_VERIFICATION_FAILED", threadId, acceptedCreation: true });
49
+ }
50
+
38
51
  function sameAccountContext(expected, current) {
39
52
  return Boolean(expected && current && ["claude", "codex"].every((provider) =>
40
53
  typeof expected[provider] === "string" && expected[provider] === current[provider]));
@@ -59,7 +72,7 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
59
72
  }
60
73
 
61
74
  export class DesktopTaskDelivery {
62
- constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext, senderContext, captureResponse = captureCodexRolloutWatermark, readResponse = readCodexNativeTurnResponse, inspectResponse = inspectCodexNativeTurn } = {}) {
75
+ constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext, senderContext, captureResponse = captureCodexRolloutWatermark, readResponse = readCodexNativeTurnResponse, inspectResponse = inspectCodexNativeTurn, resolveResponse = readResponse === readCodexNativeTurnResponse ? resolveCodexNativeDispatchResponse : null } = {}) {
63
76
  this.relay = relay;
64
77
  this.security = security;
65
78
  this.sleep = sleep;
@@ -71,9 +84,12 @@ export class DesktopTaskDelivery {
71
84
  this.captureResponse = captureResponse;
72
85
  this.readResponse = readResponse;
73
86
  this.inspectResponse = inspectResponse;
87
+ this.resolveResponse = resolveResponse;
74
88
  this.threadOperations = new Map();
75
89
  }
76
90
 
91
+ #unassignedSince = new Map();
92
+
77
93
  senderProjectScope(cwd) {
78
94
  if (!this.senderContext) return null;
79
95
  const sender = requireClaudeSenderContext(this.senderContext());
@@ -101,7 +117,7 @@ export class DesktopTaskDelivery {
101
117
  const projectScope = expectedScope ?? await this.mutationProjectScope(operation, args, { deadline });
102
118
  if (expectedScope) recheckProjectScope(expectedScope, this.senderProjectScope(expectedScope.recipient.input));
103
119
  const remaining = deadline === undefined ? undefined : deadline - this.now();
104
- if (remaining !== undefined && remaining <= 0) throw new Error("The bridge's response deadline has elapsed; this operation was not sent");
120
+ if (remaining !== undefined && remaining <= 0) throw Object.assign(new Error("The bridge's response deadline has elapsed; this operation was not sent"), { code: "DESKTOP_RESPONSE_DEADLINE" });
105
121
  const accountContext = this.accountContext?.();
106
122
  const response = await this.relay.requestDesktop(operation, args, {
107
123
  ...(remaining === undefined ? {} : { timeoutMs: remaining }),
@@ -328,6 +344,15 @@ export class DesktopTaskDelivery {
328
344
  async resolveCreation(delivered, { deadline = this.now() + DESKTOP_TOOL_BUDGET_MS } = {}) {
329
345
  const binding = delivered.creationObservation;
330
346
  if (!binding) return delivered;
347
+ let confirmedThreadId = null;
348
+ try {
349
+ return await this.#resolveCreationLoop(delivered, binding, deadline, (threadId) => { confirmedThreadId = threadId; });
350
+ } catch (error) {
351
+ throw confirmedThreadId ? acceptedCreationError(confirmedThreadId, error) : error;
352
+ }
353
+ }
354
+
355
+ async #resolveCreationLoop(delivered, binding, deadline, onConfirmed) {
331
356
  for (;;) {
332
357
  if (this.now() >= deadline) return null;
333
358
  this.security.assertCwd(delivered.cwd);
@@ -344,17 +369,29 @@ export class DesktopTaskDelivery {
344
369
  const response = row.result, threadId = response?.threadId;
345
370
  if (typeof threadId !== "string" || !threadId.trim() || response.hostId !== "local" || response.status === "outcome-unknown" ||
346
371
  response.firstTurn && response.firstTurn.status !== "accepted" || receipt.threadId && receipt.threadId !== threadId) throw new Error("Native creation is not confirmed; do not resend");
372
+ onConfirmed(threadId);
347
373
  let inspected;
348
- try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline }); }
349
- catch (error) { if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null; throw error; }
350
- if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
351
- if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
352
- this.security.assertCwd(delivered.cwd);
353
- this.senderProjectScope(delivered.cwd);
354
- if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
355
- if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
356
- this.security.registerThread(threadId);
357
- this.security.assertThread(threadId, inspected.thread.cwd);
374
+ try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline, tolerance: { since: receipt.startedAt } }); }
375
+ catch (error) {
376
+ if (error.code === WORKSPACE_UNASSIGNED) {
377
+ await this.sleep(Math.min(1000, Math.max(0, deadline - this.now())));
378
+ continue;
379
+ }
380
+ if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null;
381
+ throw acceptedCreationError(threadId, error);
382
+ }
383
+ try {
384
+ if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
385
+ if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
386
+ this.security.assertCwd(delivered.cwd);
387
+ this.senderProjectScope(delivered.cwd);
388
+ if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
389
+ if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
390
+ this.security.registerThread(threadId);
391
+ this.security.assertThread(threadId, inspected.thread.cwd);
392
+ } catch (error) {
393
+ throw acceptedCreationError(threadId, error);
394
+ }
358
395
  return { threadId, cwd: delivered.cwd, previousTurnId: null, deliveryStatus: "accepted",
359
396
  responseObservation: { operation: "create_thread", threadId, previousTurnId: null, expectedCwd: delivered.cwd,
360
397
  executorThreadId: envelope.executorThreadId, prompt: binding.args.prompt, accountContext: binding.accountContext } };
@@ -365,18 +402,18 @@ export class DesktopTaskDelivery {
365
402
  }
366
403
  }
367
404
 
368
- async inspect(threadId, cwd, { deadline } = {}) {
369
- const inspected = await this.#inspectMetadata(threadId, cwd, { deadline });
405
+ async inspect(threadId, cwd, { deadline, tolerateUnassigned = false } = {}) {
406
+ const inspected = await this.#inspectMetadata(threadId, cwd, { deadline, ...(tolerateUnassigned ? { tolerance: {} } : {}) });
370
407
  this.security.assertThread(threadId, inspected.thread.cwd);
371
408
  return inspected;
372
409
  }
373
410
 
374
- async #inspectMetadata(threadId, cwd, { deadline } = {}) {
411
+ async #inspectMetadata(threadId, cwd, { deadline, tolerance } = {}) {
375
412
  const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline, includeRelayContext: true });
376
413
  const response = envelope.result;
377
414
  const thread = response?.thread;
378
415
  if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
379
- this.security.assertCwd(thread.cwd);
416
+ this.#assertReportedWorkspace(threadId, thread.cwd, cwd, tolerance);
380
417
  const projectScope = this.senderProjectScope(thread.cwd);
381
418
  if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
382
419
  throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
@@ -385,6 +422,39 @@ export class DesktopTaskDelivery {
385
422
  return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope, scopeBinding, executorThreadId: envelope.executorThreadId ?? null };
386
423
  }
387
424
 
425
+ /**
426
+ * Codex Desktop can briefly report a filesystem root as a task's workspace
427
+ * before it finishes assigning the task's saved project (observed as "/"
428
+ * right after creation, following an account switch). Only observers that
429
+ * pass a tolerance - creation confirmation and reply observation, both of
430
+ * which already know the expected workspace - treat a root that differs from
431
+ * that workspace as "not yet assigned", and only for a bounded grace period
432
+ * measured from creation or from the first such report. During it the bridge
433
+ * acts on nothing at that path, grants no access and keeps observing. After
434
+ * it, and for every other caller or path, the normal fail-closed
435
+ * allowed-roots check applies.
436
+ */
437
+ #assertReportedWorkspace(threadId, reported, expected, tolerance) {
438
+ if (!isFilesystemRoot(reported)) this.#unassignedSince.delete(threadId);
439
+ else if (tolerance && expected && !this.#isExpectedWorkspace(reported, expected)) {
440
+ const now = this.now();
441
+ if (tolerance.since === undefined && !this.#unassignedSince.has(threadId)) this.#unassignedSince.set(threadId, now);
442
+ const since = tolerance.since ?? this.#unassignedSince.get(threadId);
443
+ if (now - since < UNASSIGNED_WORKSPACE_GRACE_MS) {
444
+ throw Object.assign(new Error(`Codex Desktop has not yet reported task ${threadId}'s project workspace (it currently reports ${reported}); the bridge did not act on that path.`), { code: WORKSPACE_UNASSIGNED, threadId });
445
+ }
446
+ }
447
+ this.security.assertCwd(reported);
448
+ }
449
+
450
+ #isExpectedWorkspace(reported, expected) {
451
+ try {
452
+ return path.relative(realpathSync.native(expected), realpathSync.native(reported)) === "";
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
388
458
  async send({ threadId, prompt, cwd, model, effort, name, deadline, onPrepared }) {
389
459
  const inspected = await this.inspect(threadId, cwd, { deadline });
390
460
  const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(inspected.thread.cwd) : undefined;
@@ -432,7 +502,7 @@ export class DesktopTaskDelivery {
432
502
  if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
433
503
  }
434
504
 
435
- async observeNativeResponse(threadId, turnId, responseObservation, { deadline } = {}) {
505
+ async observeNativeResponse(threadId, turnId, responseObservation, { deadline, resolveDispatch = false } = {}) {
436
506
  if (!responseObservation) return unavailableObservation("Codex Desktop completed the turn without exposing assistant response text or a pre-send observation binding.");
437
507
  const { expectedCwd } = responseObservation;
438
508
  const args = { threadId, hostId: "local", turnLimit: 1 };
@@ -444,13 +514,13 @@ export class DesktopTaskDelivery {
444
514
  if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its response was being observed");
445
515
  };
446
516
  await recheck();
447
- const initial = await this.inspect(threadId, expectedCwd, { deadline });
448
- if (initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
449
- const read = responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
517
+ const initial = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
518
+ if (!resolveDispatch && initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
519
+ const read = resolveDispatch ? this.resolveResponse : responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
450
520
  const observed = read({ ...responseObservation, threadId, turnId, desktopEvidence: initial });
451
521
  await recheck();
452
- const inspected = await this.inspect(threadId, expectedCwd, { deadline });
453
- if (inspected.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
522
+ const inspected = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
523
+ if (inspected.latestTurnId !== (resolveDispatch ? initial.latestTurnId : turnId)) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
454
524
  await recheck();
455
525
  return observed;
456
526
  }
@@ -484,13 +554,29 @@ export class DesktopTaskDelivery {
484
554
  const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
485
555
  const thread = result?.thread;
486
556
  if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the observed task's local workspace.");
487
- this.security.assertCwd(thread.cwd);
557
+ this.#assertReportedWorkspace(threadId, thread.cwd, responseObservation?.expectedCwd, {});
488
558
  this.security.assertThread(threadId, thread.cwd);
489
559
  this.senderProjectScope(thread.cwd);
490
560
  if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
491
561
  return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
492
562
  };
493
563
  const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
564
+ const deadlineError = (error) => {
565
+ const cause = error.cause ?? error;
566
+ return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
567
+ cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
568
+ };
569
+ const unassigned = Symbol("workspace-unassigned");
570
+ const observe = async (selectedTurnId, resolveDispatch = false) => {
571
+ try {
572
+ return await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
573
+ } catch (error) {
574
+ if (deadlineError(error)) return null;
575
+ if (error.code === WORKSPACE_UNASSIGNED) return unassigned;
576
+ throw error;
577
+ }
578
+ };
579
+ const pause = () => this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
494
580
  for (;;) {
495
581
  if (this.now() - startedAt >= timeoutMs) return expired();
496
582
  let response;
@@ -499,14 +585,20 @@ export class DesktopTaskDelivery {
499
585
  targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
500
586
  }, { deadline: startedAt + timeoutMs });
501
587
  } catch (err) {
502
- if (this.now() - startedAt >= timeoutMs) return expired();
588
+ if (deadlineError(err)) return expired();
589
+ if (err.code === WORKSPACE_UNASSIGNED) {
590
+ await pause();
591
+ continue;
592
+ }
503
593
  if (readSnapshots || !unavailableWaitRenderer(err)) throw err;
504
594
  readSnapshots = true;
505
595
  try {
506
596
  response = await readSnapshot();
507
597
  } catch (readError) {
508
- if (this.now() - startedAt >= timeoutMs) return expired();
509
- throw readError;
598
+ if (deadlineError(readError)) return expired();
599
+ if (readError.code !== WORKSPACE_UNASSIGNED) throw readError;
600
+ await pause();
601
+ continue;
510
602
  }
511
603
  }
512
604
  const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
@@ -514,15 +606,53 @@ export class DesktopTaskDelivery {
514
606
  cursor = poll.cursor;
515
607
  const turn = poll.latestTurn;
516
608
  const threadStatus = typeof poll.thread.status === "string" ? poll.thread.status : poll.thread.status?.type;
517
- if (["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus)) {
518
- return { threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt };
609
+ const needsAttention = ["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus);
610
+ const attention = () => ({ threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt });
611
+ if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
612
+ const observed = await observe(null, true);
613
+ if (!observed) return expired();
614
+ if (observed === unassigned) {
615
+ await pause();
616
+ continue;
617
+ }
618
+ turnId = observed.turnId ?? null;
619
+ if (observed.status !== "pending") {
620
+ return {
621
+ threadId, turnId, status: observed.status === "unavailable" ? "failed" : "completed",
622
+ responseStatus: observed.status, observationStatus: observed.status,
623
+ text: observed.status === "completed" ? observed.text : "",
624
+ ...(observed.reason ? { observationReason: observed.reason } : {}),
625
+ assistantItems: observed.assistantItems ?? [], replySha256: observed.replySha256 ?? null,
626
+ responseSource: observed.source ?? null,
627
+ ...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
628
+ activity: [], errors: [], durationMs: this.now() - startedAt,
629
+ };
630
+ }
631
+ if (needsAttention) return attention();
632
+ if (this.now() - startedAt >= timeoutMs) return expired();
633
+ await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
634
+ continue;
519
635
  }
520
- if (turn?.id && turn.id !== previousTurnId) {
521
- turnId = turn.id;
636
+ if (needsAttention) return attention();
637
+ const existingTurn = turn?.id === previousTurnId;
638
+ if (turn?.id && (!existingTurn || responseObservation?.previousTurnId === previousTurnId)) {
639
+ if (!existingTurn) turnId = turn.id;
522
640
  const status = turn.status;
523
641
  if (RELEASE_STATUSES.has(status)) {
524
- const observed = await this.observeNativeResponse(threadId, turnId, responseObservation, { deadline: startedAt + timeoutMs });
642
+ const observed = await observe(turn.id);
643
+ if (!observed) return expired();
644
+ if (observed === unassigned) {
645
+ await pause();
646
+ continue;
647
+ }
525
648
  const responseStatus = observed.status;
649
+ if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
650
+ turnId = null;
651
+ if (this.now() - startedAt >= timeoutMs) return expired();
652
+ await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
653
+ continue;
654
+ }
655
+ turnId = turn.id;
526
656
  return {
527
657
  threadId, turnId, status,
528
658
  text: responseStatus === "completed" ? observed.text : "",
@@ -532,6 +662,7 @@ export class DesktopTaskDelivery {
532
662
  assistantItems: observed.assistantItems ?? [],
533
663
  replySha256: observed.replySha256 ?? null,
534
664
  responseSource: observed.source ?? null,
665
+ ...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
535
666
  activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt,
536
667
  };
537
668
  }