@minhspark/codex-mcp-bridge 1.22.4 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.4",
3
+ "version": "1.22.5",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.5] - 2026-10-19
8
+
9
+ ### Fixed
10
+
11
+ - Persist confirmed Desktop send acceptance in the original owner-bound receipt so a later wait or MCP restart does not revert it to unconfirmed.
12
+ - Resolve exact peer dispatches from their original rollout watermark, including messages injected into an active Codex turn and completed replies followed by newer turns. Preserve sender, account, project and response-integrity checks, mark same-turn finals and retain exact empty memory-citation formatting.
13
+ - Include the advertised peer name in Claude's supported message envelope so the native recipient can identify the sender instead of showing an unnamed session. Codex Desktop's fixed sender badge remains controlled by the host; author headers identify the actual collaborator without impersonating user input.
14
+ - Follow verified native Claude compaction continuations when correlating an outstanding peer reply, while retaining interruption and identity checks.
15
+ - Return an unavailable reply at the observation deadline without losing accepted delivery state; continue rejecting identity and permission failures even when they occur after that deadline.
16
+
17
+ ### Verification
18
+
19
+ - The receipt fixes passed the full CI matrix on Windows, macOS and Linux with Node 22 and 24, including restart, compaction and deadline-boundary regressions.
20
+ - Live Desktop peer exchanges confirmed both directions, durable acceptance and recovery of a historical final response. Project authorization remained unchanged.
21
+
22
+ ### Upgrade notes
23
+
24
+ - A worker with an unresolved peer receipt can defer automatic reload. Verify the loaded version from the actual MCP after reconnecting; retain the original receipt and inspect the existing conversation before any follow-up.
25
+ - Native sender labels remain host-controlled. Claude can display the advertised peer name; Codex retains its native cross-chat label alongside the actual author header. This release does not add Cowork or Dispatch connectivity.
26
+
7
27
  ## [1.22.4] - 2026-10-19
8
28
 
9
29
  ### Fixed
@@ -45,6 +45,14 @@ message and does not establish a native sender label. If a handoff requires the
45
45
  recipient's peer sender attribution, use `send_to_claude_session` with a verified
46
46
  live task. Do not substitute composer submission or a text header for that route.
47
47
 
48
+ Peer messages include the bridge's advertised name through Claude's supported
49
+ `from-name` envelope field. Claude determines the native wording and verifies
50
+ the peer; the bridge does not set trusted-origin flags. Codex Desktop currently
51
+ uses its own fixed `Sent by ... from another chat` label and exposes no sender-name
52
+ override. Keep the actual `[From Claude ...]` or `[From Codex ...]` author header
53
+ in the message. A body header does not rename a native badge, and applications'
54
+ private data must not be patched to make that claim.
55
+
48
56
  If a link was already opened, retain its requestId and inspect its receipt.
49
57
  `promptSubmitted=false` with `submissionStatus=not_observed` means submission
50
58
  was not verified, not that nothing was delivered. A wrong-directory task can
@@ -68,6 +76,12 @@ new task, account, process, prompt and directory to match.
68
76
  deliveryId. For Codex-to-Claude, retain the original msgId and inspect its
69
77
  receipt. An unconfirmed acknowledgement is not proof of delivery; observe
70
78
  the original receipt instead of resending an uncertain task.
79
+ Confirmed native acceptance is retained across reconnects. A message injected
80
+ into an active Codex turn can return that turn's final answer with
81
+ `responseScope: same_turn_final`; it may cover other peer messages in that
82
+ turn. The exact dispatch must occur after the saved watermark, and commentary
83
+ alone is never a final reply. Receipt observation resolves that exact dispatch
84
+ even after newer turns start; it does not substitute the latest turn's answer.
71
85
  5. An `interrupted` receipt ends observation only: task outcome is unknown and
72
86
  background work may continue. Inspect the existing task before a scoped
73
87
  follow-up; do not repeat card creation or file operations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.4",
3
+ "version": "1.22.5",
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",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.22.4",
4
+ "version": "1.22.5",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -23,7 +23,7 @@ import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-acce
23
23
 
24
24
  exitForVersionRequest(import.meta.url);
25
25
 
26
- const VERSION = "1.22.4";
26
+ const VERSION = "1.22.5";
27
27
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
28
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
29
29
  const entryArgs = ["native-relay-companion.mjs"];
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.22.4";
33
+ const VERSION = "1.22.5";
34
34
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
35
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
36
  const FORWARD_MAX_PER_SESSION = 50;
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
  import { scanRollout } from "./rollout-reader.mjs";
6
7
  import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
7
8
 
@@ -81,20 +82,22 @@ function samePathBoundary(expected, current) {
81
82
  entry.path === current.ancestors[index].path && entry.dev === current.ancestors[index].dev && entry.ino === current.ancestors[index].ino);
82
83
  }
83
84
 
84
- function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize } = {}) {
85
+ function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize, dispatchOutputs, dispatchAfterOffset = afterOffset } = {}) {
85
86
  for (let attempt = 0; ; attempt++) {
86
- try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }); }
87
+ try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }); }
87
88
  catch (error) {
88
89
  if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
89
90
  }
90
91
  }
91
92
  }
92
93
 
93
- function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }) {
94
+ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
94
95
  const { file, sessions } = found;
95
96
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ROLLOUT_BYTES) throw new Error("The Codex rollout read limit is invalid");
96
97
  const beforeBoundary = capturePathBoundary(file, sessions);
97
98
  const records = [];
99
+ const dispatches = [];
100
+ const otherTurnStarts = [];
98
101
  const digest = crypto.createHash("sha256");
99
102
  let session, latestContext, retainedBytes = 0, turnBeforeWatermark = false;
100
103
  const snapshot = scanRollout(file, {
@@ -111,6 +114,19 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
111
114
  }
112
115
  if (record.type === "turn_context") latestContext = record.payload;
113
116
  const entry = { record, start };
117
+ if (start >= dispatchAfterOffset && dispatchOutputs && record.type === "response_item" && record.payload.type === "function_call_output" &&
118
+ record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread" && dispatchOutputs.includes(record.payload.output)) {
119
+ const metadata = record.payload.internal_chat_message_metadata_passthrough;
120
+ const selectedTurnId = record.payload.turn_id ?? record.payload.root_turn_id ?? metadata?.turn_id ?? metadata?.root_turn_id;
121
+ if (!UUID.test(selectedTurnId) || !recordBelongsToTurn(entry, threadId, selectedTurnId)) throw new Error("The native dispatch has no consistent turn identity");
122
+ dispatches.push({ turnId: selectedTurnId, start });
123
+ if (dispatches.length > 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
124
+ }
125
+ if (turnId && start >= afterOffset && (record.type === "turn_context" || record.type === "event_msg" && STARTED.has(record.payload.type)) &&
126
+ !recordBelongsToTurn(entry, threadId, turnId)) {
127
+ otherTurnStarts.push(start);
128
+ if (otherTurnStarts.length > 65536) throw new Error("The native turn boundary count exceeds its read limit");
129
+ }
114
130
  if (turnId && recordBelongsToTurn(entry, threadId, turnId)) {
115
131
  if (start < afterOffset) { turnBeforeWatermark = true; return; }
116
132
  retainedBytes += bytes;
@@ -123,7 +139,7 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
123
139
  if (!samePathBoundary(beforeBoundary, afterBoundary) || snapshot.identity.dev !== beforeBoundary.file.dev || snapshot.identity.ino !== beforeBoundary.file.ino) {
124
140
  throw new Error("The Codex rollout changed while reading");
125
141
  }
126
- return { ...snapshot, file, sessions, records, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
142
+ return { ...snapshot, file, sessions, records, dispatches, otherTurnStarts, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
127
143
  }
128
144
 
129
145
  function canonicalDirectory(value, label) {
@@ -209,10 +225,11 @@ function completedMemoryCitation(item) {
209
225
  !Number.isSafeInteger(entry.lineStart) || entry.lineStart < 1 || !Number.isSafeInteger(entry.lineEnd) || entry.lineEnd < entry.lineStart) ||
210
226
  citation.rolloutIds.some((id) => typeof id !== "string" || !UUID.test(id))) return null;
211
227
  const entries = citation.entries.map((entry) => `${entry.path}:${entry.lineStart}-${entry.lineEnd}|note=[${entry.note}]`).join("\n");
212
- return `<oai-mem-citation>\n<citation_entries>\n${entries}\n</citation_entries>\n<rollout_ids>\n${citation.rolloutIds.join("\n")}\n</rollout_ids>\n</oai-mem-citation>`;
228
+ const rolloutIds = citation.rolloutIds.join("\n");
229
+ return `<oai-mem-citation>\n<citation_entries>\n${entries ? `${entries}\n` : ""}</citation_entries>\n<rollout_ids>\n${rolloutIds ? `${rolloutIds}\n` : ""}</rollout_ids>\n</oai-mem-citation>`;
213
230
  }
214
231
 
215
- function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch } = {}) {
232
+ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch, otherTurnStarts = [] } = {}) {
216
233
  const turns = records.filter((entry) => recordBelongsToTurn(entry, threadId, turnId));
217
234
  const starts = turns.filter(({ record }) => record.type === "event_msg" && STARTED.has(record.payload.type));
218
235
  const contexts = turns.filter(({ record }) => record.type === "turn_context");
@@ -222,26 +239,39 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
222
239
  const lifecycleError = (check, fields) => validationError("The native turn lifecycle is missing, ambiguous, failed, or out of order", check, fields);
223
240
  if (failures.length) throw lifecycleError("lifecycle_failure", { count: failures.length, eventBytes: recordOffsets(failures) });
224
241
  for (const [kind, entries] of [["start", starts], ["context", contexts], ["completion", completions]]) {
225
- if (entries.length !== 1) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
242
+ if (entries.length !== 1 && (kind !== "context" || !entries.length)) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
243
+ }
244
+ if (contexts.some((entry) => !isDeepStrictEqual(entry.record.payload, contexts[0].record.payload))) {
245
+ throw lifecycleError("lifecycle_context_changed", { count: contexts.length, recordBytes: recordOffsets(contexts) });
226
246
  }
227
247
  if (canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd) {
228
248
  throw lifecycleError("lifecycle_workspace", { contextByte: contexts[0].start });
229
249
  }
230
- if (starts[0].start >= contexts[0].start || contexts[0].start >= completions[0].start) {
250
+ if (contexts.some((entry) => starts[0].start >= entry.start || entry.start >= completions[0].start)) {
231
251
  throw lifecycleError("lifecycle_order", { startByte: starts[0].start, contextByte: contexts[0].start, completionByte: completions[0].start });
232
252
  }
233
253
 
234
254
  let dispatch = null;
235
255
  if (requireDispatch) {
236
256
  const dispatches = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "function_call_output" &&
237
- record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread"));
238
- if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
257
+ record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread") &&
258
+ (!requireDispatch.existingTurn || requireDispatch.outputs.includes(record.payload.output)));
259
+ if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) || dispatches[0].start < (requireDispatch.afterOffset ?? 0) ||
260
+ (requireDispatch.existingTurn && contexts[0].start >= requireDispatch.afterOffset) ||
239
261
  contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
240
262
  throw validationError("The newly observed turn is not correlated to the exact native dispatch", "dispatch_correlation", {
241
263
  count: dispatches.length, dispatchBytes: recordOffsets(dispatches), contextByte: contexts[0].start, completionByte: completions[0].start,
242
264
  });
243
265
  }
244
266
  dispatch = dispatches[0];
267
+ if (otherTurnStarts.some((start) => start > dispatch.start && start < completions[0].start)) {
268
+ throw validationError("Another native turn superseded the dispatch before completion", "dispatch_superseded");
269
+ }
270
+ if (requireDispatch.existingTurn && turns.some(({ record, start }) => start > dispatch.start &&
271
+ (record.type === "response_item" && record.payload.type === "message" && record.payload.role === "user" ||
272
+ record.type === "event_msg" && record.payload.type === "user_message"))) {
273
+ throw validationError("New user input superseded the native dispatch in the active turn", "dispatch_superseded");
274
+ }
245
275
  }
246
276
 
247
277
  const allAssistantEntries = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "message" && record.payload.role === "assistant");
@@ -378,10 +408,52 @@ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd, desktopE
378
408
  }
379
409
  }
380
410
 
411
+ export function resolveCodexNativeDispatchResponse(binding, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
412
+ const { threadId, expectedCwd, executorThreadId, prompt, watermark, desktopEvidence } = binding;
413
+ let turnId = null;
414
+ try {
415
+ if (!UUID.test(threadId) || !UUID.test(executorThreadId) || typeof prompt !== "string" || !prompt.length) throw new Error("The native dispatch identity is invalid");
416
+ if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
417
+ const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
418
+ if (watermark.cwd !== cwd) throw new Error("The selected native task workspace changed after dispatch");
419
+ const marks = watermark.segments ?? [watermark];
420
+ if (!Array.isArray(marks) || !marks.length || marks.length > 64 || new Set(marks.map((mark) => mark.file)).size !== marks.length ||
421
+ !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256) ||
422
+ marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
423
+ const found = findRollouts(threadId, env);
424
+ if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
425
+ const outputs = delegationOutputs(executorThreadId, prompt);
426
+ const snapshots = found.map((entry) => {
427
+ const mark = marks.find(({ file }) => file === entry.file);
428
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
429
+ if (mark) {
430
+ if (snapshot.identity.dev !== mark.identity?.dev || snapshot.identity.ino !== mark.identity?.ino || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
431
+ if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
432
+ }
433
+ validateIdentity(snapshot, threadId, cwd, desktopEvidence);
434
+ return snapshot;
435
+ });
436
+ assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
437
+ const matches = snapshots.flatMap((snapshot) => snapshot.dispatches.map((dispatch) => ({ snapshot, dispatch })));
438
+ if (!matches.length) return { status: "pending", threadId, turnId: null, text: "", assistantItems: [], replySha256: null };
439
+ if (matches.length !== 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
440
+ validateSession(matches[0].snapshot, threadId, cwd, desktopEvidence);
441
+ turnId = matches[0].dispatch.turnId;
442
+ const result = readCodexNativeTurnResponse({ ...binding, turnId }, { env, maxRolloutBytes });
443
+ if (result.status === "unavailable" && result.dispatchObserved && /check=lifecycle_completion_count; count=0;/.test(result.reason)) {
444
+ return { status: "pending", threadId, turnId, dispatchObserved: true, text: "", assistantItems: [], replySha256: null };
445
+ }
446
+ return result;
447
+ } catch (error) {
448
+ return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
449
+ }
450
+ }
451
+
381
452
  export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId, expectedCwd, executorThreadId, prompt, watermark, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
453
+ let dispatchObserved = false;
382
454
  try {
383
455
  if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native response identity is invalid");
384
- if (turnId === previousTurnId) throw new Error("The observed turn was already present before dispatch");
456
+ const existingTurn = turnId === previousTurnId;
385
457
  if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact dispatched prompt is unavailable");
386
458
  if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
387
459
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
@@ -393,10 +465,11 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
393
465
  for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
394
466
  const found = findRollouts(threadId, env);
395
467
  if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
468
+ const outputs = delegationOutputs(executorThreadId, prompt);
396
469
  let retainedBytes = 0;
397
470
  const snapshots = found.map((entry) => {
398
471
  const mark = marks.find(({ file }) => file === entry.file);
399
- const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size });
472
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
400
473
  retainedBytes += snapshot.retainedBytes;
401
474
  if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
402
475
  if (mark) {
@@ -409,12 +482,20 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
409
482
  });
410
483
  assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
411
484
  const selected = uniqueTurn(snapshots);
485
+ const matchedDispatches = snapshots.flatMap((snapshot) => snapshot.dispatches);
486
+ if (matchedDispatches.length !== 1 || matchedDispatches[0].turnId !== turnId) throw new Error("The observed turn is not uniquely correlated to the exact native dispatch");
412
487
  validateSession(selected, threadId, cwd, desktopEvidence);
488
+ const selectedMark = marks.find(({ file }) => file === selected.file);
489
+ if (existingTurn && !selectedMark) throw new Error("The active native turn has no pre-send rollout watermark");
490
+ dispatchObserved = selected.records.some(({ record, start }) => start >= (selectedMark?.size ?? 0) &&
491
+ record.type === "response_item" && record.payload.type === "function_call_output" &&
492
+ record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread" && outputs.includes(record.payload.output));
413
493
  const inspected = inspectTurnRecords(selected.records, threadId, turnId, cwd, {
414
- requireDispatch: { outputs: delegationOutputs(executorThreadId, prompt) },
494
+ requireDispatch: { outputs, afterOffset: selectedMark?.size ?? 0, existingTurn },
495
+ otherTurnStarts: selected.otherTurnStarts,
415
496
  });
416
- return { ...inspected, observationStatus: inspected.status };
497
+ return { ...inspected, observationStatus: inspected.status, ...(existingTurn ? { responseScope: "same_turn_final" } : {}) };
417
498
  } catch (error) {
418
- return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
499
+ return { ...unavailable(error), ...(dispatchObserved ? { dispatchObserved: true } : {}), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
419
500
  }
420
501
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { isDeepStrictEqual } from "node:util";
4
5
  import { creationRequestHash, creationIdValid } from "./native-creation-receipts.mjs";
5
6
  import { homeDir } from "./platform.mjs";
6
7
 
@@ -52,6 +53,33 @@ export class DesktopReplyReceipts {
52
53
  return id;
53
54
  }
54
55
 
56
+ acknowledge(id, delivered, context) {
57
+ const previous = this.read(id, context);
58
+ const acknowledgement = JSON.parse(JSON.stringify(delivered));
59
+ if (acknowledgement.deliveryStatus !== "accepted" || previous.creationObservation ||
60
+ !["unconfirmed", "accepted"].includes(previous.deliveryStatus) ||
61
+ !isDeepStrictEqual({ ...acknowledgement, deliveryStatus: previous.deliveryStatus }, previous)) {
62
+ throw new Error("Delivery acknowledgement does not match the original reply binding");
63
+ }
64
+ if (previous.deliveryStatus === "accepted") return;
65
+ const file = this.file(id);
66
+ const bytes = Buffer.from(JSON.stringify({ version: 1, deliveryId: id, owner: owner(context), delivered: acknowledgement }));
67
+ if (bytes.length > LIMIT) throw new Error("Reply observation binding exceeds the receipt limit");
68
+ const temporary = `${file}.${randomUUID()}.pending`;
69
+ const fd = fs.openSync(temporary, "wx", 0o600);
70
+ try {
71
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); }
72
+ finally { fs.closeSync(fd); }
73
+ if (!isDeepStrictEqual(this.read(id, context), previous) || this.file(id) !== file) {
74
+ throw new Error("Reply receipt changed before acknowledgement");
75
+ }
76
+ fs.renameSync(temporary, file);
77
+ } finally {
78
+ try { fs.unlinkSync(temporary); }
79
+ catch (error) { if (error.code !== "ENOENT") throw error; }
80
+ }
81
+ }
82
+
55
83
  read(id, context) {
56
84
  const file = this.file(id);
57
85
  const before = fs.lstatSync(file);
package/src/index.mjs CHANGED
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
40
40
 
41
41
  exitForVersionRequest(import.meta.url);
42
42
 
43
- const VERSION = "1.22.4";
43
+ const VERSION = "1.22.5";
44
44
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
45
45
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
46
46
 
@@ -151,11 +151,12 @@ const failure = (err) => {
151
151
 
152
152
  function replyResult(delivered, deliveryId, result, notes = []) {
153
153
  result = { activity: [], errors: [], ...result };
154
+ const deliveryStatus = ["completed", "completed_no_reply"].includes(result.responseStatus) ? "accepted" : delivered.deliveryStatus ?? "accepted";
154
155
  const pending = result.status === "timeout";
155
156
  const continuation = deliveryId ? `deliveryId: ${deliveryId}\n${pending ? "Continue automatically with wait_codex_reply and this deliveryId; it only observes the original send. Do not resend the prompt." : "Keep this deliveryId to inspect the same send after reconnecting."}` : "";
156
157
  return {
157
- ...textResult([...notes, formatTurn(result, { desktop: true }), continuation].filter(Boolean).join("\n"), result.status === "failed" || result.status === "systemError"),
158
- structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus: result.responseStatus === "completed" || result.responseStatus === "completed_no_reply" ? "accepted" : delivered.deliveryStatus ?? "accepted", ...(deliveryId ? { deliveryId } : {}), nextAction: pending && deliveryId ? "wait_codex_reply" : "none" },
158
+ ...textResult([...notes, `deliveryStatus: ${deliveryStatus}`, formatTurn(result, { desktop: true }), continuation].filter(Boolean).join("\n"), result.status === "failed" || result.status === "systemError"),
159
+ structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus, ...(deliveryId ? { deliveryId } : {}), nextAction: pending && deliveryId ? "wait_codex_reply" : "none" },
159
160
  };
160
161
  }
161
162
 
@@ -625,6 +626,7 @@ registerTool(
625
626
  acceptedDelivery = prepared;
626
627
  } });
627
628
  acceptedDelivery = delivered;
629
+ if (delivered.deliveryStatus === "accepted") replyReceipts.acknowledge(deliveryId, delivered, desktopOperation.getStore());
628
630
  notes.push(delivered.deliveryStatus === "unconfirmed" ? "Desktop delivery acknowledgement timed out; delivery is unconfirmed. Continue observing this receipt; do not resend." : "sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
629
631
  if (delivered.deliveryStatus === "unconfirmed") return replyResult(delivered, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" }, notes);
630
632
  if (shouldOpen) {
@@ -694,7 +696,7 @@ registerTool(
694
696
  const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
695
697
  return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
696
698
  } catch (err) {
697
- if (acceptedDelivery?.deliveryStatus === "accepted") return textResult(`threadId: ${threadId}\nTask was accepted, but its reply receipt could not be prepared: ${err.message}. Inspect the existing task; do not resend.`, true);
699
+ if (acceptedDelivery?.deliveryStatus === "accepted") return textResult(`threadId: ${threadId}\ndeliveryId: ${deliveryId}\nTask was accepted, but its reply receipt could not be updated: ${err.message}. Inspect the existing task; do not resend.`, true);
698
700
  return failure(err);
699
701
  }
700
702
  }, {
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
38
38
 
39
39
  exitForVersionRequest(import.meta.url);
40
40
 
41
- const VERSION = "1.22.4";
41
+ const VERSION = "1.22.5";
42
42
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
43
43
 
44
44
  function errorResponse(code, message, sent, details) {
@@ -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";
@@ -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
  }