@minhspark/codex-mcp-bridge 1.19.0 → 1.19.2
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 +18 -0
- package/package.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/claude-sender-context.mjs +125 -8
- package/src/codex-native-response.mjs +8 -5
- package/src/codex-sender-context.mjs +4 -1
- package/src/index.mjs +22 -3
- package/src/mcp-supervisor.mjs +21 -7
- package/src/native-relay-companion.mjs +1 -1
- package/src/release-snapshot.mjs +94 -12
- package/src/thread-delivery.mjs +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.19.2] - 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Start both bridges from project-local npm installations. When npm hoists the bridge's dependencies into the project's `node_modules`, the package has no `node_modules` of its own, and preparing the immutable runtime failed with `ENOENT` (`scandir …node_modules`) since 1.15.0; only global installs, which keep a nested `node_modules`, started. The runtime now copies the dependency tree Node resolves from the package (hoisted, nested, scoped, aliased, linked and peer dependencies) and nothing else from the project, so it stays isolated from later installation changes. Its revision also covers the project's npm lock, so an `npm install` that changes those dependencies reloads the bridge.
|
|
12
|
+
- Stop the Codex bridge cleanly when its MCP client disconnects. The bridge ends the Windows PowerShell caller checks it started and waits for them to close, and the supervisor gives its worker up to 6 seconds to exit before stopping it. Before, the supervisor stopped the worker after 1 second without waiting, which left those checks running with the bridge's working directory in use. On Windows, deleting that directory right after a test closed its bridge then failed with `EPERM`; the tests no longer retry that deletion.
|
|
13
|
+
|
|
14
|
+
## [1.19.1] - 2026-09-29
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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).
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
7
25
|
## [1.19.0] - 2026-09-29
|
|
8
26
|
|
|
9
27
|
### Added
|
package/package.json
CHANGED
|
@@ -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.
|
|
25
|
+
const VERSION = "1.19.2";
|
|
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"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
29
|
+
const VERSION = "1.19.2";
|
|
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,42 @@ 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
|
+
const activeInspectors = new Set();
|
|
11
|
+
let inspectorsStopping = false;
|
|
12
|
+
|
|
13
|
+
export async function stopProcessInspectors() {
|
|
14
|
+
inspectorsStopping = true;
|
|
15
|
+
await Promise.all([...activeInspectors].map((inspector) => inspector.stop()));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The five-second deadline is what keeps the caller check fail-closed: a read
|
|
20
|
+
* that cannot answer in time leaves the caller unverified. On Windows the read
|
|
21
|
+
* runs in a fresh Windows PowerShell that must first compile its Toolhelp
|
|
22
|
+
* helper, and the deadline used to cover that startup too. Starting is the
|
|
23
|
+
* machine's cost, not the read's: CI refused callers with INSPECTION_TIMEOUT
|
|
24
|
+
* both before the script had begun (5,036ms) and while the helper was still
|
|
25
|
+
* compiling (5,017ms), a cold start here has been measured between 5s and 15s,
|
|
26
|
+
* and the read itself takes milliseconds once the helper exists.
|
|
27
|
+
*
|
|
28
|
+
* The helper therefore reports on stderr when it is ready, and the five-second
|
|
29
|
+
* deadline starts there. Getting ready has its own ceiling, so a whole read
|
|
30
|
+
* still ends within the 30s the bridge gives its other PowerShell identity
|
|
31
|
+
* reads (peer-protocol.mjs). Either expiry kills the process and refuses the
|
|
32
|
+
* caller; nothing is read before the helper is ready, so the wait adds no
|
|
33
|
+
* staleness.
|
|
34
|
+
*/
|
|
35
|
+
const INSPECTION_TIMEOUT_MS = 5000;
|
|
36
|
+
const INSPECTOR_START_TIMEOUT_MS = 25000;
|
|
37
|
+
const INSPECTOR_READY_MARKER = "BRIDGE_ANCESTRY_READY";
|
|
9
38
|
const WINDOWS_ANCESTRY_SOURCE = `
|
|
10
39
|
using System;
|
|
11
40
|
using System.Collections.Generic;
|
|
12
41
|
using System.ComponentModel;
|
|
13
42
|
using System.Globalization;
|
|
14
43
|
using System.Runtime.InteropServices;
|
|
44
|
+
using System.Text;
|
|
15
45
|
|
|
16
46
|
public static class ClaudeProcessAncestry {
|
|
17
47
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
@@ -83,6 +113,17 @@ public static class ClaudeProcessAncestry {
|
|
|
83
113
|
}
|
|
84
114
|
return rows.ToArray();
|
|
85
115
|
}
|
|
116
|
+
|
|
117
|
+
public static string ReadJson(uint firstPid, int limit) {
|
|
118
|
+
var json = new StringBuilder("[");
|
|
119
|
+
foreach (var row in Read(firstPid, limit)) {
|
|
120
|
+
if (json.Length > 1) json.Append(',');
|
|
121
|
+
json.Append(@"{""pid"":").Append(row.pid.ToString(CultureInfo.InvariantCulture))
|
|
122
|
+
.Append(@",""parentPid"":").Append(row.parentPid.ToString(CultureInfo.InvariantCulture))
|
|
123
|
+
.Append(@",""processStart"":""").Append(row.processStart).Append(@"""}");
|
|
124
|
+
}
|
|
125
|
+
return json.Append(']').ToString();
|
|
126
|
+
}
|
|
86
127
|
}`;
|
|
87
128
|
|
|
88
129
|
function parseWindowsAncestry(stdout, parentPid, limit) {
|
|
@@ -105,26 +146,101 @@ function parseWindowsAncestry(stdout, parentPid, limit) {
|
|
|
105
146
|
return rows;
|
|
106
147
|
}
|
|
107
148
|
|
|
108
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Runs a process the way execFile does - the same stdout and stderr strings,
|
|
151
|
+
* maxBuffer limit, and exit-code and spawn errors - except that `timeout`
|
|
152
|
+
* starts once `readyMarker` appears on stderr, and `startupTimeout` bounds the
|
|
153
|
+
* time before it. An expiry kills the process and rejects with ETIMEDOUT and
|
|
154
|
+
* the phase that ran out. The result also carries the pid, readyAfterMs and
|
|
155
|
+
* elapsedMs so tests can report where the time went.
|
|
156
|
+
*/
|
|
157
|
+
export function runProcessInspector(command, args, { timeout, startupTimeout, readyMarker, maxBuffer = Infinity, windowsHide = true } = {}) {
|
|
158
|
+
if (inspectorsStopping) return Promise.reject(Object.assign(new Error("Process inspection is shutting down"), { code: "ABORT_ERR" }));
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
const started = performance.now();
|
|
161
|
+
const child = spawn(command, args, { windowsHide, stdio: ["ignore", "pipe", "pipe"] });
|
|
162
|
+
const output = { stdout: [], stderr: [] };
|
|
163
|
+
const size = { stdout: 0, stderr: 0 };
|
|
164
|
+
let readyAfterMs = null;
|
|
165
|
+
let settled = false;
|
|
166
|
+
let timer;
|
|
167
|
+
let failure;
|
|
168
|
+
let killTimer;
|
|
169
|
+
let closeTimer;
|
|
170
|
+
let resolveClosed;
|
|
171
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
172
|
+
const inspector = { stop: () => {
|
|
173
|
+
fail(Object.assign(new Error("Process inspection cancelled during shutdown"), { code: "ABORT_ERR" }));
|
|
174
|
+
return closed.then((error) => { if (error) throw error; });
|
|
175
|
+
} };
|
|
176
|
+
activeInspectors.add(inspector);
|
|
177
|
+
const text = (stream) => Buffer.concat(output[stream]).toString("utf8");
|
|
178
|
+
const settle = (error) => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
clearTimeout(killTimer);
|
|
183
|
+
clearTimeout(closeTimer);
|
|
184
|
+
activeInspectors.delete(inspector);
|
|
185
|
+
resolveClosed(error?.cleanupFailed ? error : null);
|
|
186
|
+
const result = { stdout: text("stdout"), stderr: text("stderr"), pid: child.pid, readyAfterMs, elapsedMs: Math.round(performance.now() - started) };
|
|
187
|
+
if (error) reject(Object.assign(error, result));
|
|
188
|
+
else resolve(result);
|
|
189
|
+
};
|
|
190
|
+
const fail = (error) => {
|
|
191
|
+
if (settled || failure) return;
|
|
192
|
+
failure = error;
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
closeTimer = setTimeout(() => settle(Object.assign(failure, { cleanupFailed: true, message: `${failure.message}; helper ${child.pid} did not close within 5000 ms after termination` })), 5000);
|
|
195
|
+
if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
|
|
196
|
+
child.kill();
|
|
197
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), 250);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const arm = (phase, ms) => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
timer = setTimeout(() => fail(Object.assign(new Error(`Process inspection ${phase} deadline elapsed`), { code: "ETIMEDOUT", phase, killed: true, signal: "SIGTERM" })), ms);
|
|
203
|
+
};
|
|
204
|
+
if (readyMarker) arm("startup", startupTimeout);
|
|
205
|
+
else arm("inspection", timeout);
|
|
206
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
207
|
+
child[stream].on("data", (chunk) => {
|
|
208
|
+
if (settled || failure) return;
|
|
209
|
+
output[stream].push(chunk);
|
|
210
|
+
size[stream] += chunk.length;
|
|
211
|
+
if (size[stream] > maxBuffer) return fail(Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
|
|
212
|
+
if (stream === "stderr" && readyMarker && readyAfterMs === null && text("stderr").includes(readyMarker)) {
|
|
213
|
+
readyAfterMs = Math.round(performance.now() - started);
|
|
214
|
+
arm("inspection", timeout);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
child.on("error", fail);
|
|
219
|
+
child.on("close", (code, signal) => settle(failure ?? (code === 0 ? null : Object.assign(new Error(`Process inspection exited with ${signal ?? code}`), { code, signal }))));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function readProcessAncestry({ parentPid = process.ppid, platform = process.platform, maxDepth = MAX_ANCESTORS, run } = {}) {
|
|
109
224
|
if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || parentPid > 0xffffffff) return [];
|
|
110
225
|
const limit = Number.isInteger(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, MAX_ANCESTORS) : MAX_ANCESTORS;
|
|
111
|
-
const options = { timeout: 5000, maxBuffer: 16384, windowsHide: true };
|
|
112
226
|
if (platform === "win32") {
|
|
113
227
|
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'@\
|
|
115
|
-
const { stdout } = await run(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")],
|
|
228
|
+
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}))`;
|
|
229
|
+
const { stdout } = await (run ?? runProcessInspector)(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
|
|
230
|
+
timeout: INSPECTION_TIMEOUT_MS, startupTimeout: INSPECTOR_START_TIMEOUT_MS, readyMarker: INSPECTOR_READY_MARKER, maxBuffer: INSPECTION_OUTPUT_LIMIT, windowsHide: true,
|
|
231
|
+
});
|
|
116
232
|
return parseWindowsAncestry(stdout, parentPid, limit);
|
|
117
233
|
}
|
|
118
234
|
const rows = [];
|
|
119
235
|
const seen = new Set();
|
|
120
236
|
let next = parentPid;
|
|
121
|
-
const deadline = Date.now() +
|
|
237
|
+
const deadline = Date.now() + INSPECTION_TIMEOUT_MS;
|
|
122
238
|
while (next > 0 && !seen.has(next) && rows.length < limit) {
|
|
123
239
|
seen.add(next);
|
|
124
240
|
const remaining = deadline - Date.now();
|
|
125
241
|
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
|
-
|
|
242
|
+
const { stdout } = await (run ?? execute)("/bin/ps", ["-p", String(next), "-o", "pid=,ppid=,lstart="], {
|
|
243
|
+
timeout: remaining, maxBuffer: INSPECTION_OUTPUT_LIMIT, windowsHide: true, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
128
244
|
});
|
|
129
245
|
const match = stdout.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
130
246
|
if (!match) break;
|
|
@@ -140,6 +256,7 @@ function senderResult(status, reason, values = {}) {
|
|
|
140
256
|
}
|
|
141
257
|
|
|
142
258
|
function senderFailureCode(error) {
|
|
259
|
+
if (error?.code === "ETIMEDOUT" && error.phase === "startup") return "INSPECTOR_START_TIMEOUT";
|
|
143
260
|
if (error?.code === "ETIMEDOUT" || (error?.killed && ["SIGTERM", "SIGKILL"].includes(error.signal))) return "INSPECTION_TIMEOUT";
|
|
144
261
|
if (error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "OUTPUT_LIMIT";
|
|
145
262
|
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
|
|
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
|
|
163
|
-
|
|
163
|
+
function delegationOutputs(executorThreadId, prompt) {
|
|
164
|
+
const escaped = prompt.replace(/[&<>]/g, (character) => ({ "&": "&", "<": "<", ">": ">" })[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
|
|
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: {
|
|
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
|
-
|
|
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
|
@@ -29,12 +29,12 @@ import { exitForVersionRequest } from "./cli-version.mjs";
|
|
|
29
29
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
30
30
|
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
31
31
|
import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
|
|
32
|
-
import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
32
|
+
import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext, stopProcessInspectors } from "./claude-sender-context.mjs";
|
|
33
33
|
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.
|
|
37
|
+
const VERSION = "1.19.2";
|
|
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
|
-
}, {
|
|
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
|
|
|
@@ -1000,5 +1011,13 @@ registerTool(
|
|
|
1000
1011
|
|
|
1001
1012
|
const transport = new StdioServerTransport();
|
|
1002
1013
|
await server.connect(transport);
|
|
1014
|
+
let shutdownPromise;
|
|
1015
|
+
const shutdown = () => shutdownPromise ??= (async () => {
|
|
1016
|
+
client?.close();
|
|
1017
|
+
await server.close();
|
|
1018
|
+
await stopProcessInspectors();
|
|
1019
|
+
})();
|
|
1020
|
+
process.stdin.once("end", () => { void shutdown().catch((error) => log(`shutdown failed: ${error.message}`)); });
|
|
1021
|
+
process.once("disconnect", () => { void shutdown().catch((error) => log(`shutdown failed: ${error.message}`)); });
|
|
1003
1022
|
reload.listen();
|
|
1004
1023
|
log(desktopTasksEnabled ? `ready on ${PLATFORM_LABEL} (Codex Desktop only; external app-server disabled)` : `ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -44,6 +44,7 @@ class Worker {
|
|
|
44
44
|
env: { ...env, CODEX_BRIDGE_WORKER: "1", CODEX_BRIDGE_STAGED: "1" },
|
|
45
45
|
stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
|
|
46
46
|
});
|
|
47
|
+
this.exited = new Promise((resolve) => this.process.once("exit", resolve));
|
|
47
48
|
this.process.stderr.on("data", (data) => process.stderr.write(data));
|
|
48
49
|
this.process.stdin.on("error", (error) => this.fail(error));
|
|
49
50
|
this.process.on("error", (error) => this.fail(error));
|
|
@@ -110,13 +111,23 @@ class Worker {
|
|
|
110
111
|
|
|
111
112
|
async stop() {
|
|
112
113
|
this.retiring = true;
|
|
113
|
-
if (this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
if (this.stopping) return this.stopping;
|
|
115
|
+
this.stopping = (async () => {
|
|
116
|
+
let timer;
|
|
117
|
+
const wait = async (ms) => {
|
|
118
|
+
try {
|
|
119
|
+
return await Promise.race([this.exited.then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })]);
|
|
120
|
+
} finally { clearTimeout(timer); }
|
|
121
|
+
};
|
|
117
122
|
this.process.stdin.end();
|
|
118
123
|
if (this.process.connected) this.process.disconnect();
|
|
119
|
-
|
|
124
|
+
if (await wait(6000)) return;
|
|
125
|
+
if (this.process.exitCode === null && this.process.signalCode === null) {
|
|
126
|
+
this.process.kill("SIGKILL");
|
|
127
|
+
}
|
|
128
|
+
if (!await wait(5000)) throw new Error(`Bridge worker ${this.process.pid} did not close after termination`);
|
|
129
|
+
})();
|
|
130
|
+
return this.stopping;
|
|
120
131
|
}
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -248,13 +259,16 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
248
259
|
reloads++;
|
|
249
260
|
reason = null;
|
|
250
261
|
if (JSON.stringify(catalog) !== JSON.stringify(oldCatalog)) output({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
251
|
-
await previous.stop();
|
|
262
|
+
await previous.stop().catch((error) => log(`retired ${entry} worker: ${error.message}`));
|
|
252
263
|
workers.delete(previous);
|
|
253
264
|
log(`activated ${entry} revision ${revision.slice(0, 12)}; client connection retained`);
|
|
254
265
|
} catch (error) {
|
|
255
266
|
reason = `Update deferred: ${error.message}`;
|
|
256
267
|
retryAt = Date.now() + Math.max(pollMs, 5000);
|
|
257
|
-
if (candidate) {
|
|
268
|
+
if (candidate) {
|
|
269
|
+
await candidate.stop().catch((stopError) => log(`rejected ${entry} candidate: ${stopError.message}`));
|
|
270
|
+
workers.delete(candidate);
|
|
271
|
+
}
|
|
258
272
|
if (quiesced && active === previous && !previous.closed) {
|
|
259
273
|
try { await previous.control("resume"); }
|
|
260
274
|
catch (resumeError) { failure = `Previous runtime could not resume: ${resumeError.message}`; }
|
|
@@ -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.
|
|
37
|
+
const VERSION = "1.19.2";
|
|
38
38
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
39
39
|
|
|
40
40
|
function errorResponse(code, message, sent) {
|
package/src/release-snapshot.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } 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 { createRequire } from "node:module";
|
|
5
6
|
|
|
6
7
|
function digestFiles(root, files) {
|
|
7
8
|
const hash = createHash("sha256");
|
|
@@ -49,14 +50,14 @@ function digestMetadata(cache) {
|
|
|
49
50
|
}
|
|
50
51
|
};
|
|
51
52
|
return {
|
|
52
|
-
digest(directory, files) {
|
|
53
|
+
digest(directory, files, sources) {
|
|
53
54
|
const previous = directories.get(directory) ?? load(directory);
|
|
54
55
|
const records = new Map();
|
|
55
56
|
const hash = createHash("sha256");
|
|
56
57
|
if (previous.size !== files.length) dirty.add(directory);
|
|
57
58
|
for (const file of files) {
|
|
58
59
|
if (!safeRelativePath(file)) throw new Error(`Unsafe release file: ${file}`);
|
|
59
|
-
const fullPath = path.join(directory, file);
|
|
60
|
+
const fullPath = sources?.get(file) ?? path.join(directory, file);
|
|
60
61
|
const before = fs.statSync(fullPath, { bigint: true });
|
|
61
62
|
if (!before.isFile()) throw new Error(`Unsupported release file: ${file}`);
|
|
62
63
|
const current = signature(before);
|
|
@@ -105,25 +106,101 @@ function digestMetadata(cache) {
|
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
function inventory(root, prefix = "") {
|
|
109
|
+
function inventory(root, prefix = "", omitModules = false) {
|
|
109
110
|
const files = [];
|
|
110
111
|
for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
111
112
|
if (entry.name === ".bin") continue;
|
|
113
|
+
if (omitModules && !prefix && entry.name === "node_modules") continue;
|
|
112
114
|
const relative = path.join(prefix, entry.name);
|
|
113
115
|
const stat = entry.isSymbolicLink() ? fs.statSync(path.join(root, relative)) : entry;
|
|
114
|
-
if (stat.isDirectory()) files.push(...inventory(root, relative));
|
|
116
|
+
if (stat.isDirectory()) files.push(...inventory(root, relative, omitModules));
|
|
115
117
|
else if (stat.isFile()) files.push(relative);
|
|
116
118
|
else throw new Error(`Unsupported release file: ${relative}`);
|
|
117
119
|
}
|
|
118
120
|
return files;
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
const INSTALLATION_LOCKS = ".bridge-installation-locks.json";
|
|
124
|
+
const dependencySearchPaths = (root, name) => createRequire(path.join(root, "package.json")).resolve.paths(name) ?? [];
|
|
125
|
+
|
|
126
|
+
function installationLocks(root) {
|
|
127
|
+
const saved = path.join(root, INSTALLATION_LOCKS);
|
|
128
|
+
if (fs.existsSync(saved)) return fs.readFileSync(saved, "utf8");
|
|
129
|
+
const local = path.join(root, "node_modules");
|
|
130
|
+
const hashes = [];
|
|
131
|
+
for (const directory of dependencySearchPaths(root, "bridge-dependency-lock")) {
|
|
132
|
+
if (directory === local) continue;
|
|
133
|
+
const lock = path.join(directory, ".package-lock.json");
|
|
134
|
+
if (fs.existsSync(lock)) hashes.push(createHash("sha256").update(fs.readFileSync(lock)).digest("hex"));
|
|
135
|
+
}
|
|
136
|
+
return JSON.stringify(hashes);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dependencyFiles(root) {
|
|
140
|
+
const files = new Map();
|
|
141
|
+
if (fs.existsSync(path.join(root, "node_modules"))) {
|
|
142
|
+
for (const file of inventory(root, "node_modules")) files.set(file, path.join(root, file));
|
|
143
|
+
}
|
|
144
|
+
const queue = [{ source: root, destination: "" }];
|
|
145
|
+
const visited = new Set();
|
|
146
|
+
const packageSources = new Map();
|
|
147
|
+
const packageAt = (directory) => {
|
|
148
|
+
if (packageSources.has(directory)) return packageSources.get(directory);
|
|
149
|
+
const manifest = files.get(path.join(directory, "package.json"));
|
|
150
|
+
if (!manifest) return null;
|
|
151
|
+
const source = fs.realpathSync.native(path.dirname(manifest));
|
|
152
|
+
packageSources.set(directory, source);
|
|
153
|
+
return source;
|
|
154
|
+
};
|
|
155
|
+
for (let index = 0; index < queue.length; index++) {
|
|
156
|
+
const { source, destination } = queue[index];
|
|
157
|
+
if (visited.has(destination)) continue;
|
|
158
|
+
visited.add(destination);
|
|
159
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
|
|
160
|
+
const names = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]);
|
|
161
|
+
for (const name of [...names].sort()) {
|
|
162
|
+
if (!/^(?:@[a-zA-Z0-9._~-]+\/)?[a-zA-Z0-9._~-]+$/.test(name) || name === "." || name === "..") throw new Error(`Unsafe dependency name: ${name}`);
|
|
163
|
+
let resolved;
|
|
164
|
+
for (const modules of dependencySearchPaths(source, name)) {
|
|
165
|
+
const candidate = path.join(modules, name);
|
|
166
|
+
if (fs.existsSync(path.join(candidate, "package.json"))) { resolved = fs.realpathSync.native(candidate); break; }
|
|
167
|
+
}
|
|
168
|
+
if (!resolved) {
|
|
169
|
+
if (Object.hasOwn(manifest.optionalDependencies ?? {}, name) || manifest.peerDependenciesMeta?.[name]?.optional) continue;
|
|
170
|
+
throw new Error(`Cannot resolve required dependency ${name} from ${source}`);
|
|
171
|
+
}
|
|
172
|
+
let placement;
|
|
173
|
+
let current = destination;
|
|
174
|
+
while (true) {
|
|
175
|
+
if (path.basename(current) !== "node_modules") {
|
|
176
|
+
const candidate = path.join(current, "node_modules", name);
|
|
177
|
+
const installed = packageAt(candidate);
|
|
178
|
+
if (installed) { if (installed === resolved) placement = candidate; break; }
|
|
179
|
+
}
|
|
180
|
+
if (!current) break;
|
|
181
|
+
const parent = path.dirname(current);
|
|
182
|
+
current = parent === "." ? "" : parent;
|
|
183
|
+
}
|
|
184
|
+
if (!placement) {
|
|
185
|
+
const hoisted = path.join("node_modules", name);
|
|
186
|
+
placement = packageAt(hoisted) ? path.join(destination, "node_modules", name) : hoisted;
|
|
187
|
+
const occupied = packageAt(placement);
|
|
188
|
+
if (occupied && occupied !== resolved) throw new Error(`Conflicting dependency resolution for ${name}`);
|
|
189
|
+
for (const file of inventory(resolved, "", true)) files.set(path.join(placement, file), path.join(resolved, file));
|
|
190
|
+
packageSources.set(placement, resolved);
|
|
191
|
+
}
|
|
192
|
+
queue.push({ source: resolved, destination: placement });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return new Map([...files].sort(([left], [right]) => left.localeCompare(right, "en")));
|
|
196
|
+
}
|
|
197
|
+
|
|
121
198
|
export function sourceRevision(root) {
|
|
122
199
|
const files = ["package.json", ...inventory(root, "src")];
|
|
123
200
|
for (const file of ["package-lock.json", "node_modules/.package-lock.json"]) {
|
|
124
201
|
if (fs.existsSync(path.join(root, file))) files.push(file);
|
|
125
202
|
}
|
|
126
|
-
return digestFiles(root, files);
|
|
203
|
+
return createHash("sha256").update(digestFiles(root, files)).update(installationLocks(root)).digest("hex");
|
|
127
204
|
}
|
|
128
205
|
|
|
129
206
|
export function snapshotRoot(env = process.env) {
|
|
@@ -153,31 +230,36 @@ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRe
|
|
|
153
230
|
if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
|
|
154
231
|
cache = fs.realpathSync.native(cache);
|
|
155
232
|
const metadata = digestMetadata(cache);
|
|
156
|
-
const dependencies =
|
|
157
|
-
const dependencyRevision = metadata.digest(root, dependencies);
|
|
233
|
+
const dependencies = dependencyFiles(root);
|
|
234
|
+
const dependencyRevision = metadata.digest(root, [...dependencies.keys()], dependencies);
|
|
158
235
|
const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
|
|
159
236
|
const target = path.join(cache, key);
|
|
160
237
|
const validate = (directory) => {
|
|
161
238
|
if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
|
|
162
|
-
return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules")) === dependencyRevision;
|
|
239
|
+
return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules").sort((left, right) => left.localeCompare(right, "en"))) === dependencyRevision;
|
|
163
240
|
};
|
|
164
241
|
if (fs.existsSync(target)) {
|
|
165
242
|
if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
|
|
166
|
-
|
|
243
|
+
const currentDependencies = dependencyFiles(root);
|
|
244
|
+
if (sourceRevision(root) !== expectedRevision || metadata.digest(root, [...currentDependencies.keys()], currentDependencies) !== dependencyRevision) {
|
|
245
|
+
throw new Error("The installation changed during snapshot verification");
|
|
246
|
+
}
|
|
167
247
|
metadata.flush();
|
|
168
248
|
return { directory: target, revision: expectedRevision, key };
|
|
169
249
|
}
|
|
170
250
|
const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
|
|
171
251
|
try {
|
|
172
252
|
fs.mkdirSync(temporary, { mode: 0o700 });
|
|
173
|
-
const files = ["package.json", ...inventory(root, "src"), ...dependencies];
|
|
253
|
+
const files = ["package.json", ...inventory(root, "src"), ...dependencies.keys()];
|
|
174
254
|
if (fs.existsSync(path.join(root, "package-lock.json"))) files.push("package-lock.json");
|
|
175
255
|
const directories = new Set(["src", "node_modules", ...files.map((file) => path.dirname(file))]);
|
|
176
256
|
for (const directory of directories) fs.mkdirSync(path.join(temporary, directory), { recursive: true, mode: 0o700 });
|
|
177
257
|
for (const file of files) {
|
|
178
|
-
fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
|
|
258
|
+
fs.copyFileSync(dependencies.get(file) ?? path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
|
|
179
259
|
}
|
|
180
|
-
|
|
260
|
+
fs.writeFileSync(path.join(temporary, INSTALLATION_LOCKS), installationLocks(root), { flag: "wx", mode: 0o600 });
|
|
261
|
+
const currentDependencies = dependencyFiles(root);
|
|
262
|
+
if (sourceRevision(root) !== expectedRevision || metadata.digest(root, [...currentDependencies.keys()], currentDependencies) !== dependencyRevision || !validate(temporary)) {
|
|
181
263
|
throw new Error("The installation changed while its immutable runtime was being prepared");
|
|
182
264
|
}
|
|
183
265
|
try {
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -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
|
}
|