@minhspark/codex-mcp-bridge 1.12.2 → 1.12.4

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.12.4] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - Authenticate Windows peer connections with the destination peer key, publish keys under the canonical socket hash, use Claude-compatible reply pipe names, and encode reply addresses. Incoming unauthenticated Windows frames are refused.
10
+ - Keep receiver policy receipts separate from replies and correlate them to the original message and destination. Socket writes no longer report confirmed delivery; timeout and held/refused outcomes fail the roundtrip diagnostic without automatic retries.
11
+ - Read ordinary Claude Desktop responses from the local transcript using the injected UUID and assistant ancestry, preserving Desktop's disabled native reply tool. Late correlated responses remain available while the bridge runs.
12
+ - Declare sender permission mode only when explicitly configured, and preserve receiver approval decisions. Status reports unknown sender mode instead of inventing one.
13
+
14
+ ### Tests
15
+
16
+ - Added authenticated transport, missing/bad auth, canonical key and address, receiver status, MCP outcome, and transcript-correlation regressions. Live Windows testing confirmed authentication and returned an actual `held` receipt; the Desktop assistant roundtrip remains subject to recipient approval.
17
+
18
+ ## [1.12.3] - 2026-09-05
19
+
20
+ ### Fixed
21
+
22
+ - Discover the native tools pipe from the launching Desktop app-server's `mcp_servers.codex_app.env` override when Desktop does not pass it to custom MCP servers. Explicit pipe settings keep precedence; discovery rejects unrelated processes, ambiguous settings, and remote Windows pipes without scanning other sessions or persisting a restart-specific address.
23
+ - Cancel pending pipe discovery when the client closes, and share discovery between concurrent connection requests so a closed client cannot send a delayed message.
24
+ - Report a reachable shared companion in native relay status when another MCP instance owns the listening endpoint.
25
+
26
+ ### Tests
27
+
28
+ - Added Windows/POSIX command-line parsing, parent discovery, environment precedence, real mock-pipe delivery, cancellation, concurrent connection, and shared-companion regressions.
29
+
5
30
  ## [1.12.2] - 2026-09-05
6
31
 
7
32
  ### Fixed
package/README.md CHANGED
@@ -330,7 +330,7 @@ Under `owned`, a thread a human opened is **unreachable rather than merely restr
330
330
  | Tool | What it does | Hints |
331
331
  |---|---|---|
332
332
  | `list_claude_sessions` | Lists Claude Code sessions running on this machine (name, pid, sessionId, cwd, entrypoint). | read-only |
333
- | `send_to_claude_session` | Delivers a message into a Claude session it lands **in that session's chat**, exactly like a teammate's message and waits for the reply. `waitSec: 0` fires and forgets. `target` takes a name, pid or sessionId; **Claude session names drift over time**, so target by `sessionId` when it matters. | destructive |
333
+ | `send_to_claude_session` | Sends to a Claude inbox and waits for a correlated reply. Receipt statuses distinguish `reply_received`, `sent_unconfirmed`, `reply_timeout`, and receiver policy outcomes such as `held` or `refused`. `waitSec: 0` sends without waiting for confirmation. Prefer an exact `sessionId` because session names can change. | destructive |
334
334
  | `read_claude_inbox` | Reads **and clears** messages Claude pushed over on its own, including replies that arrived late. | destructive |
335
335
  | `read_claude_transcript` | Reads a Claude session's recent conversation without sending anything. | read-only |
336
336
  | `bind_codex_thread` | Binds a Codex thread so every message from Claude is relayed into it, **visible in the Codex desktop app**. Pass an empty string to stop. | writes |
@@ -340,25 +340,37 @@ Every tool declares MCP annotation hints (`readOnlyHint`, `destructiveHint`, `id
340
340
 
341
341
  Waited sends to the same Claude session run in order. If an earlier send timed out or used `waitSec: 0` and its reply has not arrived, a new waited send returns `PEER_REPLY_PENDING` before delivering another message. Wait for the earlier reply and inspect `read_claude_inbox`, or use `waitSec: 0` if another asynchronous message is intended. Different destination sessions remain independent.
342
342
 
343
+ Claude Desktop disables the CLI-native `SendMessage` tool. For Desktop targets, the bridge requests an ordinary answer in the destination conversation and reads only a completed assistant turn descended from the injected message UUID. Unrelated human prompts and sidechains are excluded. Late answers remain available through the inbox while the bridge process is running. This does not change Desktop tool permissions or the receiver's inbound policy.
344
+
345
+ `CLAUDE_BRIDGE_PERMISSION_MODE` optionally declares the **actual sender** permission class (`prompting` or `bypass`). The installer preserves this setting when supplied. Leave it unset when the sender's effective mode is unknown. Never choose a value just to match a recipient: Claude applies its own permission-mode and inbound-policy checks and may hold a message for user approval. The bridge reports that decision and does not retry it automatically.
346
+
347
+ `npm run check:claude` without `CLAUDE_TARGET` checks discovery only. With a target it requires `reply_received`; a socket write, a held message, or a timeout fails the roundtrip check.
348
+
343
349
  ### How each side sees the other
344
350
 
345
- - **Claude sees Codex:** `claude-bridge` registers itself as a *peer session* under `~/.claude/sessions/`. Claude lists it with `ListAgents` and messages it with `SendMessage` not hidden, not an invisible background process. The default name is `codex-<pid>`; after `bind_codex_thread` it renames itself to `codex-<first 8 chars of threadId>`, which is what makes several bridges distinguishable (Codex starts **one bridge per session**, so a few peers usually advertise at once).
351
+ - **Claude sees Codex:** `claude-bridge` registers a peer under `~/.claude/sessions/`. CLI sessions can use their permitted peer messaging tools to reply; Desktop replies use the correlated transcript path described above. The default name is `codex-<pid>`; `bind_codex_thread` renames it to `codex-<first 8 chars of threadId>`.
346
352
  - **Codex sees Claude:** `list_claude_sessions` reads that same registry, and `read_claude_transcript` shows what a Claude session is working on.
347
- - **Visible in chat:** a message from Codex appears in the Claude Desktop chat of the target session; a message from Claude is relayed into the bound Codex thread, so it appears in the Codex desktop app.
353
+ - **Visible in chat:** messages accepted by Claude appear in the target conversation. Received replies can be relayed into the bound Codex task. A `held` receipt means the receiver has not released the message to Claude yet.
354
+
355
+ ### Peer protocol
348
356
 
349
- ### Protocol (measured, not documented)
357
+ Claude Code advertises local inboxes in `~/.claude/sessions/<pid>.json`. The bridge uses a Unix socket on macOS/Linux and `\\.\pipe\LOCAL\cc-msg-<32 hex characters>` on Windows. Frames are **NDJSON**, one message per line. A Windows connection starts with an authentication line using the destination's peer key; it never uses the separate child-process token:
358
+
359
+ ```json
360
+ {"type":"auth","token":"<destination peer token>"}
361
+ ```
350
362
 
351
- Every Claude Code session writes `~/.claude/sessions/<pid>.json` and listens on `/tmp/cc-socks/<pid>.sock`. Frames are **NDJSON**, one message per line:
363
+ The message carries an explicit UUID for transcript correlation:
352
364
 
353
365
  ```json
354
- {"msgV":1,"msg_id":"<uuid>","type":"user","message":{"role":"user",
355
- "content":"<cross-session-message from=\"uds:/tmp/cc-socks/<pid>.sock\" from-mode=\"bypass\">\n...\n</cross-session-message>"},
366
+ {"msgV":1,"msg_id":"<uuid>","uuid":"<uuid>","type":"user","message":{"role":"user",
367
+ "content":"<cross-session-message from=\"uds:/tmp/cc-socks/<pid>.sock\">\n...\n</cross-session-message>"},
356
368
  "priority":"next","from":"uds:/tmp/cc-socks/<pid>.sock"}
357
369
  ```
358
370
 
359
- There is no token in the frame **the socket is mode `0600`, so owning the user account is the entire security boundary**. To receive replies you must register a peer session of your own (registry entry + socket), because Claude answers to the address in `from`.
371
+ Peer key names are `<pid>.<sha256(canonical socket)>.key`. Windows pipe names are normalized for hashing, and reply addresses are percent-encoded on the wire. Authentication and control frames are not user replies. `peer_message_status` receipts are matched to the original message ID and destination. A successful socket write alone is not a delivery acknowledgement.
360
372
 
361
- > ⚠️ This is a **Claude Code internal with no public documentation** (measured on 2.1.229). If the format changes, the Codex Claude direction breaks; fix it in `src/peer-protocol.mjs`. The Claude Codex direction goes through the official app-server and is unaffected.
373
+ See [Claude Code cross-session messaging](https://code.claude.com/docs/en/cross-session-messaging) for authentication and inbound-policy behavior. Wire compatibility was checked against installed Claude Code 2.1.260; peer internals can change between releases.
362
374
 
363
375
  ### Ping-pong guard
364
376
 
@@ -424,7 +436,7 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
424
436
 
425
437
  A companion that cannot be reached before sending falls back to the app-server path. Once a request has been written, a refusal, timeout, or lost acknowledgement does not trigger another delivery: the first attempt may already have succeeded. Invalid or oversized messages are also refused rather than passed to another backend.
426
438
 
427
- Codex Desktop supplies `CODEX_APP_TOOLS_PIPE_PATH` to its companion. The companion keeps that native connection separate from MCP stdio. Without the inherited native pipe it does not advertise a working relay endpoint; if a configured pipe is late during startup, it retries in the background.
439
+ The companion uses `CODEX_APP_TOOLS_PIPE_PATH` when inherited. Desktop builds that supply it only to their bundled `codex_app` MCP are also supported: the companion reads the exact `mcp_servers.codex_app.env` override from its launching app-server. It never picks a pipe from another session or saves a restart-specific address. The native connection remains separate from MCP stdio. If neither source provides a valid pipe, the relay stays unavailable; if a configured pipe is late during startup, it retries in the background. When several MCP instances start, status identifies a reachable shared companion instead of reporting that the relay is down.
428
440
 
429
441
  > ⚠️ `codex_app.send_message_to_thread` and the native tools pipe are **Codex Desktop internals with no public documentation**, on the same footing as the Claude peer protocol above. That is why the relay is Windows/macOS, feature-detected, optional and fallback-safe. If Codex changes it, the two places to fix are `NATIVE_DISPATCH_METHOD` and `nativeDispatchParams()` in `src/native-relay.mjs`; `CODEX_NATIVE_RELAY_METHOD` overrides the method name without a release. The request the companion sends is:
430
442
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.12.2",
3
+ "version": "1.12.4",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -34,7 +34,13 @@ try {
34
34
  arguments: { target, message, waitSec },
35
35
  });
36
36
  console.log("\n--- send_to_claude_session ---\n" + sent.content[0].text);
37
- if (sent.isError) throw new Error("Claude message delivery failed");
37
+ if (sent.isError) throw new Error("Claude message roundtrip failed; receipt or outcome is unconfirmed");
38
+ if (sent.structuredContent?.receipt?.status !== "reply_received") {
39
+ throw new Error("Claude message roundtrip unconfirmed: an actual reply is required; use CLAUDE_WAIT greater than 0");
40
+ }
41
+ console.log("\nClaude message roundtrip passed: reply received.");
42
+ } else {
43
+ console.log("\nDiscovery checks passed. Message roundtrip was not tested; set CLAUDE_TARGET to verify a reply.");
38
44
  }
39
45
  } finally {
40
46
  await client.close();
@@ -11,6 +11,8 @@ const codexBin = resolveCodexBin(process.env.CODEX_EXE);
11
11
  const serverName = process.env.CLAUDE_BRIDGE_NAME ?? "claude-bridge";
12
12
  const entry = path.join(root, "src", "claude-bridge.mjs");
13
13
  const remove = process.argv.includes("--remove");
14
+ const permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
15
+ if (!remove && permissionMode && !["bypass", "prompting"].includes(permissionMode)) throw new Error("CLAUDE_BRIDGE_PERMISSION_MODE must be bypass or prompting");
14
16
 
15
17
  if (!fs.existsSync(entry)) throw new Error(`bridge entry point missing: ${entry}`);
16
18
  if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
@@ -33,6 +35,7 @@ try {
33
35
  const args = ["mcp", "add", serverName];
34
36
  const peerName = process.env.CLAUDE_BRIDGE_PEER_NAME;
35
37
  if (peerName) args.push("--env", `CLAUDE_BRIDGE_PEER_NAME=${peerName}`);
38
+ if (permissionMode) args.push("--env", `CLAUDE_BRIDGE_PERMISSION_MODE=${permissionMode}`);
36
39
  args.push("--", process.execPath, entry);
37
40
 
38
41
  run(args);
@@ -8,7 +8,7 @@ import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
8
  import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
9
9
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
10
10
 
11
- const VERSION = "1.12.2";
11
+ const VERSION = "1.12.4";
12
12
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
13
  const entry = path.join(root, "src", "native-relay-companion.mjs");
14
14
  const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
8
8
  import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
9
9
  import { createThreadDelivery } from "./thread-delivery.mjs";
10
10
 
11
- const VERSION = "1.12.2";
11
+ const VERSION = "1.12.4";
12
12
  const FORWARD_MIN_INTERVAL_MS = 5000;
13
13
  const FORWARD_MAX_PER_SESSION = 50;
14
14
 
@@ -91,7 +91,7 @@ const server = new McpServer(
91
91
  {
92
92
  instructions:
93
93
  "Talk to a live Claude Code session from Codex. list_claude_sessions finds the session, " +
94
- "send_to_claude_session delivers a message into its chat and waits for the answer. " +
94
+ "send_to_claude_session sends to its peer transport and waits for a reply to confirm receipt. " +
95
95
  "This bridge registers itself as a peer, so Claude sees it in its own agent list and can " +
96
96
  "message back; bind_codex_thread relays those messages into a Codex thread.",
97
97
  },
@@ -130,8 +130,8 @@ server.registerTool(
130
130
  {
131
131
  title: "Send a message to a Claude session",
132
132
  description:
133
- "Deliver a message into a running Claude Code session. It appears in that session's chat exactly like " +
134
- "a message from a teammate, and Claude can reply. Set waitSec to 0 to fire and forget. " +
133
+ "Send a message to a running Claude Code session's peer transport and wait for its reply. " +
134
+ "A socket write alone does not confirm that Claude received the message. Set waitSec to 0 to send without confirmation. " +
135
135
  "A waited send is refused while earlier messages to that session still await replies; " +
136
136
  "wait for those replies and read_claude_inbox before trying again.",
137
137
  inputSchema: {
@@ -159,17 +159,33 @@ server.registerTool(
159
159
  if (!session) return textResult(`No live Claude session matches "${target}".`, true);
160
160
 
161
161
  const wait = waitSec ?? 180;
162
- const { reply } = await peer.sendAndWait(session.socket, message, { timeoutMs: wait * 1000 });
163
- const header = `delivered to ${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"})`;
162
+ const desktop = session.entrypoint === "claude-desktop";
163
+ const text = desktop ? `${message}\n\n[Bridge response routing: reply with ordinary text in this conversation. The bridge reads the response associated with this message from the local transcript; no cross-session reply tool is needed.]` : message;
164
+ const { msgId, reply, delivery } = await peer.sendAndWait(session.socket, text, {
165
+ timeoutMs: wait * 1000,
166
+ ...(desktop ? { transcriptSession: session } : {}),
167
+ });
168
+ const status = reply ? "reply_received" : delivery?.status ?? (wait === 0 ? "sent_unconfirmed" : "reply_timeout");
169
+ const receipt = { status, msgId, target: session.name ?? String(session.pid), sessionId: session.sessionId, waitSec: wait, ...(reply ? { source: reply.source ?? "peer" } : {}) };
170
+ const result = (text, isError = false) => ({ ...textResult(text, isError), structuredContent: { receipt } });
171
+ const targetLabel = `${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"})`;
164
172
 
165
- if (wait === 0) return textResult(`${header}\nnot waiting for a reply.`);
173
+ if (!reply && delivery && delivery.status !== "delivered") {
174
+ return result(`Claude inbox reported ${delivery.status} for ${targetLabel}.\n${delivery.reason}\nMessage id: ${msgId}`, true);
175
+ }
176
+
177
+ if (wait === 0) {
178
+ return result(`Sent to peer transport for ${targetLabel}.\nClaude receipt unconfirmed; not waiting for a reply.\nMessage id: ${msgId}`);
179
+ }
166
180
 
167
181
  if (!reply) {
168
- return textResult(
169
- `${header}\n\nNo reply within ${wait}s. Claude may still be working - check again with read_claude_inbox.`,
182
+ return result(
183
+ `Sent to peer transport for ${targetLabel}.\n\nNo reply within ${wait}s; Claude receipt and outcome remain unconfirmed. ` +
184
+ `Do not automatically retry: the message may still be processed. Check read_claude_inbox for a late reply.\nMessage id: ${msgId}`,
185
+ true,
170
186
  );
171
187
  }
172
- return textResult(`${header}\n\n--- Claude reply ---\n${reply.text}`);
188
+ return result(`Reply received from ${targetLabel}.\nMessage id: ${msgId}\n\n--- Claude reply ---\n${reply.text}`);
173
189
  } catch (err) {
174
190
  return failure(err);
175
191
  }
@@ -296,6 +312,7 @@ server.registerTool(
296
312
  `bridge: claude-bridge ${VERSION}`,
297
313
  `peer name: ${peer.name} (Claude sees this in its agent list)`,
298
314
  `peer socket: ${peer.socketPath}`,
315
+ `sender mode: ${peer.permissionMode ?? "unknown (recipient may hold messages for approval)"}`,
299
316
  `live sessions: ${sessions.length}`,
300
317
  `relay thread: ${forwarding.threadId ?? "(none - use bind_codex_thread)"}`,
301
318
  `delivery: ${delivery.describe()}`,
package/src/index.mjs CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  import { runTurn } from "./turn.mjs";
24
24
  import { BridgeSecurityPolicy } from "./security-policy.mjs";
25
25
 
26
- const VERSION = "1.12.2";
26
+ const VERSION = "1.12.4";
27
27
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
28
28
 
29
29
  /**
@@ -17,7 +17,7 @@ import {
17
17
  } from "./native-relay.mjs";
18
18
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
19
19
 
20
- const VERSION = "1.12.2";
20
+ const VERSION = "1.12.4";
21
21
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
22
22
 
23
23
  function errorResponse(code, message) {
@@ -201,6 +201,10 @@ export class RelaySocketServer {
201
201
  });
202
202
  }
203
203
 
204
+ async isListening() {
205
+ return this.started || this.#socketIsLive();
206
+ }
207
+
204
208
  #handleConnection(socket) {
205
209
  let buffer = Buffer.alloc(0);
206
210
  let handled = false;
@@ -350,6 +354,7 @@ if (invokedDirectly) {
350
354
  },
351
355
  },
352
356
  async () => {
357
+ const listening = await relay.isListening();
353
358
  let executor = "(unconfigured)";
354
359
  try {
355
360
  const resolved = resolveRelayThreadId();
@@ -364,7 +369,7 @@ if (invokedDirectly) {
364
369
  text: [
365
370
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
366
371
  `companion: codex-native-relay ${VERSION}`,
367
- `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
372
+ `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
368
373
  `executor: ${executor}`,
369
374
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
370
375
  `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
5
7
 
6
8
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
7
9
 
@@ -139,14 +141,168 @@ export function nativeDispatchParams({ executorThreadId, targetThreadId, message
139
141
  };
140
142
  }
141
143
 
144
+ const execFileAsync = promisify(execFile);
145
+
146
+ function splitDesktopCommandLine(commandLine, platform) {
147
+ const args = [];
148
+ let value = "";
149
+ let quote = null;
150
+ let depth = 0;
151
+ for (let index = 0; index < commandLine.length; index++) {
152
+ const char = commandLine[index];
153
+ if (platform === "win32" && char === "\\") {
154
+ let end = index;
155
+ while (commandLine[end] === "\\") end++;
156
+ const count = end - index;
157
+ if (commandLine[end] === '"') {
158
+ value += "\\".repeat(Math.floor(count / 2));
159
+ if (count % 2) value += '"';
160
+ else quote = quote ? null : '"';
161
+ index = end;
162
+ } else {
163
+ value += "\\".repeat(count);
164
+ index = end - 1;
165
+ }
166
+ continue;
167
+ }
168
+ if (platform !== "win32" && depth > 0) {
169
+ value += char;
170
+ if (quote) {
171
+ if (char === "\\" && quote === '"') value += commandLine[++index] ?? "";
172
+ else if (char === quote) quote = null;
173
+ } else if (char === '"' || char === "'") quote = char;
174
+ else if (char === "{" || char === "[") depth++;
175
+ else if (char === "}" || char === "]") depth--;
176
+ continue;
177
+ }
178
+ if (char === '"' || (platform !== "win32" && char === "'")) {
179
+ if (!quote) quote = char;
180
+ else if (quote === char) quote = null;
181
+ else value += char;
182
+ } else if (!quote && /\s/.test(char)) {
183
+ if (value) args.push(value);
184
+ value = "";
185
+ } else {
186
+ value += char;
187
+ if (platform !== "win32" && !quote && char === "{") depth++;
188
+ }
189
+ }
190
+ if (quote || depth) return [];
191
+ if (value) args.push(value);
192
+ return args;
193
+ }
194
+
195
+ function inlineTableValues(table) {
196
+ const text = table.trim();
197
+ if (!text.startsWith("{") || !text.endsWith("}")) return null;
198
+ const entries = [];
199
+ let start = 1;
200
+ let quote = null;
201
+ let depth = 0;
202
+ for (let index = 1; index < text.length - 1; index++) {
203
+ const char = text[index];
204
+ if (quote) {
205
+ if (char === "\\" && quote === '"') index++;
206
+ else if (char === quote) quote = null;
207
+ } else if (char === '"' || char === "'") quote = char;
208
+ else if (char === "{" || char === "[") depth++;
209
+ else if (char === "}" || char === "]") {
210
+ if (--depth < 0) return null;
211
+ } else if (char === "," && depth === 0) {
212
+ entries.push(text.slice(start, index));
213
+ start = index + 1;
214
+ }
215
+ }
216
+ if (quote || depth) return null;
217
+ entries.push(text.slice(start, -1));
218
+ const values = new Map();
219
+ for (const entry of entries) {
220
+ if (!entry.trim()) continue;
221
+ const match = entry.match(/^\s*(?:"([A-Za-z_][A-Za-z0-9_-]*)"|'([A-Za-z_][A-Za-z0-9_-]*)'|([A-Za-z_][A-Za-z0-9_-]*))\s*=\s*([\s\S]+)$/);
222
+ if (!match) return null;
223
+ const key = match[1] ?? match[2] ?? match[3];
224
+ if (values.has(key)) return null;
225
+ values.set(key, match[4].trim());
226
+ }
227
+ return values;
228
+ }
229
+
230
+ export function nativeToolsPipeFromCommandLine(commandLine, { platform = process.platform } = {}) {
231
+ if (typeof commandLine !== "string" || /[\r\n\0]/.test(commandLine)) return null;
232
+ const args = splitDesktopCommandLine(commandLine, platform);
233
+ const paths = platform === "win32" ? path.win32 : path.posix;
234
+ if (!/^codex(?:\.exe)?$/i.test(paths.basename(args[0] ?? ""))) return null;
235
+ const overrides = [];
236
+ let appServer = false;
237
+ for (let index = 1; index < args.length; index++) {
238
+ const arg = args[index];
239
+ if (arg === "-c" || arg === "--config") {
240
+ overrides.push(args[++index] ?? "");
241
+ } else if (arg.startsWith("--config=")) overrides.push(arg.slice(9));
242
+ else if (arg === "app-server") appServer = true;
243
+ else if (!arg.startsWith("-") && !appServer) return null;
244
+ }
245
+ if (!appServer) return null;
246
+ const candidates = [];
247
+ for (const override of overrides) {
248
+ const match = override.match(/^mcp_servers\.codex_app\s*=\s*([\s\S]+)$/);
249
+ if (!match) continue;
250
+ const config = inlineTableValues(match[1]);
251
+ if (!config) return null;
252
+ const env = inlineTableValues(config.get("env") ?? "{}");
253
+ if (!env) return null;
254
+ const raw = env.get("CODEX_APP_TOOLS_PIPE_PATH");
255
+ if (raw === undefined) continue;
256
+ let candidate;
257
+ try {
258
+ candidate = raw.startsWith('"') ? JSON.parse(raw) : /^'[^']*'$/.test(raw) ? raw.slice(1, -1) : null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ if (typeof candidate !== "string" || /[\r\n\0]/.test(candidate)) return null;
263
+ if (platform === "win32" ? !/^\\\\\.\\pipe\\[^\\]/i.test(candidate) : !path.posix.isAbsolute(candidate)) return null;
264
+ candidates.push(candidate);
265
+ }
266
+ return candidates.length && new Set(candidates).size === 1 ? candidates[0] : null;
267
+ }
268
+
269
+ async function readParentCommandLine(parentPid, platform) {
270
+ if (!Number.isSafeInteger(parentPid) || parentPid <= 0) return null;
271
+ const options = { timeout: 5000, maxBuffer: 128 * 1024, windowsHide: true };
272
+ if (platform === "win32") {
273
+ const powershell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
274
+ const script = `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${parentPid}'; if ($p.Name -eq 'codex.exe') { $p.CommandLine | ConvertTo-Json -Compress }`;
275
+ const { stdout } = await execFileAsync(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], options);
276
+ return stdout.trim() ? JSON.parse(stdout.trim()) : null;
277
+ }
278
+ const { stdout } = await execFileAsync("/bin/ps", ["-ww", "-p", String(parentPid), "-o", "args="], options);
279
+ return stdout.trim();
280
+ }
281
+
282
+ export async function resolveNativeToolsPipePath({
283
+ env = process.env,
284
+ parentPid = process.ppid,
285
+ platform = process.platform,
286
+ readParentCommandLine: readParent = readParentCommandLine,
287
+ } = {}) {
288
+ if (env.CODEX_APP_TOOLS_PIPE_PATH) return env.CODEX_APP_TOOLS_PIPE_PATH;
289
+ try {
290
+ return nativeToolsPipeFromCommandLine(await readParent(parentPid, platform), { platform });
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
142
296
  export class NativeToolsClient {
143
- constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
297
+ constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }) } = {}) {
144
298
  this.env = env;
145
299
  this.socketPath = socketPath;
146
300
  this.timeoutMs = timeoutMs;
301
+ this.resolveSocketPath = resolveSocketPath;
147
302
  this.socket = null;
148
303
  this.connectingSocket = null;
149
304
  this.connecting = null;
305
+ this.connectionGeneration = 0;
150
306
  this.pending = new Map();
151
307
  this.nextId = 1;
152
308
  }
@@ -154,10 +310,17 @@ export class NativeToolsClient {
154
310
  async connect() {
155
311
  if (this.connecting) return this.connecting;
156
312
  if (this.socket && !this.socket.destroyed) return;
157
- if (!this.socketPath) {
158
- throw new NativeRelayError("CODEX_APP_TOOLS_PIPE_PATH is missing; launch the companion from Codex Desktop", "NATIVE_PIPE_UNAVAILABLE");
159
- }
160
- this.connecting = new Promise((resolve, reject) => {
313
+ const generation = this.connectionGeneration;
314
+ this.connecting = (async () => {
315
+ const socketPath = this.socketPath || await this.resolveSocketPath();
316
+ if (generation !== this.connectionGeneration) {
317
+ throw new NativeRelayError("Native tools client closed while discovering the Desktop pipe", "NATIVE_PIPE_UNAVAILABLE");
318
+ }
319
+ this.socketPath = socketPath;
320
+ if (!this.socketPath) {
321
+ throw new NativeRelayError("CODEX_APP_TOOLS_PIPE_PATH is missing from the environment and parent Desktop app-server configuration; launch the companion from Codex Desktop", "NATIVE_PIPE_UNAVAILABLE");
322
+ }
323
+ return new Promise((resolve, reject) => {
161
324
  const socket = net.connect({ path: this.socketPath });
162
325
  this.connectingSocket = socket;
163
326
  let buffer = Buffer.alloc(0);
@@ -215,7 +378,8 @@ export class NativeToolsClient {
215
378
  });
216
379
  socket.on("error", (err) => fail(new NativeRelayError(`Codex Desktop native pipe failed: ${err.message}`, connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
217
380
  socket.on("close", () => fail(new NativeRelayError("Codex Desktop native tools pipe closed before confirming delivery", connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
218
- });
381
+ });
382
+ })();
219
383
  try {
220
384
  await this.connecting;
221
385
  } finally {
@@ -264,6 +428,7 @@ export class NativeToolsClient {
264
428
  }
265
429
 
266
430
  close() {
431
+ this.connectionGeneration++;
267
432
  const socket = this.socket;
268
433
  this.socket = null;
269
434
  for (const pending of this.pending.values()) {
@@ -21,7 +21,7 @@ const SOCKET_DIR = "/tmp/cc-socks";
21
21
  * directory to create, no mode to chmod and no file to unlink beforehand.
22
22
  */
23
23
  function peerSocketPath(pid) {
24
- return IS_WINDOWS ? `\\\\.\\pipe\\LOCAL\\cc-peer-${pid}` : path.join(SOCKET_DIR, `${pid}.sock`);
24
+ return IS_WINDOWS ? `\\\\.\\pipe\\LOCAL\\cc-msg-${crypto.randomBytes(16).toString("hex")}` : path.join(SOCKET_DIR, `${pid}.sock`);
25
25
  }
26
26
  const PEER_PROTOCOL_VERSION = 1;
27
27
  const CLAUDE_VERSION_HINT = "2.1.229";
@@ -35,32 +35,78 @@ const projectsDir = () => path.join(homeDir(), ".claude", "projects");
35
35
  * listens for peer messages on a local socket or Windows named pipe. Messages are newline-delimited
36
36
  * JSON; the wrapper element is what Claude renders in its chat surface.
37
37
  */
38
- export function buildFrame({ text, fromSocket, priority = "next" }) {
38
+ export function buildFrame({ text, fromSocket, priority = "next", permissionMode }) {
39
+ const msgId = crypto.randomUUID();
40
+ const from = `uds:${fromSocket.replace(/[^A-Za-z0-9:_/.\-]/gu, (character) => Array.from(Buffer.from(character), (byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join(""))}`;
41
+ const mode = ["bypass", "prompting"].includes(permissionMode) ? ` from-mode="${permissionMode}"` : "";
39
42
  return {
40
43
  msgV: PEER_PROTOCOL_VERSION,
41
- msg_id: crypto.randomUUID(),
44
+ msg_id: msgId,
45
+ uuid: msgId,
42
46
  type: "user",
43
47
  message: {
44
48
  role: "user",
45
- content: `<cross-session-message from="uds:${fromSocket}" from-mode="bypass">\n${text}\n</cross-session-message>`,
49
+ content: `<cross-session-message from="${from}"${mode}>\n${text}\n</cross-session-message>`,
46
50
  },
47
51
  priority,
48
- from: `uds:${fromSocket}`,
52
+ from,
49
53
  };
50
54
  }
51
55
 
52
56
  export function parseFrame(line) {
53
57
  const frame = JSON.parse(line);
58
+ if (frame?.type !== "user" || typeof frame.message?.content !== "string") return null;
54
59
  const raw = frame?.message?.content ?? "";
55
60
  const inner = raw.match(/<cross-session-message[^>]*>\n?([\s\S]*?)\n?<\/cross-session-message>/);
56
61
  return {
57
62
  msgId: frame.msg_id ?? null,
58
63
  from: frame.from ?? null,
59
- fromSocket: (frame.from ?? "").replace(/^uds:/, "") || null,
64
+ fromSocket: decodePeerAddress(frame.from),
60
65
  text: (inner ? inner[1] : raw).trim(),
61
66
  };
62
67
  }
63
68
 
69
+ function decodePeerAddress(from) {
70
+ if (typeof from !== "string" || !from.startsWith("uds:")) return null;
71
+ try { return decodeURIComponent(from.slice(4)) || null; }
72
+ catch { return null; }
73
+ }
74
+
75
+ export function peerKeyPath(pid, socket) {
76
+ const pipe = /^[\\/]{2}[.?][\\/]pipe[\\/](?:(LOCAL)[\\/])?([^\\/]+)$/i.exec(socket);
77
+ let canonical;
78
+ if (pipe && !/[. ]$/.test(pipe[2]) && ![".", ".."].includes(pipe[2])) {
79
+ canonical = `\\\\.\\pipe\\${pipe[1] ? "local\\" : ""}${pipe[2].replace(/[A-Z]/g, (letter) => letter.toLowerCase())}`;
80
+ } else if (!IS_WINDOWS && path.isAbsolute(socket)) {
81
+ canonical = path.resolve(socket);
82
+ } else {
83
+ throw new Error("Refusing a non-local or non-canonical peer socket");
84
+ }
85
+ return path.join(sessionsDir(), `${pid}.${crypto.createHash("sha256").update(canonical).digest("hex")}.key`);
86
+ }
87
+
88
+ function readPeerToken(socket) {
89
+ peerKeyPath(process.pid, socket);
90
+ const candidates = listClaudeSessions({ includeBridges: true }).filter((entry) => {
91
+ try { return peerKeyPath(entry.pid, entry.socket) === peerKeyPath(entry.pid, socket); }
92
+ catch { return false; }
93
+ });
94
+ if (candidates.length !== 1) {
95
+ if (IS_WINDOWS) throw new Error("No unique live session owns the destination inbox; message was not sent");
96
+ return null;
97
+ }
98
+ try {
99
+ const key = JSON.parse(fs.readFileSync(peerKeyPath(candidates[0].pid, socket), "utf8"));
100
+ if (typeof key.peerToken !== "string" || !/^[0-9a-f]{32}$/i.test(key.peerToken)) throw new Error("Invalid peer key");
101
+ const identity = IS_WINDOWS ? key.procStartFt : key.procStart;
102
+ if (identity && identity !== readProcessStart(candidates[0].pid)) throw new Error("Peer process identity changed");
103
+ return key.peerToken;
104
+ } catch {
105
+ if (IS_WINDOWS) throw new Error("The destination inbox authentication key is missing or invalid; message was not sent");
106
+ return null;
107
+ }
108
+ }
109
+
64
110
  /**
65
111
  * MCP servers are spawned with an empty PATH, so `ps` must be addressed by its
66
112
  * absolute path - a bare lookup throws ENOENT and takes the whole server down
@@ -69,7 +115,11 @@ export function parseFrame(line) {
69
115
  */
70
116
  function readProcessStart(pid) {
71
117
  try {
72
- return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)]).toString().trim();
118
+ if (IS_WINDOWS) {
119
+ const shell = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
120
+ return execFileSync(shell, ["-NoProfile", "-Command", `(Get-Process -Id ${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout: 3000, windowsHide: true }).toString().trim();
121
+ }
122
+ return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 3000 }).toString().trim();
73
123
  } catch {
74
124
  return "";
75
125
  }
@@ -195,6 +245,29 @@ export function readTranscript(sessionId, cwd, limit = 10) {
195
245
  return { file, messages: messages.slice(-limit) };
196
246
  }
197
247
 
248
+ export function readTranscriptReply(sessionId, cwd, msgId) {
249
+ const file = findTranscriptFile(sessionId, cwd);
250
+ let lines;
251
+ try { lines = fs.readFileSync(file, "utf8").split("\n"); }
252
+ catch { return null; }
253
+ const descendants = new Set();
254
+ for (const line of lines) {
255
+ let entry;
256
+ try { entry = JSON.parse(line); } catch { continue; }
257
+ if (entry.isSidechain) continue;
258
+ if (entry.uuid === msgId && entry.message?.role === "user") { descendants.add(msgId); continue; }
259
+ if (!descendants.has(entry.parentUuid)) continue;
260
+ const role = entry.message?.role;
261
+ const content = entry.message?.content;
262
+ if (role === "user" && !(Array.isArray(content) && content.every((part) => part.type === "tool_result"))) continue;
263
+ if (typeof entry.uuid === "string") descendants.add(entry.uuid);
264
+ if (role !== "assistant" || !["end_turn", "stop_sequence"].includes(entry.message.stop_reason)) continue;
265
+ const text = Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n") : String(content ?? "");
266
+ if (text.trim()) return { text: text.trim(), msgId: entry.uuid, source: "transcript", inReplyTo: msgId };
267
+ }
268
+ return null;
269
+ }
270
+
198
271
  /**
199
272
  * Registers this process as a peer that Claude sessions can see in their agent
200
273
  * list and reply to. Without a registered socket, Claude has no address to
@@ -216,6 +289,11 @@ export class PeerEndpoint {
216
289
  this.requestQueues = new Map();
217
290
  this.unconfirmedReplies = new Map();
218
291
  this.started = false;
292
+ this.peerToken = null;
293
+ this.permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
294
+ this.deliveryReceipts = new Map();
295
+ this.pendingMessages = new Map();
296
+ this.responsePoll = null;
219
297
  }
220
298
 
221
299
  /**
@@ -250,8 +328,8 @@ export class PeerEndpoint {
250
328
  this.#sweepDeadBridges();
251
329
  const procStart = readProcessStart(this.pid);
252
330
  const peerToken = crypto.randomBytes(16).toString("hex");
253
- const keyHash = crypto.createHash("sha256").update(`${peerToken}${procStart}`).digest("hex");
254
- this.keyPath = path.join(sessionsDir(), `${this.pid}.${keyHash}.key`);
331
+ this.peerToken = peerToken;
332
+ this.keyPath = peerKeyPath(this.pid, this.socketPath);
255
333
 
256
334
  if (!IS_WINDOWS) fs.mkdirSync(SOCKET_DIR, { recursive: true });
257
335
  fs.mkdirSync(sessionsDir(), { recursive: true });
@@ -264,12 +342,13 @@ export class PeerEndpoint {
264
342
  });
265
343
  if (!IS_WINDOWS) fs.chmodSync(this.socketPath, 0o600);
266
344
 
345
+ const processIdentity = procStart ? (IS_WINDOWS ? { procStartFt: procStart } : { procStart }) : {};
267
346
  this.registry = {
268
347
  pid: this.pid,
269
348
  sessionId: crypto.randomUUID(),
270
349
  cwd: this.cwd,
271
350
  startedAt: Date.now(),
272
- procStart,
351
+ ...processIdentity,
273
352
  version: CLAUDE_VERSION_HINT,
274
353
  peerProtocol: PEER_PROTOCOL_VERSION,
275
354
  kind: "interactive",
@@ -279,7 +358,7 @@ export class PeerEndpoint {
279
358
  nameSource: "derived",
280
359
  };
281
360
  fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
282
- fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, procStart }), { mode: 0o600 });
361
+ fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, ...processIdentity }), { mode: 0o600 });
283
362
 
284
363
  for (const signal of ["SIGINT", "SIGTERM"]) {
285
364
  process.on(signal, () => {
@@ -295,37 +374,91 @@ export class PeerEndpoint {
295
374
 
296
375
  #handleConnection(socket) {
297
376
  let buffer = "";
377
+ let authenticated = false;
378
+ let firstLine = true;
298
379
  socket.setEncoding("utf8");
299
380
  socket.on("data", (chunk) => {
300
381
  buffer += chunk;
382
+ if (buffer.length > 1048576) { socket.destroy(); return; }
301
383
  let index;
302
384
  while ((index = buffer.indexOf("\n")) >= 0) {
303
385
  const line = buffer.slice(0, index).trim();
304
386
  buffer = buffer.slice(index + 1);
305
- if (!line) continue;
387
+ if (!line) {
388
+ if (IS_WINDOWS && !authenticated) { socket.destroy(); return; }
389
+ continue;
390
+ }
306
391
  let message;
307
392
  try {
393
+ const frame = JSON.parse(line);
394
+ if (firstLine && frame?.type === "auth") {
395
+ firstLine = false;
396
+ const supplied = typeof frame.token === "string" ? Buffer.from(frame.token) : Buffer.alloc(0);
397
+ const expected = Buffer.from(this.peerToken ?? "");
398
+ authenticated = expected.length > 0 && supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
399
+ if (!authenticated) { socket.destroy(); return; }
400
+ continue;
401
+ }
402
+ firstLine = false;
403
+ if (IS_WINDOWS && !authenticated) { socket.destroy(); return; }
404
+ if (frame?.type === "control" && frame.action === "peer_message_status") {
405
+ if (typeof frame.orig_msg_id === "string" && typeof frame.from === "string") {
406
+ const receipt = {
407
+ status: frame.status_detail === "refused" ? "refused" : frame.status,
408
+ reason: typeof frame.reason === "string" ? frame.reason : "",
409
+ fromSocket: decodePeerAddress(frame.from),
410
+ };
411
+ const pending = this.pendingMessages.get(frame.orig_msg_id);
412
+ if (pending?.targetSocket === receipt.fromSocket) {
413
+ this.deliveryReceipts.set(frame.orig_msg_id, receipt);
414
+ if (["refused", "denied", "expired", "dropped"].includes(receipt.status)) this.#removePendingReply(receipt.fromSocket, frame.orig_msg_id);
415
+ }
416
+ }
417
+ continue;
418
+ }
308
419
  message = parseFrame(line);
309
420
  } catch {
310
- this.log(`ignored malformed peer frame (${line.slice(0, 80)})`);
421
+ if (IS_WINDOWS && !authenticated) { socket.destroy(); return; }
422
+ this.log("ignored malformed peer frame");
311
423
  continue;
312
424
  }
313
- const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
314
- this.inbox.push(record);
315
- this.#removePendingReply(record.fromSocket);
316
- this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
317
- for (const listener of [...this.listeners]) {
318
- try {
319
- listener(record);
320
- } catch (err) {
321
- this.log(`peer listener error: ${err.message}`);
322
- }
323
- }
425
+ if (!message) continue;
426
+ this.#receiveMessage(message);
324
427
  }
325
428
  });
326
429
  socket.on("error", (err) => this.log(`peer socket error: ${err.message}`));
327
430
  }
328
431
 
432
+ #receiveMessage(message) {
433
+ const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
434
+ this.inbox.push(record);
435
+ this.#removePendingReply(record.fromSocket, record.inReplyTo);
436
+ this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
437
+ for (const listener of [...this.listeners]) {
438
+ try { listener(record); }
439
+ catch (err) { this.log(`peer listener error: ${err.message}`); }
440
+ }
441
+ }
442
+
443
+ #refreshTranscriptReplies() {
444
+ for (const [msgId, pending] of this.pendingMessages) {
445
+ if (!pending.transcriptSession) continue;
446
+ const { sessionId, cwd } = pending.transcriptSession;
447
+ const file = findTranscriptFile(sessionId, cwd);
448
+ let stamp;
449
+ try { const stat = fs.statSync(file); stamp = `${stat.mtimeMs}:${stat.size}`; }
450
+ catch { continue; }
451
+ if (stamp === pending.transcriptStamp) continue;
452
+ pending.transcriptStamp = stamp;
453
+ const reply = readTranscriptReply(sessionId, cwd, msgId);
454
+ if (reply) this.#receiveMessage({ ...reply, fromSocket: pending.targetSocket });
455
+ }
456
+ if (![...this.pendingMessages.values()].some((entry) => entry.transcriptSession)) {
457
+ globalThis.clearInterval(this.responsePoll);
458
+ this.responsePoll = null;
459
+ }
460
+ }
461
+
329
462
  /**
330
463
  * Codex starts one bridge per session, so several peers advertise at once.
331
464
  * Renaming to the bound thread is what lets a human tell them apart in
@@ -347,9 +480,11 @@ export class PeerEndpoint {
347
480
  return () => this.listeners.delete(listener);
348
481
  }
349
482
 
350
- async send(targetSocket, text, { priority = "next" } = {}) {
351
- const frame = buildFrame({ text, fromSocket: this.socketPath, priority });
352
- const line = JSON.stringify(frame) + "\n";
483
+ async send(targetSocket, text, { priority = "next", msgId } = {}) {
484
+ const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode: this.permissionMode });
485
+ if (msgId) frame.uuid = frame.msg_id = msgId;
486
+ const token = readPeerToken(targetSocket);
487
+ const line = (token ? JSON.stringify({ type: "auth", token }) + "\n" : "") + JSON.stringify(frame) + "\n";
353
488
  for (let attempt = 1; attempt <= PEER_SEND_ATTEMPTS; attempt += 1) {
354
489
  try {
355
490
  await new Promise((resolve, reject) => {
@@ -399,7 +534,7 @@ export class PeerEndpoint {
399
534
  return frame.msg_id;
400
535
  }
401
536
 
402
- async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next" } = {}) {
537
+ async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession } = {}) {
403
538
  const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
404
539
  const pending = previous.catch(() => {}).then(async () => {
405
540
  const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
@@ -414,16 +549,29 @@ export class PeerEndpoint {
414
549
  const since = Date.now();
415
550
  const afterSequence = this.messageSequence;
416
551
  this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
417
- let msgId;
552
+ let msgId = crypto.randomUUID();
553
+ this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
418
554
  try {
419
- msgId = await this.send(targetSocket, text, { priority });
555
+ const sentId = await this.send(targetSocket, text, { priority, msgId });
556
+ if (sentId !== msgId) {
557
+ const pendingMessage = this.pendingMessages.get(msgId);
558
+ this.pendingMessages.delete(msgId);
559
+ msgId = sentId;
560
+ if (pendingMessage) this.pendingMessages.set(msgId, pendingMessage);
561
+ }
420
562
  } catch (err) {
421
- this.#removePendingReply(targetSocket);
563
+ this.#removePendingReply(targetSocket, msgId);
422
564
  throw err;
423
565
  }
566
+ if (transcriptSession && !this.responsePoll && this.pendingMessages.has(msgId)) {
567
+ this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
568
+ this.responsePoll.unref();
569
+ }
424
570
  const reply = timeoutMs > 0
425
- ? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence })
571
+ ? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence, msgId })
426
572
  : null;
573
+ const delivery = this.deliveryReceipts.get(msgId);
574
+ if (delivery?.fromSocket === targetSocket) return { msgId, reply, delivery };
427
575
  return { msgId, reply };
428
576
  });
429
577
  this.requestQueues.set(targetSocket, pending);
@@ -434,7 +582,9 @@ export class PeerEndpoint {
434
582
  }
435
583
  }
436
584
 
437
- #removePendingReply(fromSocket) {
585
+ #removePendingReply(fromSocket, msgId) {
586
+ const key = msgId ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === fromSocket)?.[0];
587
+ if (key) this.pendingMessages.delete(key);
438
588
  const pending = this.unconfirmedReplies.get(fromSocket) ?? 0;
439
589
  if (pending > 1) this.unconfirmedReplies.set(fromSocket, pending - 1);
440
590
  else this.unconfirmedReplies.delete(fromSocket);
@@ -444,22 +594,27 @@ export class PeerEndpoint {
444
594
  * Claude answers with a fresh msg_id rather than an in-reply-to field, so a
445
595
  * reply is matched by origin socket and arrival time.
446
596
  */
447
- waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null } = {}) {
597
+ waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null, msgId } = {}) {
448
598
  const matches = (record) => record.fromSocket === fromSocket
449
599
  && (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
450
600
  const existing = this.inbox.find(matches);
451
601
  if (existing) return Promise.resolve(existing);
452
602
  return new Promise((resolve) => {
453
- const timer = globalThis.setTimeout(() => {
603
+ const finish = (reply) => {
454
604
  unsubscribe();
455
- resolve(null);
456
- }, timeoutMs);
605
+ globalThis.clearTimeout(timer);
606
+ globalThis.clearInterval(poll);
607
+ resolve(reply);
608
+ };
609
+ const timer = globalThis.setTimeout(() => finish(null), timeoutMs);
457
610
  const unsubscribe = this.onMessage((record) => {
458
611
  if (!matches(record)) return;
459
- globalThis.clearTimeout(timer);
460
- unsubscribe();
461
- resolve(record);
612
+ finish(record);
462
613
  });
614
+ const poll = msgId ? globalThis.setInterval(() => {
615
+ const receipt = this.deliveryReceipts.get(msgId);
616
+ if (receipt?.fromSocket === fromSocket && ["held", "refused", "denied", "expired", "dropped"].includes(receipt.status)) { finish(null); return; }
617
+ }, 250) : null;
463
618
  });
464
619
  }
465
620
 
@@ -470,6 +625,9 @@ export class PeerEndpoint {
470
625
  }
471
626
 
472
627
  stop() {
628
+ globalThis.clearInterval(this.responsePoll);
629
+ this.responsePoll = null;
630
+ this.pendingMessages.clear();
473
631
  try {
474
632
  this.server?.close();
475
633
  } catch {}