@minhspark/codex-mcp-bridge 1.22.4 → 1.22.6
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 +44 -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 +36 -6
- package/src/native-relay-companion.mjs +1 -1
- package/src/peer-protocol.mjs +51 -7
- package/src/thread-delivery.mjs +163 -32
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.22.6] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Keep a confirmed Desktop creation identifiable when post-creation verification fails. `delegate_to_codex` and `start_codex_thread` now return the task's threadId, its deliveryId and "Task was accepted; do not resend" instead of a bare allowed-roots error that read as if nothing had been created. The confirmed threadId is kept even when a later account, receipt or scope check fails after the bridge waited for the task's workspace.
|
|
12
|
+
- Treat a filesystem root that Codex Desktop briefly reports as a newly created task's workspace, observed as `/` right after creation following an account switch, as "workspace not yet assigned" when it is not the expected project. Creation keeps observing until its deadline and then returns a pending receipt that keeps the accepted threadId; reply observation keeps polling. The tolerance lasts at most 120 seconds from creation, or from the first such report during reply observation, and the bridge acts on nothing at that path and grants no task access from it during that time.
|
|
13
|
+
- Report a pending creation whose task Desktop already confirmed as `deliveryStatus: accepted` instead of `unconfirmed`.
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
- A root report that outlasts the grace period, any other workspace outside the allowed roots, a different project, an account change or a sender mismatch still fails closed. Sends and plain thread inspection keep the normal allowed-roots refusal for a root workspace; nothing is sent.
|
|
18
|
+
|
|
19
|
+
### Verification
|
|
20
|
+
|
|
21
|
+
- 10 regression tests reproduce the defect on v1.22.5 and pass with the fix: creation confirmation with a brief `/` report, a pending result at the deadline, grace expiry, an outside-root workspace, a later account change, reply observation in both the wait and snapshot paths, and two MCP-level `delegate_to_codex` tests through the relay companion.
|
|
22
|
+
- The full CI matrix passed on Windows, macOS and Linux with Node 22 and 24. A local run outside the sandbox passed 1406 tests with 0 failures.
|
|
23
|
+
- An independent adversarial review found no security regression; its four confirmed correctness findings were fixed before merge.
|
|
24
|
+
|
|
25
|
+
### Upgrade notes
|
|
26
|
+
|
|
27
|
+
- Reconnect the MCP server after updating and confirm the loaded version from the actual runtime before relying on the fix.
|
|
28
|
+
- A creation that is pending because Desktop has not reported its workspace continues with `wait_codex_reply` and the returned deliveryId. Do not resend the prompt or create the task again.
|
|
29
|
+
- Not yet exercised against a live Desktop account switch. A placeholder workspace other than a filesystem root still fails closed as before.
|
|
30
|
+
|
|
31
|
+
## [1.22.5] - 2026-10-19
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Persist confirmed Desktop send acceptance in the original owner-bound receipt so a later wait or MCP restart does not revert it to unconfirmed.
|
|
36
|
+
- 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.
|
|
37
|
+
- 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.
|
|
38
|
+
- Follow verified native Claude compaction continuations when correlating an outstanding peer reply, while retaining interruption and identity checks.
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
41
|
+
### Verification
|
|
42
|
+
|
|
43
|
+
- 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.
|
|
44
|
+
- Live Desktop peer exchanges confirmed both directions, durable acceptance and recovery of a historical final response. Project authorization remained unchanged.
|
|
45
|
+
|
|
46
|
+
### Upgrade notes
|
|
47
|
+
|
|
48
|
+
- 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.
|
|
49
|
+
- 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.
|
|
50
|
+
|
|
7
51
|
## [1.22.4] - 2026-10-19
|
|
8
52
|
|
|
9
53
|
### 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.6",
|
|
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.6";
|
|
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.6";
|
|
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.6";
|
|
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
|
|
|
@@ -210,6 +211,25 @@ function threadNameFor({ cwd, prompt, name }) {
|
|
|
210
211
|
);
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Once a Desktop creation was dispatched, a later verification failure must
|
|
216
|
+
* never read like a refusal: the caller keeps the task and receipt identity
|
|
217
|
+
* and is told not to resend. Verification still fails closed; only the report
|
|
218
|
+
* changes.
|
|
219
|
+
*/
|
|
220
|
+
function creationVerificationFailure({ created, deliveryId, requestId, err }) {
|
|
221
|
+
const threadId = created.threadId ?? err?.threadId ?? null;
|
|
222
|
+
const lines = threadId
|
|
223
|
+
? ["Codex Desktop accepted the task, but post-creation verification failed.", `threadId: ${threadId}`]
|
|
224
|
+
: ["Codex Desktop creation was dispatched, but its outcome is not yet confirmed."];
|
|
225
|
+
lines.push(...(requestId ? [`requestId: ${requestId}`] : []), `cwd: ${created.cwd}`, ...(deliveryId ? [`deliveryId: ${deliveryId}`] : []), `error: ${err?.message ?? String(err)}`,
|
|
226
|
+
threadId ? "Task was accepted; do not resend. Resolve the verification error, then continue observing with wait_codex_reply and this deliveryId." : "Do not resend or create again. Continue observing with wait_codex_reply and this deliveryId.");
|
|
227
|
+
return {
|
|
228
|
+
...textResult(lines.join("\n"), true),
|
|
229
|
+
structuredContent: { ...failure(err).structuredContent, threadId, deliveryStatus: threadId ? "accepted" : "unconfirmed", ...(deliveryId ? { deliveryId } : {}), nextAction: deliveryId ? "wait_codex_reply" : "none" },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
213
233
|
async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp, waitForReply = true }) {
|
|
214
234
|
const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
|
|
215
235
|
const workspace = resolveWorkspacePath(cwd);
|
|
@@ -221,8 +241,17 @@ async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort
|
|
|
221
241
|
const deliveryId = created.deliveryId;
|
|
222
242
|
let delivered = deliveryId ? replyReceipts.read(deliveryId, desktopOperation.getStore()) : null;
|
|
223
243
|
if (delivered?.creationObservation) {
|
|
224
|
-
|
|
225
|
-
|
|
244
|
+
let resolved;
|
|
245
|
+
try {
|
|
246
|
+
resolved = await desktopTasks.resolveCreation(delivered, { deadline });
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return creationVerificationFailure({ created, deliveryId, requestId, err });
|
|
249
|
+
}
|
|
250
|
+
if (!resolved) {
|
|
251
|
+
const threadId = created.threadId ?? null;
|
|
252
|
+
return replyResult({ ...delivered, threadId, ...(threadId ? { deliveryStatus: "accepted" } : {}) }, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" },
|
|
253
|
+
[...(threadId ? [`threadId: ${threadId}`, "Task was accepted; Codex Desktop has not yet confirmed its workspace or creation receipt."] : []), "Creation acknowledgement pending; do not create again."]);
|
|
254
|
+
}
|
|
226
255
|
delivered = resolved;
|
|
227
256
|
created.threadId = resolved.threadId;
|
|
228
257
|
}
|
|
@@ -625,6 +654,7 @@ registerTool(
|
|
|
625
654
|
acceptedDelivery = prepared;
|
|
626
655
|
} });
|
|
627
656
|
acceptedDelivery = delivered;
|
|
657
|
+
if (delivered.deliveryStatus === "accepted") replyReceipts.acknowledge(deliveryId, delivered, desktopOperation.getStore());
|
|
628
658
|
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
659
|
if (delivered.deliveryStatus === "unconfirmed") return replyResult(delivered, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" }, notes);
|
|
630
660
|
if (shouldOpen) {
|
|
@@ -694,7 +724,7 @@ registerTool(
|
|
|
694
724
|
const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
|
|
695
725
|
return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
|
|
696
726
|
} catch (err) {
|
|
697
|
-
if (acceptedDelivery?.deliveryStatus === "accepted") return textResult(`threadId: ${threadId}\nTask was accepted, but its reply receipt could not be
|
|
727
|
+
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
728
|
return failure(err);
|
|
699
729
|
}
|
|
700
730
|
}, {
|
|
@@ -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.6";
|
|
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
|
|
|
@@ -26,6 +26,8 @@ import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
|
26
26
|
export const NATIVE_BACKEND = "codex-desktop-native";
|
|
27
27
|
export const APP_SERVER_BACKEND = "app-server";
|
|
28
28
|
export const DESKTOP_TOOL_BUDGET_MS = 40000;
|
|
29
|
+
export const WORKSPACE_UNASSIGNED = "DESKTOP_WORKSPACE_UNASSIGNED";
|
|
30
|
+
export const UNASSIGNED_WORKSPACE_GRACE_MS = 120000;
|
|
29
31
|
const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
30
32
|
const METADATA_READ_OPTIONS = { includeOutputs: false, maxOutputCharsPerItem: 1 };
|
|
31
33
|
|
|
@@ -35,6 +37,17 @@ function unavailableWaitRenderer(error) {
|
|
|
35
37
|
/^(Codex app tool request failed\.?|The app tool call ended before producing a result\.?)$/.test(cause.message);
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
function isFilesystemRoot(value) {
|
|
41
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) return false;
|
|
42
|
+
const resolved = path.resolve(value);
|
|
43
|
+
return path.parse(resolved).root === resolved;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function acceptedCreationError(threadId, error) {
|
|
47
|
+
if (error.acceptedCreation) return error;
|
|
48
|
+
return Object.assign(new Error(`Codex Desktop created task ${threadId}, but its post-creation verification failed: ${error.message}. threadId: ${threadId}. Task was accepted; do not resend. Its reply was withheld until the verification error is resolved.`, { cause: error }), { code: "DESKTOP_CREATION_VERIFICATION_FAILED", threadId, acceptedCreation: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
function sameAccountContext(expected, current) {
|
|
39
52
|
return Boolean(expected && current && ["claude", "codex"].every((provider) =>
|
|
40
53
|
typeof expected[provider] === "string" && expected[provider] === current[provider]));
|
|
@@ -59,7 +72,7 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
|
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
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 } = {}) {
|
|
75
|
+
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
76
|
this.relay = relay;
|
|
64
77
|
this.security = security;
|
|
65
78
|
this.sleep = sleep;
|
|
@@ -71,9 +84,12 @@ export class DesktopTaskDelivery {
|
|
|
71
84
|
this.captureResponse = captureResponse;
|
|
72
85
|
this.readResponse = readResponse;
|
|
73
86
|
this.inspectResponse = inspectResponse;
|
|
87
|
+
this.resolveResponse = resolveResponse;
|
|
74
88
|
this.threadOperations = new Map();
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
#unassignedSince = new Map();
|
|
92
|
+
|
|
77
93
|
senderProjectScope(cwd) {
|
|
78
94
|
if (!this.senderContext) return null;
|
|
79
95
|
const sender = requireClaudeSenderContext(this.senderContext());
|
|
@@ -101,7 +117,7 @@ export class DesktopTaskDelivery {
|
|
|
101
117
|
const projectScope = expectedScope ?? await this.mutationProjectScope(operation, args, { deadline });
|
|
102
118
|
if (expectedScope) recheckProjectScope(expectedScope, this.senderProjectScope(expectedScope.recipient.input));
|
|
103
119
|
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");
|
|
120
|
+
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
121
|
const accountContext = this.accountContext?.();
|
|
106
122
|
const response = await this.relay.requestDesktop(operation, args, {
|
|
107
123
|
...(remaining === undefined ? {} : { timeoutMs: remaining }),
|
|
@@ -328,6 +344,15 @@ export class DesktopTaskDelivery {
|
|
|
328
344
|
async resolveCreation(delivered, { deadline = this.now() + DESKTOP_TOOL_BUDGET_MS } = {}) {
|
|
329
345
|
const binding = delivered.creationObservation;
|
|
330
346
|
if (!binding) return delivered;
|
|
347
|
+
let confirmedThreadId = null;
|
|
348
|
+
try {
|
|
349
|
+
return await this.#resolveCreationLoop(delivered, binding, deadline, (threadId) => { confirmedThreadId = threadId; });
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw confirmedThreadId ? acceptedCreationError(confirmedThreadId, error) : error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async #resolveCreationLoop(delivered, binding, deadline, onConfirmed) {
|
|
331
356
|
for (;;) {
|
|
332
357
|
if (this.now() >= deadline) return null;
|
|
333
358
|
this.security.assertCwd(delivered.cwd);
|
|
@@ -344,17 +369,29 @@ export class DesktopTaskDelivery {
|
|
|
344
369
|
const response = row.result, threadId = response?.threadId;
|
|
345
370
|
if (typeof threadId !== "string" || !threadId.trim() || response.hostId !== "local" || response.status === "outcome-unknown" ||
|
|
346
371
|
response.firstTurn && response.firstTurn.status !== "accepted" || receipt.threadId && receipt.threadId !== threadId) throw new Error("Native creation is not confirmed; do not resend");
|
|
372
|
+
onConfirmed(threadId);
|
|
347
373
|
let inspected;
|
|
348
|
-
try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline }); }
|
|
349
|
-
catch (error) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
374
|
+
try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline, tolerance: { since: receipt.startedAt } }); }
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (error.code === WORKSPACE_UNASSIGNED) {
|
|
377
|
+
await this.sleep(Math.min(1000, Math.max(0, deadline - this.now())));
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null;
|
|
381
|
+
throw acceptedCreationError(threadId, error);
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
|
|
385
|
+
if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
|
|
386
|
+
this.security.assertCwd(delivered.cwd);
|
|
387
|
+
this.senderProjectScope(delivered.cwd);
|
|
388
|
+
if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
|
|
389
|
+
if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
|
|
390
|
+
this.security.registerThread(threadId);
|
|
391
|
+
this.security.assertThread(threadId, inspected.thread.cwd);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
throw acceptedCreationError(threadId, error);
|
|
394
|
+
}
|
|
358
395
|
return { threadId, cwd: delivered.cwd, previousTurnId: null, deliveryStatus: "accepted",
|
|
359
396
|
responseObservation: { operation: "create_thread", threadId, previousTurnId: null, expectedCwd: delivered.cwd,
|
|
360
397
|
executorThreadId: envelope.executorThreadId, prompt: binding.args.prompt, accountContext: binding.accountContext } };
|
|
@@ -365,18 +402,18 @@ export class DesktopTaskDelivery {
|
|
|
365
402
|
}
|
|
366
403
|
}
|
|
367
404
|
|
|
368
|
-
async inspect(threadId, cwd, { deadline } = {}) {
|
|
369
|
-
const inspected = await this.#inspectMetadata(threadId, cwd, { deadline });
|
|
405
|
+
async inspect(threadId, cwd, { deadline, tolerateUnassigned = false } = {}) {
|
|
406
|
+
const inspected = await this.#inspectMetadata(threadId, cwd, { deadline, ...(tolerateUnassigned ? { tolerance: {} } : {}) });
|
|
370
407
|
this.security.assertThread(threadId, inspected.thread.cwd);
|
|
371
408
|
return inspected;
|
|
372
409
|
}
|
|
373
410
|
|
|
374
|
-
async #inspectMetadata(threadId, cwd, { deadline } = {}) {
|
|
411
|
+
async #inspectMetadata(threadId, cwd, { deadline, tolerance } = {}) {
|
|
375
412
|
const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline, includeRelayContext: true });
|
|
376
413
|
const response = envelope.result;
|
|
377
414
|
const thread = response?.thread;
|
|
378
415
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
379
|
-
this
|
|
416
|
+
this.#assertReportedWorkspace(threadId, thread.cwd, cwd, tolerance);
|
|
380
417
|
const projectScope = this.senderProjectScope(thread.cwd);
|
|
381
418
|
if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
|
|
382
419
|
throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
|
|
@@ -385,6 +422,39 @@ export class DesktopTaskDelivery {
|
|
|
385
422
|
return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope, scopeBinding, executorThreadId: envelope.executorThreadId ?? null };
|
|
386
423
|
}
|
|
387
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Codex Desktop can briefly report a filesystem root as a task's workspace
|
|
427
|
+
* before it finishes assigning the task's saved project (observed as "/"
|
|
428
|
+
* right after creation, following an account switch). Only observers that
|
|
429
|
+
* pass a tolerance - creation confirmation and reply observation, both of
|
|
430
|
+
* which already know the expected workspace - treat a root that differs from
|
|
431
|
+
* that workspace as "not yet assigned", and only for a bounded grace period
|
|
432
|
+
* measured from creation or from the first such report. During it the bridge
|
|
433
|
+
* acts on nothing at that path, grants no access and keeps observing. After
|
|
434
|
+
* it, and for every other caller or path, the normal fail-closed
|
|
435
|
+
* allowed-roots check applies.
|
|
436
|
+
*/
|
|
437
|
+
#assertReportedWorkspace(threadId, reported, expected, tolerance) {
|
|
438
|
+
if (!isFilesystemRoot(reported)) this.#unassignedSince.delete(threadId);
|
|
439
|
+
else if (tolerance && expected && !this.#isExpectedWorkspace(reported, expected)) {
|
|
440
|
+
const now = this.now();
|
|
441
|
+
if (tolerance.since === undefined && !this.#unassignedSince.has(threadId)) this.#unassignedSince.set(threadId, now);
|
|
442
|
+
const since = tolerance.since ?? this.#unassignedSince.get(threadId);
|
|
443
|
+
if (now - since < UNASSIGNED_WORKSPACE_GRACE_MS) {
|
|
444
|
+
throw Object.assign(new Error(`Codex Desktop has not yet reported task ${threadId}'s project workspace (it currently reports ${reported}); the bridge did not act on that path.`), { code: WORKSPACE_UNASSIGNED, threadId });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
this.security.assertCwd(reported);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#isExpectedWorkspace(reported, expected) {
|
|
451
|
+
try {
|
|
452
|
+
return path.relative(realpathSync.native(expected), realpathSync.native(reported)) === "";
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
388
458
|
async send({ threadId, prompt, cwd, model, effort, name, deadline, onPrepared }) {
|
|
389
459
|
const inspected = await this.inspect(threadId, cwd, { deadline });
|
|
390
460
|
const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(inspected.thread.cwd) : undefined;
|
|
@@ -432,7 +502,7 @@ export class DesktopTaskDelivery {
|
|
|
432
502
|
if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
|
|
433
503
|
}
|
|
434
504
|
|
|
435
|
-
async observeNativeResponse(threadId, turnId, responseObservation, { deadline } = {}) {
|
|
505
|
+
async observeNativeResponse(threadId, turnId, responseObservation, { deadline, resolveDispatch = false } = {}) {
|
|
436
506
|
if (!responseObservation) return unavailableObservation("Codex Desktop completed the turn without exposing assistant response text or a pre-send observation binding.");
|
|
437
507
|
const { expectedCwd } = responseObservation;
|
|
438
508
|
const args = { threadId, hostId: "local", turnLimit: 1 };
|
|
@@ -444,13 +514,13 @@ export class DesktopTaskDelivery {
|
|
|
444
514
|
if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its response was being observed");
|
|
445
515
|
};
|
|
446
516
|
await recheck();
|
|
447
|
-
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;
|
|
517
|
+
const initial = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
|
|
518
|
+
if (!resolveDispatch && initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
|
|
519
|
+
const read = resolveDispatch ? this.resolveResponse : responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
|
|
450
520
|
const observed = read({ ...responseObservation, threadId, turnId, desktopEvidence: initial });
|
|
451
521
|
await recheck();
|
|
452
|
-
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");
|
|
522
|
+
const inspected = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
|
|
523
|
+
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
524
|
await recheck();
|
|
455
525
|
return observed;
|
|
456
526
|
}
|
|
@@ -484,13 +554,29 @@ export class DesktopTaskDelivery {
|
|
|
484
554
|
const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
|
|
485
555
|
const thread = result?.thread;
|
|
486
556
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the observed task's local workspace.");
|
|
487
|
-
this
|
|
557
|
+
this.#assertReportedWorkspace(threadId, thread.cwd, responseObservation?.expectedCwd, {});
|
|
488
558
|
this.security.assertThread(threadId, thread.cwd);
|
|
489
559
|
this.senderProjectScope(thread.cwd);
|
|
490
560
|
if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
|
|
491
561
|
return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
|
|
492
562
|
};
|
|
493
563
|
const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
564
|
+
const deadlineError = (error) => {
|
|
565
|
+
const cause = error.cause ?? error;
|
|
566
|
+
return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
|
|
567
|
+
cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
|
|
568
|
+
};
|
|
569
|
+
const unassigned = Symbol("workspace-unassigned");
|
|
570
|
+
const observe = async (selectedTurnId, resolveDispatch = false) => {
|
|
571
|
+
try {
|
|
572
|
+
return await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (deadlineError(error)) return null;
|
|
575
|
+
if (error.code === WORKSPACE_UNASSIGNED) return unassigned;
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
const pause = () => this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
494
580
|
for (;;) {
|
|
495
581
|
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
496
582
|
let response;
|
|
@@ -499,14 +585,20 @@ export class DesktopTaskDelivery {
|
|
|
499
585
|
targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
|
|
500
586
|
}, { deadline: startedAt + timeoutMs });
|
|
501
587
|
} catch (err) {
|
|
502
|
-
if (
|
|
588
|
+
if (deadlineError(err)) return expired();
|
|
589
|
+
if (err.code === WORKSPACE_UNASSIGNED) {
|
|
590
|
+
await pause();
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
503
593
|
if (readSnapshots || !unavailableWaitRenderer(err)) throw err;
|
|
504
594
|
readSnapshots = true;
|
|
505
595
|
try {
|
|
506
596
|
response = await readSnapshot();
|
|
507
597
|
} catch (readError) {
|
|
508
|
-
if (
|
|
509
|
-
throw readError;
|
|
598
|
+
if (deadlineError(readError)) return expired();
|
|
599
|
+
if (readError.code !== WORKSPACE_UNASSIGNED) throw readError;
|
|
600
|
+
await pause();
|
|
601
|
+
continue;
|
|
510
602
|
}
|
|
511
603
|
}
|
|
512
604
|
const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
|
|
@@ -514,15 +606,53 @@ export class DesktopTaskDelivery {
|
|
|
514
606
|
cursor = poll.cursor;
|
|
515
607
|
const turn = poll.latestTurn;
|
|
516
608
|
const threadStatus = typeof poll.thread.status === "string" ? poll.thread.status : poll.thread.status?.type;
|
|
517
|
-
|
|
518
|
-
|
|
609
|
+
const needsAttention = ["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus);
|
|
610
|
+
const attention = () => ({ threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
611
|
+
if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
|
|
612
|
+
const observed = await observe(null, true);
|
|
613
|
+
if (!observed) return expired();
|
|
614
|
+
if (observed === unassigned) {
|
|
615
|
+
await pause();
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
turnId = observed.turnId ?? null;
|
|
619
|
+
if (observed.status !== "pending") {
|
|
620
|
+
return {
|
|
621
|
+
threadId, turnId, status: observed.status === "unavailable" ? "failed" : "completed",
|
|
622
|
+
responseStatus: observed.status, observationStatus: observed.status,
|
|
623
|
+
text: observed.status === "completed" ? observed.text : "",
|
|
624
|
+
...(observed.reason ? { observationReason: observed.reason } : {}),
|
|
625
|
+
assistantItems: observed.assistantItems ?? [], replySha256: observed.replySha256 ?? null,
|
|
626
|
+
responseSource: observed.source ?? null,
|
|
627
|
+
...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
|
|
628
|
+
activity: [], errors: [], durationMs: this.now() - startedAt,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
if (needsAttention) return attention();
|
|
632
|
+
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
633
|
+
await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
634
|
+
continue;
|
|
519
635
|
}
|
|
520
|
-
if (
|
|
521
|
-
|
|
636
|
+
if (needsAttention) return attention();
|
|
637
|
+
const existingTurn = turn?.id === previousTurnId;
|
|
638
|
+
if (turn?.id && (!existingTurn || responseObservation?.previousTurnId === previousTurnId)) {
|
|
639
|
+
if (!existingTurn) turnId = turn.id;
|
|
522
640
|
const status = turn.status;
|
|
523
641
|
if (RELEASE_STATUSES.has(status)) {
|
|
524
|
-
const observed = await
|
|
642
|
+
const observed = await observe(turn.id);
|
|
643
|
+
if (!observed) return expired();
|
|
644
|
+
if (observed === unassigned) {
|
|
645
|
+
await pause();
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
525
648
|
const responseStatus = observed.status;
|
|
649
|
+
if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
|
|
650
|
+
turnId = null;
|
|
651
|
+
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
652
|
+
await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
turnId = turn.id;
|
|
526
656
|
return {
|
|
527
657
|
threadId, turnId, status,
|
|
528
658
|
text: responseStatus === "completed" ? observed.text : "",
|
|
@@ -532,6 +662,7 @@ export class DesktopTaskDelivery {
|
|
|
532
662
|
assistantItems: observed.assistantItems ?? [],
|
|
533
663
|
replySha256: observed.replySha256 ?? null,
|
|
534
664
|
responseSource: observed.source ?? null,
|
|
665
|
+
...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
|
|
535
666
|
activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt,
|
|
536
667
|
};
|
|
537
668
|
}
|