@minhspark/codex-mcp-bridge 1.19.0 → 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,17 @@ 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
+
7
18
  ## [1.19.0] - 2026-09-29
8
19
 
9
20
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.19.0",
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.19.0";
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.19.0";
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;
@@ -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,7 @@ 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"]);
12
13
  const PROMPTING_POLICIES = new Set(["on-request", "on-failure", "untrusted"]);
13
14
  const GRANULAR_CATEGORIES = ["sandbox_approval", "rules", "mcp_elicitations"];
14
15
  const MANAGED_REVIEWERS = new Set(["user", "auto_review"]);
@@ -213,7 +214,9 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
213
214
  const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
214
215
  const { session, context, lifecycle } = state;
215
216
  if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
216
- 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`);
217
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");
218
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");
219
222
  const cwd = fs.realpathSync.native(context.cwd);
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.19.0";
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
 
@@ -538,6 +538,7 @@ registerTool(
538
538
  },
539
539
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
540
540
  const deadline = desktopTasksEnabled ? desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
541
+ let acceptedDelivery;
541
542
  return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
542
543
  const notes = [];
543
544
  const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
@@ -547,6 +548,7 @@ registerTool(
547
548
  const workspace = cwd ? resolveWorkspacePath(cwd) : null;
548
549
  if (workspace) security.assertCwd(workspace.path);
549
550
  const delivered = await desktopTasks.send({ threadId, prompt, cwd: workspace?.path, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, name, deadline });
551
+ acceptedDelivery = delivered;
550
552
  notes.push("sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
551
553
  if (shouldOpen) {
552
554
  try {
@@ -617,7 +619,16 @@ registerTool(
617
619
  } catch (err) {
618
620
  return failure(err);
619
621
  }
620
- }, { 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);
621
632
  },
622
633
  );
623
634
 
@@ -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.19.0";
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) {
@@ -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
  }