@minhspark/codex-mcp-bridge 1.25.3 → 1.26.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.
Files changed (38) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +45 -20
  4. package/docs/new-macos-setup.md +47 -21
  5. package/docs/new-windows-setup.md +34 -9
  6. package/docs/project-onboarding.md +21 -8
  7. package/package.json +2 -1
  8. package/plugin.json +1 -1
  9. package/scripts/background-relay-hidden.vbs +11 -0
  10. package/scripts/install-antigravity-bridge.mjs +26 -14
  11. package/scripts/install-background-relay.mjs +437 -0
  12. package/scripts/install-native-relay.mjs +1 -1
  13. package/scripts/setup-background-relay.mjs +58 -0
  14. package/scripts/worker-relay.mjs +41 -0
  15. package/src/antigravity-bridge.mjs +18 -3
  16. package/src/antigravity-cli.mjs +13 -2
  17. package/src/antigravity-outbound-bridge.mjs +27 -6
  18. package/src/antigravity-outbound-targets.mjs +59 -4
  19. package/src/background-antigravity-worker.mjs +192 -0
  20. package/src/background-claude-worker.mjs +240 -0
  21. package/src/background-codex-worker.mjs +344 -0
  22. package/src/background-relay-client.mjs +107 -0
  23. package/src/background-relay-config.mjs +98 -0
  24. package/src/background-relay-daemon.mjs +60 -0
  25. package/src/background-relay-mcp.mjs +45 -0
  26. package/src/background-relay-protocol.mjs +54 -0
  27. package/src/background-relay-registration.mjs +198 -0
  28. package/src/background-relay-service.mjs +311 -0
  29. package/src/background-relay-storage.mjs +203 -0
  30. package/src/background-relay-storage.ps1 +64 -0
  31. package/src/background-relay.mjs +373 -0
  32. package/src/claude-bridge.mjs +6 -3
  33. package/src/index.mjs +7 -2
  34. package/src/mcp-supervisor.mjs +17 -11
  35. package/src/native-relay-companion.mjs +1 -1
  36. package/src/reload-control.mjs +2 -2
  37. package/src/setup.mjs +26 -9
  38. package/src/windows-pipe-acl.ps1 +1 -1
@@ -0,0 +1,373 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import { bridgeDiagnostic } from "./recipient-preflight.mjs";
7
+ import { createProjectScope, readRegularConfigFile } from "./project-policy.mjs";
8
+ import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
9
+ import { BACKGROUND_AGENTS, RELAY_UUID, canStartBackgroundWorker, privateRelayDirectory, relayFailure, writeRelayJson } from "./background-relay-config.mjs";
10
+ import { RELAY_FRAME_LIMIT, RELAY_REPLY_LIMIT, validRelayHello, relayChallenge, verifyRelayOperation, relayResponse } from "./background-relay-protocol.mjs";
11
+ import { assertPrivateRelayFile, assertPrivateRelayFiles } from "./background-relay-storage.mjs";
12
+
13
+ const { version } = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
14
+ const terminal = new Set(["completed", "failed", "unknown"]);
15
+ const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
16
+ const equalSecret = (left, right) => typeof left === "string" && typeof right === "string" && Buffer.byteLength(left) === Buffer.byteLength(right) && timingSafeEqual(Buffer.from(left), Buffer.from(right));
17
+
18
+ export async function backgroundAdapters() {
19
+ const [codex, claude, antigravity] = await Promise.all([import("./background-codex-worker.mjs"), import("./background-claude-worker.mjs"), import("./background-antigravity-worker.mjs")]);
20
+ return { codex: { inspect: codex.inspectCodexWorker, run: codex.runCodexWorker },
21
+ claude: { inspect: claude.inspectClaudeWorker, run: claude.runClaudeWorker },
22
+ antigravity: { inspect: antigravity.inspectAntigravityWorker, run: antigravity.runAntigravityWorker } };
23
+ }
24
+
25
+ export class BackgroundRelay {
26
+ constructor({ config, adapters, scope, env = process.env, maxConcurrent = 6, maxQueued = 64, maxRecords = 1024, now = () => new Date().toISOString() }) {
27
+ if (!config?.enabled) throw relayFailure("WORKER_RELAY_DISABLED", "The independent relay is disabled");
28
+ this.config = config;
29
+ this.adapters = adapters;
30
+ this.scope = scope ?? createProjectScope(config.projectPolicy);
31
+ this.env = { ...env };
32
+ this.maxConcurrent = maxConcurrent;
33
+ this.maxQueued = maxQueued;
34
+ this.maxRecords = maxRecords;
35
+ this.now = now;
36
+ this.jobs = new Map();
37
+ this.capabilities = new Map();
38
+ this.active = new Map();
39
+ this.health = new Map();
40
+ this.accepting = true;
41
+ this.pumping = false;
42
+ this.startedAt = this.now();
43
+ privateRelayDirectory(config.paths.jobs);
44
+ }
45
+
46
+ persist(job) { writeRelayJson(path.join(this.config.paths.jobs, `${job.requestId}.json`), job); }
47
+
48
+ async initialize() {
49
+ const files = fs.readdirSync(this.config.paths.jobs).filter(name => RELAY_UUID.test(name.replace(/\.json$/, "")) && name.endsWith(".json"));
50
+ if (files.length > this.maxRecords) throw relayFailure("WORKER_STORAGE_LIMIT", "Too many stored worker receipts; inspect and archive completed receipts before starting more work");
51
+ assertPrivateRelayFiles(files.map(file => path.join(this.config.paths.jobs, file)));
52
+ for (const file of files) {
53
+ let job;
54
+ try {
55
+ const receiptFile = path.join(this.config.paths.jobs, file);
56
+ assertPrivateRelayFile(receiptFile);
57
+ job = JSON.parse(readRegularConfigFile(receiptFile, { maxBytes: 4 * 1024 * 1024, label: "Worker receipt" }));
58
+ }
59
+ catch (error) {
60
+ if (error.code === "WORKER_STORAGE_UNSAFE") throw error;
61
+ throw relayFailure("WORKER_RECEIPT_INVALID", "A stored worker receipt is malformed; it has not been overwritten");
62
+ }
63
+ if (!job || job.schemaVersion !== 1 || !RELAY_UUID.test(job.requestId) || file !== `${job.requestId}.json`
64
+ || !BACKGROUND_AGENTS.includes(job.agent) || !["queued", "running", ...terminal].includes(job.status)
65
+ || typeof job.prompt !== "string" || !job.binding?.path || !RELAY_UUID.test(job.rootId)
66
+ || !Number.isInteger(job.depth) || job.depth < 0 || job.depth > 4) {
67
+ throw relayFailure("WORKER_RECEIPT_INVALID", "A stored worker receipt has an unsupported schema; it has not been overwritten");
68
+ }
69
+ if (job.status === "running") {
70
+ job.status = "unknown";
71
+ job.deliveryState = "unknown";
72
+ job.updatedAt = this.now();
73
+ job.diagnostic = bridgeDiagnostic(relayFailure("WORKER_INTERRUPTED", "The relay restarted while this worker was active. Its work may have continued; inspect the result before any new request.", "unknown", "relay_restart"), { component: "background-relay", operation: "send_to_worker", requestId: job.requestId });
74
+ this.persist(job);
75
+ }
76
+ this.jobs.set(job.requestId, job);
77
+ }
78
+ void this.refreshHealth();
79
+ this.schedule();
80
+ }
81
+
82
+ authenticate(request) {
83
+ if (!request.workerId && equalSecret(request.token, this.config.token)) return { kind: "local-client", id: "local-user" };
84
+ const capability = this.capabilities.get(request.workerId);
85
+ if (!capability || !equalSecret(request.token, capability.token) || !this.active.has(capability.requestId)) {
86
+ throw relayFailure("WORKER_IDENTITY_INVALID", "Worker capability is missing, expired, or does not match an active background worker");
87
+ }
88
+ return { kind: "background-worker", id: request.workerId, agent: capability.agent, requestId: capability.requestId, rootId: capability.rootId, binding: capability.binding };
89
+ }
90
+
91
+ transportSecret(workerId) {
92
+ const token = workerId === null ? this.config.token : this.capabilities.get(workerId)?.token;
93
+ this.authenticate({ workerId, token });
94
+ return token;
95
+ }
96
+
97
+ async refreshHealth() {
98
+ if (this.refreshing) return this.refreshing;
99
+ this.refreshing = Promise.all(BACKGROUND_AGENTS.map(async agent => {
100
+ const provider = this.config.providers[agent];
101
+ if (!provider.enabled) { this.health.set(agent, { agent, available: false, ready: false, canAcceptWork: false, readiness: "disabled", authenticated: null, code: "WORKER_DISABLED", checkedAt: this.now() }); return; }
102
+ try {
103
+ const result = await this.adapters[agent].inspect({ env: this.env, executable: provider.executable ?? undefined });
104
+ const canAcceptWork = canStartBackgroundWorker(agent, result);
105
+ this.health.set(agent, { agent, available: result.available === true, ready: result.ready === true,
106
+ canAcceptWork, readiness: result.ready === true ? "ready" : canAcceptWork ? "authentication-unverified" : "blocked",
107
+ lastSuccessfulAt: this.health.get(agent)?.lastSuccessfulAt ?? null,
108
+ authenticated: result.authenticated ?? null, version: result.version ?? null,
109
+ code: result.code ?? null, checkedAt: this.now(), diagnostic: !canAcceptWork && result.reason ? bridgeDiagnostic(relayFailure(result.code ?? "WORKER_NOT_READY", result.reason), { component: `background-${agent}`, operation: "worker_status", readOnly: true }) : null });
110
+ } catch (error) {
111
+ this.health.set(agent, { agent, available: false, ready: false, canAcceptWork: false, readiness: "blocked", authenticated: null, code: error.code ?? "WORKER_INSPECTION_FAILED", checkedAt: this.now(),
112
+ diagnostic: bridgeDiagnostic(error, { component: `background-${agent}`, operation: "worker_status", readOnly: true }) });
113
+ }
114
+ })).finally(() => { this.refreshing = null; });
115
+ return this.refreshing;
116
+ }
117
+
118
+ canRead(actor, job) { return actor.kind === "local-client" || job.rootId === actor.rootId && job.binding.path === actor.binding.path; }
119
+
120
+ receipt(job) {
121
+ return { requestId: job.requestId, workerId: job.workerId, rootId: job.rootId, parentRequestId: job.parentRequestId,
122
+ agent: job.agent, source: job.source, surface: "background-cli", status: job.status,
123
+ deliveryState: job.deliveryState, sent: job.deliveryState === "sent" ? true : job.deliveryState === "not_sent" ? false : null,
124
+ sessionId: job.sessionId ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt,
125
+ ...(terminal.has(job.status) ? { output: job.output ?? "", outputTruncated: job.outputTruncated === true } : {}),
126
+ ...(job.diagnostic ? { diagnostic: job.diagnostic } : {}) };
127
+ }
128
+
129
+ status(actor) {
130
+ const visible = [...this.jobs.values()].filter(job => this.canRead(actor, job));
131
+ return { schemaVersion: 1, version, pid: process.pid, runtimeId: this.env.AGENT_RELAY_RUNTIME_ID ?? null, startedAt: this.startedAt, transportReady: true,
132
+ accepting: this.accepting, desktopRequired: false, opensApps: false, opensChats: false,
133
+ active: this.active.size, queued: visible.filter(job => job.status === "queued").length,
134
+ workers: BACKGROUND_AGENTS.map(agent => this.health.get(agent) ?? { agent, available: null, authenticated: null, ready: false, code: "WORKER_INSPECTION_PENDING" }),
135
+ recentErrors: visible.filter(job => job.diagnostic).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 10).map(job => this.receipt(job)) };
136
+ }
137
+
138
+ async handle(request) {
139
+ if (!request || request.schemaVersion !== 1 || !RELAY_UUID.test(request.id ?? "") || typeof request.method !== "string") throw relayFailure("WORKER_REQUEST_INVALID", "Unsupported background relay request");
140
+ const actor = this.authenticate(request);
141
+ const params = request.params ?? {};
142
+ if (request.method === "status") return this.status(actor);
143
+ if (request.method === "list") return { workers: this.status(actor).workers,
144
+ tasks: [...this.jobs.values()].filter(job => this.canRead(actor, job)).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 50).map(job => this.receipt(job)) };
145
+ if (request.method === "read") {
146
+ const job = this.jobs.get(params.requestId);
147
+ if (!job || !this.canRead(actor, job)) throw relayFailure("WORKER_RECEIPT_NOT_FOUND", "No worker receipt is available to this caller for that request ID");
148
+ if (params.waitSec !== undefined && (!Number.isInteger(params.waitSec) || params.waitSec < 0 || params.waitSec > 60)) throw relayFailure("WORKER_REQUEST_INVALID", "waitSec must be an integer between 0 and 60");
149
+ const deadline = Date.now() + (params.waitSec ?? 0) * 1000;
150
+ while (!terminal.has(job.status) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
151
+ return this.receipt(job);
152
+ }
153
+ if (request.method === "send") return this.enqueue(actor, params);
154
+ if (request.method === "drain" && actor.kind === "local-client") {
155
+ this.accepting = false;
156
+ return { draining: true, active: this.active.size, queued: [...this.jobs.values()].filter(job => job.status === "queued").length };
157
+ }
158
+ throw relayFailure("WORKER_METHOD_NOT_ALLOWED", "This background relay operation is not available to the caller");
159
+ }
160
+
161
+ enqueue(actor, params) {
162
+ if (!BACKGROUND_AGENTS.includes(params.agent) || !RELAY_UUID.test(params.requestId ?? "") || typeof params.prompt !== "string" || !params.prompt.trim()
163
+ || Buffer.byteLength(params.prompt) > 256 * 1024 || typeof params.cwd !== "string" || !path.isAbsolute(params.cwd)) {
164
+ throw relayFailure("WORKER_REQUEST_INVALID", "Provide an agent, request UUID, nonempty bounded prompt and absolute project directory");
165
+ }
166
+ let binding;
167
+ try { binding = this.scope.capture(params.cwd); }
168
+ catch (cause) { throw Object.assign(relayFailure("WORKER_PROJECT_NOT_AUTHORIZED", "The selected project is not authorized by the shared project policy"), { cause }); }
169
+ if (actor.kind === "background-worker") {
170
+ try { this.scope.recheck(actor.binding); }
171
+ catch (cause) { throw Object.assign(relayFailure("WORKER_PROJECT_REVOKED", "The worker project grant was revoked or its directory identity changed"), { cause }); }
172
+ if (binding.path !== actor.binding.path || binding.identity !== actor.binding.identity) throw relayFailure("WORKER_PROJECT_MISMATCH", "A background worker can delegate only inside its authorized project");
173
+ }
174
+ const fingerprint = digest({ agent: params.agent, cwd: binding.path, prompt: params.prompt, owner: actor.kind === "local-client" ? "local-user" : actor.rootId });
175
+ const existing = this.jobs.get(params.requestId);
176
+ if (existing) {
177
+ if (!this.canRead(actor, existing) || existing.fingerprint !== fingerprint) throw relayFailure("WORKER_REQUEST_CONFLICT", "This request ID already belongs to a different delivery");
178
+ return this.receipt(existing);
179
+ }
180
+ if (!this.accepting && actor.kind === "local-client") throw relayFailure("WORKER_RELAY_DRAINING", "The relay is finishing existing work before an update");
181
+ if (!this.config.providers[params.agent].enabled) throw relayFailure("WORKER_DISABLED", "The selected worker is intentionally disabled");
182
+ const parent = actor.kind === "background-worker" ? this.jobs.get(actor.requestId) : null;
183
+ const depth = parent ? parent.depth + 1 : 0;
184
+ if (depth > 4 || parent && [...this.jobs.values()].filter(job => job.rootId === parent.rootId).length >= 16) throw relayFailure("WORKER_DELEGATION_LIMIT", "This worker task has reached its delegation limit");
185
+ if (parent && (this.active.size >= this.maxConcurrent || [...this.active.values()].filter(item => item.agent === params.agent).length >= 2)) {
186
+ throw relayFailure("WORKER_DELEGATION_CAPACITY", "The requested worker has no free execution slot. Finish or inspect existing delegated tasks before retrying this unsent request; it was not queued behind waiting parent workers.");
187
+ }
188
+ if (this.jobs.size >= this.maxRecords || [...this.jobs.values()].filter(job => job.status === "queued").length >= this.maxQueued) throw relayFailure("WORKER_QUEUE_FULL", "The background relay queue or receipt store is full");
189
+ const job = { schemaVersion: 1, requestId: params.requestId, workerId: randomUUID(), logicalSessionId: randomUUID(),
190
+ rootId: parent?.rootId ?? params.requestId, parentRequestId: parent?.requestId ?? null, depth,
191
+ agent: params.agent, source: { kind: actor.kind, id: actor.id, ...(actor.agent ? { agent: actor.agent } : {}) },
192
+ binding, fingerprint, prompt: params.prompt, status: "queued", deliveryState: "not_sent", createdAt: this.now(), updatedAt: this.now() };
193
+ this.persist(job);
194
+ this.jobs.set(job.requestId, job);
195
+ this.schedule();
196
+ return this.receipt(job);
197
+ }
198
+
199
+ schedule() {
200
+ if (this.pumping) return;
201
+ this.pumping = true;
202
+ setImmediate(() => {
203
+ this.pumping = false;
204
+ for (const job of this.jobs.values()) {
205
+ if (this.active.size >= this.maxConcurrent) break;
206
+ if (job.status !== "queued") continue;
207
+ const running = [...this.active.values()].filter(item => item.agent === job.agent).length;
208
+ if (running >= 2) continue;
209
+ const controller = new AbortController();
210
+ this.active.set(job.requestId, { agent: job.agent, controller });
211
+ void this.execute(job, controller.signal).finally(() => { this.active.delete(job.requestId); this.capabilities.delete(job.workerId); this.schedule(); });
212
+ }
213
+ });
214
+ }
215
+
216
+ async execute(job, signal) {
217
+ try {
218
+ try { this.scope.recheck(job.binding); }
219
+ catch (cause) { throw Object.assign(relayFailure("WORKER_PROJECT_REVOKED", "The queued worker project is no longer authorized"), { cause }); }
220
+ job.status = "running";
221
+ job.updatedAt = this.now();
222
+ this.persist(job);
223
+ const provider = this.config.providers[job.agent];
224
+ const readiness = await this.adapters[job.agent].inspect({ env: this.env, executable: provider.executable ?? undefined });
225
+ if (!canStartBackgroundWorker(job.agent, readiness)) throw relayFailure(readiness.code ?? "WORKER_NOT_READY", readiness.reason ?? "The requested CLI worker is not ready");
226
+ try { this.scope.recheck(job.binding); }
227
+ catch (cause) { throw Object.assign(relayFailure("WORKER_PROJECT_REVOKED", "The worker project was revoked before dispatch"), { cause }); }
228
+ const capability = { token: randomBytes(32).toString("base64url"), requestId: job.requestId, rootId: job.rootId, binding: job.binding, agent: job.agent };
229
+ this.capabilities.set(job.workerId, capability);
230
+ const workerEnv = Object.fromEntries(Object.entries(this.env).filter(([key]) => !/^(?:AGENT_RELAY_|CODEX_THREAD_ID$|CODEX_APP_|CODEX_BRIDGE_|CLAUDE_BRIDGE_|CLAUDE_CODE_ENTRYPOINT$|ELECTRON_)/i.test(key)));
231
+ const relayEnv = { AGENT_RELAY_ENDPOINT: this.config.endpoint, AGENT_RELAY_TOKEN: capability.token,
232
+ AGENT_RELAY_WORKER_ID: job.workerId, AGENT_RELAY_REQUEST_ID: job.requestId };
233
+ const prompt = ["You are an independently identified background CLI worker, not a Desktop chat.",
234
+ "Use only worker-relay tools for authorized cross-agent delegation in this project. Desktop hosts may be closed or frozen.",
235
+ "Do not open, restart, focus, create, resume or navigate Desktop apps/chats, use Computer Use, or copy Desktop credentials.",
236
+ "Keep request IDs and inspect delivery receipts. Never replay uncertain work. Follow the existing tool permissions and project instructions.",
237
+ "Report actual relay/tool failures with their codes. Do not claim a handoff succeeded without a correlated receipt.",
238
+ "Authorized task:", job.prompt].join("\n\n");
239
+ job.deliveryState = "unknown";
240
+ job.updatedAt = this.now();
241
+ this.persist(job);
242
+ const result = await this.adapters[job.agent].run({ executable: readiness.executable ?? provider.executable ?? undefined,
243
+ cwd: job.binding.path, prompt, requestId: job.requestId, sessionId: job.logicalSessionId, env: workerEnv,
244
+ relayMcp: { command: process.execPath, args: [fileURLToPath(new URL("./background-relay-mcp.mjs", import.meta.url))], env: relayEnv }, signal,
245
+ onEvent: event => {
246
+ if (event?.sessionId && RELAY_UUID.test(event.sessionId)) job.sessionId = event.sessionId;
247
+ if (event?.type === "accepted") job.deliveryState = "sent";
248
+ job.updatedAt = this.now();
249
+ this.persist(job);
250
+ } });
251
+ job.status = "completed";
252
+ job.deliveryState = "sent";
253
+ const output = String(result.output ?? "");
254
+ job.output = output.slice(0, 256 * 1024);
255
+ job.outputTruncated = output.length > job.output.length;
256
+ if (RELAY_UUID.test(result.sessionId ?? "")) job.sessionId = result.sessionId;
257
+ this.health.set(job.agent, { agent: job.agent, available: true, authenticated: true, ready: true, canAcceptWork: true, readiness: "ready", lastSuccessfulAt: this.now(), checkedAt: this.now(), evidence: "completed-background-job" });
258
+ } catch (error) {
259
+ const rawState = error.deliveryState ?? (job.deliveryState === "not_sent" ? "not_sent" : "unknown");
260
+ const state = rawState === "not-sent" ? "not_sent" : ["not_sent", "sent", "unknown"].includes(rawState) ? rawState : "unknown";
261
+ job.status = state === "unknown" ? "unknown" : "failed";
262
+ job.deliveryState = state;
263
+ job.diagnostic = bridgeDiagnostic(error, { component: `background-${job.agent}`, operation: "send_to_worker", requestId: job.requestId, deliveryState: state });
264
+ } finally {
265
+ job.updatedAt = this.now();
266
+ try { this.persist(job); }
267
+ catch (error) {
268
+ job.status = "unknown";
269
+ job.deliveryState = "unknown";
270
+ job.diagnostic = bridgeDiagnostic(error, { component: "background-relay", operation: "persist_receipt", code: "WORKER_RECEIPT_WRITE_FAILED", requestId: job.requestId, deliveryState: "unknown" });
271
+ this.accepting = false;
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ export async function serveBackgroundRelay(relay, { protectPipe = protectCurrentUserPipe } = {}) {
278
+ const endpoint = relay.config.endpoint;
279
+ if (process.platform !== "win32" && fs.existsSync(endpoint)) {
280
+ const before = fs.lstatSync(endpoint);
281
+ if (!before.isSocket() || typeof process.getuid === "function" && before.uid !== process.getuid()) throw relayFailure("WORKER_ENDPOINT_UNSAFE", "The relay endpoint is not an owned local socket");
282
+ const stale = await new Promise(resolve => {
283
+ const probe = net.createConnection(endpoint);
284
+ const timer = setTimeout(() => { probe.destroy(); resolve(false); }, 500);
285
+ probe.once("connect", () => { clearTimeout(timer); probe.destroy(); resolve(false); });
286
+ probe.once("error", error => { clearTimeout(timer); resolve(error.code === "ECONNREFUSED" || error.code === "ENOENT"); });
287
+ });
288
+ if (!stale) throw relayFailure("WORKER_RELAY_ALREADY_RUNNING", "A relay may already own this endpoint; it was not replaced");
289
+ try {
290
+ const current = fs.lstatSync(endpoint);
291
+ if (!current.isSocket() || before.dev !== current.dev || before.ino !== current.ino) throw relayFailure("WORKER_ENDPOINT_CHANGED", "The relay endpoint changed during recovery");
292
+ fs.unlinkSync(endpoint);
293
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
294
+ }
295
+ const sockets = new Set();
296
+ const provisional = new Set();
297
+ let protectedEndpoint = process.platform !== "win32";
298
+ const server = net.createServer(socket => {
299
+ if (!protectedEndpoint) {
300
+ if (provisional.size >= 32) { socket.destroy(); return; }
301
+ provisional.add(socket);
302
+ socket.once("close", () => provisional.delete(socket));
303
+ socket.on("error", () => {});
304
+ socket.setTimeout(15000, () => socket.destroy());
305
+ return;
306
+ }
307
+ if (sockets.size >= 128) { socket.destroy(); return; }
308
+ sockets.add(socket);
309
+ socket.setEncoding("utf8");
310
+ socket.setTimeout(5000, () => socket.destroy());
311
+ socket.on("error", () => {});
312
+ socket.on("close", () => sockets.delete(socket));
313
+ let buffer = "";
314
+ let handled = false;
315
+ let hello;
316
+ let challenge;
317
+ let secret;
318
+ socket.on("data", data => {
319
+ if (handled) { socket.destroy(); return; }
320
+ buffer += data;
321
+ if (Buffer.byteLength(buffer) > (hello ? RELAY_FRAME_LIMIT : 4096)) { socket.destroy(); return; }
322
+ const end = buffer.indexOf("\n");
323
+ if (end < 0) return;
324
+ if (buffer.slice(end + 1).length) { socket.destroy(); return; }
325
+ let frame;
326
+ try { frame = JSON.parse(buffer.slice(0, end)); }
327
+ catch { socket.destroy(); return; }
328
+ buffer = "";
329
+ if (!hello) {
330
+ if (!validRelayHello(frame)) { socket.destroy(); return; }
331
+ try { secret = relay.transportSecret(frame.workerId); }
332
+ catch { socket.destroy(); return; }
333
+ hello = frame;
334
+ challenge = relayChallenge(secret, hello);
335
+ socket.write(JSON.stringify(challenge) + "\n");
336
+ return;
337
+ }
338
+ if (!verifyRelayOperation(secret, hello, challenge, frame)) { socket.destroy(); return; }
339
+ handled = true;
340
+ socket.setTimeout(70000, () => socket.destroy());
341
+ const request = frame.operation;
342
+ const send = response => {
343
+ let wire = JSON.stringify(relayResponse(secret, hello, challenge, response)) + "\n";
344
+ if (Buffer.byteLength(wire) > RELAY_REPLY_LIMIT) wire = JSON.stringify(relayResponse(secret, hello, challenge, { schemaVersion: 1, id: request.id,
345
+ error: { code: "WORKER_REPLY_TOO_LARGE", message: "The relay response exceeded its limit", deliveryState: request.method === "send" ? "unknown" : "not_sent", phase: "relay_transport" } })) + "\n";
346
+ if (!socket.destroyed) socket.end(wire);
347
+ };
348
+ Promise.resolve().then(() => {
349
+ relay.transportSecret(hello.workerId);
350
+ return relay.handle({ ...request, token: secret });
351
+ }).then(result => send({ schemaVersion: 1, id: request.id, result }), error => {
352
+ const diagnostic = bridgeDiagnostic(error, { component: "background-relay", operation: request.method, requestId: request.params?.requestId });
353
+ send({ schemaVersion: 1, id: request.id, error: { code: diagnostic.code, message: diagnostic.reason,
354
+ deliveryState: diagnostic.deliveryState, phase: diagnostic.phase, diagnostic } });
355
+ }).catch(() => socket.destroy());
356
+ });
357
+ });
358
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(endpoint, resolve); });
359
+ try {
360
+ if (process.platform === "win32") {
361
+ await protectPipe(endpoint);
362
+ protectedEndpoint = true;
363
+ for (const socket of provisional) socket.destroy();
364
+ provisional.clear();
365
+ }
366
+ else fs.chmodSync(endpoint, 0o600);
367
+ } catch (cause) {
368
+ for (const socket of provisional) socket.destroy();
369
+ await new Promise(resolve => server.close(resolve));
370
+ throw Object.assign(relayFailure("WORKER_ENDPOINT_PROTECTION_FAILED", "The independent relay endpoint could not verify its local-user access restrictions", "not_sent", "startup"), { cause });
371
+ }
372
+ return { server, close: () => new Promise(resolve => { for (const socket of sockets) socket.destroy(); server.close(resolve); }) };
373
+ }
@@ -27,10 +27,11 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
27
27
  import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
28
28
  import { registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
29
29
  import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
+ import { BACKGROUND_WORKER_GUIDANCE, registerBackgroundTools } from "./background-relay-mcp.mjs";
30
31
 
31
32
  exitForVersionRequest(import.meta.url);
32
33
 
33
- const VERSION = "1.25.3";
34
+ const VERSION = "1.26.1";
34
35
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
36
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
37
  const FORWARD_MAX_PER_SESSION = 50;
@@ -328,9 +329,9 @@ const server = new McpServer(
328
329
  "list_claude_sessions includes saved tasks, but a saved task is not proof of a live receiving endpoint. target auto with expectedTaskId rediscovers only the exact task without opening an app or chat; an already-live session is sent directly without opening a composer. A dormant task returns CLAUDE_SESSION_NOT_READY with sent false. There is no supported silent cold-start API. For independent new work authorized by the user or standing instructions, use start_claude_session; never use creation to warm a relay. Use trustProject only with explicit or standing authorization to trust that project. " +
329
330
  "The sender class follows the verified approval policy; automated review flags are reported but never change it. " +
330
331
  "A Desktop recipient whose settings refuse or hold inbound messages, or whose task metadata shows a different permission class without an explicit accept, is refused before sending, because Claude Desktop cannot show the approval dialog. " +
331
- "Never launch a CLI session or an external app-server as a substitute. A receipt confirms a reply, not visual verification in the app. " +
332
+ "Never launch a CLI session or an external app-server as a substitute for an existing Desktop task. A receipt confirms a reply, not visual verification in the app. " +
332
333
  "A held receipt does not prove that Desktop exposes an approval button; verify the UI before asking the user to approve. " +
333
- AGENT_PROMPT_GUIDANCE,
334
+ BACKGROUND_WORKER_GUIDANCE + AGENT_PROMPT_GUIDANCE,
334
335
  },
335
336
  );
336
337
 
@@ -346,6 +347,8 @@ function registerTool(name, definition, handler) {
346
347
  });
347
348
  }
348
349
 
350
+ registerBackgroundTools(registerTool);
351
+
349
352
  registerProjectOnboardingTools(registerTool, { options: { policyRegistration: "codex" }, beforePrepare: async (extra) => {
350
353
  runtime.assertCurrent();
351
354
  if (!desktopOnly) throw new Error("Project onboarding requires a verified Desktop caller");
package/src/index.mjs CHANGED
@@ -39,10 +39,12 @@ import { registerDesktopReadTools } from "./desktop-read-tools.mjs";
39
39
  import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
40
40
  import { createRelayWake } from "./relay-wake.mjs";
41
41
  import { withBridgeDiagnostic } from "./recipient-preflight.mjs";
42
+ import { BACKGROUND_TOOLS } from "./background-relay-config.mjs";
43
+ import { BACKGROUND_WORKER_GUIDANCE, registerBackgroundTools } from "./background-relay-mcp.mjs";
42
44
 
43
45
  exitForVersionRequest(import.meta.url);
44
46
 
45
- const VERSION = "1.25.3";
47
+ const VERSION = "1.26.1";
46
48
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
47
49
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
48
50
 
@@ -461,7 +463,7 @@ const server = new McpServer(
461
463
  "Use send_to_codex_thread only when an existing threadId is intentional; use " +
462
464
  "list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring. " +
463
465
  "When a Desktop send returns nextAction=wait_codex_reply, continue observing with that tool and the returned deliveryId until completion, user input is required, or the user stops the task. Never resend to obtain a reply. Each observation is bounded, and the receipt survives reconnection. " +
464
- AGENT_PROMPT_GUIDANCE,
466
+ BACKGROUND_WORKER_GUIDANCE + AGENT_PROMPT_GUIDANCE,
465
467
  },
466
468
  );
467
469
 
@@ -471,6 +473,7 @@ function registerTool(name, definition, handler) {
471
473
  try {
472
474
  result = await reload.run(async () => {
473
475
  if (!definition.annotations?.readOnlyHint) runtime.assertCurrent();
476
+ if (BACKGROUND_TOOLS.includes(name)) return handler(...args);
474
477
  if (name === "codex_bridge_status") {
475
478
  const state = runtime.status();
476
479
  if (!state.current) return { ...failure(new Error(`${state.reason}; reconnect this MCP server in the existing task.`)), structuredContent: { runtime: state } };
@@ -522,6 +525,8 @@ function registerTool(name, definition, handler) {
522
525
  });
523
526
  }
524
527
 
528
+ registerBackgroundTools(registerTool);
529
+
525
530
  registerDesktopReadTools(registerTool, {
526
531
  desktopTasksEnabled, desktopTasks, replyReceipts, security, client,
527
532
  context: () => desktopOperation.getStore(), assertOperation: assertDesktopOperation,
@@ -10,7 +10,8 @@ import { CLIENT_CLOSE_DRAIN_MS, CLIENT_CLOSE_STOP_MS } from "./reload-control.mj
10
10
  import { withBridgeDiagnostic } from "./recipient-preflight.mjs";
11
11
 
12
12
  const WORKER_NODE = stableInstallPath(process.execPath);
13
- const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
13
+ const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs", "antigravity-bridge.mjs", "antigravity-outbound-bridge.mjs"]);
14
+ const ANTIGRAVITY_ENTRIES = new Set(["antigravity-bridge.mjs", "antigravity-outbound-bridge.mjs"]);
14
15
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
15
16
  const MAX_QUEUE = 256;
16
17
  const RETIRE_GRACE_MS = 6000;
@@ -151,7 +152,7 @@ class Worker {
151
152
  }
152
153
 
153
154
  export async function runSupervisor(entry, options = {}) {
154
- if (!ENTRIES.has(entry)) throw new Error("Expected index.mjs, claude-bridge.mjs, or native-relay-companion.mjs");
155
+ if (!ENTRIES.has(entry)) throw new Error(`Expected one of: ${[...ENTRIES].join(", ")}`);
155
156
  const installRoot = stableInstallPath(fs.realpathSync.native(options.root ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")));
156
157
  const currentRoot = () => fs.realpathSync.native(installRoot);
157
158
  const root = currentRoot();
@@ -162,7 +163,8 @@ export async function runSupervisor(entry, options = {}) {
162
163
  const log = (message) => process.stderr.write(`[bridge-supervisor] ${message}\n`);
163
164
  const output = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
164
165
  const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: installRoot };
165
- const routingConfiguration = () => entry === "native-relay-companion.mjs" ? null : desktopTasksConfigured(env);
166
+ const recoverable = entry === "native-relay-companion.mjs" || ANTIGRAVITY_ENTRIES.has(entry);
167
+ const routingConfiguration = () => recoverable ? null : desktopTasksConfigured(env);
166
168
  if (entry === "native-relay-companion.mjs" && !env.CODEX_APP_TOOLS_PIPE_PATH) {
167
169
  const { resolveNativeToolsPipePath } = await import("./native-relay.mjs");
168
170
  const pipe = await resolveNativeToolsPipePath();
@@ -192,7 +194,7 @@ export async function runSupervisor(entry, options = {}) {
192
194
  let activeSince = Date.now();
193
195
  const recoveryDelayMs = Math.min(30000, supervisorInterval(env.CODEX_BRIDGE_RECOVERY_DELAY_MS, 1000));
194
196
  const recoveryLimit = 5;
195
- const canRecover = () => entry === "native-relay-companion.mjs" && ready && initializeResult && initializeCatalog;
197
+ const canRecover = () => recoverable && ready && initializeResult && initializeCatalog;
196
198
  const scheduleRecovery = () => {
197
199
  if (recoveryAttempts >= recoveryLimit) return;
198
200
  recoveryAt = Date.now() + Math.min(30000, recoveryDelayMs * 2 ** recoveryAttempts);
@@ -226,14 +228,14 @@ export async function runSupervisor(entry, options = {}) {
226
228
  const respond = async () => {
227
229
  if (pending.method === "initialize" && !message.error) {
228
230
  initializeResult = message.result;
229
- if (entry === "native-relay-companion.mjs") {
231
+ if (recoverable) {
230
232
  initializeCatalog = await worker.rpc("tools/list", {});
231
- if (!Array.isArray(initializeCatalog.tools) || !initializeCatalog.tools.length || initializeCatalog.nextCursor) throw new Error("The native relay tool catalog is incomplete");
233
+ if (!Array.isArray(initializeCatalog.tools) || !initializeCatalog.tools.length || initializeCatalog.nextCursor) throw new Error("The bridge tool catalog is incomplete");
232
234
  }
233
235
  await worker.control("activate");
234
236
  ready = true;
235
237
  }
236
- if (message.result && ["codex_bridge_status", "claude_bridge_status", "native_relay_status"].includes(pending.tool)) {
238
+ if (message.result && ["codex_bridge_status", "claude_bridge_status", "native_relay_status", "antigravity_bridge_status", "agent_bridge_status"].includes(pending.tool)) {
237
239
  message.result.structuredContent = { ...message.result.structuredContent, autoReload: diagnostics() };
238
240
  message.result.content = [...(message.result.content ?? []), { type: "text", text: `automatic reload: ${diagnostics().state}\nsupervisor pid: ${process.pid}\nworker pid: ${worker.process.pid}\nreload count: ${reloads}\nrecovery count: ${recoveries}${reason ? `\nreload detail: ${reason}` : ""}` }];
239
241
  }
@@ -293,19 +295,23 @@ export async function runSupervisor(entry, options = {}) {
293
295
  }
294
296
  candidate.write({ jsonrpc: "2.0", method: "notifications/initialized" });
295
297
  const catalog = await candidate.rpc("tools/list", {});
296
- if (JSON.stringify(catalog) !== JSON.stringify(initializeCatalog)) throw new Error("The replacement tool catalog requires an explicit client reconnect");
298
+ if (!Array.isArray(catalog.tools) || !catalog.tools.length || catalog.nextCursor) throw new Error("The replacement tool catalog is incomplete");
299
+ const catalogChanged = JSON.stringify(catalog) !== JSON.stringify(initializeCatalog);
300
+ if (catalogChanged && (!ANTIGRAVITY_ENTRIES.has(entry) || initializeResult.capabilities?.tools?.listChanged !== true)) throw new Error("The replacement tool catalog requires an explicit client reconnect");
297
301
  await candidate.control("activate");
298
302
  if (stopping || candidate.closed) throw new Error("The relay replacement stopped before activation completed");
299
303
  active = candidate;
300
304
  candidate = null;
305
+ initializeCatalog = catalog;
301
306
  activeSince = Date.now();
302
307
  failure = null;
303
308
  reason = null;
304
309
  recoveries++;
305
- log(`recovered native relay worker after exit; recovery ${recoveries}, no requests replayed`);
310
+ if (catalogChanged) output({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
311
+ log(`recovered ${entry} worker after exit; recovery ${recoveries}, no requests replayed`);
306
312
  schedulePrune();
307
313
  } catch (error) {
308
- failure = `Native relay recovery failed: ${error.message}; no requests were replayed`;
314
+ failure = `Bridge worker recovery failed: ${error.message}; no requests were replayed`;
309
315
  if (candidate) {
310
316
  try {
311
317
  await candidate.stop();
@@ -402,7 +408,7 @@ export async function runSupervisor(entry, options = {}) {
402
408
  }
403
409
  return;
404
410
  }
405
- if (failure) { if (hasId(message)) output(errorMessage(message.id, `${failure}${canRecover() && recoveryAttempts < recoveryLimit ? "; native relay recovery is pending, this request was not sent" : ""}`, { operation: message.params?.name ?? message.method, deliveryState: "not_sent" })); return; }
411
+ if (failure) { if (hasId(message)) output(errorMessage(message.id, `${failure}${canRecover() && recoveryAttempts < recoveryLimit ? "; bridge worker recovery is pending, this request was not sent" : ""}`, { operation: message.params?.name ?? message.method, deliveryState: "not_sent" })); return; }
406
412
  if (switching || !active) {
407
413
  const bytes = queue.reduce((size, queued) => size + Buffer.byteLength(JSON.stringify(queued)), 0);
408
414
  if (queue.length >= MAX_QUEUE || bytes + Buffer.byteLength(JSON.stringify(message)) > MAX_FRAME_BYTES) { if (hasId(message)) output(errorMessage(message.id, "Reload request queue is full; no operation was dispatched", { code: "MCP_QUEUE_FULL", operation: message.params?.name ?? message.method, deliveryState: "not_sent" })); return; }
@@ -43,7 +43,7 @@ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } fro
43
43
 
44
44
  exitForVersionRequest(import.meta.url);
45
45
 
46
- const VERSION = "1.25.3";
46
+ const VERSION = "1.26.1";
47
47
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
48
48
 
49
49
  function errorResponse(code, message, sent, details) {
@@ -26,7 +26,7 @@ export async function drainPendingAccepts(turns = 3) {
26
26
  }
27
27
 
28
28
  export function createReloadControl({ entry, inspect = () => null, quiesce = () => {}, exportState = () => ({}), restore = () => {}, activate = () => {}, resume = activate, env = process.env, channel = process } = {}) {
29
- if (!["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"].includes(entry)) throw new Error("Invalid reload entry point");
29
+ if (!["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs", "antigravity-bridge.mjs", "antigravity-outbound-bridge.mjs"].includes(entry)) throw new Error("Invalid reload entry point");
30
30
  const enabled = env.CODEX_BRIDGE_WORKER === "1" && typeof channel.send === "function";
31
31
  let phase = enabled && env.CODEX_BRIDGE_STAGED === "1" ? "staged" : "active";
32
32
  let active = 0;
@@ -83,7 +83,7 @@ export function createReloadControl({ entry, inspect = () => null, quiesce = ()
83
83
  inspect: status,
84
84
  defer(reason) { deferred ??= String(reason); },
85
85
  async run(handler) {
86
- if (phase !== "active") throw new Error(`Bridge worker is ${phase}; no tool operation was started`);
86
+ if (phase !== "active") throw Object.assign(new Error(`Bridge worker is ${phase}; no tool operation was started`), { code: "MCP_WORKER_QUIESCED", phase: "lifecycle", deliveryState: "not_sent", sent: false });
87
87
  active += 1;
88
88
  try { return await handler(); }
89
89
  finally { active -= 1; }