@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/docs/daily-handoffs.md +14 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/codex-native-response.mjs +96 -15
- package/src/desktop-reply-receipts.mjs +28 -0
- package/src/index.mjs +6 -4
- package/src/native-relay-companion.mjs +1 -1
- package/src/peer-protocol.mjs +51 -7
- package/src/thread-delivery.mjs +59 -14
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
|
package/docs/daily-handoffs.md
CHANGED
|
@@ -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
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
|
+
"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.
|
|
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"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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/thread-delivery.mjs
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
518
|
-
|
|
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 (
|
|
521
|
-
|
|
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
|
|
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
|
}
|