@minhspark/codex-mcp-bridge 1.26.9 → 1.27.0

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.9",
3
+ "version": "1.27.0",
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
@@ -2,7 +2,41 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
- ## [Unreleased]
5
+ ## [1.27.0] - 2026-11-05
6
+
7
+ ### Added
8
+
9
+ - A versioned local Antigravity human-dashboard channel with exact conversation/workspace validation, fixed human sender identity, durable idempotent request receipts and explicit preflight/uncertain delivery outcomes. It requires the app-launched sidecar and never treats native acceptance as a completed reply.
10
+
11
+ ### Verification and limits
12
+
13
+ - Unit tests cover preflight, idempotent receipts and refusal paths on Windows, macOS and Linux with Node 22 and 24. Delivery through a live Antigravity sidecar was not exercised before this release.
14
+ - Upgrade with `npm install -g @minhspark/codex-mcp-bridge@latest --allow-scripts=@minhspark/codex-mcp-bridge --foreground-scripts`, then restart Antigravity so its sidecar loads the new channel.
15
+
16
+ ## [1.26.10] - 2026-11-03
17
+
18
+ ### Fixed
19
+
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - Report background relay readiness separately from unverified Desktop connections after updating. A ready background worker does not establish a live native Desktop relay.
25
+ - Reject malformed transcript session identifiers before composing filesystem paths, and validate executable candidates before inspecting their file type. Configured external executable locations remain supported.
26
+
27
+ ### Verification and limits
28
+
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+
33
+ ### Upgrade
34
+
35
+ ```powershell
36
+ npx.cmd -y @minhspark/codex-mcp-bridge@latest
37
+ ```
38
+
39
+ 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`.
6
40
 
7
41
  ## [1.26.9] - 2026-11-03
8
42
 
@@ -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,
@@ -136,3 +154,7 @@ is still null. Continue with `wait_codex_reply`; do not create again to obtain t
136
154
  ID. The updated companion retains late creation acknowledgements. If it reports
137
155
  unsupported `get_creation_receipt`, reload both bridge workers after installing
138
156
  the update; that compatibility check occurs before any new task is dispatched.
157
+
158
+ ## Local human dashboards
159
+
160
+ Local human dashboards can use `src/antigravity-human.mjs`: `inspectHumanConversation({ conversationId, cwd })`, `sendHumanMessage({ requestId, conversationId, cwd, prompt })`, and `readHumanReceipt({ requestId, conversationId, cwd })`. The native app must have launched an updated sidecar advertising `humanDispatchVersion: 1`. Requests use an existing desktop conversation UUID, its canonical workspace and a stable request UUID. The sender is fixed to `human-web` / `minh-spark-company`; caller-supplied agent identities are rejected. Reusing a request ID never resends it, and changed request contents are refused. Poll the receipt after an uncertain outcome. Native acceptance does not prove a reply or completion. This channel does not answer permission prompts or create conversations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.26.9",
3
+ "version": "1.27.0",
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.9",
4
+ "version": "1.27.0",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -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.9";
28
+ const VERSION = "1.27.0";
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"];
@@ -220,7 +220,7 @@ export async function runUpdate({ args = [], env = process.env, platform = proce
220
220
  if (setup?.configured !== true) throw failure("UPDATE_SETUP_INCOMPLETE", `The global bridge settings are incomplete. ${setupFailureSummary(setup) ?? SETUP_INSPECTION} ${RETRY}`, "verification");
221
221
  onProgress("Verifying the owned relay runtime.");
222
222
  const service = await awaitService(globalRoot, false, "verification");
223
- 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 };
224
224
  }
225
225
 
226
226
  export async function runUpdateCli(args = process.argv.slice(2)) {
@@ -228,7 +228,7 @@ export async function runUpdateCli(args = process.argv.slice(2)) {
228
228
  process.chdir(neutralDirectory([ROOT], process.env));
229
229
  const result = await runUpdate({ args, onProgress: message => process.stderr.write(`${message}\n`) });
230
230
  process.stdout.write(result.help ? `${result.help}\n` : args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n`
231
- : `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`);
232
232
  } catch (error) {
233
233
  const options = { component: "bridge-update", operation: "update", deliveryState: "not_applicable" };
234
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);
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import crypto from "node:crypto";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -8,6 +9,207 @@ import { promisify } from "node:util";
8
9
  const execute = promisify(execFile);
9
10
  const SURFACES = { desktop: "antigravity", cli: "antigravity-cli" };
10
11
  const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
12
+ const TRANSCRIPT_ROW_LIMIT = 4096;
13
+ const TRANSCRIPT_BYTE_LIMIT = 32 * 1024 * 1024;
14
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
15
+ const RESTART_NOTICE = "[Notice] All your subagents and background tasks have been stopped due to server restart. If you want a subagent to continue working, it needs to be revived by sending it a new message. If resuming work, please check on status and restart as needed.";
16
+
17
+ function transcriptFailure(code = "ANTIGRAVITY_TRANSCRIPT_UNSUPPORTED") {
18
+ return Object.assign(new Error("Native Antigravity reply observation is unavailable; retain the original receipt and do not resend."), { code });
19
+ }
20
+
21
+ function protobufFields(value) {
22
+ if (!(value instanceof Uint8Array) || value.length > 2 * 1024 * 1024) throw transcriptFailure();
23
+ const buffer = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
24
+ const fields = [];
25
+ let offset = 0;
26
+ const integer = () => {
27
+ let result = 0n;
28
+ for (let shift = 0n; shift < 70n; shift += 7n) {
29
+ if (offset >= buffer.length) throw transcriptFailure();
30
+ const byte = buffer[offset++];
31
+ result |= BigInt(byte & 127) << shift;
32
+ if (!(byte & 128)) {
33
+ if (result > BigInt(Number.MAX_SAFE_INTEGER)) throw transcriptFailure();
34
+ return Number(result);
35
+ }
36
+ }
37
+ throw transcriptFailure();
38
+ };
39
+ while (offset < buffer.length) {
40
+ if (fields.length >= 4096) throw transcriptFailure();
41
+ const tag = integer();
42
+ const field = Math.floor(tag / 8);
43
+ const wire = tag % 8;
44
+ if (!field || field > 536870911) throw transcriptFailure();
45
+ if (wire === 0) fields.push({ field, wire, value: integer() });
46
+ else {
47
+ const length = wire === 2 ? integer() : wire === 1 ? 8 : wire === 5 ? 4 : -1;
48
+ if (length < 0 || length > buffer.length - offset) throw transcriptFailure();
49
+ fields.push({ field, wire, value: buffer.subarray(offset, offset + length) });
50
+ offset += length;
51
+ }
52
+ }
53
+ return fields;
54
+ }
55
+
56
+ function protobufField(fields, number, wire) {
57
+ const matches = fields.filter((field) => field.field === number);
58
+ if (matches.length > 1 || matches.length && matches[0].wire !== wire) throw transcriptFailure();
59
+ return matches[0]?.value;
60
+ }
61
+
62
+ function protobufText(fields, number) {
63
+ const value = protobufField(fields, number, 2);
64
+ return value === undefined ? "" : utf8.decode(value);
65
+ }
66
+
67
+ function protobufTime(fields, number) {
68
+ const value = protobufField(fields, number, 2);
69
+ if (!value) return null;
70
+ const timestamp = protobufFields(value);
71
+ const seconds = protobufField(timestamp, 1, 0) ?? 0;
72
+ const nanos = protobufField(timestamp, 2, 0) ?? 0;
73
+ if (nanos >= 1e9 || seconds > 253402300799) throw transcriptFailure();
74
+ return seconds * 1000 + nanos / 1e6;
75
+ }
76
+
77
+ function decodeNativeStep(row) {
78
+ if (!Number.isSafeInteger(row.idx) || row.idx < 0 || row.step_format !== 0) throw transcriptFailure();
79
+ const fields = protobufFields(row.step_payload);
80
+ if (protobufField(fields, 1, 0) !== row.step_type || protobufField(fields, 4, 0) !== row.status) throw transcriptFailure();
81
+ const rawMetadata = protobufField(fields, 5, 2);
82
+ if (!rawMetadata || !Buffer.from(row.metadata ?? []).equals(rawMetadata)) throw transcriptFailure();
83
+ const metadata = protobufFields(rawMetadata);
84
+ const step = { index: row.idx, type: row.step_type, status: row.status, executionId: protobufText(metadata, 12), createdAt: protobufTime(metadata, 1), completedAt: protobufTime(metadata, 8) };
85
+ if (step.type === 101) {
86
+ const system = protobufFields(protobufField(fields, 114, 2));
87
+ const rawAgent = protobufField(system, 4, 2);
88
+ if (rawAgent) {
89
+ const agent = protobufFields(rawAgent);
90
+ step.message = { id: protobufText(agent, 1), recipient: protobufText(agent, 2), sender: protobufText(agent, 3), content: protobufText(agent, 4), hidden: protobufField(agent, 11, 0) === 1 };
91
+ }
92
+ }
93
+ if (step.type === 15) {
94
+ const response = protobufFields(protobufField(fields, 20, 2));
95
+ step.response = protobufText(response, 8) || protobufText(response, 1);
96
+ step.hasTools = response.some((field) => field.field === 7);
97
+ step.stopReason = protobufField(response, 12, 0);
98
+ step.finishedAt = protobufTime(metadata, 7);
99
+ }
100
+ return step;
101
+ }
102
+
103
+ async function readNativeSteps({ home, conversationId, boundaryOnly = false, afterStepIndex = -1, findRoot }) {
104
+ if (!UUID.test(conversationId ?? "")) throw transcriptFailure();
105
+ let directory = fs.realpathSync.native(home);
106
+ for (const segment of [".gemini", "antigravity", "conversations"]) {
107
+ directory = path.join(directory, segment);
108
+ const stat = fs.lstatSync(directory);
109
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw transcriptFailure();
110
+ }
111
+ const filename = path.join(directory, `${conversationId}.db`);
112
+ const before = fs.lstatSync(filename, { bigint: true });
113
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > 512n * 1024n * 1024n) throw transcriptFailure();
114
+ for (const suffix of ["-wal", "-shm"]) {
115
+ try {
116
+ const stat = fs.lstatSync(`${filename}${suffix}`);
117
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw transcriptFailure();
118
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
119
+ }
120
+ const { DatabaseSync } = await import("node:sqlite");
121
+ const db = new DatabaseSync(filename, { readOnly: true });
122
+ try {
123
+ db.exec("PRAGMA query_only=ON; BEGIN");
124
+ const trajectory = db.prepare("SELECT trajectory_id, cascade_id FROM trajectory_meta LIMIT 2").all();
125
+ if (trajectory.length !== 1 || trajectory[0].cascade_id !== conversationId || !UUID.test(trajectory[0].trajectory_id ?? "")) throw transcriptFailure();
126
+ const maximum = db.prepare("SELECT MAX(idx) AS lastStepIndex FROM steps").get().lastStepIndex ?? -1;
127
+ if (!Number.isSafeInteger(maximum) || maximum < -1) throw transcriptFailure();
128
+ const readSteps = (lowerBound, rootsOnly = false) => {
129
+ const selector = rootsOnly ? "step_type = 101" : "idx > ?";
130
+ const parameters = rootsOnly ? [] : [lowerBound];
131
+ const rows = db.prepare(`SELECT idx, step_type, status, step_format, length(metadata) AS metadata_bytes, length(step_payload) AS payload_bytes FROM steps WHERE ${selector} ORDER BY idx LIMIT ?`).all(...parameters, TRANSCRIPT_ROW_LIMIT + 1);
132
+ if (rows.length > TRANSCRIPT_ROW_LIMIT || rows.some((row) => row.metadata_bytes > 2 * 1024 * 1024 || row.payload_bytes > 2 * 1024 * 1024)
133
+ || rows.reduce((sum, row) => sum + row.metadata_bytes + row.payload_bytes, 0) > TRANSCRIPT_BYTE_LIMIT) throw transcriptFailure("ANTIGRAVITY_TRANSCRIPT_LIMIT");
134
+ return rows.length ? db.prepare(`SELECT idx, step_type, status, step_format, metadata, step_payload FROM steps WHERE idx >= ? AND idx <= ?${rootsOnly ? " AND step_type = 101" : ""} ORDER BY idx`).all(rows[0].idx, rows.at(-1).idx).map(decodeNativeStep) : [];
135
+ };
136
+ let steps = [];
137
+ let roots;
138
+ if (!boundaryOnly) {
139
+ if (findRoot) {
140
+ roots = readSteps(-1, true).filter(findRoot);
141
+ if (roots.length === 1) steps = readSteps(roots[0].index - 1);
142
+ } else steps = readSteps(afterStepIndex);
143
+ }
144
+ const after = fs.lstatSync(filename, { bigint: true });
145
+ if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1n || before.dev !== after.dev || before.ino !== after.ino || before.birthtimeNs !== after.birthtimeNs) throw transcriptFailure();
146
+ return { trajectoryId: trajectory[0].trajectory_id, lastStepIndex: maximum, steps, ...(roots ? { roots } : {}) };
147
+ } finally { db.close(); }
148
+ }
149
+
150
+ export async function captureAntigravityObservation({ home = os.homedir(), conversationId, prompt, projectId }) {
151
+ const promptSha256 = crypto.createHash("sha256").update(prompt).digest("hex");
152
+ try {
153
+ const snapshot = await readNativeSteps({ home, conversationId, boundaryOnly: true });
154
+ return { version: 1, status: "ready", trajectoryId: snapshot.trajectoryId, beforeStepIndex: snapshot.lastStepIndex, promptSha256, projectId };
155
+ } catch {
156
+ return { version: 1, status: "unavailable", code: "ANTIGRAVITY_TRANSCRIPT_UNAVAILABLE", promptSha256, projectId };
157
+ }
158
+ }
159
+
160
+ export async function observeAntigravityDelivery(record, { home = os.homedir(), list = listAntigravityConversations } = {}) {
161
+ if (record?.surface !== "desktop" || !["accepted", "unknown", "sent_unconfirmed"].includes(record.status)) return record;
162
+ const observation = (status, code) => ({ ...record, replyObservation: { status, code, source: "native-transcript", retrySafe: false } });
163
+ try {
164
+ if (!UUID.test(record.conversationId ?? "") || !/^[a-f0-9]{64}$/.test(record.digest ?? "") || !["codex", "claude"].includes(record.sender?.provider)
165
+ || typeof record.sender.id !== "string" || !record.sender.id) throw transcriptFailure();
166
+ const matches = (await list({ home, surface: "desktop", limit: 1000 })).filter((row) => row.conversationId === record.conversationId);
167
+ if (matches.length !== 1 || matches[0].surface !== "desktop" || canonicalCwd(matches[0].cwd) !== canonicalCwd(record.cwd)) throw transcriptFailure("ANTIGRAVITY_REPLY_SCOPE_CHANGED");
168
+ const boundary = record.nativeObservation;
169
+ if (boundary && (boundary.version !== 1 || !["ready", "unavailable"].includes(boundary.status) || !/^[a-f0-9]{64}$/.test(boundary.promptSha256 ?? "") || boundary.projectId !== matches[0].projectId)) throw transcriptFailure();
170
+ if (boundary?.status === "ready" && (!Number.isSafeInteger(boundary.beforeStepIndex) || boundary.beforeStepIndex < -1 || !UUID.test(boundary.trajectoryId ?? ""))) throw transcriptFailure();
171
+ const createdAt = Date.parse(record.createdAt);
172
+ const finishedAt = Date.parse(record.finishedAt);
173
+ if (!Number.isFinite(createdAt) || boundary?.status !== "ready" && !Number.isFinite(finishedAt)) throw transcriptFailure();
174
+ const matchesRoot = (step) => {
175
+ if (step.type !== 101 || !step.message || step.status !== 3 || !UUID.test(step.message.id) || step.message.recipient !== record.conversationId
176
+ || step.createdAt === null || step.createdAt < createdAt - (boundary?.status === "ready" ? 2000 : 0) || !step.executionId) return false;
177
+ if (boundary?.status !== "ready" && step.createdAt > Math.min(createdAt + 120000, finishedAt + 30000)) return false;
178
+ const prompt = step.message.content;
179
+ if (boundary?.promptSha256 && crypto.createHash("sha256").update(prompt).digest("hex") !== boundary.promptSha256) return false;
180
+ const digest = crypto.createHash("sha256").update(JSON.stringify({ surface: "desktop", conversationId: record.conversationId, cwd: record.cwd, sender: record.sender, prompt })).digest("hex");
181
+ return digest === record.digest;
182
+ };
183
+ const snapshot = await readNativeSteps({ home, conversationId: record.conversationId, ...(boundary?.status === "ready" ? { afterStepIndex: boundary.beforeStepIndex } : { findRoot: matchesRoot }) });
184
+ if (boundary?.status === "ready" && (boundary.trajectoryId !== snapshot.trajectoryId || snapshot.lastStepIndex < boundary.beforeStepIndex)) throw transcriptFailure("ANTIGRAVITY_TRANSCRIPT_REPLACED");
185
+ const roots = snapshot.roots ?? snapshot.steps.filter(matchesRoot);
186
+ if (!roots.length) return observation("pending", "ANTIGRAVITY_MESSAGE_NOT_OBSERVED");
187
+ if (roots.length !== 1) return observation("ambiguous", "ANTIGRAVITY_REPLY_AMBIGUOUS");
188
+ const root = roots[0];
189
+ let reply;
190
+ for (const step of snapshot.steps.filter((step) => step.index > root.index)) {
191
+ if (step.type === 14 || step.executionId !== root.executionId) break;
192
+ if (step.type === 101) {
193
+ if (step.message?.hidden && step.message.sender === "system" && step.message.content === RESTART_NOTICE) continue;
194
+ if (!reply) return observation("ambiguous", "ANTIGRAVITY_REPLY_INTERLEAVED");
195
+ break;
196
+ }
197
+ reply = step.type === 15 && step.status === 3 && step.response && !step.hasTools && step.stopReason === 2
198
+ && step.finishedAt !== null && step.completedAt !== null && step.finishedAt >= root.createdAt && step.completedAt >= step.finishedAt ? step : null;
199
+ }
200
+ if (!reply) return observation("pending", "ANTIGRAVITY_REPLY_NOT_OBSERVED");
201
+ const current = (await list({ home, surface: "desktop", limit: 1000 })).filter((row) => row.conversationId === record.conversationId);
202
+ if (current.length !== 1 || current[0].surface !== "desktop" || current[0].projectId !== matches[0].projectId || canonicalCwd(current[0].cwd) !== canonicalCwd(record.cwd)) throw transcriptFailure("ANTIGRAVITY_REPLY_SCOPE_CHANGED");
203
+ if (current[0].status !== "CASCADE_RUN_STATUS_IDLE") return observation("pending", "ANTIGRAVITY_REPLY_STILL_RUNNING");
204
+ if (Buffer.byteLength(reply.response) > 256 * 1024) throw transcriptFailure("ANTIGRAVITY_REPLY_LIMIT");
205
+ const evidence = { source: "native-transcript", nativeMessageId: root.message.id, rootStepIndex: root.index, replyStepIndex: reply.index,
206
+ executionId: root.executionId, trajectoryId: snapshot.trajectoryId, replySha256: crypto.createHash("sha256").update(reply.response).digest("hex") };
207
+ return { ...record, status: "reply_received", replyObservation: { status: "reply_received", retrySafe: false, ...evidence },
208
+ result: { ...record.result, status: "reply_received", completed: true, response: reply.response, reply: reply.response, ...evidence } };
209
+ } catch (error) {
210
+ return observation("unavailable", /^ANTIGRAVITY_(?:TRANSCRIPT|REPLY)_[A-Z_]+$/.test(error.code ?? "") ? error.code : "ANTIGRAVITY_TRANSCRIPT_UNAVAILABLE");
211
+ }
212
+ }
11
213
 
12
214
  function failure(code, message) {
13
215
  return Object.assign(new Error(message), { code, deliveryState: "not_sent" });
@@ -105,7 +307,7 @@ export async function runAgentApi(options) {
105
307
  }
106
308
  }
107
309
 
108
- async function executeAgentApi({ command, conversationId, prompt, expectedCwd, home = os.homedir(), env = process.env, executable = env.ANTIGRAVITY_AGENTAPI_EXE, run = execute, list = listAntigravityConversations }) {
310
+ async function executeAgentApi({ command, conversationId, prompt, expectedCwd, home = os.homedir(), env = process.env, executable = env.ANTIGRAVITY_AGENTAPI_EXE, run = execute, list = listAntigravityConversations, beforeDispatch }) {
109
311
  if (!UUID.test(conversationId)) throw failure("ANTIGRAVITY_ID_INVALID", "An exact conversation UUID is required");
110
312
  if (!["metadata", "send"].includes(command)) throw new Error("Unsupported Antigravity agentapi command");
111
313
  if (!env.ANTIGRAVITY_LS_ADDRESS || !env.ANTIGRAVITY_CSRF_TOKEN) throw failure("ANTIGRAVITY_SIDECAR_REQUIRED", "Antigravity must launch the bridge sidecar before native requests are available");
@@ -141,6 +343,8 @@ async function executeAgentApi({ command, conversationId, prompt, expectedCwd, h
141
343
  if (metadata.cwd !== cwd || metadata.projectId !== before.projectId) throw failure("ANTIGRAVITY_METADATA_MISMATCH", "Native metadata does not match the selected conversation workspace and project");
142
344
  const current = await select();
143
345
  if (current.projectId !== before.projectId) throw failure("ANTIGRAVITY_METADATA_MISMATCH", "Conversation project changed during delivery preflight");
346
+ const nativeObservation = await captureAntigravityObservation({ home, conversationId, prompt, projectId: current.projectId });
347
+ await beforeDispatch?.(nativeObservation);
144
348
  await invoke(["send-message", conversationId, prompt]);
145
- return { conversationId, status: "accepted", completed: false, surface: "desktop" };
349
+ return { conversationId, status: "accepted", completed: false, surface: "desktop", nativeObservation };
146
350
  }
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { callAntigravityDesktop } from "./antigravity-ipc.mjs";
4
+
5
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
+ export const HUMAN_SENDER = Object.freeze({ provider: "human-web", id: "minh-spark-company" });
7
+
8
+ function invalid(message, code = "ANTIGRAVITY_HUMAN_REQUEST_INVALID") {
9
+ return Object.assign(new Error(message), { code, phase: "validation", deliveryState: "not_sent" });
10
+ }
11
+
12
+ export function humanWorkspace(value) {
13
+ if (typeof value !== "string" || !path.isAbsolute(value)) throw invalid("An absolute existing workspace is required");
14
+ try {
15
+ const resolved = fs.realpathSync.native(value);
16
+ if (!fs.statSync(resolved).isDirectory()) throw new Error("not a directory");
17
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
18
+ } catch { throw invalid("The requested workspace is unavailable"); }
19
+ }
20
+
21
+ export function humanDeliveryInput(input, operation) {
22
+ if (!input || typeof input !== "object" || !UUID.test(input.conversationId) || input.sender !== undefined) throw invalid("An exact conversation UUID and a fixed human sender are required");
23
+ const out = { conversationId: input.conversationId.toLowerCase(), cwd: humanWorkspace(input.cwd) };
24
+ if (operation !== "inspect") {
25
+ if (typeof input.requestId !== "string" || !UUID.test(input.requestId)) throw invalid("requestId must be a UUID");
26
+ out.requestId = input.requestId.toLowerCase();
27
+ }
28
+ if (operation === "send") {
29
+ if (typeof input.prompt !== "string" || !input.prompt.trim() || input.prompt.length > 30000 || input.prompt.includes("\0")) throw invalid("Prompt must contain between 1 and 30000 characters without NUL");
30
+ out.prompt = input.prompt;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ export function humanReceipt(record, target) {
36
+ if (record === null) return null;
37
+ if (!record || record.requestId !== target.requestId || record.conversationId !== target.conversationId || record.surface !== "desktop" || record.sender?.provider !== HUMAN_SENDER.provider || record.sender?.id !== HUMAN_SENDER.id || humanWorkspace(record.cwd) !== target.cwd) throw invalid("Receipt does not belong to this human delivery", "ANTIGRAVITY_HUMAN_RECEIPT_MISMATCH");
38
+ return record;
39
+ }
40
+
41
+ async function callHuman(operation, input, options = {}) {
42
+ const target = humanDeliveryInput(input, operation);
43
+ const call = options.call ?? ((request) => callAntigravityDesktop(request, { directory: options.directory, timeoutMs: options.timeoutMs ?? 10000 }));
44
+ let status;
45
+ try { status = await call({ operation: "status" }); }
46
+ catch (error) {
47
+ if (operation === "send") {
48
+ error.deliveryState = "not_sent";
49
+ error.phase = "preflight";
50
+ }
51
+ throw error;
52
+ }
53
+ if (status?.available !== true || status.humanDispatchVersion !== 1) throw invalid("The app-launched Antigravity sidecar must be updated to support human dashboard messages", "ANTIGRAVITY_HUMAN_UNSUPPORTED");
54
+ const result = await call({ operation: `human-${operation}`, ...target });
55
+ if (operation !== "inspect") {
56
+ try {
57
+ if (operation === "send" && result === null) throw invalid("No delivery receipt returned");
58
+ return humanReceipt(result, target);
59
+ } catch (error) {
60
+ if (operation === "send") { error.deliveryState = "unknown"; error.phase = "response"; }
61
+ throw error;
62
+ }
63
+ }
64
+ if (!result || result.conversationId !== target.conversationId || humanWorkspace(result.cwd) !== target.cwd || result.surface !== "desktop" || result.available !== true || result.status !== "CASCADE_RUN_STATUS_IDLE") throw invalid("Native conversation does not match the requested idle workspace", "ANTIGRAVITY_HUMAN_TARGET_MISMATCH");
65
+ return result;
66
+ }
67
+
68
+ export function inspectHumanConversation(input, options) { return callHuman("inspect", input, options); }
69
+ export function sendHumanMessage(input, options) { return callHuman("send", input, options); }
70
+ export function readHumanReceipt(input, options) { return callHuman("receipt", input, options); }
@@ -236,7 +236,7 @@ export function createAntigravityOutboundTargets(options = {}) {
236
236
  await context.check();
237
237
  return { target, sessionId: recipient.sessionId, taskId, cwd: context.cwd, msgId, status: outcome.delivery?.status ?? "sent_unconfirmed", completed: false, accountContext: context.accountContext };
238
238
  } catch (error) {
239
- if (error.msgId) receipts.set(error.msgId, { sender: context.identity, accountContext: context.accountContext, cwd: context.cwd });
239
+ if (error.msgId && error.code !== "PEER_REPLY_PENDING") receipts.set(error.msgId, { sender: context.identity, accountContext: context.accountContext, cwd: context.cwd });
240
240
  if (msgId) error.msgId = msgId;
241
241
  if (msgId || error.deliveryUncertain) error.deliveryState = "unknown";
242
242
  else if (!error.deliveryState) error.deliveryState = "not_sent";
@@ -3,9 +3,10 @@ import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { antigravityBridgeDirectory, serveAntigravityDesktop } from "./antigravity-ipc.mjs";
5
5
  import { AntigravityDeliveries } from "./antigravity-deliveries.mjs";
6
- import { listAntigravityConversations, runAgentApi, sanitizeAntigravityMetadata } from "./antigravity-desktop.mjs";
6
+ import { listAntigravityConversations, observeAntigravityDelivery, runAgentApi, sanitizeAntigravityMetadata } from "./antigravity-desktop.mjs";
7
7
  import { exitForVersionRequest } from "./cli-version.mjs";
8
8
  import { readProcessAncestry } from "./claude-sender-context.mjs";
9
+ import { HUMAN_SENDER, humanDeliveryInput, humanReceipt, humanWorkspace } from "./antigravity-human.mjs";
9
10
 
10
11
  export async function createAntigravityParentGuard({ parentPid = process.ppid, inspect = readProcessAncestry } = {}) {
11
12
  if (!Number.isSafeInteger(parentPid) || parentPid <= 1 || parentPid > 0xffffffff) throw new Error("Antigravity sidecar parent is unavailable; relaunch it from the native app");
@@ -34,7 +35,7 @@ export async function createAntigravityParentGuard({ parentPid = process.ppid, i
34
35
  };
35
36
  }
36
37
 
37
- export async function startAntigravitySidecar({ directory = antigravityBridgeDirectory(), env = process.env, createParentGuard = createAntigravityParentGuard, serve = serveAntigravityDesktop, parentCheckIntervalMs = 5000, onParentLost = () => {} } = {}) {
38
+ export async function startAntigravitySidecar({ directory = antigravityBridgeDirectory(), env = process.env, createParentGuard = createAntigravityParentGuard, serve = serveAntigravityDesktop, parentCheckIntervalMs = 5000, onParentLost = () => {}, list = listAntigravityConversations, runNative = runAgentApi, observeDelivery = observeAntigravityDelivery } = {}) {
38
39
  if (!env.ANTIGRAVITY_AGENTAPI_EXE || !env.ANTIGRAVITY_LS_ADDRESS) throw new Error("Antigravity must launch this sidecar. Do not run it as a replacement app server.");
39
40
  const assertParent = await createParentGuard();
40
41
  const deliveries = new AntigravityDeliveries(path.join(directory, "desktop-deliveries"));
@@ -59,7 +60,33 @@ export async function startAntigravitySidecar({ directory = antigravityBridgeDir
59
60
  sidecar = await serve(async (request) => {
60
61
  await checkParent();
61
62
  if (!request || typeof request !== "object") throw new Error("Invalid sidecar request");
62
- if (request.operation === "status") return { available: true, transport: "native-agentapi", permissionPolicy: "native-unchanged" };
63
+ if (request.operation === "status") return { available: true, transport: "native-agentapi", permissionPolicy: "native-unchanged", humanDispatchVersion: 1 };
64
+ if (["human-inspect", "human-send", "human-receipt"].includes(request.operation)) {
65
+ const operation = request.operation.slice("human-".length);
66
+ const target = humanDeliveryInput(request, operation);
67
+ if (operation === "receipt") return humanReceipt(deliveries.read(target.requestId), target);
68
+ const inspect = async () => {
69
+ await checkParent();
70
+ const records = (await list({ surface: "desktop", limit: 1000 })).filter((record) => record.conversationId === target.conversationId);
71
+ if (records.length !== 1 || records[0].surface !== "desktop" || humanWorkspace(records[0].cwd) !== target.cwd) throw Object.assign(new Error("Exact Antigravity conversation and workspace are unavailable"), { code: "ANTIGRAVITY_RECIPIENT_MISSING", deliveryState: "not_sent" });
72
+ const record = records[0];
73
+ if (record.status !== "CASCADE_RUN_STATUS_IDLE") throw Object.assign(new Error("Antigravity conversation is busy; no message was sent"), { code: "ANTIGRAVITY_RECIPIENT_BUSY", deliveryState: "not_sent" });
74
+ const metadata = sanitizeAntigravityMetadata(await runNative({ command: "metadata", conversationId: target.conversationId, env }), target.conversationId);
75
+ if (humanWorkspace(metadata.cwd) !== target.cwd || metadata.projectId !== record.projectId) throw Object.assign(new Error("Native Antigravity workspace or project changed"), { code: "ANTIGRAVITY_METADATA_MISMATCH", deliveryState: "not_sent" });
76
+ await checkParent();
77
+ return { ...record, ...metadata, available: true };
78
+ };
79
+ if (operation === "inspect") return inspect();
80
+ return deliveries.dispatch({ ...target, surface: "desktop", sender: HUMAN_SENDER }, async () => {
81
+ await checkParent();
82
+ if (sending.has(target.conversationId)) throw Object.assign(new Error("Another delivery is pending for this conversation"), { deliveryState: "not_sent" });
83
+ sending.add(target.conversationId);
84
+ try {
85
+ await inspect();
86
+ return await runNative({ command: "send", conversationId: target.conversationId, expectedCwd: target.cwd, prompt: target.prompt, env });
87
+ } finally { sending.delete(target.conversationId); }
88
+ });
89
+ }
63
90
  if (request.operation === "list") return listAntigravityConversations({ surface: "desktop" });
64
91
  if (request.operation === "read") {
65
92
  const records = await listAntigravityConversations({ surface: "desktop" });
@@ -68,7 +95,7 @@ export async function startAntigravitySidecar({ directory = antigravityBridgeDir
68
95
  const native = sanitizeAntigravityMetadata(await runAgentApi({ command: "metadata", conversationId: request.conversationId, env }), request.conversationId);
69
96
  return { ...record, ...native };
70
97
  }
71
- if (request.operation === "receipt") return deliveries.read(request.requestId);
98
+ if (request.operation === "receipt") return deliveries.refresh(request.requestId, observeDelivery);
72
99
  if (request.operation !== "send" || typeof request.prompt !== "string" || !request.prompt.trim() || request.prompt.length > 30000 || request.prompt.includes("\0")
73
100
  || typeof request.cwd !== "string" || !path.isAbsolute(request.cwd) || !request.sender || !["codex", "claude"].includes(request.sender.provider)
74
101
  || typeof request.sender.id !== "string" || !request.sender.id) throw new Error("Invalid Antigravity delivery request");
@@ -76,7 +103,8 @@ export async function startAntigravitySidecar({ directory = antigravityBridgeDir
76
103
  await checkParent();
77
104
  if (sending.has(request.conversationId)) throw Object.assign(new Error("Another delivery is pending for this conversation"), { deliveryState: "not_sent" });
78
105
  sending.add(request.conversationId);
79
- try { return await runAgentApi({ command: "send", conversationId: request.conversationId, expectedCwd: request.cwd, prompt: request.prompt, env }); }
106
+ try { return await runNative({ command: "send", conversationId: request.conversationId, expectedCwd: request.cwd, prompt: request.prompt, env,
107
+ beforeDispatch: (observation) => deliveries.captureObservation(request.requestId, observation) }); }
80
108
  finally { sending.delete(request.conversationId); }
81
109
  });
82
110
  }, { directory });
@@ -61,22 +61,29 @@ function workerEnv(env, relayEnv = {}) {
61
61
  }
62
62
 
63
63
  function binary(executable, env, platform = process.platform) {
64
- const selected = executable ?? resolveCodexBin(undefined, { env, platform });
64
+ const selected = executable ?? env.CODEX_BIN ?? env.CODEX_CLI_PATH ?? resolveCodexBin(undefined, { env, platform });
65
65
  try {
66
66
  if (typeof selected !== "string" || !path.isAbsolute(selected) || /[\u0000-\u001f\u007f]/.test(selected)) throw new Error();
67
67
  const resolved = fs.realpathSync.native(selected);
68
68
  if (!fs.statSync(resolved).isFile() || platform === "win32" && !/\.(?:exe|com)$/i.test(resolved)) throw new Error();
69
69
  fs.accessSync(resolved, platform === "win32" ? fs.constants.R_OK : fs.constants.X_OK);
70
70
  return resolved;
71
- } catch { throw failure("CLI_UNAVAILABLE", "inspect"); }
71
+ } catch (cause) { throw Object.assign(failure("CLI_UNAVAILABLE", "inspect"), { cause }); }
72
72
  }
73
73
 
74
- export async function inspectCodexWorker({ env = process.env, executable, platform = process.platform, run = execFileAsync } = {}) {
74
+ function missingExecutable(error) {
75
+ return ["ENOENT", "ENOTDIR"].includes(error?.code) || ["ENOENT", "ENOTDIR"].includes(error?.cause?.code);
76
+ }
77
+
78
+ async function inspectCodexWorkerAttempt({ env, executable, platform, run, retryMissing = false }) {
75
79
  const result = { agent: "codex", available: false, authenticated: null, ready: false };
76
80
  if (!object(env) || typeof run !== "function") return { ...result, code: "INVALID_INPUT", reason: ERRORS.INVALID_INPUT };
77
81
  let selected;
78
82
  try { selected = binary(executable, env, platform); }
79
- catch (error) { return { ...result, code: error.code, reason: error.message }; }
83
+ catch (error) {
84
+ if (retryMissing && missingExecutable(error)) throw error;
85
+ return { ...result, code: error.code, reason: error.message };
86
+ }
80
87
  const options = { env: workerEnv(env), windowsHide: true, shell: false, timeout: 10000, maxBuffer: MAX_CAPTURE, encoding: "utf8" };
81
88
  try {
82
89
  const version = await run(selected, ["--version"], options);
@@ -88,6 +95,8 @@ export async function inspectCodexWorker({ env = process.env, executable, platfo
88
95
  return { ...result, code: "CLI_UNSUPPORTED", reason: ERRORS.CLI_UNSUPPORTED };
89
96
  }
90
97
  } catch (error) {
98
+ if (retryMissing && missingExecutable(error)) throw error;
99
+ if (missingExecutable(error)) return { ...result, available: false, code: "CLI_UNAVAILABLE", reason: ERRORS.CLI_UNAVAILABLE };
91
100
  const code = diagnosticCode(error.stderr, result.available ? "CLI_UNSUPPORTED" : "CLI_UNAVAILABLE");
92
101
  return { ...result, code, reason: ERRORS[code] };
93
102
  }
@@ -100,11 +109,24 @@ export async function inspectCodexWorker({ env = process.env, executable, platfo
100
109
  const code = diagnosticCode(text, "AUTH_UNVERIFIED");
101
110
  return { ...result, authenticated: code === "AUTH_REQUIRED" ? false : null, code, reason: ERRORS[code] };
102
111
  } catch (error) {
112
+ if (retryMissing && missingExecutable(error)) throw error;
113
+ if (missingExecutable(error)) return { ...result, available: false, code: "CLI_UNAVAILABLE", reason: ERRORS.CLI_UNAVAILABLE };
103
114
  const code = diagnosticCode(`${error.stdout ?? ""}\n${error.stderr ?? ""}`, "AUTH_UNVERIFIED");
104
115
  return { ...result, authenticated: code === "AUTH_REQUIRED" ? false : null, code, reason: ERRORS[code] };
105
116
  }
106
117
  }
107
118
 
119
+ export async function inspectCodexWorker({ env = process.env, executable, platform = process.platform, run = execFileAsync } = {}) {
120
+ const settings = { env, executable, platform, run };
121
+ const pinned = executable != null || env?.CODEX_BIN != null || env?.CODEX_CLI_PATH != null;
122
+ try { return await inspectCodexWorkerAttempt({ ...settings, retryMissing: !pinned }); }
123
+ catch (error) {
124
+ if (!missingExecutable(error)) throw error;
125
+ await new Promise((resolve) => setTimeout(resolve, 100));
126
+ return inspectCodexWorkerAttempt(settings);
127
+ }
128
+ }
129
+
108
130
  function capture(executable, args, { cwd, env, spawn, signal, timeoutMs }) {
109
131
  return new Promise((resolve, reject) => {
110
132
  if (signal?.aborted) return reject(failure("ABORTED", "configure"));
@@ -31,7 +31,7 @@ import { BACKGROUND_WORKER_GUIDANCE, registerBackgroundTools } from "./backgroun
31
31
 
32
32
  exitForVersionRequest(import.meta.url);
33
33
 
34
- const VERSION = "1.26.9";
34
+ const VERSION = "1.27.0";
35
35
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
36
36
  const FORWARD_MIN_INTERVAL_MS = 5000;
37
37
  const FORWARD_MAX_PER_SESSION = 50;
package/src/index.mjs CHANGED
@@ -44,7 +44,7 @@ import { BACKGROUND_WORKER_GUIDANCE, registerBackgroundTools } from "./backgroun
44
44
 
45
45
  exitForVersionRequest(import.meta.url);
46
46
 
47
- const VERSION = "1.26.9";
47
+ const VERSION = "1.27.0";
48
48
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
49
49
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
50
50
 
@@ -43,7 +43,7 @@ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } fro
43
43
 
44
44
  exitForVersionRequest(import.meta.url);
45
45
 
46
- const VERSION = "1.26.9";
46
+ const VERSION = "1.27.0";
47
47
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
48
48
 
49
49
  function errorResponse(code, message, sent, details) {
@@ -284,6 +284,7 @@ export function assertClaudeSessionProcess(session) {
284
284
  * rather than by reconstructing the slug.
285
285
  */
286
286
  function findTranscriptFile(sessionId, cwd) {
287
+ if (typeof sessionId !== "string" || !sessionId || sessionId.length > 128 || /[^a-zA-Z0-9_-]/.test(sessionId)) return null;
287
288
  const dir = projectsDir();
288
289
  const guess = path.join(dir, String(cwd ?? "").replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
289
290
  if (fs.existsSync(guess)) return guess;
@@ -297,7 +298,7 @@ function findTranscriptFile(sessionId, cwd) {
297
298
 
298
299
  export function readTranscript(sessionId, cwd, limit = 10) {
299
300
  const file = findTranscriptFile(sessionId, cwd);
300
- if (!fs.existsSync(file)) return { file, messages: [] };
301
+ if (!file || !fs.existsSync(file)) return { file, messages: [] };
301
302
  const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
302
303
  const messages = [];
303
304
  for (const line of lines) {
@@ -325,7 +326,7 @@ export function readTranscript(sessionId, cwd, limit = 10) {
325
326
 
326
327
  export function readInitialTranscriptMessage(sessionId, cwd) {
327
328
  const file = findTranscriptFile(sessionId, cwd);
328
- if (!fs.existsSync(file)) return { file, messages: [] };
329
+ if (!file || !fs.existsSync(file)) return { file, messages: [] };
329
330
  const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
330
331
  try {
331
332
  const stat = fs.fstatSync(descriptor);
@@ -388,6 +389,46 @@ function isTerminalDelivery(receipt) {
388
389
  return ["refused", "denied", "expired", "dropped"].includes(receipt?.status) || isInterruptedDelivery(receipt);
389
390
  }
390
391
 
392
+ function resumableTranscriptDelivery(id, sent, receipt) {
393
+ const uuid = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
394
+ const session = sent?.transcriptSession;
395
+ return Boolean(sent) && uuid(id) && !sent.reply && !sent.failed && !isTerminalDelivery(receipt)
396
+ && Number.isFinite(sent.sentAt) && (!receipt || receipt.status === "delivered" && receipt.fromSocket === sent.targetSocket)
397
+ && session?.entrypoint === "claude-desktop" && uuid(session.sessionId)
398
+ && typeof session.cwd === "string" && path.isAbsolute(session.cwd)
399
+ && typeof sent.targetSocket === "string" && sent.targetSocket && session.socket === sent.targetSocket
400
+ && session.desktop?.status === "matched" && typeof session.desktop.taskId === "string"
401
+ && session.desktop.taskId.startsWith("local_") && uuid(session.desktop.taskId.slice(6))
402
+ && session.desktop.cwd === session.cwd
403
+ && /^[a-f0-9]{64}$/i.test(sent.accountContext?.claude ?? "") && /^[a-f0-9]{64}$/i.test(sent.accountContext?.codex ?? "")
404
+ && session.desktop.accountFingerprint === sent.accountContext.claude
405
+ && hasTranscriptDeliveryRoot(session.sessionId, session.cwd, id);
406
+ }
407
+
408
+ function hasTranscriptDeliveryRoot(sessionId, cwd, msgId) {
409
+ const file = findTranscriptFile(sessionId, cwd);
410
+ if (!file) return false;
411
+ let lines;
412
+ try { lines = fs.readFileSync(file, "utf8").split("\n"); }
413
+ catch { return false; }
414
+ let roots = 0;
415
+ const ids = new Set();
416
+ for (const line of lines) {
417
+ if (!line.trim()) continue;
418
+ let entry;
419
+ try { entry = JSON.parse(line); } catch { return false; }
420
+ if (!entry || typeof entry !== "object" || entry.isSidechain) continue;
421
+ if (typeof entry.uuid === "string") {
422
+ if (ids.has(entry.uuid)) return false;
423
+ ids.add(entry.uuid);
424
+ }
425
+ if (!injectedMessageRoot(entry, msgId)) continue;
426
+ if (entry.entrypoint !== "claude-desktop" || entry.sessionId !== sessionId || entry.cwd !== cwd) return false;
427
+ roots++;
428
+ }
429
+ return roots === 1;
430
+ }
431
+
391
432
  export function readTranscriptReply(sessionId, cwd, msgId) {
392
433
  return readTranscriptOutcome(sessionId, cwd, msgId)?.reply ?? null;
393
434
  }
@@ -460,6 +501,7 @@ function compactContinuation(entry, root, entriesById, transcriptIds, sessionId,
460
501
  // user turn's answer to the original request or claim its side effects stopped.
461
502
  export function readTranscriptOutcome(sessionId, cwd, msgId) {
462
503
  const file = findTranscriptFile(sessionId, cwd);
504
+ if (!file) return null;
463
505
  let lines;
464
506
  try { lines = fs.readFileSync(file, "utf8").split("\n"); }
465
507
  catch { return null; }
@@ -509,6 +551,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
509
551
  }
510
552
  }
511
553
  const descendants = new Map();
554
+ const monitorTasksByEntry = new Map();
512
555
  const backgroundCalls = new Map();
513
556
  const openLeaves = new Set();
514
557
  const passiveLeaves = new Set();
@@ -520,7 +563,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
520
563
  interruptions.push({ entryId: entry.uuid, backgroundTaskIds: [...outstanding.keys()] });
521
564
  };
522
565
  for (const entry of orderedEntries) {
523
- if (entry === root) { descendants.set(entry.uuid, new Map()); openLeaves.add(entry.uuid); continue; }
566
+ if (entry === root) { descendants.set(entry.uuid, new Map()); monitorTasksByEntry.set(entry.uuid, new Set()); openLeaves.add(entry.uuid); continue; }
524
567
  let parentUuid = entry.parentUuid;
525
568
  if (!ambiguous && !interruptions.length && descendants.has(entry.logicalParentUuid)
526
569
  && openLeaves.has(entry.logicalParentUuid)) {
@@ -549,6 +592,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
549
592
  const role = entry.message?.role;
550
593
  const content = entry.message?.content;
551
594
  const outstanding = new Map(descendants.get(parentUuid));
595
+ const monitorTasks = new Set(monitorTasksByEntry.get(parentUuid));
552
596
  const toolResults = Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "tool_result");
553
597
  const imageCompanion = isImageToolCompanion(entry, entriesById);
554
598
  if (role === "user" && !toolResults && !imageCompanion && !compactSummaries.has(entry.uuid)) {
@@ -575,13 +619,20 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
575
619
  }
576
620
  if (typeof taskNotification === "string" && /^<task-notification>\s[\s\S]*<\/task-notification>$/.test(taskNotification.trim())) {
577
621
  const field = (name) => {
622
+ if (taskNotification.split(`<${name}>`).length !== 2 || taskNotification.split(`</${name}>`).length !== 2) return null;
578
623
  const matches = [...taskNotification.matchAll(new RegExp(`<${name}>([^<>]+)</${name}>`, "g"))];
579
624
  return matches.length === 1 ? matches[0][1] : null;
580
625
  };
581
626
  const taskId = field("task-id");
627
+ const hasField = name => taskNotification.split(`<${name}>`).length === 2
628
+ && taskNotification.split(`</${name}>`).length === 2
629
+ && new RegExp(`<${name}>[\\s\\S]+?</${name}>`).test(taskNotification);
630
+ const monitorProgress = monitorTasks.has(taskId) && !outstanding.has(taskId)
631
+ && hasField("summary") && hasField("event")
632
+ && !/<\/?(?:status|tool-use-id)(?:\s|>)/.test(taskNotification);
582
633
  if (!outstanding.has(taskId) || outstanding.get(taskId) !== field("tool-use-id")
583
634
  || !["completed", "failed", "killed"].includes(field("status"))) {
584
- if (role === "user") continue;
635
+ if (role === "user" && !monitorProgress) continue;
585
636
  } else outstanding.delete(taskId);
586
637
  }
587
638
  if (typeof entry.uuid !== "string" || !entry.uuid.trim()) continue;
@@ -600,6 +651,18 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
600
651
  // Parallel tool results and hook records can be leaf siblings of the real
601
652
  // continuation. They are completed observations, not separate reply turns.
602
653
  const parentEntry = entriesById.get(entry.parentUuid);
654
+ const monitorTaskId = entry.toolUseResult?.taskId;
655
+ if (role === "user" && toolResults && content.length === 1 && typeof monitorTaskId === "string" && /^[\w-]{1,128}$/.test(monitorTaskId)
656
+ && entry.sourceToolAssistantUUID === parentEntry?.uuid
657
+ && parentEntry?.message?.role === "assistant"
658
+ && Number.isSafeInteger(entry.toolUseResult.timeoutMs) && entry.toolUseResult.timeoutMs > 0
659
+ && typeof entry.toolUseResult.persistent === "boolean") {
660
+ const matches = content.filter(part => !part.is_error && typeof part.tool_use_id === "string" && part.tool_use_id
661
+ && Array.isArray(parentEntry.message.content)
662
+ && parentEntry.message.content.filter(call => call.type === "tool_use" && call.name === "Monitor" && call.id === part.tool_use_id).length === 1);
663
+ if (matches.length === 1) monitorTasks.add(monitorTaskId);
664
+ }
665
+ monitorTasksByEntry.set(entry.uuid, monitorTasks);
603
666
  const matchedResults = toolResults && !entry.toolUseResult?.backgroundTaskId
604
667
  && content.every((part) => Array.isArray(parentEntry?.message?.content)
605
668
  && parentEntry.message.content.some((call) => call.type === "tool_use" && call.id === part.tool_use_id));
@@ -654,6 +717,7 @@ export class PeerEndpoint {
654
717
  this.sentMessages = new Map();
655
718
  this.pendingMessages = new Map();
656
719
  this.responsePoll = null;
720
+ this.transcriptPollingPaused = false;
657
721
  this.connections = new Set();
658
722
  this.activityRevision = 0;
659
723
  this.reloadPaused = false;
@@ -779,6 +843,8 @@ export class PeerEndpoint {
779
843
  }
780
844
 
781
845
  this.started = true;
846
+ this.transcriptPollingPaused = false;
847
+ this.#startTranscriptPolling();
782
848
  this.log(`peer "${this.name}" listening on ${this.socketPath}`);
783
849
  }
784
850
 
@@ -903,11 +969,20 @@ export class PeerEndpoint {
903
969
  }
904
970
  }
905
971
 
972
+ #startTranscriptPolling() {
973
+ if (this.reloadPaused || this.transcriptPollingPaused || this.responsePoll
974
+ || ![...this.pendingMessages.values()].some(entry => entry.transcriptSession)) return;
975
+ this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
976
+ this.responsePoll.unref();
977
+ }
978
+
906
979
  #refreshTranscriptReplies() {
980
+ if (this.reloadPaused || this.transcriptPollingPaused) return;
907
981
  for (const [msgId, pending] of this.pendingMessages) {
908
982
  if (!pending.transcriptSession) continue;
909
983
  const { sessionId, cwd } = pending.transcriptSession;
910
984
  const file = findTranscriptFile(sessionId, cwd);
985
+ if (!file) continue;
911
986
  let stamp;
912
987
  try { const stat = fs.statSync(file, { bigint: true }); stamp = `${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}`; }
913
988
  catch { continue; }
@@ -1051,6 +1126,7 @@ export class PeerEndpoint {
1051
1126
  );
1052
1127
  error.code = "PEER_REPLY_PENDING";
1053
1128
  error.sent = false;
1129
+ error.msgId = [...this.pendingMessages].find(([, entry]) => entry.targetSocket === targetSocket)?.[0];
1054
1130
  throw error;
1055
1131
  }
1056
1132
  const since = Date.now();
@@ -1059,10 +1135,7 @@ export class PeerEndpoint {
1059
1135
  let msgId = crypto.randomUUID();
1060
1136
  this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
1061
1137
  this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}), ...(accountContext ? { accountContext: Object.freeze({ ...accountContext }) } : {}), ...(scopeBindings ? { scopeBindings: Object.freeze(structuredClone(scopeBindings)) } : {}) });
1062
- if (transcriptSession && !this.responsePoll) {
1063
- this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
1064
- this.responsePoll.unref();
1065
- }
1138
+ this.#startTranscriptPolling();
1066
1139
  try {
1067
1140
  const sentId = await this.send(targetSocket, text, { priority, msgId, permissionMode, beforeSend });
1068
1141
  if (sentId !== msgId) {
@@ -1191,9 +1264,21 @@ export class PeerEndpoint {
1191
1264
  reloadReason() {
1192
1265
  this.#refreshTranscriptReplies();
1193
1266
  if (this.connections.size) return "Peer socket connections are still open";
1194
- if (this.requestQueues.size || this.pendingMessages.size || this.unconfirmedReplies.size) return "Claude messages still have pending or unconfirmed delivery";
1267
+ if (this.requestQueues.size) return "Claude messages are still being sent";
1268
+ const counts = new Map();
1269
+ for (const [id, pending] of this.pendingMessages) {
1270
+ const sent = this.sentMessages.get(id);
1271
+ if (!resumableTranscriptDelivery(id, sent, this.deliveryReceipts.get(id))
1272
+ || pending.targetSocket !== sent.targetSocket || JSON.stringify(pending.transcriptSession) !== JSON.stringify(sent.transcriptSession)) {
1273
+ return "Claude messages still have pending or unconfirmed delivery";
1274
+ }
1275
+ counts.set(sent.targetSocket, (counts.get(sent.targetSocket) ?? 0) + 1);
1276
+ }
1277
+ if (counts.size !== this.unconfirmedReplies.size || [...counts].some(([socket, count]) => this.unconfirmedReplies.get(socket) !== count)) {
1278
+ return "Claude messages still have pending or unconfirmed delivery";
1279
+ }
1195
1280
  for (const [id, sent] of this.sentMessages) {
1196
- if (!sent.reply && !sent.failed && !isTerminalDelivery(this.deliveryReceipts.get(id))) {
1281
+ if (!sent.reply && !sent.failed && !isTerminalDelivery(this.deliveryReceipts.get(id)) && !this.pendingMessages.has(id)) {
1197
1282
  return "A Claude delivery outcome remains unconfirmed";
1198
1283
  }
1199
1284
  }
@@ -1243,14 +1328,17 @@ export class PeerEndpoint {
1243
1328
  const reason = this.reloadReason();
1244
1329
  if (reason || this.started && !this.reloadPaused) throw new Error(reason ?? "Quiesce the peer endpoint before exporting state");
1245
1330
  return cloneReloadState({ name: this.name, messageSequence: this.messageSequence, inbox: this.inbox,
1246
- sentMessages: [...this.sentMessages], deliveryReceipts: [...this.deliveryReceipts] });
1331
+ sentMessages: [...this.sentMessages], deliveryReceipts: [...this.deliveryReceipts],
1332
+ pendingMessages: [...this.pendingMessages].map(([id, pending]) => [id, { targetSocket: pending.targetSocket, transcriptSession: pending.transcriptSession }]) });
1247
1333
  }
1248
1334
 
1249
1335
  restoreReloadState(input) {
1250
- if (this.started || this.connections.size || this.sentMessages.size || this.inbox.length) throw new Error("Peer reload state requires an empty dormant endpoint");
1336
+ if (this.started || this.connections.size || this.sentMessages.size || this.inbox.length || this.pendingMessages.size
1337
+ || this.unconfirmedReplies.size || this.requestQueues.size || this.responsePoll) throw new Error("Peer reload state requires an empty dormant endpoint");
1251
1338
  const state = cloneReloadState(input);
1252
1339
  if (!state || typeof state.name !== "string" || !state.name || !Number.isSafeInteger(state.messageSequence) || state.messageSequence < 0
1253
- || !Array.isArray(state.inbox) || !Array.isArray(state.sentMessages) || !Array.isArray(state.deliveryReceipts)) throw new Error("Invalid peer reload state");
1340
+ || !Array.isArray(state.inbox) || !Array.isArray(state.sentMessages) || !Array.isArray(state.deliveryReceipts)
1341
+ || state.pendingMessages !== undefined && !Array.isArray(state.pendingMessages)) throw new Error("Invalid peer reload state");
1254
1342
  const restoreMap = (entries) => {
1255
1343
  const restored = new Map();
1256
1344
  for (const pair of entries) {
@@ -1262,6 +1350,17 @@ export class PeerEndpoint {
1262
1350
  };
1263
1351
  const sentMessages = restoreMap(state.sentMessages);
1264
1352
  const deliveryReceipts = restoreMap(state.deliveryReceipts);
1353
+ const pendingMessages = restoreMap(state.pendingMessages ?? []);
1354
+ const unconfirmedReplies = new Map();
1355
+ for (const [id, pending] of pendingMessages) {
1356
+ const sent = sentMessages.get(id);
1357
+ if (!resumableTranscriptDelivery(id, sent, deliveryReceipts.get(id))
1358
+ || pending.targetSocket !== sent.targetSocket || JSON.stringify(pending.transcriptSession) !== JSON.stringify(sent.transcriptSession)) {
1359
+ throw new Error("Invalid pending Claude transcript reload record");
1360
+ }
1361
+ delete pending.transcriptStamp;
1362
+ unconfirmedReplies.set(sent.targetSocket, (unconfirmedReplies.get(sent.targetSocket) ?? 0) + 1);
1363
+ }
1265
1364
  for (const record of state.inbox) {
1266
1365
  if (!record || typeof record.text !== "string" || !Number.isSafeInteger(record.sequence) || record.sequence < 1
1267
1366
  || record.sequence > state.messageSequence || !Number.isFinite(record.receivedAt)) throw new Error("Invalid inbox reload record");
@@ -1269,7 +1368,7 @@ export class PeerEndpoint {
1269
1368
  for (const [id, sent] of sentMessages) {
1270
1369
  if (typeof sent.targetSocket !== "string" || !sent.targetSocket || !Number.isFinite(sent.sentAt)
1271
1370
  || sent.reply && (typeof sent.reply.text !== "string" || sent.reply.inReplyTo !== id)
1272
- || !sent.reply && !sent.failed && !isTerminalDelivery(deliveryReceipts.get(id))
1371
+ || !sent.reply && !sent.failed && !isTerminalDelivery(deliveryReceipts.get(id)) && !pendingMessages.has(id)
1273
1372
  || isInterruptedDelivery(deliveryReceipts.get(id)) && deliveryReceipts.get(id).inReplyTo !== id) {
1274
1373
  throw new Error("Invalid or unconfirmed Claude delivery reload record");
1275
1374
  }
@@ -1279,6 +1378,9 @@ export class PeerEndpoint {
1279
1378
  this.inbox = state.inbox;
1280
1379
  this.sentMessages = sentMessages;
1281
1380
  this.deliveryReceipts = deliveryReceipts;
1381
+ this.pendingMessages = pendingMessages;
1382
+ this.unconfirmedReplies = unconfirmedReplies;
1383
+ this.transcriptPollingPaused = true;
1282
1384
  }
1283
1385
 
1284
1386
  #removeRegistration() {
package/src/platform.mjs CHANGED
@@ -88,9 +88,10 @@ function workspaceRoots() {
88
88
  }
89
89
 
90
90
  function isRunnable(candidate) {
91
- if (!candidate || !existsSync(candidate)) return false;
92
- if (IS_WINDOWS) return true;
91
+ if (typeof candidate !== "string" || !candidate || candidate.includes("\0")) return false;
93
92
  try {
93
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) return false;
94
+ if (IS_WINDOWS) return true;
94
95
  accessSync(candidate, constants.X_OK);
95
96
  return true;
96
97
  } catch {
@@ -98,9 +99,9 @@ function isRunnable(candidate) {
98
99
  }
99
100
  }
100
101
 
101
- function windowsCodexCandidates() {
102
- const roaming = process.env.APPDATA;
103
- const local = process.env.LOCALAPPDATA;
102
+ function windowsCodexCandidates(env, runnable) {
103
+ const roaming = env.APPDATA;
104
+ const local = env.LOCALAPPDATA;
104
105
  const versionedRoot = local && path.join(local, "OpenAI", "Codex", "bin");
105
106
  let versioned = [];
106
107
  if (versionedRoot) {
@@ -108,7 +109,7 @@ function windowsCodexCandidates() {
108
109
  versioned = readdirSync(versionedRoot, { withFileTypes: true })
109
110
  .filter((entry) => entry.isDirectory())
110
111
  .map((entry) => path.join(versionedRoot, entry.name, "codex.exe"))
111
- .filter((candidate) => isRunnable(candidate))
112
+ .filter((candidate) => runnable(candidate))
112
113
  .sort((left, right) => {
113
114
  try {
114
115
  return statSync(path.dirname(right)).mtimeMs - statSync(path.dirname(left)).mtimeMs;
@@ -119,16 +120,16 @@ function windowsCodexCandidates() {
119
120
  } catch {}
120
121
  }
121
122
  return [
122
- process.env.CODEX_CLI_PATH,
123
+ env.CODEX_CLI_PATH,
123
124
  ...versioned,
124
125
  local && path.join(local, "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
125
126
  roaming && path.join(roaming, "npm", "codex.cmd"),
126
- process.env.ProgramFiles && path.join(process.env.ProgramFiles, "nodejs", "codex.cmd"),
127
+ env.ProgramFiles && path.join(env.ProgramFiles, "nodejs", "codex.cmd"),
127
128
  ];
128
129
  }
129
130
 
130
- function unixCodexCandidates() {
131
- const home = homeDir();
131
+ function unixCodexCandidates(env) {
132
+ const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
132
133
  return [
133
134
  path.join(home, ".local", "bin", "codex"),
134
135
  path.join(home, ".npm-global", "bin", "codex"),
@@ -147,17 +148,17 @@ function unixCodexCandidates() {
147
148
  * two versions write that same state, so prefer the app's binary whenever the
148
149
  * app is installed and fall back to the standalone CLI installs otherwise.
149
150
  */
150
- function macosCodexCandidates() {
151
+ function macosCodexCandidates(env) {
151
152
  return [
152
153
  hasCodexDesktopApp() ? CODEX_DESKTOP_BIN_MACOS : null,
153
- ...unixCodexCandidates(),
154
+ ...unixCodexCandidates(env),
154
155
  ];
155
156
  }
156
157
 
157
- function pathCandidates() {
158
- const separator = IS_WINDOWS ? ";" : ":";
159
- const names = IS_WINDOWS ? ["codex.exe", "codex.cmd"] : ["codex"];
160
- return (process.env.PATH ?? "")
158
+ function pathCandidates(env, platform) {
159
+ const separator = platform === "win32" ? ";" : ":";
160
+ const names = platform === "win32" ? ["codex.exe", "codex.cmd"] : ["codex"];
161
+ return (env.PATH ?? "")
161
162
  .split(separator)
162
163
  .filter(Boolean)
163
164
  .flatMap((dir) => names.map((name) => path.join(dir, name)));
@@ -217,8 +218,8 @@ export function resolveCodexBin(explicit, {
217
218
  explicit,
218
219
  env.CODEX_BIN,
219
220
  ...(discovered ?? [
220
- ...(IS_WINDOWS ? windowsCodexCandidates() : IS_MACOS ? macosCodexCandidates() : unixCodexCandidates()),
221
- ...pathCandidates(),
221
+ ...(platform === "win32" ? windowsCodexCandidates(env, runnable) : platform === "darwin" ? macosCodexCandidates(env) : unixCodexCandidates(env)),
222
+ ...pathCandidates(env, platform),
222
223
  ]),
223
224
  ].filter(Boolean);
224
225
 
@@ -55,8 +55,9 @@ export function bridgeDiagnostic(error, options = {}) {
55
55
  phase: name(previous?.phase ?? options.phase ?? error?.phase ?? error?.diagnostic?.stage, deliveryState === "not_sent" ? "preflight" : options.readOnly ? "inspection" : "delivery"),
56
56
  deliveryState,
57
57
  sent: deliveryState === "not_sent" ? false : deliveryState === "sent" ? true : null,
58
- retrySafe: deliveryState === "not_sent",
59
- nextAction: deliveryState === "not_sent" ? "Inspect the reported cause and bridge status before retrying. Do not open or create chats to load a relay."
58
+ retrySafe: deliveryState === "not_sent" && code !== "PEER_REPLY_PENDING",
59
+ nextAction: code === "PEER_REPLY_PENDING" ? "Read read_claude_delivery with the original msgId. Wait for its correlated outcome; do not resend or start another bridge to bypass pending work."
60
+ : deliveryState === "not_sent" ? "Inspect the reported cause and bridge status before retrying. Do not open or create chats to load a relay."
60
61
  : deliveryState === "not_applicable" ? "Inspect bridge status and include this diagnostic in the report."
61
62
  : "Inspect the original delivery receipt. Do not resend automatically or create a replacement chat.",
62
63
  reason: diagnosticText(previous?.reason ?? error?.preflight?.reason ?? error?.message ?? error),