@minhspark/codex-mcp-bridge 1.26.8 → 1.26.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.26.8",
3
+ "version": "1.26.10",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.26.10] - 2026-11-03
8
+
9
+ ### Fixed
10
+
11
+ - Recover Claude replies whose native ancestry passes through a verified Monitor notification. Preserve outstanding background work and reject unrelated or malformed notifications. Pending-delivery diagnostics now retain the original message ID and direct callers to its receipt without suggesting a duplicate send.
12
+ - Observe Antigravity Desktop replies through a bounded, read-only native transcript adapter. New deliveries retain a pre-send boundary and prompt hash; completion requires the exact original message, execution, public final response and idle conversation. Unsupported schemas, interleaved messages and ambiguous matches remain unconfirmed. Reply reads retain sender, account and project isolation.
13
+ - Migrate pending Claude transcript observations during future worker updates only after a unique native message proves acceptance. Held and unobserved deliveries keep their locks and defer reload, preserving their original reply channel.
14
+ - Resolve Codex CLI candidates from the worker's actual environment, skip incomplete version directories and retry read-only inspection once if an unpinned executable disappears during an app update. Explicit executable selections, authentication failures and real jobs are never retried or silently redirected.
15
+ - Report background relay readiness separately from unverified Desktop connections after updating. A ready background worker does not establish a live native Desktop relay.
16
+ - Reject malformed transcript session identifiers before composing filesystem paths, and validate executable candidates before inspecting their file type. Configured external executable locations remain supported.
17
+
18
+ ### Verification and limits
19
+
20
+ - Regression coverage includes Monitor ancestry, pending receipt ownership, reload safety, exact native reply correlation, account changes and CLI replacement races. Historical native transcripts confirmed both reply-observation failures without resending their prompts.
21
+ - An already-running older worker can still defer its own upgrade while a receipt is pending. Preserve that receipt and any verified historical result before reconnecting its MCP client; a missing receipt after reconnecting is not evidence of non-delivery. Legacy Antigravity receipts without the original account binding cannot disclose recovered reply text.
22
+ - A Desktop relay exiting on another machine still requires that machine's MCP host log and shutdown reason. This release does not infer an idle-chat cause or replace Desktop delivery with a CLI worker.
23
+
24
+ ### Upgrade
25
+
26
+ ```powershell
27
+ npx.cmd -y @minhspark/codex-mcp-bridge@latest
28
+ ```
29
+
30
+ For noninteractive Windows updates, use `codex-bridge-update --user`. Reconnect affected existing MCP clients after active work finishes, then check their status tools. The global installation path remains available with `npm install -g @minhspark/codex-mcp-bridge@latest --allow-scripts=@minhspark/codex-mcp-bridge --foreground-scripts`.
31
+
32
+ ## [1.26.9] - 2026-11-03
33
+
34
+ ### Fixed
35
+
36
+ - Shorten the "Applying managed settings" step of `npx @minhspark/codex-mcp-bridge@latest`. Each Codex `mcp` command takes several seconds to start on a machine with many MCP servers and plugins, and one update ran about sixteen of them. The Codex installers now read their own entry with one `codex mcp get` instead of listing every server first, the verification reads all three registrations from a single `codex mcp list`, and the updater no longer runs a second, separate verification process after setup has already verified the saved settings.
37
+ - Show which component the updater is configuring while managed settings are applied, instead of a single line that looked frozen for a minute or more.
38
+ - Stop reporting `MCP registration is missing` for `claude-bridge`, `codex-native-relay` and `antigravity-bridge` when Codex CLI was merely too slow to answer. A slow inventory now says so, and the three registrations are reported as not checked. Codex `mcp` commands used by setup may take up to 120 seconds instead of 30, so a busy computer no longer fails an otherwise complete update.
39
+
40
+ ### Verification
41
+
42
+ - Regression coverage checks that verification makes exactly one Codex inventory call, that only Codex's own "No MCP server named" error counts as an absent entry, that a timed-out inventory is reported as unchecked, and that the updater runs setup once and relays only its progress lines.
43
+
44
+ ### Upgrade
45
+
46
+ ```powershell
47
+ npx.cmd -y @minhspark/codex-mcp-bridge@latest
48
+ ```
49
+
50
+ This command runs the 1.26.9 updater itself, so the shorter sequence applies to this update.
51
+
7
52
  ## [1.26.8] - 2026-11-03
8
53
 
9
54
  ### Fixed
@@ -82,6 +82,12 @@ new task, account, process, prompt and directory to match.
82
82
  turn. The exact dispatch must occur after the saved watermark, and commentary
83
83
  alone is never a final reply. Receipt observation resolves that exact dispatch
84
84
  even after newer turns start; it does not substitute the latest turn's answer.
85
+ For Antigravity Desktop, retain `requestId` and use
86
+ `read_antigravity_delivery`. `accepted` confirms only native acceptance;
87
+ `reply_received` requires an exact native message and execution with a public
88
+ final reply. Unsupported transcript formats or ambiguous interleaving keep
89
+ the delivery unconfirmed. Receipts without their original account binding
90
+ cannot disclose recovered reply text. None of these states authorizes a resend.
85
91
  5. An `interrupted` receipt ends observation only: task outcome is unknown and
86
92
  background work may continue. Inspect the existing task before a scoped
87
93
  follow-up; do not repeat card creation or file operations.
@@ -113,6 +119,18 @@ defer hot reload. Save the original receipt and any verified historical reply
113
119
  before reconnecting. An unknown ID after reconnecting is not evidence of
114
120
  non-delivery. Verify recovery with one harmless message, not by resending work.
115
121
 
122
+ Updated workers can transfer a pending Claude transcript observation only when
123
+ the exact native message has already been observed. Held or unobserved deliveries
124
+ retain their original callback channel and continue to defer reload. Installing
125
+ new code cannot change the migration behavior of an already-running old worker.
126
+
127
+ Updater success verifies managed settings and the background relay separately
128
+ from Desktop connectivity. Check `codex_bridge_status`, `claude_bridge_status`
129
+ and `antigravity_bridge_status` in the existing clients. If a native companion
130
+ starts and then exits, retain its MCP host stderr and shutdown reason. A missing
131
+ relay pipe alone does not identify the cause; do not infer that an empty chat or
132
+ a dummy prompt is required to keep it alive.
133
+
116
134
  Card revocation and communication revocation are independent. Archiving a
117
135
  conversation does not guarantee worktree/branch removal. Check running work,
118
136
  uncommitted files and unique commits before cleanup; preserve configuration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.26.8",
3
+ "version": "1.26.10",
4
4
  "description": "Connect Claude, Codex, and Antigravity through MCP, with native Desktop handoffs and bridge-owned Antigravity CLI sessions.",
5
5
  "keywords": [
6
6
  "mcp",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.26.8",
4
+ "version": "1.26.10",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { randomUUID } from "node:crypto";
7
- import { applyStdioMcpRegistration, stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
7
+ import { applyStdioMcpRegistration, CODEX_MCP_TIMEOUT_MS, readCodexMcpServer, stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
8
8
  import { bridgeEntrypointConflict, isBridgeSource, isMainModule, isNodeLauncher, stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
9
9
  import { backupRegularConfigFile } from "../src/project-policy.mjs";
10
10
  import { homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
@@ -256,10 +256,8 @@ export function runAntigravityInstaller(args = process.argv.slice(2), { prepareR
256
256
  let codexExisting = null;
257
257
  if (options.targets.includes("codex")) {
258
258
  const codexBin = validateCodexExecutable(resolveCodexBin(process.env.CODEX_EXE));
259
- run = (command) => execFileSync(codexBin, command, { encoding: "utf8", env: spawnEnv(), windowsHide: true, shell: false, timeout: 30000, stdio: "pipe" });
260
- const inventory = JSON.parse(run(["mcp", "list", "--json"]));
261
- if (!Array.isArray(inventory)) throw new Error("Codex returned an invalid MCP inventory");
262
- if (inventory.some((item) => item.name === NAME)) codexExisting = JSON.parse(run(["mcp", "get", NAME, "--json"]));
259
+ run = (command) => execFileSync(codexBin, command, { encoding: "utf8", env: spawnEnv(), windowsHide: true, shell: false, timeout: CODEX_MCP_TIMEOUT_MS, stdio: "pipe" });
260
+ codexExisting = readCodexMcpServer(run, NAME);
263
261
  }
264
262
  const plan = planAntigravityInstallation({ ...options, home, root, node, codexExisting });
265
263
  if (!options.remove) {
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { PLATFORM_LABEL, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
8
8
  import { exitForVersionRequest } from "../src/cli-version.mjs";
9
9
  import { desktopTasksConfigured } from "../src/native-relay.mjs";
10
- import { applyStdioMcpRegistration, codexMcpRegistration } from "../src/codex-mcp-registration.mjs";
10
+ import { applyStdioMcpRegistration, codexMcpRegistration, readCodexMcpServer } from "../src/codex-mcp-registration.mjs";
11
11
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
12
12
  import { codexAccessPaths, enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
13
13
  import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
@@ -37,10 +37,7 @@ if (remove) {
37
37
  process.exit(0);
38
38
  }
39
39
 
40
- const servers = JSON.parse(run(["mcp", "list", "--json"]));
41
- if (!Array.isArray(servers)) throw new Error("Codex returned an invalid MCP inventory; existing configuration was not changed");
42
- const existing = servers.some((server) => server.name === serverName)
43
- ? JSON.parse(run(["mcp", "get", serverName, "--json"])) : null;
40
+ const existing = readCodexMcpServer(run, serverName);
44
41
  const registration = codexMcpRegistration({ name: serverName, existing, node: nodeBin, entry, entryArgs, env: process.env, desktopOnly: desktopTasksConfigured() });
45
42
  createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...registration.environment }) });
46
43
  const applied = applyStdioMcpRegistration({ name: serverName, registration, existing, run, configPath: codexAccessPaths().config });
@@ -17,7 +17,7 @@ import {
17
17
  spawnEnv,
18
18
  } from "../src/platform.mjs";
19
19
  import { exitForVersionRequest } from "../src/cli-version.mjs";
20
- import { applyStdioMcpRegistration, stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
20
+ import { applyStdioMcpRegistration, readCodexMcpServer, stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
21
21
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
22
22
  import { codexAccessPaths, enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
23
23
  import { stableInstallPath } from "../src/install-paths.mjs";
@@ -25,7 +25,7 @@ import { removeRelayFile } from "../src/native-relay-files.mjs";
25
25
 
26
26
  exitForVersionRequest(import.meta.url);
27
27
 
28
- const VERSION = "1.26.8";
28
+ const VERSION = "1.26.10";
29
29
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
30
30
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
31
31
  const entryArgs = ["native-relay-companion.mjs"];
@@ -69,10 +69,7 @@ if (!IS_MACOS && !IS_WINDOWS) {
69
69
  console.log("claude-bridge will keep using the app-server path here.");
70
70
  }
71
71
 
72
- const servers = JSON.parse(run(["mcp", "list", "--json"]));
73
- if (!Array.isArray(servers)) throw new Error("Codex returned an invalid MCP inventory; existing configuration was not changed");
74
- const existingServer = servers.some((server) => server.name === serverName)
75
- ? JSON.parse(run(["mcp", "get", serverName, "--json"])) : null;
72
+ const existingServer = readCodexMcpServer(run, serverName);
76
73
  /**
77
74
  * Registering under whichever Node happens to run this installer is what made
78
75
  * the companion unreachable on macOS: Codex Desktop rejects a peer whose
@@ -105,7 +102,7 @@ if (runtime.source === "process.execPath" && hasCodexDesktopApp()) {
105
102
  console.log("runtime shipped inside Codex Desktop if that happens.");
106
103
  }
107
104
  console.log(`registered MCP server "${serverName}" with Codex (${applied.mode === "retarget" ? "existing entry retargeted in place; its other settings were kept" : "codex mcp add"}):`);
108
- console.log(run(["mcp", "get", serverName]));
105
+ if (!process.argv.includes("--setup")) console.log(run(["mcp", "get", serverName]));
109
106
  if (applied.notice) console.log(`NOTE: ${applied.notice}`);
110
107
  else if (ensureEnvVars.length) console.log(`env_vars includes ${ensureEnvVars.join(", ")}; the temporary pipe path itself is never saved.`);
111
108
 
package/scripts/setup.mjs CHANGED
@@ -4,7 +4,8 @@ import { printSetup, runSetup, setupFailureResult } from "../src/setup.mjs";
4
4
 
5
5
  exitForVersionRequest(import.meta.url);
6
6
  try {
7
- const result = runSetup({ args: process.argv.slice(2) });
7
+ const onProgress = process.argv.includes("--progress") ? (step) => process.stderr.write(`setup-progress: ${step}\n`) : undefined;
8
+ const result = runSetup({ args: process.argv.slice(2), onProgress });
8
9
  printSetup(result, { json: process.argv.includes("--json") });
9
10
  if (result.configured === false && !result.dryRun) process.exitCode = 1;
10
11
  } catch (error) {
@@ -65,9 +65,18 @@ export function resolveUpdateNpm(env = process.env) {
65
65
  throw failure("UPDATE_NPM_NOT_FOUND", "A supported npm CLI could not be located. Run this updater through npm exec.", "preflight");
66
66
  }
67
67
 
68
- function execute(command, args, options) {
68
+ const SETUP_PROGRESS = /^setup-progress: ([^\u0000-\u001f\u007f]{1,120})$/;
69
+
70
+ function execute(command, args, { onStderrLine, ...options }) {
69
71
  return new Promise(resolve => {
70
- execFile(command, args, options, (error, stdout, stderr) => resolve({ status: error ? 1 : 0, error, stdout, stderr }));
72
+ const child = execFile(command, args, options, (error, stdout, stderr) => resolve({ status: error ? 1 : 0, error, stdout, stderr }));
73
+ if (!onStderrLine || !child.stderr) return;
74
+ let pending = "";
75
+ child.stderr.on("data", chunk => {
76
+ const lines = (pending + chunk).split(/\r?\n/);
77
+ pending = lines.pop();
78
+ for (const line of lines) onStderrLine(line);
79
+ });
71
80
  });
72
81
  }
73
82
 
@@ -141,11 +150,11 @@ export async function runUpdate({ args = [], env = process.env, platform = proce
141
150
  const prefixArgs = userPrefix ? ["--prefix", userPrefix] : [];
142
151
  let cwd = neutralDirectory([root], env);
143
152
  let childEnv = Object.fromEntries(Object.entries(env).filter(([name]) => !/^AGENT_RELAY_(?!DIRECTORY$)/i.test(name)));
144
- const command = async (arguments_, phase, timeout = 600000, environment = childEnv, setupCommand = false) => {
153
+ const command = async (arguments_, phase, timeout = 600000, environment = childEnv, setupCommand = false, onStderrLine) => {
145
154
  let result;
146
155
  try {
147
156
  result = await run(process.execPath, arguments_, { env: environment, cwd, encoding: "utf8", windowsHide: true,
148
- shell: false, timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
157
+ shell: false, timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...(onStderrLine ? { onStderrLine } : {}) });
149
158
  } catch (error) { throw childFailure({ error, stdout: error.stdout, stderr: error.stderr }, phase, setupCommand); }
150
159
  if (result?.error || (result?.status ?? result?.exitCode ?? 0) !== 0) throw childFailure(result, phase, setupCommand);
151
160
  return String(result?.stdout ?? "");
@@ -203,15 +212,15 @@ export async function runUpdate({ args = [], env = process.env, platform = proce
203
212
  await command([npm, "install", "--global", "--prefix", prefix, `${PACKAGE}@${version}`, "--ignore-scripts"],
204
213
  "installation", 600000, { ...childEnv, CODEX_BRIDGE_SKIP_SETUP: "1" });
205
214
  if (packageVersion(globalRoot) !== version) throw failure("UPDATE_VERSION_MISMATCH", "The global package version does not match the updater version. Rerun the same update command.", "verification");
206
- for (const check of [false, true]) {
207
- onProgress(check ? "Verifying the global bridge settings and owned relay runtime." : "Applying managed settings from the installed bridge.");
208
- let setup;
209
- try { setup = JSON.parse(await command([path.join(globalRoot, "scripts", "setup.mjs"), ...(check ? ["--check"] : []), "--json"], "verification", 600000, childEnv, true)); }
210
- catch (error) { if (error?.code) throw error; throw failure("UPDATE_SETUP_INCOMPLETE", `Global setup did not return a verifiable result. ${SETUP_INSPECTION} ${RETRY}`, "verification"); }
211
- if (setup?.configured !== true) throw failure("UPDATE_SETUP_INCOMPLETE", `The global bridge settings are incomplete. ${setupFailureSummary(setup) ?? SETUP_INSPECTION} ${RETRY}`, "verification");
212
- }
215
+ onProgress("Applying and verifying managed settings from the installed bridge. Each step can take up to a minute while Codex CLI reads its configuration.");
216
+ const setupProgress = line => { const step = SETUP_PROGRESS.exec(line)?.[1]; if (step) onProgress(` ${step}`); };
217
+ let setup;
218
+ try { setup = JSON.parse(await command([path.join(globalRoot, "scripts", "setup.mjs"), "--json", "--progress"], "verification", 600000, childEnv, true, setupProgress)); }
219
+ catch (error) { if (error?.code) throw error; throw failure("UPDATE_SETUP_INCOMPLETE", `Global setup did not return a verifiable result. ${SETUP_INSPECTION} ${RETRY}`, "verification"); }
220
+ if (setup?.configured !== true) throw failure("UPDATE_SETUP_INCOMPLETE", `The global bridge settings are incomplete. ${setupFailureSummary(setup) ?? SETUP_INSPECTION} ${RETRY}`, "verification");
221
+ onProgress("Verifying the owned relay runtime.");
213
222
  const service = await awaitService(globalRoot, false, "verification");
214
- return { updated: true, configured: true, version, previousVersion, migrated, service, opensApps: false, opensChats: false };
223
+ return { updated: true, configured: true, version, previousVersion, migrated, service, desktopConnectionsVerified: false, opensApps: false, opensChats: false };
215
224
  }
216
225
 
217
226
  export async function runUpdateCli(args = process.argv.slice(2)) {
@@ -219,7 +228,7 @@ export async function runUpdateCli(args = process.argv.slice(2)) {
219
228
  process.chdir(neutralDirectory([ROOT], process.env));
220
229
  const result = await runUpdate({ args, onProgress: message => process.stderr.write(`${message}\n`) });
221
230
  process.stdout.write(result.help ? `${result.help}\n` : args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n`
222
- : `Bridge ${result.version} updated; managed settings verified. Background relay ${result.service.ready ? "ready" : "remains intentionally disabled"}.\n`);
231
+ : `Bridge ${result.version} updated; managed settings verified. Background relay ${result.service.ready ? "ready" : "remains intentionally disabled"}.\nDesktop connections have not been verified. Check codex_bridge_status, claude_bridge_status and antigravity_bridge_status in the existing apps.\n`);
223
232
  } catch (error) {
224
233
  const options = { component: "bridge-update", operation: "update", deliveryState: "not_applicable" };
225
234
  if (args.includes("--json")) process.stdout.write(JSON.stringify({ updated: false, pending: error.pending === true, diagnostic: bridgeDiagnostic(error, options) }, null, 2) + "\n");
@@ -7,9 +7,11 @@ import { z } from "zod";
7
7
  import { resolveCodexSenderContext } from "./codex-sender-context.mjs";
8
8
  import { readClaudeSenderContext, requireClaudeSenderContext, stopProcessInspectors } from "./claude-sender-context.mjs";
9
9
  import { readClaudeAccountContext } from "./desktop-account-context.mjs";
10
+ import { readCodexAccountContext } from "./codex-account-context.mjs";
10
11
  import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
11
12
  import { antigravityBridgeDirectory, callAntigravityDesktop } from "./antigravity-ipc.mjs";
12
13
  import { AntigravityDeliveries, antigravityDeliveryError } from "./antigravity-deliveries.mjs";
14
+ import { observeAntigravityDelivery } from "./antigravity-desktop.mjs";
13
15
  import { AntigravityCliSession } from "./antigravity-cli.mjs";
14
16
  import { exitForVersionRequest } from "./cli-version.mjs";
15
17
  import { NativeDesktopRelay, desktopTaskSocketPath, accountRelaySocketPath } from "./native-relay.mjs";
@@ -45,6 +47,7 @@ const reload = createReloadControl({
45
47
 
46
48
  async function identifySender(extra) {
47
49
  if (caller === "codex") {
50
+ const account = readCodexAccountContext();
48
51
  const context = await resolveCodexSenderContext(extra?._meta, { inspectDesktopTask: async (threadId) => {
49
52
  const accounts = requireBridgeAccounts(readBridgeAccounts());
50
53
  const { result } = await senderRelay.requestDesktop("read_thread", { threadId, hostId: "local", turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 1 }, { accountContext: accounts, beforeSend: () => assertAccountIdentity(accounts) });
@@ -52,11 +55,16 @@ async function identifySender(extra) {
52
55
  return { thread: result?.thread, latestTurnId: result?.turns?.[0]?.id ?? null };
53
56
  } });
54
57
  if (context.status !== "verified") throw new Error(context.reason ?? "Unverified Codex sender");
55
- return { provider: "codex", id: context.threadId, cwd: context.cwd };
58
+ const current = readCodexAccountContext();
59
+ if (account.status !== "verified" || current.status !== "verified" || !/^[a-f0-9]{64}$/.test(account.fingerprint ?? "")) throw new Error("The calling Codex account is not verified");
60
+ if (current.fingerprint !== account.fingerprint) throw new Error("The calling Codex account changed during sender verification");
61
+ return { provider: "codex", id: context.threadId, cwd: context.cwd, accountFingerprint: account.fingerprint };
56
62
  }
57
63
  if (caller === "claude") {
58
64
  const context = requireClaudeSenderContext(await readClaudeSenderContext({ account: readClaudeAccountContext() }));
59
- return { provider: "claude", id: context.sessionId, cwd: context.cwd };
65
+ const current = readClaudeAccountContext();
66
+ if (current.status !== "verified" || !/^[a-f0-9]{64}$/.test(context.accountFingerprint ?? "") || current.fingerprint !== context.accountFingerprint) throw new Error("The calling Claude account changed during sender verification");
67
+ return { provider: "claude", id: context.sessionId, cwd: context.cwd, accountFingerprint: context.accountFingerprint };
60
68
  }
61
69
  throw new Error("Install this bridge for Codex or Claude; unknown callers cannot read conversations or send work");
62
70
  }
@@ -89,10 +97,25 @@ function sameCwd(expected, actual) {
89
97
 
90
98
  function owns(record, identity) {
91
99
  if (!record || record.sender?.provider !== identity.provider || record.sender?.id !== identity.id) throw preflightFailure("ANTIGRAVITY_RECEIPT_OWNER_MISMATCH", "Delivery does not belong to this sending conversation");
100
+ if (Object.hasOwn(record.sender, "accountFingerprint") && record.sender.accountFingerprint !== identity.accountFingerprint) throw preflightFailure("ANTIGRAVITY_RECEIPT_ACCOUNT_MISMATCH", "Delivery does not belong to the currently signed-in sending account");
92
101
  sameCwd(identity.cwd, record.cwd);
93
102
  return record;
94
103
  }
95
104
 
105
+ function sameSender(left, right) {
106
+ return left?.provider === right?.provider && left?.id === right?.id && left?.accountFingerprint === right?.accountFingerprint;
107
+ }
108
+
109
+ function accountUnboundReceipt(record) {
110
+ const recovered = record.status === "reply_received" || record.result?.status === "reply_received" || record.replyObservation?.status === "reply_received";
111
+ const status = recovered ? "unknown" : record.status;
112
+ const { response, reply, ...originalResult } = record.result ?? {};
113
+ return { ...record, status,
114
+ result: recovered ? { conversationId: record.conversationId, surface: "desktop", status, completed: false } : originalResult,
115
+ replyObservation: { status: "unavailable", code: "ANTIGRAVITY_RECEIPT_ACCOUNT_UNBOUND", source: "native-transcript", retrySafe: false,
116
+ reason: "The original delivery did not record its sending account. Recovered reply text is withheld; retain the original receipt and do not resend." } };
117
+ }
118
+
96
119
  function handoff(prompt, identity) {
97
120
  return `[Agent handoff from ${identity.provider} conversation ${identity.id}. Native Antigravity permissions remain in effect.]\n\n${prompt}`;
98
121
  }
@@ -146,7 +169,7 @@ register("list_antigravity_conversations", "List native Desktop conversations an
146
169
  const identity = await sender(extra);
147
170
  const records = surface === "desktop" ? await callAntigravityDesktop({ operation: "list" }) : [...sessions.values()].map((entry) => ({ ...entry.session.read(), sender: entry.sender }));
148
171
  return records.filter((record) => {
149
- try { sameCwd(identity.cwd, record.cwd); return surface !== "cli" || record.sender.id === identity.id && record.sender.provider === identity.provider; }
172
+ try { sameCwd(identity.cwd, record.cwd); return surface !== "cli" || sameSender(record.sender, identity); }
150
173
  catch { return false; }
151
174
  });
152
175
  });
@@ -156,14 +179,26 @@ register("send_to_antigravity_conversation", "Send one authorized prompt to an e
156
179
  const cwd = sameCwd(identity.cwd, args.cwd);
157
180
  const prompt = handoff(args.prompt, identity);
158
181
  if (args.surface === "desktop" && (prompt.length > 30000 || prompt.includes("\0"))) throw preflightFailure("ANTIGRAVITY_PROMPT_INVALID", "Desktop handoff must fit within 30000 characters and contain no NUL");
159
- if (args.surface === "desktop") return callAntigravityDesktop({ operation: "send", ...args, cwd, prompt, sender: identity });
182
+ if (args.surface === "desktop") {
183
+ const receipt = await callAntigravityDesktop({ operation: "send", ...args, cwd, prompt, sender: identity });
184
+ try {
185
+ const current = await sender(extra);
186
+ if (!sameSender(current, identity)) throw preflightFailure("ANTIGRAVITY_SENDER_CHANGED", "Sender changed while inspecting the native delivery receipt");
187
+ owns(receipt, current);
188
+ return Object.hasOwn(receipt.sender, "accountFingerprint") ? receipt : accountUnboundReceipt(receipt);
189
+ } catch (error) {
190
+ error.phase = "receipt";
191
+ error.deliveryState = "unknown";
192
+ throw error;
193
+ }
194
+ }
160
195
  const entry = sessions.get(args.conversationId);
161
- if (!entry || entry.sender.id !== identity.id || entry.sender.provider !== identity.provider) throw preflightFailure("ANTIGRAVITY_SESSION_OWNER_MISMATCH", "CLI session is not owned by this sender and bridge process");
196
+ if (!entry || !sameSender(entry.sender, identity)) throw preflightFailure("ANTIGRAVITY_SESSION_OWNER_MISMATCH", "CLI session is not owned by this sender and bridge process");
162
197
  sameCwd(cwd, entry.session.read().cwd);
163
198
  return deliveries.dispatch({ ...args, cwd, sender: identity, prompt }, async () => {
164
199
  const current = await sender(extra);
165
200
  sameCwd(cwd, current.cwd);
166
- if (current.id !== identity.id) throw preflightFailure("ANTIGRAVITY_SENDER_CHANGED", "Sender changed before delivery");
201
+ if (!sameSender(current, identity)) throw preflightFailure("ANTIGRAVITY_SENDER_CHANGED", "Sender changed before delivery");
167
202
  return entry.session.send(prompt);
168
203
  }, { component: "antigravity-bridge", operation: "send_to_antigravity_conversation" });
169
204
  });
@@ -188,7 +223,7 @@ register("start_antigravity_cli_session", "Create a new bridge-owned Antigravity
188
223
  reserved = false;
189
224
  const current = await sender(extra);
190
225
  sameCwd(cwd, current.cwd);
191
- if (current.id !== identity.id) throw preflightFailure("ANTIGRAVITY_SENDER_CHANGED", "Sender changed before delivery");
226
+ if (!sameSender(current, identity)) throw preflightFailure("ANTIGRAVITY_SENDER_CHANGED", "Sender changed before delivery");
192
227
  sending = true;
193
228
  return await session.send(handoff(args.prompt, identity));
194
229
  } catch (error) {
@@ -208,16 +243,21 @@ register("start_antigravity_cli_session", "Create a new bridge-owned Antigravity
208
243
  }, { component: "antigravity-bridge", operation: "start_antigravity_cli_session" });
209
244
  });
210
245
 
211
- register("read_antigravity_delivery", "Read the original receipt without resending. Desktop accepted means the native API accepted it, not that the agent completed a turn.", { surface: z.enum(["desktop", "cli"]), requestId: z.string().uuid() }, true, async (args, extra) => {
246
+ register("read_antigravity_delivery", "Read the original receipt without resending. Desktop accepted means native acceptance; reply_received requires a final reply correlated to the original message and execution.", { surface: z.enum(["desktop", "cli"]), requestId: z.string().uuid() }, true, async (args, extra) => {
212
247
  const identity = await sender(extra);
213
248
  const record = args.surface === "desktop" ? await callAntigravityDesktop({ operation: "receipt", requestId: args.requestId }) : deliveries.read(args.requestId);
214
- return owns(record, identity);
249
+ owns(record, identity);
250
+ if (args.surface !== "desktop") return record;
251
+ const observed = Object.hasOwn(record.sender, "accountFingerprint") ? await observeAntigravityDelivery(record) : accountUnboundReceipt(record);
252
+ const current = await sender(extra);
253
+ if (!sameSender(current, identity)) throw preflightFailure("ANTIGRAVITY_SENDER_CHANGED", "Sender changed while inspecting the original delivery");
254
+ return owns(observed, current);
215
255
  });
216
256
 
217
257
  register("close_antigravity_cli_session", "Close an idle bridge-owned CLI session belonging to the caller.", { conversationId: z.string().uuid() }, false, async ({ conversationId }, extra) => {
218
258
  const identity = await sender(extra);
219
259
  const entry = sessions.get(conversationId);
220
- if (!entry || entry.sender.id !== identity.id || entry.sender.provider !== identity.provider) throw preflightFailure("ANTIGRAVITY_SESSION_OWNER_MISMATCH", "CLI session is not owned by this sender");
260
+ if (!entry || !sameSender(entry.sender, identity)) throw preflightFailure("ANTIGRAVITY_SESSION_OWNER_MISMATCH", "CLI session is not owned by this sender");
221
261
  sameCwd(identity.cwd, entry.session.read().cwd);
222
262
  if (["running", "starting"].includes(entry.session.read().status)) throw preflightFailure("ANTIGRAVITY_SESSION_BUSY", "Wait for the active CLI delivery before closing");
223
263
  const closed = await entry.session.close();
@@ -64,6 +64,29 @@ export class AntigravityDeliveries {
64
64
  this.write(record);
65
65
  }
66
66
 
67
+ captureObservation(requestId, observation) {
68
+ if (!this.active.has(requestId)) throw new Error("Cannot prepare inactive delivery observation");
69
+ if (observation?.version !== 1 || !["ready", "unavailable"].includes(observation.status)
70
+ || !/^[a-f0-9]{64}$/.test(observation.promptSha256 ?? "") || typeof observation.projectId !== "string"
71
+ || observation.status === "ready" && (!UUID.test(observation.trajectoryId ?? "") || !Number.isSafeInteger(observation.beforeStepIndex) || observation.beforeStepIndex < -1)) throw new Error("Invalid native observation boundary");
72
+ const record = this.read(requestId);
73
+ record.nativeObservation = { version: 1, status: observation.status, promptSha256: observation.promptSha256, projectId: observation.projectId,
74
+ ...(observation.status === "ready" ? { trajectoryId: observation.trajectoryId, beforeStepIndex: observation.beforeStepIndex } : { code: "ANTIGRAVITY_TRANSCRIPT_UNAVAILABLE" }) };
75
+ this.write(record);
76
+ }
77
+
78
+ async refresh(requestId, observe) {
79
+ const record = this.read(requestId);
80
+ if (!record || this.active.has(requestId)) return record;
81
+ const refreshed = await observe(record);
82
+ if (!refreshed || refreshed.requestId !== record.requestId || refreshed.digest !== record.digest || refreshed.cwd !== record.cwd
83
+ || refreshed.conversationId !== record.conversationId || refreshed.surface !== record.surface || JSON.stringify(refreshed.sender) !== JSON.stringify(record.sender)) throw new Error("Observation changed the delivery identity");
84
+ const current = this.read(requestId);
85
+ if (this.active.has(requestId) || JSON.stringify(current) !== JSON.stringify(record)) return current;
86
+ if (refreshed.status === "reply_received") this.write(refreshed);
87
+ return refreshed;
88
+ }
89
+
67
90
  write(record) {
68
91
  const temporary = `${this.file(record.requestId)}.${process.pid}.tmp`;
69
92
  fs.writeFileSync(temporary, JSON.stringify(record), { flag: "wx", mode: 0o600 });
@@ -91,7 +114,7 @@ export class AntigravityDeliveries {
91
114
  }
92
115
  this.active.add(requestId);
93
116
  void Promise.resolve().then(operation).then((result) => {
94
- record.status = ["accepted", "sent_unconfirmed", "held", "refused"].includes(result?.status) ? result.status : result?.status && result.status !== "SUCCESS" ? "failed" : "completed";
117
+ record.status = ["accepted", "sent_unconfirmed", "held", "refused", "reply_received"].includes(result?.status) ? result.status : result?.status && result.status !== "SUCCESS" ? "failed" : "completed";
95
118
  record.result = result;
96
119
  record.conversationId = result?.conversationId ?? result?.conversation_id ?? record.conversationId;
97
120
  if (record.status === "failed") {
@@ -107,7 +130,9 @@ export class AntigravityDeliveries {
107
130
  }).finally(() => {
108
131
  record.finishedAt = new Date().toISOString();
109
132
  try {
110
- record.conversationId ??= this.read(requestId)?.conversationId;
133
+ const previous = this.read(requestId);
134
+ record.conversationId ??= previous?.conversationId;
135
+ record.nativeObservation ??= previous?.nativeObservation;
111
136
  this.write(record);
112
137
  } catch (error) { process.stderr.write(`Could not persist Antigravity delivery outcome: ${error.code ?? "WRITE_FAILED"}\n`); }
113
138
  this.active.delete(requestId);