@minhspark/codex-mcp-bridge 1.22.3 → 1.22.5

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.
@@ -38,9 +38,11 @@ const projectsDir = () => path.join(homeDir(), ".claude", "projects");
38
38
  * listens for peer messages on a local socket or Windows named pipe. Messages are newline-delimited
39
39
  * JSON; the wrapper element is what Claude renders in its chat surface.
40
40
  */
41
- export function buildFrame({ text, fromSocket, priority = "next", permissionMode }) {
41
+ export function buildFrame({ text, fromSocket, fromName, priority = "next", permissionMode }) {
42
42
  const msgId = crypto.randomUUID();
43
43
  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(""))}`;
44
+ const displayName = typeof fromName === "string" ? fromName.replace(/["<>\p{Cf}\p{Cc}\p{Cs}\p{Zl}\p{Zp}]/gu, "").trim() : "";
45
+ const name = displayName ? ` from-name="${displayName}"` : "";
44
46
  const mode = ["bypass", "prompting"].includes(permissionMode) ? ` from-mode="${permissionMode}"` : "";
45
47
  return {
46
48
  msgV: PEER_PROTOCOL_VERSION,
@@ -49,7 +51,7 @@ export function buildFrame({ text, fromSocket, priority = "next", permissionMode
49
51
  type: "user",
50
52
  message: {
51
53
  role: "user",
52
- content: `<cross-session-message from="${from}"${mode}>\n${text}\n</cross-session-message>`,
54
+ content: `<cross-session-message from="${from}"${name}${mode}>\n${text}\n</cross-session-message>`,
53
55
  },
54
56
  priority,
55
57
  from,
@@ -413,6 +415,38 @@ function isImageToolCompanion(entry, entriesById) {
413
415
  && typeof tool.id === "string" && tool.id === part.tool_use_id));
414
416
  }
415
417
 
418
+ function compactContinuation(entry, root, entriesById, sessionId, cwd) {
419
+ if (entry.type !== "system" || entry.subtype !== "compact_boundary" || entry.parentUuid !== null
420
+ || typeof entry.logicalParentUuid !== "string") return null;
421
+ const sameIdentity = (record) => record?.entrypoint === "claude-desktop"
422
+ && record.sessionId === sessionId && record.cwd === root.cwd;
423
+ if (!sameIdentity(root) || !sameIdentity(entry) || typeof root.cwd !== "string"
424
+ || path.resolve(root.cwd) !== path.resolve(cwd)) return null;
425
+ const segment = entry.compactMetadata?.preservedSegment;
426
+ const preserved = entry.compactMetadata?.preservedMessages;
427
+ const ids = preserved?.uuids;
428
+ if (!Array.isArray(ids) || !ids.length || ids.length > 65536
429
+ || ids.some((id) => typeof id !== "string" || !id) || new Set(ids).size !== ids.length
430
+ || !Array.isArray(preserved.allUuids) || preserved.allUuids.length !== ids.length
431
+ || ids.some((id, index) => preserved.allUuids[index] !== id)
432
+ || !ids.includes(root.uuid) || segment?.headUuid !== ids[0]
433
+ || segment.tailUuid !== ids.at(-1) || segment.tailUuid !== entry.logicalParentUuid
434
+ || typeof segment.anchorUuid !== "string" || preserved.anchorUuid !== segment.anchorUuid) return null;
435
+ if (ids.some((id, index) => !sameIdentity(entriesById.get(id))
436
+ || index > 0 && entriesById.get(id).parentUuid !== ids[index - 1])
437
+ || ids.includes(entriesById.get(ids[0]).parentUuid)) return null;
438
+ const summary = entriesById.get(segment.anchorUuid);
439
+ if (!sameIdentity(summary) || summary.type !== "user" || summary.message?.role !== "user"
440
+ || summary.isCompactSummary !== true || summary.isVisibleInTranscriptOnly !== true
441
+ || summary.turnOrigin !== "peer" || summary.origin != null || summary.promptSource != null) return null;
442
+ let ancestor = entriesById.get(summary.parentUuid);
443
+ for (let depth = 0; depth < 64 && ancestor?.type === "attachment"; depth++) {
444
+ if (!sameIdentity(ancestor)) return null;
445
+ ancestor = entriesById.get(ancestor.parentUuid);
446
+ }
447
+ return ancestor === entry ? summary.uuid : null;
448
+ }
449
+
416
450
  // Interruption closes reply observation, not execution. Never attach a later
417
451
  // user turn's answer to the original request or claim its side effects stopped.
418
452
  export function readTranscriptOutcome(sessionId, cwd, msgId) {
@@ -445,6 +479,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
445
479
  const openLeaves = new Set();
446
480
  const passiveLeaves = new Set();
447
481
  const interruptions = [];
482
+ const compactSummaries = new Set();
448
483
  const interrupt = (entry, outstanding) => {
449
484
  if (typeof entry.uuid !== "string" || !entry.uuid.trim()) return;
450
485
  openLeaves.delete(entry.parentUuid);
@@ -452,7 +487,16 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
452
487
  };
453
488
  for (const entry of entries) {
454
489
  if (entry === root) { descendants.set(entry.uuid, new Map()); openLeaves.add(entry.uuid); continue; }
455
- if (!descendants.has(entry.parentUuid)) continue;
490
+ let parentUuid = entry.parentUuid;
491
+ if (!ambiguous && !interruptions.length && descendants.has(entry.logicalParentUuid)
492
+ && openLeaves.has(entry.logicalParentUuid)) {
493
+ const summaryId = compactContinuation(entry, root, entriesById, sessionId, cwd);
494
+ if (summaryId) {
495
+ parentUuid = entry.logicalParentUuid;
496
+ compactSummaries.add(summaryId);
497
+ }
498
+ }
499
+ if (!descendants.has(parentUuid)) continue;
456
500
  if (entry.type === "attachment" && entry.attachment?.type === "queued_command") {
457
501
  // Only a native command record is a boundary. Its text is never used as
458
502
  // proof of authorization, cancellation, or successful task completion.
@@ -469,10 +513,10 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
469
513
  }
470
514
  const role = entry.message?.role;
471
515
  const content = entry.message?.content;
472
- const outstanding = new Map(descendants.get(entry.parentUuid));
516
+ const outstanding = new Map(descendants.get(parentUuid));
473
517
  const toolResults = Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "tool_result");
474
518
  const imageCompanion = isImageToolCompanion(entry, entriesById);
475
- if (role === "user" && !toolResults && !imageCompanion) {
519
+ if (role === "user" && !toolResults && !imageCompanion && !compactSummaries.has(entry.uuid)) {
476
520
  const notificationText = typeof content === "string" ? content
477
521
  : Array.isArray(content) ? content.filter((part) => typeof part?.text === "string").map((part) => part.text).join("\n") : "";
478
522
  const nativeHuman = entry.origin?.kind === "human" && entry.promptSource === "sdk" && entry.turnOrigin === "human";
@@ -524,7 +568,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
524
568
  && ["hook_success", "total_tokens_reminder", "deferred_tools_record"].includes(entry.attachment?.type)) {
525
569
  passiveLeaves.add(entry.uuid);
526
570
  }
527
- openLeaves.delete(entry.parentUuid);
571
+ openLeaves.delete(parentUuid);
528
572
  openLeaves.add(entry.uuid);
529
573
  if (role !== "assistant" || !["end_turn", "stop_sequence"].includes(entry.message.stop_reason)) continue;
530
574
  if (outstanding.size > 0) continue;
@@ -858,7 +902,7 @@ export class PeerEndpoint {
858
902
 
859
903
  async send(targetSocket, text, { priority = "next", msgId, permissionMode = this.permissionMode, beforeSend } = {}) {
860
904
  if (this.reloadPaused) throw new Error("The peer endpoint is quiesced; no message was sent");
861
- const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode });
905
+ const frame = buildFrame({ text, fromSocket: this.socketPath, fromName: this.name, priority, permissionMode });
862
906
  if (msgId) frame.uuid = frame.msg_id = msgId;
863
907
  const token = readPeerToken(targetSocket);
864
908
  const line = (token ? JSON.stringify({ type: "auth", token }) + "\n" : "") + JSON.stringify(frame) + "\n";
package/src/platform.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
- import { accessSync, constants, existsSync, readdirSync, statSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -338,7 +338,7 @@ function remapCandidates(input) {
338
338
  * checkout is unavailable locally. UNC shares are tried after local remap
339
339
  * candidates so a disconnected network location cannot stall the bridge.
340
340
  */
341
- export function resolveWorkspacePath(input) {
341
+ export function resolveWorkspacePath(input, { requireUnique = false } = {}) {
342
342
  if (!input) return { path: input, remapped: false, writable: false, note: null };
343
343
  const original = input;
344
344
  const mapped = pathMapCandidates(input);
@@ -348,7 +348,11 @@ export function resolveWorkspacePath(input) {
348
348
  : [...mapped, input, ...remapped];
349
349
  const ordered = candidates.filter((candidate, index) => candidates.indexOf(candidate) === index);
350
350
 
351
- const writable = ordered.find((candidate) => existsSync(candidate) && isWritableDir(candidate));
351
+ const usable = requireUnique ? ordered.filter((candidate) => existsSync(candidate) && isWritableDir(candidate)) : null;
352
+ if (usable && new Set(usable.map(candidate => realpathSync.native(candidate))).size > 1) {
353
+ throw new Error("Multiple usable workspace directories match; select an unambiguous project path");
354
+ }
355
+ const writable = usable ? usable[0] : ordered.find((candidate) => existsSync(candidate) && isWritableDir(candidate));
352
356
  if (writable) {
353
357
  return {
354
358
  path: path.resolve(writable),
@@ -150,6 +150,15 @@ export function createProjectScope(file, options = {}) {
150
150
  assert: (candidate, label) => capture(candidate, label).path,
151
151
  allows(candidate) { try { capture(candidate); return true; } catch { return false; } },
152
152
  capture,
153
+ assertDiscoverySource(candidate) {
154
+ const original = canonicalPolicyPath(candidate);
155
+ const namespaces = [path.resolve(candidate), original];
156
+ const { policy } = readProjectPolicy(file);
157
+ if (policy.denies.some(entry => namespaces.some(source => inside(entry.path, source) || entry.worktrees?.some(root => inside(root.path, source))))) {
158
+ throw new Error("The original project path was revoked; relocation discovery is not authorized");
159
+ }
160
+ return original;
161
+ },
153
162
  recheck(binding, label = "Working directory") {
154
163
  if (!binding?.identity) throw new Error(`${label} has no original directory identity`);
155
164
  const now = capture(binding.path, label);
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { creationRequestHash } from "./native-creation-receipts.mjs";
7
7
  import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
8
- import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse } from "./codex-native-response.mjs";
8
+ import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse, resolveCodexNativeDispatchResponse } from "./codex-native-response.mjs";
9
9
  import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
10
10
  import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
11
11
 
@@ -59,7 +59,7 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
59
59
  }
60
60
 
61
61
  export class DesktopTaskDelivery {
62
- constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext, senderContext, captureResponse = captureCodexRolloutWatermark, readResponse = readCodexNativeTurnResponse, inspectResponse = inspectCodexNativeTurn } = {}) {
62
+ constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext, senderContext, captureResponse = captureCodexRolloutWatermark, readResponse = readCodexNativeTurnResponse, inspectResponse = inspectCodexNativeTurn, resolveResponse = readResponse === readCodexNativeTurnResponse ? resolveCodexNativeDispatchResponse : null } = {}) {
63
63
  this.relay = relay;
64
64
  this.security = security;
65
65
  this.sleep = sleep;
@@ -71,6 +71,7 @@ export class DesktopTaskDelivery {
71
71
  this.captureResponse = captureResponse;
72
72
  this.readResponse = readResponse;
73
73
  this.inspectResponse = inspectResponse;
74
+ this.resolveResponse = resolveResponse;
74
75
  this.threadOperations = new Map();
75
76
  }
76
77
 
@@ -101,7 +102,7 @@ export class DesktopTaskDelivery {
101
102
  const projectScope = expectedScope ?? await this.mutationProjectScope(operation, args, { deadline });
102
103
  if (expectedScope) recheckProjectScope(expectedScope, this.senderProjectScope(expectedScope.recipient.input));
103
104
  const remaining = deadline === undefined ? undefined : deadline - this.now();
104
- if (remaining !== undefined && remaining <= 0) throw new Error("The bridge's response deadline has elapsed; this operation was not sent");
105
+ if (remaining !== undefined && remaining <= 0) throw Object.assign(new Error("The bridge's response deadline has elapsed; this operation was not sent"), { code: "DESKTOP_RESPONSE_DEADLINE" });
105
106
  const accountContext = this.accountContext?.();
106
107
  const response = await this.relay.requestDesktop(operation, args, {
107
108
  ...(remaining === undefined ? {} : { timeoutMs: remaining }),
@@ -432,7 +433,7 @@ export class DesktopTaskDelivery {
432
433
  if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
433
434
  }
434
435
 
435
- async observeNativeResponse(threadId, turnId, responseObservation, { deadline } = {}) {
436
+ async observeNativeResponse(threadId, turnId, responseObservation, { deadline, resolveDispatch = false } = {}) {
436
437
  if (!responseObservation) return unavailableObservation("Codex Desktop completed the turn without exposing assistant response text or a pre-send observation binding.");
437
438
  const { expectedCwd } = responseObservation;
438
439
  const args = { threadId, hostId: "local", turnLimit: 1 };
@@ -445,12 +446,12 @@ export class DesktopTaskDelivery {
445
446
  };
446
447
  await recheck();
447
448
  const initial = await this.inspect(threadId, expectedCwd, { deadline });
448
- if (initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
449
- const read = responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
449
+ if (!resolveDispatch && initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
450
+ const read = resolveDispatch ? this.resolveResponse : responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
450
451
  const observed = read({ ...responseObservation, threadId, turnId, desktopEvidence: initial });
451
452
  await recheck();
452
453
  const inspected = await this.inspect(threadId, expectedCwd, { deadline });
453
- if (inspected.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
454
+ if (inspected.latestTurnId !== (resolveDispatch ? initial.latestTurnId : turnId)) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
454
455
  await recheck();
455
456
  return observed;
456
457
  }
@@ -491,6 +492,19 @@ export class DesktopTaskDelivery {
491
492
  return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
492
493
  };
493
494
  const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
495
+ const deadlineError = (error) => {
496
+ const cause = error.cause ?? error;
497
+ return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
498
+ cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
499
+ };
500
+ const observe = async (selectedTurnId, resolveDispatch = false) => {
501
+ try {
502
+ return await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
503
+ } catch (error) {
504
+ if (deadlineError(error)) return null;
505
+ throw error;
506
+ }
507
+ };
494
508
  for (;;) {
495
509
  if (this.now() - startedAt >= timeoutMs) return expired();
496
510
  let response;
@@ -499,13 +513,13 @@ export class DesktopTaskDelivery {
499
513
  targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
500
514
  }, { deadline: startedAt + timeoutMs });
501
515
  } catch (err) {
502
- if (this.now() - startedAt >= timeoutMs) return expired();
516
+ if (deadlineError(err)) return expired();
503
517
  if (readSnapshots || !unavailableWaitRenderer(err)) throw err;
504
518
  readSnapshots = true;
505
519
  try {
506
520
  response = await readSnapshot();
507
521
  } catch (readError) {
508
- if (this.now() - startedAt >= timeoutMs) return expired();
522
+ if (deadlineError(readError)) return expired();
509
523
  throw readError;
510
524
  }
511
525
  }
@@ -514,15 +528,45 @@ export class DesktopTaskDelivery {
514
528
  cursor = poll.cursor;
515
529
  const turn = poll.latestTurn;
516
530
  const threadStatus = typeof poll.thread.status === "string" ? poll.thread.status : poll.thread.status?.type;
517
- if (["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus)) {
518
- return { threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt };
531
+ const needsAttention = ["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus);
532
+ const attention = () => ({ threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt });
533
+ if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
534
+ const observed = await observe(null, true);
535
+ if (!observed) return expired();
536
+ turnId = observed.turnId ?? null;
537
+ if (observed.status !== "pending") {
538
+ return {
539
+ threadId, turnId, status: observed.status === "unavailable" ? "failed" : "completed",
540
+ responseStatus: observed.status, observationStatus: observed.status,
541
+ text: observed.status === "completed" ? observed.text : "",
542
+ ...(observed.reason ? { observationReason: observed.reason } : {}),
543
+ assistantItems: observed.assistantItems ?? [], replySha256: observed.replySha256 ?? null,
544
+ responseSource: observed.source ?? null,
545
+ ...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
546
+ activity: [], errors: [], durationMs: this.now() - startedAt,
547
+ };
548
+ }
549
+ if (needsAttention) return attention();
550
+ if (this.now() - startedAt >= timeoutMs) return expired();
551
+ await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
552
+ continue;
519
553
  }
520
- if (turn?.id && turn.id !== previousTurnId) {
521
- turnId = turn.id;
554
+ if (needsAttention) return attention();
555
+ const existingTurn = turn?.id === previousTurnId;
556
+ if (turn?.id && (!existingTurn || responseObservation?.previousTurnId === previousTurnId)) {
557
+ if (!existingTurn) turnId = turn.id;
522
558
  const status = turn.status;
523
559
  if (RELEASE_STATUSES.has(status)) {
524
- const observed = await this.observeNativeResponse(threadId, turnId, responseObservation, { deadline: startedAt + timeoutMs });
560
+ const observed = await observe(turn.id);
561
+ if (!observed) return expired();
525
562
  const responseStatus = observed.status;
563
+ if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
564
+ turnId = null;
565
+ if (this.now() - startedAt >= timeoutMs) return expired();
566
+ await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
567
+ continue;
568
+ }
569
+ turnId = turn.id;
526
570
  return {
527
571
  threadId, turnId, status,
528
572
  text: responseStatus === "completed" ? observed.text : "",
@@ -532,6 +576,7 @@ export class DesktopTaskDelivery {
532
576
  assistantItems: observed.assistantItems ?? [],
533
577
  replySha256: observed.replySha256 ?? null,
534
578
  responseSource: observed.source ?? null,
579
+ ...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
535
580
  activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt,
536
581
  };
537
582
  }