@minhspark/codex-mcp-bridge 1.15.0 → 1.15.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
@@ -2,6 +2,20 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [1.15.1] - 2026-09-14
8
+
9
+ ### Fixed
10
+
11
+ - Allow bounded cold startup time for the Windows snapshot identity test on slower CI hosts while preserving the production five-second inspection deadline and the real MCP caller check.
12
+ - Discover the native tools pipe on current Windows Codex Desktop builds that load app tools through a plugin instead of an inline app-server override. Validate the Desktop process ancestry and pipe owner before connecting, so the relay can start without a pinned pipe address.
13
+ - Keep native relay reloads usable when another companion takes over a shared listener during the handoff, while still rejecting a missing endpoint.
14
+ - Migrate legacy Desktop installations explicitly with `install-claude-desktop.mjs --desktop-tasks`, disable external app-server autostart, and leave automatic routing unpinned so a later relay installation can take effect.
15
+ - Reload effective shared routing changes on the existing MCP connection when safely idle. Allow legacy-to-Desktop upgrades while preserving confirmed receipts without replay; reject automatic downgrades to the external app-server. Clarify that open Desktop tasks are valid native destinations.
16
+ - Reuse validated dependency hashes for unchanged files when preparing cached runtimes, avoiding repeated content reads that can delay MCP initialization during Windows startup. Changed source dependencies and modified cached copies still require verification; missing or invalid hash metadata falls back to reading file contents.
17
+ - Log runtime preparation start and elapsed time before the MCP worker launches, so startup delays can be distinguished from worker initialization failures.
18
+
5
19
  ## [1.15.0] - 2026-09-07
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -32,6 +32,8 @@ Reusing a receipt also verifies that the saved project still has the same identi
32
32
 
33
33
  The updated companion exposes a separate `-desktop-tasks` endpoint so a previous companion holding the legacy reply socket need not be killed during an upgrade. `codex_bridge_status` verifies the native connection through Desktop's local project list; `native_relay_status` reports both endpoints. Reconnect the companion after upgrading so it accepts the current native operations.
34
34
 
35
+ On Windows, the companion supports both legacy inline app-server configuration and current Codex Desktop plugin startup. When no native pipe is inherited, discovery checks the companion's Desktop process ancestry and the named pipe's server process, then uses a read-only project-list probe to distinguish app tools from browser-only pipes. A running companion alone does not establish readiness: verify that `native_relay_status` reports its account relay listening and that `codex_bridge_status` can read Desktop projects.
36
+
35
37
  Desktop mode never contacts, starts, or falls back to an external app-server, including during status checks and thread discovery. The Claude Desktop installer records `CODEX_BRIDGE_AUTOSTART=0` in this mode. If Desktop or its companion is unavailable, the bridge reports the failure and leaves existing tasks in Desktop. `stop_codex_app_server` is a no-op in Desktop mode; stop an obsolete external service through its own launcher after confirming it has no active work.
36
38
 
37
39
  `list_codex_threads` filters Desktop's recent/pinned task snapshot by authorization, workspace/title, and result limit. The snapshot can omit agent-created tasks visible in the sidebar and does not cover the complete archive. Absence from this list does not mean creation failed: read the returned task ID directly. Desktop does not expose the external server's `loadedOnly` state; requesting it returns an explanation without contacting that server.
@@ -142,12 +144,18 @@ Install the new files with `npm install -g @minhspark/codex-mcp-bridge@latest`.
142
144
 
143
145
  After this one-time migration, the stable supervisor keeps the client's MCP stdio connection open while it starts each worker from an immutable source snapshot. The installers prepare this snapshot before changing the registration, avoiding a slow first copy during MCP initialization. Compatible installed-source changes become candidates after the tree stabilizes. A candidate must initialize successfully before it can replace an idle worker. Active calls, pending deliveries, late replies, and relay work defer the switch; an update never silently resends a prompt. A failed candidate leaves the existing worker and its delivery state available.
144
146
 
147
+ Snapshot verification reuses previously calculated dependency hashes when file identity, size, modification time, and change time still match. Changed files are read and hashed again; directory inventories still detect additions and deletions. Missing or invalid hash metadata falls back to reading file contents. This reduces repeated disk reads during simultaneous MCP startup, especially just after Windows boots. Supervisor stderr logs show when runtime preparation starts and how long it takes.
148
+
145
149
  Call `codex_bridge_status`, `claude_bridge_status`, or `native_relay_status` from the actual app task to inspect `autoReload`, the loaded version, and any pending update or blocker. Reading an old message receipt still refers to the worker that accepted that message. Pending or uncertain messages can keep an update deferred until they are resolved; a new process or an unknown message ID is never evidence that a send failed. If the supervisor protocol itself changes incompatibly, reconnect that MCP server once again.
146
150
 
147
151
  Auto-reload watches the **installed source at the configured path**. It does not poll npm, download a release, run `git pull`, or upgrade Claude/Codex themselves. Keep the normal package update mechanism to install new versions. Changes to environment variables or access settings supplied by the MCP client require a client MCP reload to pass the new configuration to the supervisor. Subsequent account switches remain detectable per call without this configuration reload.
148
152
 
149
153
  The installers preserve existing environment and access settings; explicit supported environment variables override their stored values. Claude's `--reset` explicitly returns to defaults. Codex entries with custom access, timeout, or transport settings are left intact with the exact command and arguments to update in place. `CLAUDE_DESKTOP_USER_DATA` pins only the data directory, never an account identity. Re-running an installer is unnecessary for later compatible source updates. The global install path remains stable across package versions.
150
154
 
155
+ To migrate a legacy Claude registration to native Desktop task delivery, run `codex-mcp-bridge-install --desktop-tasks` (from a checkout: `node scripts/install-claude-desktop.mjs --desktop-tasks`). This explicit flag overrides a stored or inherited `CODEX_BRIDGE_DESKTOP_TASKS=0` and disables external app-server autostart while preserving access settings. Without the flag or an explicit routing setting, the installer leaves routing automatic so a later native relay installation can enable Desktop tasks. Existing explicit `0` settings remain unchanged until the flag or an environment override is supplied.
156
+
157
+ Reconnect `codex-bridge` in the existing Claude task after changing its registration; source auto-reload cannot replace environment variables inherited by a running MCP process. If the task has a separate Claude Code MCP registration, update that registration too. Verify `codex_bridge_status` from the actual sending task reports Desktop tasks enabled, the native relay available, and a current runtime. Keep the destination open in Codex Desktop. An earlier unconfirmed send must be inspected before sending again.
158
+
151
159
  #### Show the final npm installation result
152
160
 
153
161
  Enable the optional shell integration once after installing the package:
@@ -510,7 +518,9 @@ Claude
510
518
 
511
519
  Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Never a guess: an invented executor fails inside Codex with a message that says nothing about the configuration that actually caused it. The recorded thread works as an executor even if it has never been opened in the app.
512
520
 
513
- **It is a backend, not a replacement.** `claude-bridge` picks between two delivery backends and reports which one it used; the tools, the peer protocol, the routing, the rate limits and `CodexAppServerClient` are untouched. The native path is used only when all of these hold — otherwise the app-server path runs exactly as it did before:
521
+ **Desktop-only delivery.** With Desktop tasks enabled, both `send_to_codex_thread` and correlated Claude replies use the native relay. Desktop keeps its writer lock throughout delivery. If the relay is unavailable, the bridge reports that failure and does not start or use an external app-server.
522
+
523
+ **Legacy reply delivery.** When Desktop tasks are explicitly disabled, `claude-bridge` can choose between native and external app-server delivery. The native path requires:
514
524
 
515
525
  | Condition | Otherwise |
516
526
  |---|---|
@@ -518,7 +528,7 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
518
528
  | `CODEX_BRIDGE_NATIVE_RELAY` is not `0` | switched off by hand |
519
529
  | the Windows named pipe or macOS unix socket exists | the companion is not installed, or Codex Desktop is not running |
520
530
 
521
- A companion that cannot be reached before sending falls back to the app-server path. Once a request has been written, a refusal, timeout, or lost acknowledgement does not trigger another delivery: the first attempt may already have succeeded. Invalid or oversized messages are also refused rather than passed to another backend.
531
+ Only legacy reply delivery falls back to the app-server path when a companion cannot be reached before sending. Once a request has been written, a refusal, timeout, or lost acknowledgement does not trigger another delivery: the first attempt may already have succeeded. Invalid or oversized messages are also refused rather than passed to another backend.
522
532
 
523
533
  The companion uses `CODEX_APP_TOOLS_PIPE_PATH` when inherited. Desktop builds that supply it only to their bundled `codex_app` MCP are also supported: the companion reads the exact `mcp_servers.codex_app.env` override from its launching app-server. It never picks a pipe from another session or saves a restart-specific address. The native connection remains separate from MCP stdio. If neither source provides a valid pipe, the relay stays unavailable; if a configured pipe is late during startup, it retries in the background. When several MCP instances start, status identifies a reachable shared companion instead of reporting that the relay is down.
524
534
 
@@ -537,11 +547,11 @@ The native pipe uses a 4-byte UInt32LE payload length followed by UTF-8 JSON-RPC
537
547
 
538
548
  ### Caveats
539
549
 
540
- - The Codex desktop app runs its own app-server over stdio (`ChatGPT.app/Contents/Resources/codex … app-server`, **no** `--listen`), so nothing external can attach to it. `~/.codex/ipc/ipc.sock` is the Electron app's internal IPC, not an app-server. Threads opened there can still be driven through the bridge, but by resuming from the rollout `.jsonl` rather than attaching live. The [native relay](#codex-desktop-native-relay) is not an exception to this: the companion never attaches to that app-server, it is *launched by* it as one of the app's own MCP servers.
541
- - **A thread currently open in the desktop app cannot be written to** through a second app-server — Codex holds a per-thread writer lock (`~/.codex/thread-writer-locks/`) and returns `thread <id> already has an active writer`. That error is the guard working, not data loss. Check `status` with `list_codex_threads` first and only send when it is `idle` or `notLoaded` and not open in the app. For the Claude → Codex relay specifically, the [native relay](#codex-desktop-native-relay) removes the second writer instead of waiting for the lock.
542
- - **Bridge-created threads are named before they are opened.** The bridge calls the app-server's `thread/name/set` with the requested title, or derives `[project] first line of prompt`, then opens the exact `codex://threads/<id>` link. This gives Codex Desktop a visible session title and preserves the precise `cwd` in the thread metadata.
550
+ - Codex Desktop runs its own app-server. Its internal IPC endpoint is not an external app-server connection. The [native relay](#codex-desktop-native-relay), launched in Desktop's context, invokes Desktop's own tools to deliver into an existing task without resuming it through another server.
551
+ - **The writer-lock limitation applies to the legacy external app-server path.** A second writer receives `thread <id> already has an active writer`; an `idle` task can still be owned by Desktop. Native Desktop delivery supports tasks open in the app. Check `codex_bridge_status` in the actual sending task and migrate a legacy registration with `--desktop-tasks`; do not delete lock files, stop Desktop, or ask for manual copying merely because Desktop owns the task.
552
+ - **New Desktop tasks receive their saved project and title through Desktop's native creation tool.** Legacy bridge-created threads instead use the app-server's `thread/name/set` and an exact `codex://threads/<id>` link. Both paths preserve the requested workspace.
543
553
  - A repo living on the NTFS partition of a dual-boot machine (`/Volumes/<label>/...`) is **read-only** under macOS. Keep a separate checkout on an APFS volume to run and edit it.
544
- - `codex app-server daemon start` uses the `unix://` transport with a control socket at `~/.codex/app-server-control/app-server-control.sock`. The bridge does **not** use that path (different framing, no public API) — it always talks over `ws://`.
554
+ - `codex app-server daemon start` uses the `unix://` transport with a control socket at `~/.codex/app-server-control/app-server-control.sock`. The legacy external bridge uses `ws://` instead; Desktop-only delivery uses the native relay.
545
555
 
546
556
  ## Troubleshooting
547
557
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.15.0",
3
+ "version": "1.15.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",
package/scripts/check.mjs CHANGED
@@ -2,9 +2,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { desktopTasksConfigured } from "../src/native-relay.mjs";
5
6
 
6
7
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
8
  const bridgeEnvNames = [
9
+ "CODEX_HOME",
10
+ "CLAUDE_DESKTOP_USER_DATA",
8
11
  "CODEX_APP_SERVER_URL",
9
12
  "CODEX_BIN",
10
13
  "CODEX_BRIDGE_ALLOWED_ROOTS",
@@ -40,7 +43,9 @@ try {
40
43
  await client.connect(transport);
41
44
  const tools = await client.listTools();
42
45
  console.log("tools:", tools.tools.map((t) => t.name).join(", "));
43
- const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
46
+ const listed = await client.callTool(desktopTasksConfigured()
47
+ ? { name: "codex_bridge_status", arguments: {} }
48
+ : { name: "list_codex_threads", arguments: { limit: 3 } });
44
49
  console.log(listed.content[0].text.slice(0, 600));
45
50
  if (listed.isError) process.exitCode = 1;
46
51
  } catch (err) {
@@ -13,6 +13,7 @@ exitForVersionRequest(import.meta.url);
13
13
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
14
  const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
15
15
  const reset = process.argv.includes("--reset");
16
+ const forceDesktopTasks = process.argv.includes("--desktop-tasks");
16
17
 
17
18
  /**
18
19
  * This bridge is intentionally the local hand-off point between Claude and
@@ -55,10 +56,13 @@ cfg.mcpServers = cfg.mcpServers ?? {};
55
56
  const previousEntry = (reset ? {} : cfg.mcpServers["codex-bridge"]) ?? {};
56
57
  const previousEnv = previousEntry.env ?? {};
57
58
  const settled = (name, fallback) => process.env[name] ?? previousEnv[name] ?? fallback;
58
- const desktopMode = settled("CODEX_BRIDGE_DESKTOP_TASKS", desktopTasksConfigured() ? "1" : "0");
59
+ const desktopMode = forceDesktopTasks ? "1" : settled("CODEX_BRIDGE_DESKTOP_TASKS");
60
+ const desktopEnabled = desktopMode === undefined ? desktopTasksConfigured() : desktopMode === "1";
59
61
 
60
62
  const kept = Object.keys(previousEnv).filter(
61
- (name) => process.env[name] === undefined && name !== "CODEX_BIN",
63
+ (name) => process.env[name] === undefined && name !== "CODEX_BIN" &&
64
+ !(forceDesktopTasks && name === "CODEX_BRIDGE_DESKTOP_TASKS") &&
65
+ !(desktopEnabled && name === "CODEX_BRIDGE_AUTOSTART"),
62
66
  );
63
67
 
64
68
  cfg.mcpServers["codex-bridge"] = {
@@ -93,8 +97,8 @@ cfg.mcpServers["codex-bridge"] = {
93
97
  CODEX_BRIDGE_SANDBOX: settled("CODEX_BRIDGE_SANDBOX", "workspace-write"),
94
98
  CODEX_BRIDGE_OPEN_IN_APP: settled("CODEX_BRIDGE_OPEN_IN_APP", IS_WINDOWS ? "1" : "0"),
95
99
  CODEX_BRIDGE_RELEASE_AFTER_TURN: settled("CODEX_BRIDGE_RELEASE_AFTER_TURN", IS_WINDOWS ? "1" : "0"),
96
- CODEX_BRIDGE_DESKTOP_TASKS: desktopMode,
97
- CODEX_BRIDGE_AUTOSTART: desktopMode === "1" ? "0" : settled("CODEX_BRIDGE_AUTOSTART", "1"),
100
+ ...(desktopMode !== undefined ? { CODEX_BRIDGE_DESKTOP_TASKS: desktopMode } : {}),
101
+ CODEX_BRIDGE_AUTOSTART: desktopEnabled ? "0" : settled("CODEX_BRIDGE_AUTOSTART", "1"),
98
102
  ...(process.env.CODEX_BRIDGE_ALLOWED_THREADS !== undefined
99
103
  ? { CODEX_BRIDGE_ALLOWED_THREADS: process.env.CODEX_BRIDGE_ALLOWED_THREADS }
100
104
  : {}),
@@ -120,6 +124,7 @@ fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
120
124
  console.log(`platform: ${PLATFORM_LABEL}`);
121
125
  console.log(`updated ${cfgPath}`);
122
126
  console.log(JSON.stringify(cfg.mcpServers["codex-bridge"], null, 2));
127
+ console.log(`Desktop tasks: ${desktopEnabled ? "enabled" : "disabled"} (${desktopMode === undefined ? "automatic relay configuration" : forceDesktopTasks ? "--desktop-tasks" : "explicit environment setting"}).`);
123
128
 
124
129
  if (reset) {
125
130
  console.log("\n--reset: existing values were discarded in favour of the defaults.");
@@ -149,3 +154,4 @@ if (cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_THREAD_POLICY === "owned") {
149
154
 
150
155
  console.log("\nReconnect codex-bridge once in the existing Claude task to load the supervisor. Subsequent compatible installed-source updates reload automatically when the worker is safely idle.");
151
156
  console.log("Verify codex_bridge_status from that task: autoReload must be enabled and runtime state must be current. A separate diagnostic process does not verify the app's loaded MCP process.");
157
+ if (desktopEnabled) console.log("Verify Desktop tasks are enabled and the native relay is available. Configuration changes require MCP reconnect even when source auto-reload is enabled; keep the destination task open in Codex Desktop.");
@@ -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.15.0";
25
+ const VERSION = "1.15.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"];
@@ -15,14 +15,14 @@ import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
15
15
  import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
16
16
  import { readCodexSenderContext } from "./codex-sender-context.mjs";
17
17
  import { ReplyForwarder } from "./reply-forwarder.mjs";
18
- import { clientReloadReason, createReloadControl } from "./reload-control.mjs";
18
+ import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
19
19
  import { readClaudeAccountContext } from "./desktop-account-context.mjs";
20
20
  import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
21
21
  import { resolveClaudeDesktopSession } from "./claude-session-router.mjs";
22
22
 
23
23
  exitForVersionRequest(import.meta.url);
24
24
 
25
- const VERSION = "1.15.0";
25
+ const VERSION = "1.15.1";
26
26
  const FORWARD_MIN_INTERVAL_MS = 5000;
27
27
  const FORWARD_MAX_PER_SESSION = 50;
28
28
 
@@ -74,7 +74,8 @@ const reload = createReloadControl({
74
74
  quiesce: async () => { await peer.quiesce(); codex.close(); },
75
75
  exportState: () => ({ desktopOnly, peer: peer.exportReloadState(), forwarding: replyForwarder.exportReloadState(), threadId: forwarding.threadId }),
76
76
  restore: (state) => {
77
- if (state.desktopOnly !== desktopOnly || state.threadId !== null && (typeof state.threadId !== "string" || !state.threadId.trim())) {
77
+ assertRoutingReload(state.desktopOnly, desktopOnly);
78
+ if (state.threadId !== null && (typeof state.threadId !== "string" || !state.threadId.trim())) {
78
79
  throw new Error("Invalid or incompatible Claude worker reload state");
79
80
  }
80
81
  peer.restoreReloadState(state.peer);
package/src/index.mjs CHANGED
@@ -27,13 +27,13 @@ import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.m
27
27
  import { desktopTasksConfigured } from "./native-relay.mjs";
28
28
  import { exitForVersionRequest } from "./cli-version.mjs";
29
29
  import { createRuntimeState } from "./runtime-state.mjs";
30
- import { clientReloadReason, createReloadControl } from "./reload-control.mjs";
30
+ import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
31
31
  import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
32
32
  import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
33
33
 
34
34
  exitForVersionRequest(import.meta.url);
35
35
 
36
- const VERSION = "1.15.0";
36
+ const VERSION = "1.15.1";
37
37
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
38
38
 
39
39
  /**
@@ -101,7 +101,8 @@ const reload = createReloadControl({
101
101
  quiesce: () => { client?.close(); },
102
102
  exportState: () => ({ desktopTasksEnabled, ownedThreadIds: [...security.ownedThreadIds] }),
103
103
  restore: (state) => {
104
- if (state.desktopTasksEnabled !== desktopTasksEnabled || !Array.isArray(state.ownedThreadIds)
104
+ assertRoutingReload(state.desktopTasksEnabled, desktopTasksEnabled);
105
+ if (!Array.isArray(state.ownedThreadIds)
105
106
  || state.ownedThreadIds.some((id) => typeof id !== "string" || !id.trim())
106
107
  || new Set(state.ownedThreadIds).size !== state.ownedThreadIds.length) throw new Error("Invalid or incompatible Codex worker reload state");
107
108
  security.ownedThreadIds = new Set(state.ownedThreadIds);
@@ -480,7 +481,10 @@ registerTool(
480
481
  title: "Send a prompt to a Codex thread",
481
482
  description:
482
483
  "Send a prompt as a new user turn inside an existing Codex thread and wait for Codex to answer. " +
483
- "The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId.",
484
+ "The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId. " +
485
+ "Desktop-owned tasks must use Desktop native delivery; an open task is a valid destination. " +
486
+ "If legacy delivery reports an active writer, inspect codex_bridge_status and repair the native relay/configuration. " +
487
+ "Do not close the task, create a replacement, or ask the user to copy the message manually.",
484
488
  inputSchema: {
485
489
  threadId: z.string().describe("Codex thread id (UUID) - get it from list_codex_threads"),
486
490
  prompt: z.string().describe("The message to send to Codex, exactly as a user would type it"),
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
7
+ import { desktopTasksConfigured } from "./native-relay.mjs";
7
8
 
8
9
  const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
9
10
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
@@ -129,6 +130,7 @@ export async function runSupervisor(entry, options = {}) {
129
130
  const log = (message) => process.stderr.write(`[bridge-supervisor] ${message}\n`);
130
131
  const output = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
131
132
  const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: root };
133
+ const routingConfiguration = () => entry === "native-relay-companion.mjs" ? null : desktopTasksConfigured(env);
132
134
  if (entry === "native-relay-companion.mjs" && !env.CODEX_APP_TOOLS_PIPE_PATH) {
133
135
  const { resolveNativeToolsPipePath } = await import("./native-relay.mjs");
134
136
  const pipe = await resolveNativeToolsPipePath();
@@ -145,6 +147,7 @@ export async function runSupervisor(entry, options = {}) {
145
147
  let failure = null;
146
148
  let sequence = 0;
147
149
  let observed = null;
150
+ let observedRouting = null;
148
151
  let observedAt = 0;
149
152
  let retryAt = 0;
150
153
  let reason = null;
@@ -154,7 +157,9 @@ export async function runSupervisor(entry, options = {}) {
154
157
  const reverse = new Map();
155
158
  const workers = new Set();
156
159
  const diagnostics = () => ({ enabled: true, supervisorPid: process.pid, workerPid: active?.process.pid ?? null,
157
- revision: active?.release.revision ?? null, availableRevision: observed, pending: Boolean(observed && observed !== active?.release.revision),
160
+ revision: active?.release.revision ?? null, availableRevision: observed,
161
+ routingConfiguration: active?.routingConfiguration ?? null, availableRoutingConfiguration: observedRouting,
162
+ pending: Boolean(observed && (observed !== active?.release.revision || observedRouting !== active?.routingConfiguration)),
158
163
  state: failure ? "failed" : switching ? "reloading" : reason ? "deferred" : "current", reason: failure ?? reason, reloads });
159
164
 
160
165
  const handlers = {
@@ -197,19 +202,21 @@ export async function runSupervisor(entry, options = {}) {
197
202
  if (worker === active && !worker.retiring && !stopping) { failure = `${error.message}; automatic replay is disabled because delivery state may be unknown`; log(failure); }
198
203
  },
199
204
  };
200
- const spawn = (release) => {
205
+ const spawn = (release, routing) => {
201
206
  if (stopping) throw new Error("The MCP client disconnected before activation");
202
207
  const worker = new Worker(release, entry, env, handlers);
208
+ worker.routingConfiguration = routing;
203
209
  workers.add(worker);
204
210
  return worker;
205
211
  };
206
212
  const check = async () => {
207
213
  if (switching || !ready || stopping || failure) return;
208
214
  let revision;
209
- try { revision = sourceRevision(root); }
215
+ let routing;
216
+ try { revision = sourceRevision(root); routing = routingConfiguration(); }
210
217
  catch (error) { reason = `Installation is not ready: ${error.message}`; return; }
211
- if (observed !== revision) { observed = revision; observedAt = Date.now(); retryAt = 0; }
212
- if (revision === active.release.revision) { reason = null; return; }
218
+ if (observed !== revision || observedRouting !== routing) { observed = revision; observedRouting = routing; observedAt = Date.now(); retryAt = 0; }
219
+ if (revision === active.release.revision && routing === active.routingConfiguration) { reason = null; return; }
213
220
  if (Date.now() - observedAt < settleMs || Date.now() < retryAt) { reason ??= "Waiting for a complete, stable installation"; return; }
214
221
  if (active.clientPending.size || reverse.size) { reason = "A request is still running; its worker is retained"; return; }
215
222
  switching = true;
@@ -220,7 +227,7 @@ export async function runSupervisor(entry, options = {}) {
220
227
  const state = await previous.control("inspect");
221
228
  if (!state.reloadable) { reason = state.reason ?? "Pending deliveries prevent a safe reload"; return; }
222
229
  const release = createReleaseSnapshot(root, { expectedRevision: revision });
223
- candidate = spawn(release);
230
+ candidate = spawn(release, routing);
224
231
  const initialized = await candidate.rpc("initialize", initializeParams);
225
232
  if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
226
233
  throw new Error("The new MCP protocol or capabilities require an explicit client reconnect");
@@ -230,6 +237,7 @@ export async function runSupervisor(entry, options = {}) {
230
237
  if (!Array.isArray(catalog.tools) || !catalog.tools.length || catalog.nextCursor) throw new Error("The candidate tool catalog is incomplete");
231
238
  const oldCatalog = await previous.rpc("tools/list", {});
232
239
  if (sourceRevision(root) !== revision) throw new Error("The installation changed before activation");
240
+ if (routingConfiguration() !== routing) throw new Error("The routing configuration changed before activation");
233
241
  quiesced = true;
234
242
  const exported = await previous.control("quiesce");
235
243
  await candidate.control("restore", exported.state);
@@ -296,9 +304,13 @@ export async function runSupervisor(entry, options = {}) {
296
304
  await Promise.allSettled([...workers].map((worker) => worker.stop()));
297
305
  process.stdin.pause();
298
306
  };
307
+ const preparationStarted = performance.now();
308
+ log(`preparing ${entry} runtime`);
299
309
  const initial = createReleaseSnapshot(root);
310
+ log(`prepared ${entry} runtime in ${Math.round(performance.now() - preparationStarted)} ms`);
300
311
  observed = initial.revision;
301
- active = spawn(initial);
312
+ observedRouting = routingConfiguration();
313
+ active = spawn(initial, observedRouting);
302
314
  const timer = setInterval(() => { void check(); }, pollMs);
303
315
  lines(process.stdin, (message) => {
304
316
  if (!message || message.jsonrpc !== "2.0" || (!message.method && !hasId(message))) throw new Error("Invalid client JSON-RPC message");
@@ -30,7 +30,7 @@ import { createRuntimeState } from "./runtime-state.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.15.0";
33
+ const VERSION = "1.15.1";
34
34
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
35
35
 
36
36
  function errorResponse(code, message, sent) {
@@ -369,9 +369,10 @@ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () =>
369
369
  let resolveReady;
370
370
  const ready = new Promise((resolve) => { resolveReady = resolve; });
371
371
  let pendingAttempt = null;
372
+ let nativeConnected = false;
372
373
  const attempt = async () => {
373
374
  if (stopped) return;
374
- let nativeConnected = false;
375
+ nativeConnected = false;
375
376
  try {
376
377
  await nativeTools.connect();
377
378
  nativeConnected = true;
@@ -413,6 +414,7 @@ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () =>
413
414
  return {
414
415
  ready,
415
416
  firstAttempt,
417
+ get connected() { return nativeConnected; },
416
418
  get busy() { return pendingAttempt !== null; },
417
419
  stop() {
418
420
  if (stopped) return pendingAttempt ?? Promise.resolve();
@@ -454,7 +456,9 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
454
456
  for (const relay of servers) relay.accepting = true;
455
457
  startups = servers.map((relay) => startRelayWhenAvailable({ nativeTools, relay, log: logFn }));
456
458
  await Promise.all(startups.map((startup) => startup.firstAttempt));
457
- if (servers.some((relay) => ownedSockets.includes(relay.socketPath) && !relay.started)) {
459
+ const restoredListeners = await Promise.all(servers.map(async (relay, index) =>
460
+ !ownedSockets.includes(relay.socketPath) || startups[index].connected && await relay.isListening()));
461
+ if (restoredListeners.some((available) => !available)) {
458
462
  await stop();
459
463
  throw new Error("The replacement native relay could not reclaim its listening sockets");
460
464
  }
@@ -418,18 +418,130 @@ async function readParentCommandLine(parentPid, platform) {
418
418
  return stdout.trim();
419
419
  }
420
420
 
421
+ export function nativeToolsPipeCandidatesFromWindowsSnapshot(snapshot, { parentPid, localAppData = process.env.LOCALAPPDATA } = {}) {
422
+ if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || !Array.isArray(snapshot?.ancestors)
423
+ || snapshot.ancestors.length < 2 || snapshot.ancestors.length > 4 || !Array.isArray(snapshot.pipes)) return null;
424
+ const ancestors = snapshot.ancestors;
425
+ let expectedPid = parentPid;
426
+ const visited = new Set();
427
+ for (const ancestor of ancestors) {
428
+ if (!Number.isSafeInteger(ancestor?.pid) || ancestor.pid !== expectedPid || visited.has(ancestor.pid)
429
+ || typeof ancestor.executablePath !== "string" || /[\r\n\0]/.test(ancestor.executablePath)) return null;
430
+ visited.add(ancestor.pid);
431
+ expectedPid = ancestor.parentPid;
432
+ }
433
+ const desktop = ancestors.at(-1);
434
+ const appServer = ancestors.at(-2);
435
+ const desktopPath = path.win32.normalize(desktop.executablePath);
436
+ if (!/^[a-z]:\\Program Files\\WindowsApps\\OpenAI\.Codex_[0-9.]+_(?:x64|arm64)__2p2nqsd0c76g0\\app\\ChatGPT\.exe$/i.test(desktopPath)) return null;
437
+ if (ancestors.slice(0, -2).some((ancestor) => path.win32.basename(ancestor.executablePath).toLowerCase() !== "node.exe")) return null;
438
+ const serverPath = path.win32.normalize(appServer.executablePath);
439
+ const bundledServer = path.win32.join(path.win32.dirname(desktopPath), "resources", "codex.exe");
440
+ const downloadedRoot = localAppData ? path.win32.join(localAppData, "OpenAI", "Codex", "bin") : null;
441
+ const downloadedRelative = downloadedRoot ? path.win32.relative(downloadedRoot, serverPath) : "";
442
+ if (serverPath.toLowerCase() !== bundledServer.toLowerCase() && !/^[a-f0-9]{8,64}\\codex\.exe$/i.test(downloadedRelative)) return null;
443
+ if (typeof appServer.commandLine !== "string" || /[\r\n\0]/.test(appServer.commandLine)) return null;
444
+ const args = splitDesktopCommandLine(appServer.commandLine, "win32");
445
+ if (!args.length || path.win32.normalize(args[0]).toLowerCase() !== serverPath.toLowerCase()) return null;
446
+ let command = null;
447
+ for (let index = 1; index < args.length; index++) {
448
+ if (args[index] === "-c" || args[index] === "--config") index++;
449
+ else if (!args[index].startsWith("-")) { command = args[index]; break; }
450
+ }
451
+ if (command !== "app-server") return null;
452
+ const candidates = snapshot.pipes.filter((pipe) => pipe?.serverPid === desktop.pid
453
+ && typeof pipe.path === "string" && /^\\\\\.\\pipe\\codex-browser-use-[a-z0-9-]+$/i.test(pipe.path));
454
+ const paths = [...new Set(candidates.map((pipe) => pipe.path))].sort();
455
+ return paths;
456
+ }
457
+
458
+ async function readWindowsNativePipeSnapshot(parentPid) {
459
+ if (!Number.isSafeInteger(parentPid) || parentPid <= 0) return null;
460
+ const powershell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
461
+ const script = String.raw`
462
+ $ErrorActionPreference = 'Stop'
463
+ $ancestors = @()
464
+ $nextProcessId = ${parentPid}
465
+ for ($depth = 0; $depth -lt 4; $depth++) {
466
+ $ancestor = Get-CimInstance Win32_Process -Filter "ProcessId=$nextProcessId"
467
+ if (!$ancestor) { break }
468
+ $ancestors += @{ pid = [int]$ancestor.ProcessId; parentPid = [int]$ancestor.ParentProcessId; executablePath = $ancestor.ExecutablePath; commandLine = $ancestor.CommandLine }
469
+ if ($ancestor.Name -ieq 'ChatGPT.exe') { break }
470
+ if ($ancestor.Name -ine 'node.exe' -and $ancestor.Name -ine 'codex.exe') { break }
471
+ $nextProcessId = [int]$ancestor.ParentProcessId
472
+ }
473
+ $pipes = @()
474
+ if ($ancestors.Count -ge 2 -and $ancestor.Name -ieq 'ChatGPT.exe') {
475
+ Add-Type -TypeDefinition @'
476
+ using System;
477
+ using System.Runtime.InteropServices;
478
+ using Microsoft.Win32.SafeHandles;
479
+ public static class CodexNativePipeOwner {
480
+ [DllImport("kernel32.dll", SetLastError = true)]
481
+ public static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId);
482
+ }
483
+ '@
484
+ $names = @([System.IO.Directory]::GetFiles('\\.\pipe\') | Where-Object { $_ -match '^\\\\\.\\pipe\\codex-browser-use-[a-zA-Z0-9-]+$' } | Sort-Object | Select-Object -First 128)
485
+ foreach ($pipePath in $names) {
486
+ $client = $null
487
+ try {
488
+ $client = [System.IO.Pipes.NamedPipeClientStream]::new('.', $pipePath.Substring(9), [System.IO.Pipes.PipeDirection]::InOut, [System.IO.Pipes.PipeOptions]::Asynchronous)
489
+ $client.Connect(30)
490
+ $serverProcessId = [uint32]0
491
+ if ([CodexNativePipeOwner]::GetNamedPipeServerProcessId($client.SafePipeHandle, [ref]$serverProcessId) -and $serverProcessId -eq $ancestor.ProcessId) {
492
+ $pipes += @{ path = $pipePath; serverPid = [int]$serverProcessId }
493
+ }
494
+ } catch {} finally { if ($client) { $client.Dispose() } }
495
+ }
496
+ }
497
+ @{ ancestors = @($ancestors); pipes = @($pipes) } | ConvertTo-Json -Depth 4 -Compress
498
+ `;
499
+ const { stdout } = await execFileAsync(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
500
+ timeout: 5000, maxBuffer: 128 * 1024, windowsHide: true,
501
+ });
502
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
503
+ }
504
+
505
+ async function probeWindowsNativeToolsPipe(socketPath, { env, timeoutMs }) {
506
+ const executorThreadId = resolveRelayThreadId(env).threadId;
507
+ const client = new NativeToolsClient({ env, socketPath, timeoutMs });
508
+ const timer = globalThis.setTimeout(() => client.close(), timeoutMs);
509
+ try {
510
+ const nativeResult = await client.dispatchDesktop({ executorThreadId, operation: "list_projects", arguments: {} });
511
+ return Array.isArray(decodeNativeToolResult(nativeResult)?.projects);
512
+ } finally {
513
+ globalThis.clearTimeout(timer);
514
+ client.close();
515
+ }
516
+ }
517
+
421
518
  export async function resolveNativeToolsPipePath({
422
519
  env = process.env,
423
520
  parentPid = process.ppid,
424
521
  platform = process.platform,
425
522
  readParentCommandLine: readParent = readParentCommandLine,
523
+ readWindowsSnapshot = readWindowsNativePipeSnapshot,
524
+ probeWindowsPipe = probeWindowsNativeToolsPipe,
525
+ windowsDiscoveryTimeoutMs = 7000,
426
526
  } = {}) {
427
527
  if (env.CODEX_APP_TOOLS_PIPE_PATH) return env.CODEX_APP_TOOLS_PIPE_PATH;
428
528
  try {
429
- return nativeToolsPipeFromCommandLine(await readParent(parentPid, platform), { platform });
430
- } catch {
529
+ const inherited = nativeToolsPipeFromCommandLine(await readParent(parentPid, platform), { platform });
530
+ if (inherited) return inherited;
531
+ } catch {}
532
+ if (platform !== "win32") return null;
533
+ const deadline = Date.now() + Math.max(1, Math.min(7000, Number(windowsDiscoveryTimeoutMs) || 7000));
534
+ try {
535
+ const candidates = nativeToolsPipeCandidatesFromWindowsSnapshot(await readWindowsSnapshot(parentPid), { parentPid, localAppData: env.LOCALAPPDATA }) ?? [];
536
+ for (const candidate of candidates) {
537
+ const remaining = deadline - Date.now();
538
+ if (remaining <= 0) break;
539
+ try {
540
+ if (await probeWindowsPipe(candidate, { env, timeoutMs: Math.min(750, remaining) })) return candidate;
541
+ } catch {}
542
+ }
431
543
  return null;
432
- }
544
+ } catch { return null; }
433
545
  }
434
546
 
435
547
  export class NativeToolsClient {
@@ -9,6 +9,102 @@ function digestFiles(root, files) {
9
9
  return hash.digest("hex");
10
10
  }
11
11
 
12
+ const DIGEST_FIELDS = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
13
+ const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
14
+
15
+ function safeRelativePath(file) {
16
+ return typeof file === "string" && file.length > 0 && !file.includes("\0")
17
+ && !path.posix.isAbsolute(file) && !path.win32.parse(file).root
18
+ && path.normalize(file) === file && !file.split(/[\\/]/).some((part) => !part || part === "." || part === "..");
19
+ }
20
+
21
+ function digestMetadata(cache) {
22
+ const directories = new Map();
23
+ const dirty = new Set();
24
+ const metadataPath = (directory) => path.join(cache, `.digests-${createHash("sha256").update(directory).digest("hex")}.json`);
25
+ const signature = (stat) => Object.fromEntries(DIGEST_FIELDS.map((field) => [field, String(stat[field])]));
26
+ const matches = (left, right) => left && DIGEST_FIELDS.every((field) => left[field] === right[field]);
27
+ const load = (directory) => {
28
+ dirty.add(directory);
29
+ let descriptor;
30
+ try {
31
+ const file = metadataPath(directory);
32
+ const info = fs.lstatSync(file, { bigint: true });
33
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > 32n * 1024n * 1024n) return new Map();
34
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
35
+ const opened = fs.fstatSync(descriptor, { bigint: true });
36
+ if (!opened.isFile() || opened.nlink !== 1n || !matches(signature(info), signature(opened))) return new Map();
37
+ const metadata = JSON.parse(fs.readFileSync(descriptor, "utf8"));
38
+ if (metadata?.schema !== 1 || metadata.directory !== directory || !metadata.files || typeof metadata.files !== "object" || Array.isArray(metadata.files)) return new Map();
39
+ const entries = Object.entries(metadata.files);
40
+ if (entries.some(([file, record]) => !safeRelativePath(file) || !record || typeof record !== "object" || Array.isArray(record)
41
+ || typeof record.hash !== "string" || !DIGEST_PATTERN.test(record.hash)
42
+ || DIGEST_FIELDS.some((field) => typeof record[field] !== "string" || !/^-?\d+$/.test(record[field])))) return new Map();
43
+ dirty.delete(directory);
44
+ return new Map(entries);
45
+ } catch {
46
+ return new Map();
47
+ } finally {
48
+ if (descriptor !== undefined) fs.closeSync(descriptor);
49
+ }
50
+ };
51
+ return {
52
+ digest(directory, files) {
53
+ const previous = directories.get(directory) ?? load(directory);
54
+ const records = new Map();
55
+ const hash = createHash("sha256");
56
+ if (previous.size !== files.length) dirty.add(directory);
57
+ for (const file of files) {
58
+ if (!safeRelativePath(file)) throw new Error(`Unsafe release file: ${file}`);
59
+ const fullPath = path.join(directory, file);
60
+ const before = fs.statSync(fullPath, { bigint: true });
61
+ if (!before.isFile()) throw new Error(`Unsupported release file: ${file}`);
62
+ const current = signature(before);
63
+ let record = previous.get(file);
64
+ if (!matches(record, current)) {
65
+ dirty.add(directory);
66
+ const contentHash = createHash("sha256").update(fs.readFileSync(fullPath)).digest("hex");
67
+ const after = fs.statSync(fullPath, { bigint: true });
68
+ if (!after.isFile() || !matches(current, signature(after))) throw new Error("A release file changed during integrity verification");
69
+ record = { ...current, hash: contentHash };
70
+ }
71
+ records.set(file, record);
72
+ hash.update(file).update("\0").update(record.hash).update("\0");
73
+ }
74
+ directories.set(directory, records);
75
+ return hash.digest("hex");
76
+ },
77
+ relocate(from, to) {
78
+ if (directories.has(from)) {
79
+ directories.set(to, directories.get(from));
80
+ dirty.add(to);
81
+ }
82
+ directories.delete(from);
83
+ dirty.delete(from);
84
+ },
85
+ forget(directory) { directories.delete(directory); dirty.delete(directory); },
86
+ flush() {
87
+ for (const [directory, records] of directories) {
88
+ if (!dirty.has(directory)) continue;
89
+ const file = metadataPath(directory);
90
+ const temporary = path.join(cache, `.digests-${process.pid}-${randomUUID()}.tmp`);
91
+ try {
92
+ try {
93
+ const info = fs.lstatSync(file);
94
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) continue;
95
+ } catch (error) { if (error.code !== "ENOENT") continue; }
96
+ fs.writeFileSync(temporary, JSON.stringify({ schema: 1, directory, files: Object.fromEntries(records) }), { flag: "wx", mode: 0o600 });
97
+ fs.renameSync(temporary, file);
98
+ dirty.delete(directory);
99
+ } catch {
100
+ } finally {
101
+ try { fs.rmSync(temporary, { force: true }); } catch {}
102
+ }
103
+ }
104
+ },
105
+ };
106
+ }
107
+
12
108
  function inventory(root, prefix = "") {
13
109
  const files = [];
14
110
  for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
@@ -56,17 +152,19 @@ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRe
56
152
  fs.mkdirSync(cache, { recursive: true, mode: 0o700 });
57
153
  if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
58
154
  cache = fs.realpathSync.native(cache);
155
+ const metadata = digestMetadata(cache);
59
156
  const dependencies = inventory(root, "node_modules");
60
- const dependencyRevision = digestFiles(root, dependencies);
157
+ const dependencyRevision = metadata.digest(root, dependencies);
61
158
  const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
62
159
  const target = path.join(cache, key);
63
160
  const validate = (directory) => {
64
161
  if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
65
- return sourceRevision(directory) === expectedRevision && digestFiles(directory, inventory(directory, "node_modules")) === dependencyRevision;
162
+ return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules")) === dependencyRevision;
66
163
  };
67
164
  if (fs.existsSync(target)) {
68
165
  if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
69
166
  if (sourceRevision(root) !== expectedRevision) throw new Error("The installation changed during snapshot verification");
167
+ metadata.flush();
70
168
  return { directory: target, revision: expectedRevision, key };
71
169
  }
72
170
  const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
@@ -79,14 +177,19 @@ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRe
79
177
  for (const file of files) {
80
178
  fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
81
179
  }
82
- if (sourceRevision(root) !== expectedRevision || digestFiles(root, inventory(root, "node_modules")) !== dependencyRevision || !validate(temporary)) {
180
+ if (sourceRevision(root) !== expectedRevision || metadata.digest(root, inventory(root, "node_modules")) !== dependencyRevision || !validate(temporary)) {
83
181
  throw new Error("The installation changed while its immutable runtime was being prepared");
84
182
  }
85
- try { fs.renameSync(temporary, target); }
183
+ try {
184
+ fs.renameSync(temporary, target);
185
+ metadata.relocate(temporary, target);
186
+ }
86
187
  catch (error) {
87
188
  if (!fs.existsSync(target) || !validate(target)) throw error;
88
189
  removeOwned(temporary, cache);
190
+ metadata.forget(temporary);
89
191
  }
192
+ metadata.flush();
90
193
  return { directory: target, revision: expectedRevision, key };
91
194
  } catch (error) {
92
195
  removeOwned(temporary, cache);
@@ -1,3 +1,9 @@
1
+ export function assertRoutingReload(previous, next) {
2
+ if (typeof previous !== "boolean" || typeof next !== "boolean" || previous && !next) {
3
+ throw new Error("Invalid routing reload or Desktop-to-app-server downgrade; reconnect explicitly to change to legacy mode");
4
+ }
5
+ }
6
+
1
7
  export function cloneReloadState(value) {
2
8
  const encoded = JSON.stringify(value);
3
9
  if (typeof encoded !== "string" || Buffer.byteLength(encoded) > 16 * 1024 * 1024) {