@minhspark/codex-mcp-bridge 1.23.0 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { readProcessAncestry, runProcessInspector } from "./claude-sender-context.mjs";
8
+ import { antigravityBridgeDirectory, callAntigravityDesktop } from "./antigravity-ipc.mjs";
9
+
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ const PREFIX = "antigravity.google/";
12
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
13
+ class ContextError extends Error {}
14
+ const fail = (reason) => { throw new ContextError(reason); };
15
+ const hash = (value) => crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
16
+
17
+ export async function readAntigravityConfigurationJson(file, { filesystem = fs } = {}) {
18
+ const valid = (stat) => stat.isFile() && stat.nlink === 1n && stat.size >= 0n && stat.size <= 1048576n;
19
+ const same = (left, right) => ["dev", "ino", "size", "mtimeNs", "ctimeNs", "nlink"].every((key) => left[key] === right[key]);
20
+ const handle = await filesystem.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
21
+ try {
22
+ const opened = await handle.stat({ bigint: true });
23
+ const linked = await filesystem.lstat(file, { bigint: true });
24
+ if (!valid(opened) || !valid(linked)) fail("Native configuration must be a bounded regular file with one link");
25
+ if (!same(opened, linked)) fail("Native configuration changed while opening");
26
+ const buffer = Buffer.alloc(Number(opened.size) + 1);
27
+ let length = 0;
28
+ while (length < buffer.length) {
29
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
30
+ if (bytesRead === 0) break;
31
+ length += bytesRead;
32
+ }
33
+ const after = await handle.stat({ bigint: true });
34
+ const current = await filesystem.lstat(file, { bigint: true });
35
+ if (length !== Number(opened.size) || !valid(after) || !valid(current) || !same(opened, after) || !same(after, current)) fail("Native configuration changed while reading");
36
+ return JSON.parse(buffer.subarray(0, length).toString("utf8"));
37
+ } finally { await handle.close(); }
38
+ }
39
+
40
+ async function readExecutables(rows, { platform }) {
41
+ if (platform === "win32") {
42
+ const ids = rows.map((row) => row.pid);
43
+ const script = `$ErrorActionPreference='Stop'; @(${ids.join(",")}) | ForEach-Object { $processId=$_; $p=Get-Process -Id $processId -ErrorAction SilentlyContinue; [PSCustomObject]@{pid=$processId;executable=$p.Path} } | ConvertTo-Json -Compress`;
44
+ const shell = path.win32.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
45
+ const result = await runProcessInspector(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { timeout: 10000, maxBuffer: 16384, windowsHide: true });
46
+ const parsed = JSON.parse(result.stdout);
47
+ return Array.isArray(parsed) ? parsed : [parsed];
48
+ }
49
+ if (platform === "darwin") return Promise.all(rows.map(async ({ pid }) => {
50
+ const result = await runProcessInspector("/bin/ps", ["-p", String(pid), "-o", "comm="], { timeout: 5000, maxBuffer: 4096 });
51
+ return { pid, executable: result.stdout.trim() };
52
+ }));
53
+ fail("Antigravity Desktop host verification is unsupported on this platform");
54
+ }
55
+
56
+ function validAncestry(rows, parentPid) {
57
+ return Array.isArray(rows) && rows.length > 0 && rows.length <= 8 && rows[0].pid === parentPid
58
+ && new Set(rows.map((row) => row.pid)).size === rows.length
59
+ && rows.every((row, index) => Number.isSafeInteger(row.pid) && row.pid > 0 && row.pid <= 0xffffffff
60
+ && Number.isSafeInteger(row.parentPid) && row.parentPid >= 0 && row.parentPid <= 0xffffffff
61
+ && typeof row.processStart === "string" && row.processStart.length > 0
62
+ && (!index || rows[index - 1].parentPid === row.pid));
63
+ }
64
+
65
+ function grants(value) {
66
+ if (value === undefined) return { allow: [], ask: [], deny: [] };
67
+ if (!object(value) || Object.keys(value).some((key) => !["allow", "ask", "deny"].includes(key))) fail("Native permission grants have an unsupported schema");
68
+ const result = {};
69
+ for (const key of ["allow", "ask", "deny"]) {
70
+ const items = value[key] ?? [];
71
+ if (!Array.isArray(items) || items.some((item) => typeof item !== "string" || !item.trim())) fail("Native permission grants have an unsupported schema");
72
+ result[key] = items;
73
+ }
74
+ return result;
75
+ }
76
+
77
+ function permissions(global, project) {
78
+ const settings = global?.userSettings;
79
+ if (!object(settings) || !object(project.settings ?? {})) fail("Native permission settings are unavailable");
80
+ const local = project.settings ?? {};
81
+ const wrapper = project.permissionGrants ?? {};
82
+ if (!object(wrapper) || Object.keys(wrapper).some((key) => !["permissionGrants", "v2Migrated"].includes(key))) fail("Native project permission grants have an unsupported schema");
83
+ if (settings.permissionGrantsV2Migrated !== true || (wrapper.v2Migrated !== undefined && wrapper.v2Migrated !== true)) fail("Native permission migration state is unsupported");
84
+ const globalGrants = grants(settings.globalPermissionGrants);
85
+ const projectGrants = grants(wrapper.permissionGrants);
86
+ const preset = local.permissionPreset ?? settings.permissionPreset;
87
+ const execution = local.autoExecutionPolicy ?? settings.autoExecutionPolicy;
88
+ const sandbox = local.sandboxMode ?? settings.enableTerminalSandbox;
89
+ const fileAccess = local.fileAccessPolicy ?? settings.nonWorkspaceFileAccessPolicy;
90
+ const artifact = local.artifactReviewMode ?? settings.artifactReviewMode;
91
+ const browser = local.browserJsExecutionPolicy ?? settings.browserJsExecutionPolicy;
92
+ const knownPresets = ["AGENT_PERMISSION_PRESET_TURBO", "AGENT_PERMISSION_PRESET_REQUEST_REVIEW", "AGENT_PERMISSION_PRESET_DEFAULT", "AGENT_PERMISSION_PRESET_AUTO"];
93
+ if (preset !== undefined && !knownPresets.includes(preset)) fail("Native permission preset is unsupported");
94
+ if (!['CASCADE_COMMANDS_AUTO_EXECUTION_EAGER', 'CASCADE_COMMANDS_AUTO_EXECUTION_OFF', 'CASCADE_COMMANDS_AUTO_EXECUTION_AUTO', 'CASCADE_COMMANDS_AUTO_EXECUTION_PROCEED_IN_SANDBOX'].includes(execution)
95
+ || typeof sandbox !== "boolean" || !["AGENT_SETTING_POLICY_ALLOW", "AGENT_SETTING_POLICY_ASK", "AGENT_SETTING_POLICY_DENY"].includes(fileAccess)
96
+ || !["ARTIFACT_REVIEW_MODE_ALWAYS", "ARTIFACT_REVIEW_MODE_TURBO", "ARTIFACT_REVIEW_MODE_AUTO"].includes(artifact)
97
+ || !["BROWSER_JS_EXECUTION_POLICY_DISABLED", "BROWSER_JS_EXECUTION_POLICY_ALWAYS_ASK", "BROWSER_JS_EXECUTION_POLICY_MODEL_DECIDES", "BROWSER_JS_EXECUTION_POLICY_TURBO"].includes(browser)) fail("Native permission settings are incomplete or unsupported");
98
+ const restricted = globalGrants.ask.length + globalGrants.deny.length + projectGrants.ask.length + projectGrants.deny.length > 0;
99
+ const unrestricted = (!preset || preset === "AGENT_PERMISSION_PRESET_TURBO") && execution === "CASCADE_COMMANDS_AUTO_EXECUTION_EAGER" && sandbox === false && fileAccess === "AGENT_SETTING_POLICY_ALLOW" && artifact === "ARTIFACT_REVIEW_MODE_TURBO" && browser === "BROWSER_JS_EXECUTION_POLICY_TURBO" && !restricted;
100
+ const mode = unrestricted ? "bypass" : "prompting";
101
+ return { mode, fingerprint: hash({ preset, execution, sandbox, fileAccess, artifact, browser, globalGrants, projectGrants, globalMigration: settings.permissionGrantsV2Migrated, projectMigration: wrapper.v2Migrated }) };
102
+ }
103
+
104
+ export async function resolveAntigravitySenderContext(meta, dependencies = {}) {
105
+ let reason = "Native Antigravity caller metadata is missing or invalid";
106
+ try {
107
+ if (!object(meta)) fail(reason);
108
+ const conversationId = meta[PREFIX + "conversation_id"];
109
+ const workspaces = meta[PREFIX + "workspace_paths"];
110
+ if (typeof conversationId !== "string" || !UUID.test(conversationId) || (workspaces !== undefined && (!Array.isArray(workspaces) || workspaces.length !== 1))) fail(reason);
111
+ const parent = meta[PREFIX + "parent_conversation_id"];
112
+ const root = meta[PREFIX + "root_conversation_id"];
113
+ if ((parent !== undefined && parent !== null && parent !== "") || (root !== undefined && root !== null && root !== "" && root !== conversationId)) fail("Nested Antigravity callers are not supported");
114
+ const { home = os.homedir(), platform = process.platform, parentPid = process.ppid, readAncestry = readProcessAncestry, readExecutables: executables = readExecutables, readJson: json = readAntigravityConfigurationJson, realpath = fs.realpath } = dependencies;
115
+ const paths = platform === "win32" ? path.win32 : path.posix;
116
+ const canonical = async (value) => {
117
+ if (typeof value !== "string" || value.includes("\0") || !paths.isAbsolute(value)) fail("Native workspace path is invalid");
118
+ const resolved = paths.normalize(await realpath(value));
119
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
120
+ };
121
+ reason = "Native Antigravity Desktop process ancestry could not be verified";
122
+ const before = await readAncestry({ parentPid, platform });
123
+ if (!validAncestry(before, parentPid)) fail(reason);
124
+ const images = await executables(before, { platform });
125
+ if (!Array.isArray(images) || images.length !== before.length || new Set(images.map((image) => image.pid)).size !== before.length) fail(reason);
126
+ let appIndex = -1;
127
+ let expected;
128
+ for (let index = 0; index < before.length; index++) {
129
+ const executable = await canonical(images.find((image) => image.pid === before[index].pid)?.executable);
130
+ if (expected && executable === expected) { appIndex = index; break; }
131
+ const suffix = platform === "win32" ? "\\resources\\bin\\language_server.exe" : "/Contents/Resources/bin/language_server";
132
+ if (executable.endsWith(suffix)) {
133
+ const rootPath = executable.slice(0, -suffix.length);
134
+ expected = platform === "win32" ? paths.join(rootPath, "antigravity.exe") : paths.join(rootPath, "Contents", "MacOS", "Antigravity");
135
+ }
136
+ }
137
+ if (appIndex < 0) fail(reason);
138
+ reason = "Native Antigravity conversation could not be verified";
139
+ const readNative = dependencies.readNative ?? ((id) => callAntigravityDesktop({ operation: "read", conversationId: id }, { directory: antigravityBridgeDirectory(home) }));
140
+ const native = await readNative(conversationId);
141
+ if (native?.conversationId !== conversationId || native.surface !== "desktop" || !UUID.test(native.projectId)) fail(reason);
142
+ const cwd = await canonical(native.cwd);
143
+ if (workspaces !== undefined && await canonical(workspaces[0]) !== cwd) fail(reason);
144
+ const projectId = native.projectId;
145
+ reason = "Native Antigravity project permissions could not be verified";
146
+ const globalPath = paths.join(home, ".gemini", "config", "config.json");
147
+ const projectPath = paths.join(home, ".gemini", "config", "projects", projectId + ".json");
148
+ const global = await json(globalPath);
149
+ const project = await json(projectPath);
150
+ const resources = project?.projectResources?.resources;
151
+ if (project?.id !== projectId || !Array.isArray(resources) || resources.length !== 1) fail(reason);
152
+ const uri = resources[0]?.gitFolder?.folderUri;
153
+ if (typeof uri !== "string" || await canonical(fileURLToPath(uri, { windows: platform === "win32" })) !== cwd) fail(reason);
154
+ const policy = permissions(global, project);
155
+ const after = await readAncestry({ parentPid, platform });
156
+ if (!validAncestry(after, parentPid) || hash(before.slice(0, appIndex + 1)) !== hash(after.slice(0, appIndex + 1))) fail("Native Antigravity host changed during verification");
157
+ if (hash(global) !== hash(await json(globalPath)) || hash(project) !== hash(await json(projectPath))) fail("Native Antigravity permissions changed during verification");
158
+ return { status: "verified", provider: "antigravity", id: conversationId, conversationId, cwd, projectId, surface: "desktop", mode: policy.mode, permissionFingerprint: policy.fingerprint };
159
+ } catch (error) {
160
+ return { status: "unverified", provider: "antigravity", reason: error instanceof ContextError ? error.message : reason };
161
+ }
162
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { antigravityBridgeDirectory, serveAntigravityDesktop } from "./antigravity-ipc.mjs";
5
+ import { AntigravityDeliveries } from "./antigravity-deliveries.mjs";
6
+ import { listAntigravityConversations, runAgentApi, sanitizeAntigravityMetadata } from "./antigravity-desktop.mjs";
7
+ import { exitForVersionRequest } from "./cli-version.mjs";
8
+ import { readProcessAncestry } from "./claude-sender-context.mjs";
9
+
10
+ export async function createAntigravityParentGuard({ parentPid = process.ppid, inspect = readProcessAncestry } = {}) {
11
+ if (!Number.isSafeInteger(parentPid) || parentPid <= 1 || parentPid > 0xffffffff) throw new Error("Antigravity sidecar parent is unavailable; relaunch it from the native app");
12
+ const read = async () => {
13
+ const rows = await inspect({ parentPid, maxDepth: 1 });
14
+ const row = rows?.[0];
15
+ return rows?.length === 1 && row?.pid === parentPid && typeof row.processStart === "string" && row.processStart ? { pid: row.pid, processStart: row.processStart } : null;
16
+ };
17
+ let initial;
18
+ try { initial = await read(); } catch {}
19
+ if (!initial) throw new Error("Antigravity sidecar parent is unavailable; relaunch it from the native app");
20
+ let unavailable = false;
21
+ let pending;
22
+ return async () => {
23
+ if (!unavailable) {
24
+ pending ??= (async () => {
25
+ try {
26
+ const current = await read();
27
+ unavailable = !current || current.pid !== initial.pid || current.processStart !== initial.processStart;
28
+ } catch { unavailable = true; }
29
+ })();
30
+ await pending;
31
+ pending = undefined;
32
+ }
33
+ if (unavailable) throw Object.assign(new Error("Antigravity sidecar parent exited or changed; relaunch it from the native app"), { deliveryState: "not_sent" });
34
+ };
35
+ }
36
+
37
+ export async function startAntigravitySidecar({ directory = antigravityBridgeDirectory(), env = process.env, createParentGuard = createAntigravityParentGuard, serve = serveAntigravityDesktop, parentCheckIntervalMs = 5000, onParentLost = () => {} } = {}) {
38
+ 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
+ const assertParent = await createParentGuard();
40
+ const deliveries = new AntigravityDeliveries(path.join(directory, "desktop-deliveries"));
41
+ const sending = new Set();
42
+ let sidecar;
43
+ let timer;
44
+ let closing;
45
+ let closed = false;
46
+ let parentLost = false;
47
+ const close = () => {
48
+ closed = true;
49
+ clearTimeout(timer);
50
+ return closing ??= Promise.resolve().then(() => sidecar?.close());
51
+ };
52
+ const checkParent = async () => {
53
+ try { await assertParent(); }
54
+ catch (error) {
55
+ if (!parentLost) { parentLost = true; void close().then(onParentLost); }
56
+ throw error;
57
+ }
58
+ };
59
+ sidecar = await serve(async (request) => {
60
+ await checkParent();
61
+ 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 === "list") return listAntigravityConversations({ surface: "desktop" });
64
+ if (request.operation === "read") {
65
+ const records = await listAntigravityConversations({ surface: "desktop" });
66
+ const record = records.find((item) => item.conversationId === request.conversationId);
67
+ if (!record) throw new Error("Exact Antigravity Desktop conversation not found");
68
+ const native = sanitizeAntigravityMetadata(await runAgentApi({ command: "metadata", conversationId: request.conversationId, env }), request.conversationId);
69
+ return { ...record, ...native };
70
+ }
71
+ if (request.operation === "receipt") return deliveries.read(request.requestId);
72
+ if (request.operation !== "send" || typeof request.prompt !== "string" || !request.prompt.trim() || request.prompt.length > 30000 || request.prompt.includes("\0")
73
+ || typeof request.cwd !== "string" || !path.isAbsolute(request.cwd) || !request.sender || !["codex", "claude"].includes(request.sender.provider)
74
+ || typeof request.sender.id !== "string" || !request.sender.id) throw new Error("Invalid Antigravity delivery request");
75
+ return deliveries.dispatch({ requestId: request.requestId, surface: "desktop", conversationId: request.conversationId, cwd: request.cwd, sender: request.sender, prompt: request.prompt }, async () => {
76
+ await checkParent();
77
+ if (sending.has(request.conversationId)) throw Object.assign(new Error("Another delivery is pending for this conversation"), { deliveryState: "not_sent" });
78
+ sending.add(request.conversationId);
79
+ try { return await runAgentApi({ command: "send", conversationId: request.conversationId, expectedCwd: request.cwd, prompt: request.prompt, env }); }
80
+ finally { sending.delete(request.conversationId); }
81
+ });
82
+ }, { directory });
83
+ try { await checkParent(); } catch (error) { await close(); throw error; }
84
+ const schedule = () => {
85
+ if (closed) return;
86
+ timer = setTimeout(async () => {
87
+ try { await checkParent(); } catch { return; }
88
+ schedule();
89
+ }, parentCheckIntervalMs);
90
+ timer.unref();
91
+ };
92
+ schedule();
93
+ return { ...sidecar, close };
94
+ }
95
+
96
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
97
+ exitForVersionRequest(import.meta.url);
98
+ const sidecar = await startAntigravitySidecar({ onParentLost: () => process.exit(0) });
99
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void sidecar.close().then(() => process.exit(0)); });
100
+ }
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.23.0";
33
+ const VERSION = "1.24.1";
34
34
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
35
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
36
  const FORWARD_MAX_PER_SESSION = 50;
@@ -109,7 +109,7 @@ const replyForwarder = new ReplyForwarder({
109
109
  if (desktopOnly) assertAccountIdentity(record.accountContext);
110
110
  recheckScopeBindings(record.scopeBindings);
111
111
  },
112
- deliver: (threadId, record) => delivery.deliver(threadId, `[message from Claude session ${record.fromSocket ?? "?"}]\n${record.absorbed ? "[This reply is the closing text of a turn that absorbed the message while it was running; it may not address the message.]\n" : ""}\n${record.text}`,
112
+ deliver: (threadId, record) => delivery.deliver(threadId, `${record.kind === "delivery_status" ? "[Bridge notification about Claude reply observation; not a Claude reply]" : `[message from Claude session ${record.fromSocket ?? "?"}]`}\n${record.absorbed ? "[This reply is the closing text of a turn that absorbed the message while it was running; it may not address the message.]\n" : ""}\n${record.text}`,
113
113
  desktopOnly ? { beforeSend: () => { assertAccountIdentity(record.accountContext); recheckScopeBindings(record.scopeBindings); }, accountContext: record.accountContext } : {}),
114
114
  });
115
115
 
@@ -593,13 +593,13 @@ registerTool(
593
593
  }
594
594
 
595
595
  if (wait === 0) {
596
- return result(`Sent to peer transport for ${targetLabel}.\nClaude receipt unconfirmed; not waiting for a reply.\nMessage id: ${msgId}`);
596
+ return result(`Sent to peer transport for ${targetLabel}.\nClaude receipt unconfirmed; not waiting for a reply.\nMessage id: ${msgId}\nCheck read_claude_delivery with this message ID for terminal status, and read_claude_inbox for late replies or bridge notifications. Do not resend automatically.`);
597
597
  }
598
598
 
599
599
  if (!reply) {
600
600
  return result(
601
601
  `Sent to peer transport for ${targetLabel}.\n\nNo reply within ${wait}s; Claude receipt and outcome remain unconfirmed. ` +
602
- `Do not automatically retry: the message may still be processed. Check read_claude_inbox for a late reply.\nMessage id: ${msgId}`,
602
+ `Do not automatically retry: the message may still be processed. Check read_claude_delivery for terminal status and read_claude_inbox for a late reply or bridge notification.\nMessage id: ${msgId}`,
603
603
  true,
604
604
  );
605
605
  }
@@ -633,7 +633,7 @@ registerTool(
633
633
  title: "Read messages Claude sent to this bridge",
634
634
  description:
635
635
  "Read and consume the oldest requested page of Claude messages, preserving unread messages. " +
636
- "Includes late replies and their Codex forwarding status.",
636
+ "Includes late replies, terminal bridge-status notifications (not assistant replies), and their Codex forwarding status.",
637
637
  inputSchema: {
638
638
  limit: z.number().int().min(1).max(100).optional().describe("How many messages to return (default 20)"),
639
639
  },
@@ -655,7 +655,7 @@ registerTool(
655
655
  .map((m) => {
656
656
  const at = new Date(m.receivedAt).toISOString().replace("T", " ").slice(0, 19);
657
657
  const state = replyForwarder.read(m.inReplyTo ?? m.msgId) ?? m.forwardingError;
658
- return `[${at}] from ${m.fromSocket ?? "?"}${state ? `\nCodex forwarding: ${state.status}${state.reason ? ` (${state.reason})` : ""}` : ""}\n${m.text}`;
658
+ return `[${at}] ${m.kind === "delivery_status" ? "bridge status for" : "from"} ${m.fromSocket ?? "?"}${state ? `\nCodex forwarding: ${state.status}${state.reason ? ` (${state.reason})` : ""}` : ""}\n${m.text}`;
659
659
  })
660
660
  .join("\n\n"),
661
661
  );
@@ -140,7 +140,7 @@ function scanSegment(file, maxBytes, { threadId, turnId, afterOffset, prefixSize
140
140
  if (start >= dispatchAfterOffset && dispatchOutputs && record.type === "response_item" && record.payload.type === "function_call_output" &&
141
141
  record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread" && dispatchOutputs.includes(record.payload.output)) {
142
142
  const metadata = record.payload.internal_chat_message_metadata_passthrough;
143
- const selectedTurnId = record.payload.turn_id ?? record.payload.root_turn_id ?? metadata?.turn_id ?? metadata?.root_turn_id;
143
+ const selectedTurnId = record.payload.turn_id ?? metadata?.turn_id ?? record.payload.root_turn_id ?? metadata?.root_turn_id;
144
144
  if (!UUID.test(selectedTurnId) || !recordBelongsToTurn(entry, threadId, selectedTurnId)) throw new Error("The native dispatch has no consistent turn identity");
145
145
  dispatches.push({ turnId: selectedTurnId, start });
146
146
  if (dispatches.length > 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
@@ -223,7 +223,11 @@ function replyHash(text) {
223
223
  function recordBelongsToTurn(entry, threadId, turnId) {
224
224
  const payload = entry.record.payload;
225
225
  const metadata = object(payload.internal_chat_message_metadata_passthrough) ? payload.internal_chat_message_metadata_passthrough : {};
226
- const turnFields = [payload.turn_id, payload.root_turn_id, metadata.turn_id, metadata.root_turn_id].filter((value) => value !== undefined);
226
+ // root_turn_id describes causal ancestry and may belong to another thread.
227
+ // Only older records without explicit turn IDs use it as their identity.
228
+ const explicitTurns = [payload.turn_id, metadata.turn_id].filter((value) => value !== undefined);
229
+ const turnFields = explicitTurns.length ? explicitTurns
230
+ : [payload.root_turn_id, metadata.root_turn_id].filter((value) => value !== undefined);
227
231
  if (!turnFields.includes(turnId)) return false;
228
232
  if (turnFields.some((value) => value !== turnId)) throw validationError("The native response contains contradictory turn identity", "turn_identity", { recordByte: entry.start });
229
233
  const threadFields = [payload.thread_id, metadata.thread_id].filter((value) => value !== undefined);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.mjs";
3
+ import { DESKTOP_TOOL_BUDGET_MS, OBSERVATION_UNAVAILABLE_GUIDANCE } from "./thread-delivery.mjs";
4
4
  import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
5
5
  import { ResponseSnapshots, responsePreferencesSchema, normalizeResponsePreferences, projectHistory, projectReply, assertResponseResultSize } from "./response-preferences.mjs";
6
6
 
@@ -102,7 +102,8 @@ export function registerDesktopReadTools(registerTool, {
102
102
  metadata: { ...metadata, threadId: delivered.threadId, turnId: result.turnId ?? null,
103
103
  turnStatus: ["completed", "interrupted", "failed"].includes(result.status) ? result.status : null, deliveryId,
104
104
  deliveryStatus: ["completed", "completed_no_reply"].includes(result.responseStatus) ? "accepted" : delivered.deliveryStatus ?? "accepted",
105
- nextAction: result.status === "timeout" ? "wait_codex_reply" : "none" },
105
+ nextAction: result.status === "observation_unavailable" ? "inspect_existing_task" : result.status === "timeout" ? "wait_codex_reply" : "none",
106
+ ...(result.status === "observation_unavailable" ? { guidance: OBSERVATION_UNAVAILABLE_GUIDANCE } : {}) },
106
107
  items: projectReply(assistantItems ?? [], result.turnId) });
107
108
  };
108
109
  if (!delivered.threadId || Date.now() >= observationDeadline) return finish(timeout());
package/src/index.mjs CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  import { runTurn } from "./turn.mjs";
25
25
  import { BridgeSecurityPolicy } from "./security-policy.mjs";
26
26
  import { enableCodexFullAccess, fullAccessEnabled } from "./codex-full-access.mjs";
27
- import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.mjs";
27
+ import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS, OBSERVATION_UNAVAILABLE_GUIDANCE } from "./thread-delivery.mjs";
28
28
  import { desktopTasksConfigured } from "./native-relay.mjs";
29
29
  import { exitForVersionRequest } from "./cli-version.mjs";
30
30
  import { createRuntimeState } from "./runtime-state.mjs";
@@ -41,7 +41,7 @@ import { createRelayWake, relayKeeperEnabled, relayWakeStateFile, startRelayKeep
41
41
 
42
42
  exitForVersionRequest(import.meta.url);
43
43
 
44
- const VERSION = "1.23.0";
44
+ const VERSION = "1.24.1";
45
45
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
46
46
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
47
47
 
@@ -170,10 +170,12 @@ function replyResult(delivered, deliveryId, result, notes = []) {
170
170
  result = { activity: [], errors: [], ...result };
171
171
  const deliveryStatus = ["completed", "completed_no_reply"].includes(result.responseStatus) ? "accepted" : delivered.deliveryStatus ?? "accepted";
172
172
  const pending = result.status === "timeout";
173
+ const observationUnavailable = result.status === "observation_unavailable";
174
+ if (observationUnavailable) notes = [...notes, OBSERVATION_UNAVAILABLE_GUIDANCE];
173
175
  const continuation = deliveryId ? `deliveryId: ${deliveryId}\n${pending ? "Continue automatically with wait_codex_reply and this deliveryId; it only observes the original send. Do not resend the prompt." : "Keep this deliveryId to inspect the same send after reconnecting."}` : "";
174
176
  return {
175
177
  ...textResult([...notes, `deliveryStatus: ${deliveryStatus}`, formatTurn(result, { desktop: true }), continuation].filter(Boolean).join("\n"), result.status === "failed" || result.status === "systemError"),
176
- structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus, ...(deliveryId ? { deliveryId } : {}), nextAction: pending && deliveryId ? "wait_codex_reply" : "none" },
178
+ structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus, ...(deliveryId ? { deliveryId } : {}), nextAction: observationUnavailable ? "inspect_existing_task" : pending && deliveryId ? "wait_codex_reply" : "none" },
177
179
  };
178
180
  }
179
181
 
@@ -42,7 +42,7 @@ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } fro
42
42
 
43
43
  exitForVersionRequest(import.meta.url);
44
44
 
45
- const VERSION = "1.23.0";
45
+ const VERSION = "1.24.1";
46
46
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
47
47
 
48
48
  function errorResponse(code, message, sent, details) {
@@ -875,9 +875,7 @@ export class PeerEndpoint {
875
875
  this.log(`ignored uncorrelated inbound peer message from ${message.fromSocket ?? "?"} in hardened mode`);
876
876
  return;
877
877
  }
878
- this.activityRevision += 1;
879
- const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
880
- this.inbox.push(record);
878
+ const record = { ...message };
881
879
  if (key && correlated && pending.targetSocket === record.fromSocket) {
882
880
  const sent = this.sentMessages.get(key);
883
881
  if (sent) {
@@ -891,6 +889,13 @@ export class PeerEndpoint {
891
889
  }
892
890
  this.#removePendingReply(record.fromSocket, key);
893
891
  }
892
+ this.#publishMessage(record);
893
+ }
894
+
895
+ #publishMessage(record) {
896
+ this.activityRevision += 1;
897
+ Object.assign(record, { receivedAt: Date.now(), sequence: ++this.messageSequence });
898
+ this.inbox.push(record);
894
899
  this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.length} characters`);
895
900
  for (const listener of [...this.listeners]) {
896
901
  try { listener(record); }
@@ -911,9 +916,22 @@ export class PeerEndpoint {
911
916
  const outcome = readTranscriptOutcome(sessionId, cwd, msgId);
912
917
  if (outcome?.reply) this.#receiveMessage({ ...outcome.reply, fromSocket: pending.targetSocket });
913
918
  else if (outcome?.status === "interrupted") {
914
- this.activityRevision += 1;
915
919
  this.deliveryReceipts.set(msgId, { ...outcome, fromSocket: pending.targetSocket, observedAt: Date.now() });
916
920
  this.#removePendingReply(pending.targetSocket, msgId);
921
+ const sent = this.sentMessages.get(msgId);
922
+ // A terminal observation is a bridge notification, never a Claude reply.
923
+ // Retain the send's original routing and scope for inbox filtering and
924
+ // forwarding; do not infer either from the currently active task.
925
+ this.#publishMessage({
926
+ kind: "delivery_status", source: "bridge", status: "interrupted",
927
+ msgId: `interrupted:${msgId}`, inReplyTo: msgId, fromSocket: pending.targetSocket,
928
+ replyThreadId: sent?.replyThreadId ?? null, accountContext: sent?.accountContext ?? null,
929
+ cwd: sent?.transcriptSession?.cwd ?? null, senderCwd: sent?.scopeBindings?.sender?.path ?? null,
930
+ scopeBindings: sent?.scopeBindings ?? null,
931
+ outcome: "unknown", waitEnded: true, taskCancelled: false, retrySafe: false,
932
+ reasonCode: outcome.reasonCode, interruptedBy: outcome.interruptedBy, backgroundTaskIds: outcome.backgroundTaskIds,
933
+ text: `[Bridge delivery status: ${outcome.reasonCode}]\nMessage id: ${msgId}\n${outcome.reason}`,
934
+ });
917
935
  this.log(`reply observation interrupted for ${msgId}; task outcome remains unknown`);
918
936
  }
919
937
  }
@@ -1096,7 +1114,7 @@ export class PeerEndpoint {
1096
1114
  */
1097
1115
  waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null, msgId } = {}) {
1098
1116
  const expectsTranscript = Boolean(msgId && this.sentMessages.get(msgId)?.transcriptSession);
1099
- const matches = (record) => record.fromSocket === fromSocket
1117
+ const matches = (record) => record.kind !== "delivery_status" && record.fromSocket === fromSocket
1100
1118
  && (!expectsTranscript || (record.source === "transcript" && record.inReplyTo === msgId))
1101
1119
  && (!record.inReplyTo || !msgId || record.inReplyTo === msgId)
1102
1120
  && (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
@@ -27,6 +27,7 @@ import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
27
27
  export const NATIVE_BACKEND = "codex-desktop-native";
28
28
  export const APP_SERVER_BACKEND = "app-server";
29
29
  export const DESKTOP_TOOL_BUDGET_MS = 40000;
30
+ export const OBSERVATION_UNAVAILABLE_GUIDANCE = "The response could not be verified; this does not establish that delivery or the task failed. Inspect the existing task and this receipt; do not resend the prompt.";
30
31
  export const WORKSPACE_UNASSIGNED = "DESKTOP_WORKSPACE_UNASSIGNED";
31
32
  export const UNASSIGNED_WORKSPACE_GRACE_MS = 120000;
32
33
  const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
@@ -644,8 +645,9 @@ export class DesktopTaskDelivery {
644
645
  }
645
646
  turnId = observed.turnId ?? null;
646
647
  if (observed.status !== "pending") {
648
+ const nativeFailure = turnId && turn?.id === turnId && ["failed", "interrupted"].includes(turn.status) ? turn.status : null;
647
649
  return {
648
- threadId, turnId, status: observed.status === "unavailable" ? "failed" : "completed",
650
+ threadId, turnId, status: observed.status === "unavailable" ? nativeFailure ?? "observation_unavailable" : "completed",
649
651
  responseStatus: observed.status, observationStatus: observed.status,
650
652
  text: observed.status === "completed" ? observed.text : "",
651
653
  ...(observed.reason ? { observationReason: observed.reason } : {}),