@minhspark/codex-mcp-bridge 1.24.0 → 1.25.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.
@@ -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);
package/src/platform.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
2
  import { accessSync, constants, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { createRequire } from "node:module";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -168,16 +169,67 @@ function pathCandidates() {
168
169
  * ~/.local/bin, ~/.npm-global/bin or Homebrew. Probe every known install
169
170
  * location for the running platform before giving up on PATH resolution.
170
171
  */
171
- export function resolveCodexBin(explicit) {
172
+ export function resolvePackagedCodexBin({
173
+ platform = process.platform,
174
+ arch = process.arch,
175
+ resolvePackage = (specifier, from) => createRequire(from).resolve(specifier),
176
+ exists = isRunnable,
177
+ } = {}) {
178
+ const packagePlatform = platform === "android" ? "linux" : platform;
179
+ const target = {
180
+ "linux-x64": "x86_64-unknown-linux-musl",
181
+ "linux-arm64": "aarch64-unknown-linux-musl",
182
+ "darwin-x64": "x86_64-apple-darwin",
183
+ "darwin-arm64": "aarch64-apple-darwin",
184
+ "win32-x64": "x86_64-pc-windows-msvc",
185
+ "win32-arm64": "aarch64-pc-windows-msvc",
186
+ }[`${packagePlatform}-${arch}`];
187
+ if (!target) return null;
188
+
189
+ let codexManifest;
190
+ try { codexManifest = resolvePackage("@openai/codex/package.json", import.meta.url); } catch {}
191
+ if (typeof codexManifest !== "string" || !path.isAbsolute(codexManifest)) codexManifest = null;
192
+ const packageName = `@openai/codex-${packagePlatform}-${arch}/package.json`;
193
+ const roots = [];
194
+ for (const anchor of [codexManifest, import.meta.url].filter(Boolean)) {
195
+ try {
196
+ const manifest = resolvePackage(packageName, anchor);
197
+ if (typeof manifest === "string" && path.isAbsolute(manifest)) roots.push(path.dirname(manifest));
198
+ } catch {}
199
+ }
200
+ if (codexManifest) roots.push(path.dirname(codexManifest));
201
+ for (const root of new Set(roots)) {
202
+ const candidate = path.join(root, "vendor", target, "bin", platform === "win32" ? "codex.exe" : "codex");
203
+ if (exists(candidate)) return candidate;
204
+ }
205
+ return null;
206
+ }
207
+
208
+ export function resolveCodexBin(explicit, {
209
+ env = process.env,
210
+ platform = process.platform,
211
+ arch = process.arch,
212
+ candidates: discovered,
213
+ runnable = isRunnable,
214
+ packaged = resolvePackagedCodexBin,
215
+ } = {}) {
172
216
  const candidates = [
173
217
  explicit,
174
- process.env.CODEX_BIN,
175
- ...(IS_WINDOWS ? windowsCodexCandidates() : IS_MACOS ? macosCodexCandidates() : unixCodexCandidates()),
176
- ...pathCandidates(),
218
+ env.CODEX_BIN,
219
+ ...(discovered ?? [
220
+ ...(IS_WINDOWS ? windowsCodexCandidates() : IS_MACOS ? macosCodexCandidates() : unixCodexCandidates()),
221
+ ...pathCandidates(),
222
+ ]),
177
223
  ].filter(Boolean);
178
224
 
225
+ const deferredShim = (candidate) => platform === "win32" && /\.(?:cmd|bat)$/i.test(candidate);
226
+ for (const candidate of candidates) {
227
+ if (!deferredShim(candidate) && runnable(candidate)) return candidate;
228
+ }
229
+ const bundled = packaged({ platform, arch });
230
+ if (bundled) return bundled;
179
231
  for (const candidate of candidates) {
180
- if (isRunnable(candidate)) return candidate;
232
+ if (deferredShim(candidate) && runnable(candidate)) return candidate;
181
233
  }
182
234
  return "codex";
183
235
  }
package/src/setup.mjs ADDED
@@ -0,0 +1,204 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { resolveCodexBin, resolvePackagedCodexBin } from "./platform.mjs";
8
+ import { readRegularConfigFile } from "./project-policy.mjs";
9
+ import { stableInstallPath } from "./install-paths.mjs";
10
+ import { resolveClaudeDesktopConfigRoot } from "./desktop-account-context.mjs";
11
+
12
+ const ROOT = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
13
+ const CODEX_PACKAGE = "@openai/codex@0.166.0";
14
+ const MAX_CONFIG = 16 * 1024 * 1024;
15
+
16
+ export function setupArguments(args) {
17
+ const allowed = new Set(["--postinstall", "--check", "--dry-run", "--json", "--no-download", "--help"]);
18
+ if (args.some((arg) => !allowed.has(arg))) throw new Error("Unknown setup option; use codex-bridge-setup --help");
19
+ if (args.includes("--check") && args.includes("--dry-run")) throw new Error("Use --check or --dry-run, not both");
20
+ return Object.fromEntries([...allowed].map((arg) => [arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), args.includes(arg)]));
21
+ }
22
+
23
+ export function automaticSetupEnabled(env = process.env) {
24
+ return env.npm_config_global === "true" && env.CODEX_BRIDGE_SKIP_SETUP !== "1";
25
+ }
26
+
27
+ export function setupPaths(env = process.env, platform = process.platform) {
28
+ const home = env.HOME || env.USERPROFILE || os.homedir();
29
+ const codeDirectory = env.CLAUDE_CONFIG_DIR || home;
30
+ const configRoot = env.XDG_CONFIG_HOME || path.join(home, ".config");
31
+ const desktop = env.CLAUDE_DESKTOP_CONFIG || path.join(resolveClaudeDesktopConfigRoot({ env, platform }), "claude_desktop_config.json");
32
+ return { home, code: path.join(codeDirectory, ".claude.json"), desktop,
33
+ codex: env.CODEX_HOME || path.join(home, ".codex"),
34
+ gemini: path.join(home, ".gemini", "config"), tools: path.join(configRoot, "GptClaudeBridge", "tools") };
35
+ }
36
+
37
+ function readJson(file) {
38
+ const text = readRegularConfigFile(file, { missing: true, maxBytes: MAX_CONFIG });
39
+ if (text === null) return null;
40
+ const value = JSON.parse(text.replace(/^\uFEFF/, ""));
41
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Expected a JSON object: ${file}`);
42
+ return value;
43
+ }
44
+
45
+ function execute(command, args, env, timeout = 120000) {
46
+ const result = spawnSync(command, args, { env, encoding: "utf8", windowsHide: true, shell: false, timeout,
47
+ maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
48
+ if (result.error || result.status !== 0) {
49
+ const lines = String(result.stderr || "").split(/\r?\n/).filter((line) => /^Error:|^(?:Expected|Refusing|Invalid|Unknown|Shared|Claude|Codex|Configuration|The existing|Missing|Unsafe)/.test(line)
50
+ && !/Command failed|--env|password|secret|token|authorization/i.test(line));
51
+ const detail = lines.slice(0, 3).join(" ") || result.error?.code || `exit ${result.status}`;
52
+ throw new Error(`${path.basename(command)} ${path.basename(args[0] ?? "")}: ${detail}`);
53
+ }
54
+ return result.stdout || "";
55
+ }
56
+
57
+ function usableBinary(binary, platform) {
58
+ return typeof binary === "string" && path.isAbsolute(binary) && fs.existsSync(binary)
59
+ && fs.statSync(binary).isFile() && (platform !== "win32" || /\.(exe|com)$/i.test(binary));
60
+ }
61
+
62
+ function managedBinary(paths, platform) {
63
+ return resolvePackagedCodexBin({ platform, resolvePackage: (specifier, from) => createRequire(
64
+ specifier === "@openai/codex/package.json" ? path.join(paths.tools, "package.json") : from,
65
+ ).resolve(specifier) });
66
+ }
67
+
68
+ export function ensureSetupCodex({ env, paths, platform, download = true, run = execute, resolve = resolveCodexBin }) {
69
+ const existing = resolve(env.CODEX_EXE, { env, platform });
70
+ if (usableBinary(existing, platform)) return { binary: existing, installed: false };
71
+ const managed = managedBinary(paths, platform);
72
+ if (usableBinary(managed, platform)) return { binary: managed, installed: false };
73
+ if (!download) throw new Error("Codex CLI was not found. Rerun setup without --no-download to install it automatically.");
74
+ const candidates = [env.npm_execpath, path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js")];
75
+ const npm = candidates.find((file) => file && path.isAbsolute(file) && fs.existsSync(file) && /(?:npm-cli|npm)\.js$/i.test(file));
76
+ if (!npm) throw new Error("Codex CLI is missing and npm could not be located. Rerun the global npm install command to finish setup.");
77
+ fs.mkdirSync(paths.tools, { recursive: true, mode: 0o700 });
78
+ run(process.execPath, [npm, "install", "--global=false", "--prefix", paths.tools, "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", "--no-save", CODEX_PACKAGE],
79
+ { ...env, npm_config_global: "false", CODEX_BRIDGE_SKIP_SETUP: "1" }, 180000);
80
+ const binary = managedBinary(paths, platform);
81
+ if (!usableBinary(binary, platform)) throw new Error("The Codex CLI download did not provide a supported native executable. Check npm optional dependencies and rerun setup.");
82
+ return { binary, installed: true };
83
+ }
84
+
85
+ export function setupEnvironment(env, paths, codex) {
86
+ const result = Object.fromEntries(Object.entries(env).filter(([name]) => !/^(?:CODEX_BRIDGE_|CLAUDE_BRIDGE_|CODEX_NATIVE_RELAY_|CODEX_RELAY_ID$|CODEX_THREAD_ID$|CODEX_APP_TOOLS_PIPE_PATH$)/.test(name)));
87
+ return { ...result, HOME: paths.home, USERPROFILE: paths.home, CODEX_HOME: paths.codex,
88
+ CLAUDE_DESKTOP_CONFIG: paths.desktop, CODEX_EXE: codex, CODEX_BIN: codex };
89
+ }
90
+
91
+ function configurationCheck(label, check, components) {
92
+ try { check(); components.push({ name: label, status: "configured" }); }
93
+ catch (error) { components.push({ name: label, status: "incomplete", detail: error.message }); }
94
+ }
95
+
96
+ function assertEntry(entry, worker, root, { caller } = {}) {
97
+ if (!entry || typeof entry.command !== "string" || !fs.existsSync(entry.command)) throw new Error("MCP command is missing or unavailable");
98
+ const args = entry.args ?? [];
99
+ const expected = worker === "index.mjs" || worker === "claude-bridge.mjs" || worker === "native-relay-companion.mjs"
100
+ ? [path.join(root, "src", "mcp-supervisor.mjs"), worker] : [path.join(root, "src", worker)];
101
+ if (JSON.stringify(args) !== JSON.stringify(expected)) throw new Error("MCP entry points to a different installation or unsupported launcher");
102
+ if (entry.enabled === false || entry.disabled === true) throw new Error("MCP entry is disabled; the saved choice was preserved");
103
+ if (caller && entry.env?.ANTIGRAVITY_BRIDGE_CALLER !== caller) throw new Error("Antigravity caller registration is missing or conflicting");
104
+ }
105
+
106
+ export function checkSetup({ env = process.env, platform = process.platform, root = ROOT, run = execute, resolve = resolveCodexBin } = {}) {
107
+ const paths = setupPaths(env, platform);
108
+ const components = [];
109
+ let codex;
110
+ let inventory;
111
+ configurationCheck("Codex CLI", () => {
112
+ codex = ensureSetupCodex({ env, paths, platform, download: false, run, resolve }).binary;
113
+ inventory = JSON.parse(run(codex, ["mcp", "list", "--json"], setupEnvironment(env, paths, codex), 30000));
114
+ if (!Array.isArray(inventory)) throw new Error("Codex returned an invalid MCP inventory");
115
+ }, components);
116
+ const native = platform === "win32" || platform === "darwin";
117
+ for (const [name, worker] of [["claude-bridge", "claude-bridge.mjs"], ...(native ? [["codex-native-relay", "native-relay-companion.mjs"]] : []), ["antigravity-bridge", "antigravity-bridge.mjs"]]) {
118
+ configurationCheck(`Codex / ${name}`, () => {
119
+ const item = inventory?.find((server) => server.name === name);
120
+ if (!item) throw new Error("MCP registration is missing");
121
+ const entry = JSON.parse(run(codex, ["mcp", "get", name, "--json"], setupEnvironment(env, paths, codex), 30000));
122
+ assertEntry({ ...entry.transport, enabled: entry.enabled }, worker, root, name === "antigravity-bridge" ? { caller: "codex" } : {});
123
+ if (name === "codex-native-relay" && !entry.transport.env_vars?.includes("CODEX_APP_TOOLS_PIPE_PATH")) throw new Error("Native pipe forwarding is missing");
124
+ }, components);
125
+ }
126
+ for (const [label, file, name, worker, caller] of [
127
+ ["Claude Code / codex-bridge", paths.code, "codex-bridge", "index.mjs"],
128
+ ["Claude Desktop / codex-bridge-desktop", paths.desktop, "codex-bridge-desktop", "index.mjs"],
129
+ ["Claude Code / antigravity-bridge", paths.code, "antigravity-bridge", "antigravity-bridge.mjs", "claude"],
130
+ ["Antigravity / agent-bridge", path.join(paths.gemini, "mcp_config.json"), "agent-bridge", "antigravity-outbound-bridge.mjs"],
131
+ ]) configurationCheck(label, () => {
132
+ const entry = readJson(file)?.mcpServers?.[name];
133
+ assertEntry(entry, worker, root, { caller });
134
+ if (name === "codex-bridge-desktop" && entry.env?.CODEX_BRIDGE_DESKTOP_TASKS === "1") {
135
+ throw new Error("Shared Desktop chat cannot use native Code routing. Its saved choice was preserved; explicitly set CODEX_BRIDGE_DESKTOP_TASKS=0 for the local app-server route and CODEX_BRIDGE_AUTOSTART=1 if the bridge should start that server.");
136
+ }
137
+ }, components);
138
+ configurationCheck("Antigravity / codex-bridge sidecar", () => {
139
+ assertEntry(readJson(path.join(paths.gemini, "sidecars", "codex-bridge", "sidecar.json")), "antigravity-sidecar.mjs", root);
140
+ if (readJson(path.join(paths.gemini, "config.json"))?.sidecars?.["codex-bridge"]?.enabled !== true) throw new Error("Antigravity sidecar is not enabled");
141
+ }, components);
142
+ if (native) configurationCheck("Native relay executor", () => {
143
+ const relay = readJson(path.join(paths.codex, "native-relay.json"));
144
+ if (!relay?.relayThreadId) throw new Error("Native relay executor has not been initialized");
145
+ }, components);
146
+ configurationCheck("Claude registration names", () => {
147
+ if (readJson(paths.desktop)?.mcpServers?.["codex-bridge"]) throw new Error("Shared codex-bridge shadows the Claude Code registration");
148
+ }, components);
149
+ return { configured: components.every((item) => item.status === "configured"), liveVerified: false, components };
150
+ }
151
+
152
+ export function runSetup({ args = [], env = process.env, platform = process.platform, root = ROOT, run = execute, resolve = resolveCodexBin } = {}) {
153
+ const options = setupArguments(args);
154
+ if (options.help) return { help: "Usage: codex-bridge-setup [--check|--dry-run] [--json] [--no-download]\nGlobal npm installation configures Claude Desktop, Claude Code, Codex and Antigravity automatically. CODEX_BRIDGE_SKIP_SETUP=1 opts out." };
155
+ if (options.postinstall && !automaticSetupEnabled(env)) return { skipped: true, reason: "Automatic configuration runs only for a global npm install; use codex-bridge-setup for manual setup." };
156
+ if (options.check) return checkSetup({ env, platform, root, run, resolve });
157
+ const native = platform === "win32" || platform === "darwin";
158
+ const paths = setupPaths(env, platform);
159
+ const planned = ["Claude Code / codex-bridge", "Claude Desktop / codex-bridge-desktop", ...(native ? ["Codex / codex-native-relay and executor"] : []), "Codex / claude-bridge", "Antigravity / all components"];
160
+ if (options.dryRun) return { dryRun: true, configured: false, liveVerified: false, components: planned.map((name) => ({ name, status: "planned" })), files: [paths.code, paths.desktop, path.join(paths.codex, "config.toml"), paths.gemini] };
161
+ if (env.SUDO_USER && env.SUDO_USER !== "root" && typeof process.getuid === "function" && process.getuid() === 0) throw new Error("Run the global install without sudo in the account that runs the apps; setup will not write another account's configuration.");
162
+ const runtime = ensureSetupCodex({ env, paths, platform, download: !options.noDownload, run, resolve });
163
+ fs.mkdirSync(paths.codex, { recursive: true, mode: 0o700 });
164
+ const base = setupEnvironment(env, paths, runtime.binary);
165
+ const components = [];
166
+ const install = (name, script, arguments_, environment = base) => {
167
+ try {
168
+ const output = run(process.execPath, [path.join(root, "scripts", script), ...arguments_], environment, 180000);
169
+ let notices = [];
170
+ try { notices = JSON.parse(output).notices ?? []; } catch {}
171
+ components.push({ name, status: "configured", ...(notices.length ? { notices } : {}) });
172
+ return true;
173
+ } catch (error) { components.push({ name, status: "incomplete", detail: error.message }); return false; }
174
+ };
175
+ const code = install(planned[0], "install-claude-code.mjs", ["--install", "--desktop-config", paths.desktop], {
176
+ ...base, CODEX_BRIDGE_DESKTOP_TASKS: native ? "1" : "0", CODEX_BRIDGE_AUTOSTART: native ? "0" : "1", CODEX_BRIDGE_THREAD_POLICY: "roots",
177
+ });
178
+ if (code) install(planned[1], "install-claude-desktop.mjs", ["--entry-name", "codex-bridge-desktop", "--quiet"]);
179
+ if (native) {
180
+ const relay = readJson(path.join(paths.codex, "native-relay.json"));
181
+ install("Codex / codex-native-relay and executor", "install-native-relay.mjs", ["--setup", ...(relay?.desktopTasks === undefined ? ["--desktop-tasks"] : [])]);
182
+ }
183
+ install("Codex / claude-bridge", "install-codex-mcp.mjs", ["--setup"]);
184
+ if (code) install("Antigravity / all components", "install-antigravity-bridge.mjs", ["--all", "--setup"]);
185
+ const inspection = checkSetup({ env: base, platform, root, run, resolve: () => runtime.binary });
186
+ return { configured: components.every((item) => item.status === "configured") && inspection.configured, liveVerified: false,
187
+ cliInstalled: runtime.installed, components, verification: inspection.components,
188
+ nextActions: ["Open or reconnect Claude, Codex and Antigravity after active work finishes so they load the new entries.",
189
+ "Use the same saved project in Claude Desktop Code and Codex Desktop. Keep the apps signed in.",
190
+ "Verify codex_bridge_status in Claude Code, native_relay_status in Codex, and agent_bridge_status in Antigravity. Shared Claude Desktop chat uses the local app-server route."] };
191
+ }
192
+
193
+ export function printSetup(result, { json = false, output = console.log } = {}) {
194
+ if (json) { output(JSON.stringify(result, null, 2)); return; }
195
+ if (result.help) { output(result.help); return; }
196
+ if (result.skipped) { output(result.reason); return; }
197
+ output(result.dryRun ? "Bridge setup preview (no changes)" : result.configured ? "Bridge configuration verified. Live connections still require the apps to load it." : "Bridge setup is incomplete. Fix the items below, then run codex-bridge-setup again.");
198
+ for (const item of result.components ?? []) {
199
+ output(`[${item.status}] ${item.name}${item.detail ? `: ${item.detail}` : ""}`);
200
+ for (const notice of item.notices ?? []) output(` ${notice}`);
201
+ }
202
+ for (const item of result.verification ?? []) if (item.status !== "configured") output(`[${item.status}] ${item.name}: ${item.detail}`);
203
+ for (const action of result.nextActions ?? []) output(action);
204
+ }
@@ -27,9 +27,11 @@ 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"]);
34
+ const UNMATCHED_REPLY_GRACE_MS = 3000;
33
35
  const METADATA_READ_OPTIONS = { includeOutputs: false, maxOutputCharsPerItem: 1 };
34
36
 
35
37
  function unavailableWaitRenderer(error) {
@@ -572,6 +574,7 @@ export class DesktopTaskDelivery {
572
574
  let readSnapshots = false;
573
575
  let deferredReason = null;
574
576
  let deferredTurn = null;
577
+ let unmatchedTerminal = null;
575
578
  const readSnapshot = async () => {
576
579
  const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
577
580
  const thread = result?.thread;
@@ -614,6 +617,7 @@ export class DesktopTaskDelivery {
614
617
  } catch (err) {
615
618
  if (deadlineError(err)) return expired();
616
619
  if (err.code === WORKSPACE_UNASSIGNED) {
620
+ unmatchedTerminal = null;
617
621
  await pause();
618
622
  continue;
619
623
  }
@@ -639,13 +643,15 @@ export class DesktopTaskDelivery {
639
643
  const observed = await observe(null, true);
640
644
  if (!observed) return expired();
641
645
  if (observed === observeLater) {
646
+ unmatchedTerminal = null;
642
647
  await pause();
643
648
  continue;
644
649
  }
645
650
  turnId = observed.turnId ?? null;
646
651
  if (observed.status !== "pending") {
652
+ const nativeFailure = turnId && turn?.id === turnId && ["failed", "interrupted"].includes(turn.status) ? turn.status : null;
647
653
  return {
648
- threadId, turnId, status: observed.status === "unavailable" ? "failed" : "completed",
654
+ threadId, turnId, status: observed.status === "unavailable" ? nativeFailure ?? "observation_unavailable" : "completed",
649
655
  responseStatus: observed.status, observationStatus: observed.status,
650
656
  text: observed.status === "completed" ? observed.text : "",
651
657
  ...(observed.reason ? { observationReason: observed.reason } : {}),
@@ -656,6 +662,34 @@ export class DesktopTaskDelivery {
656
662
  };
657
663
  }
658
664
  if (needsAttention) return attention();
665
+ if (turnId === null && observed.dispatchObserved !== true && threadStatus === "idle" &&
666
+ typeof turn?.id === "string" && turn.id && turn.id !== previousTurnId && RELEASE_STATUSES.has(turn.status)) {
667
+ if (unmatchedTerminal?.id !== turn.id || unmatchedTerminal.status !== turn.status) {
668
+ unmatchedTerminal = { id: turn.id, status: turn.status, since: this.now(), polls: 1 };
669
+ } else unmatchedTerminal.polls++;
670
+ if (unmatchedTerminal.polls >= 3 && this.now() - unmatchedTerminal.since >= UNMATCHED_REPLY_GRACE_MS) {
671
+ let snapshot;
672
+ try { snapshot = (await readSnapshot()).polls[0]; }
673
+ catch (error) {
674
+ if (deadlineError(error)) return expired();
675
+ if (error.code === WORKSPACE_UNASSIGNED) {
676
+ unmatchedTerminal = null;
677
+ await pause();
678
+ continue;
679
+ }
680
+ throw error;
681
+ }
682
+ const status = typeof snapshot.thread.status === "string" ? snapshot.thread.status : snapshot.thread.status?.type;
683
+ if (status === "idle" && snapshot.latestTurn?.id === unmatchedTerminal.id && snapshot.latestTurn.status === unmatchedTerminal.status) {
684
+ return {
685
+ threadId, turnId: null, status: "observation_unavailable", responseStatus: "unavailable", observationStatus: "unavailable",
686
+ observationReason: "Codex Desktop reports an idle task, but its latest finished turn could not be correlated to this delivery after the rollout grace period. Inspect the existing task and receipt; do not resend the prompt.",
687
+ text: "", assistantItems: [], replySha256: null, responseSource: null, activity: [], errors: [], durationMs: this.now() - startedAt,
688
+ };
689
+ }
690
+ unmatchedTerminal = null;
691
+ }
692
+ } else unmatchedTerminal = null;
659
693
  if (this.now() - startedAt >= timeoutMs) return expired();
660
694
  await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
661
695
  continue;