@minhspark/codex-mcp-bridge 1.22.9 → 1.22.11
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 +33 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +10 -2
- package/src/codex-native-response.mjs +56 -52
- package/src/codex-sender-context.mjs +16 -18
- package/src/index.mjs +1 -1
- package/src/mcp-supervisor.mjs +6 -3
- package/src/native-relay-companion.mjs +25 -4
- package/src/peer-protocol.mjs +8 -0
- package/src/reload-control.mjs +35 -0
- package/src/reply-forwarder.mjs +6 -1
- package/src/rollout-segments.mjs +27 -0
- package/src/thread-delivery.mjs +24 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.22.11] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Stop `claude-bridge` and native relay companion workers from outliving their MCP client. The supervisor ends a worker's stdin and IPC channel and waits before killing it, but MCP clients such as the SDK stdio transport kill the supervisor about 4 seconds after closing it. `index.mjs` already exited on stdin end; the other two workers did not, so they kept running with parent PID 1 and kept their relay and peer sockets. Each `npm test` run left two such processes, and the same could happen in Codex Desktop or Claude Desktop whenever an MCP client closed.
|
|
12
|
+
- Both workers now exit on their own when stdin ends or the supervisor channel disconnects. They first stop taking new work and wait up to 35 seconds for work already accepted: the companion refuses new relay connections with `RELAY_STOPPING` (`sent: false`) and finishes accepted connections, active requests and pending Desktop dispatches; `claude-bridge` unregisters its peer endpoint and finishes open peer connections, in-flight sends and queued reply forwards. A late activation from the supervisor cannot reopen the relay during this drain, and losing the supervisor's output pipes no longer crashes the worker mid-drain. The supervisor waits for that drain before forcing a stop when its client closes; retiring a quiesced worker after a reload keeps its 6-second limit.
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
|
|
16
|
+
- Every MCP supervisor test now records the supervisor and worker PIDs it sees and fails if any outlives its client, killing leftovers so a failure cannot accumulate processes. On v1.22.10, 4 tests fail: the real `claude-bridge` and companion upgrade tests leave their workers behind, and a new test that kills the supervisor finds both workers still running.
|
|
17
|
+
- New tests cover the drain: an accepted relay delivery finishes before exit, a late connection is refused with `RELAY_STOPPING`, a late activation is rejected, a worker whose parent is killed during a drain still exits 0 after its work finishes (it crashes on v1.22.10's output handling), and the peer endpoint stops accepting while keeping open connections.
|
|
18
|
+
- Two full `npm test` runs on macOS left no process from a `mcp-supervisor-test-` directory; the run on v1.22.10 left two.
|
|
19
|
+
|
|
20
|
+
### Limitations
|
|
21
|
+
|
|
22
|
+
- On Windows the supervisor's workers belong to its job object and end with it, so they never outlived their client there, and a drain cannot continue after the client kills the supervisor. The supervisor-kill test passes on Windows for that reason rather than through the drain.
|
|
23
|
+
|
|
24
|
+
## [1.22.10] - 2026-10-20
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Re-read the whole rollout set when Codex appends between per-segment reads. The pre-send watermark, dispatch, turn and creation readers and Codex sender verification now repeat find, read and set check together, up to three times, instead of failing with `A rollout segment changed while reading`. Only read races qualify: identity, history, account, workspace and dispatch mismatches still fail at once.
|
|
29
|
+
- Keep observing an accepted Desktop send or creation while Codex is still writing its rollout. When a read keeps reporting that the rollout changed while reading, the wait polls again until its deadline and then returns the pending receipt with that reason, instead of reporting the delivery as failed. Seen live on 2026-10-20: an accepted send reported `failed` while the reply was later recovered from the same receipt. A rollout whose last record stays incomplete, for example after Codex stopped mid-write, is still reported at once with its turn status and reason.
|
|
30
|
+
|
|
31
|
+
### Verification
|
|
32
|
+
|
|
33
|
+
- 8 new regression tests reproduce the append during the set check for the watermark, the turn and dispatch reply readers, sender verification and both wait paths; all fail on v1.22.8. Two more pin that a permanently incomplete rollout is reported without waiting, and a unit test pins which messages are retried.
|
|
34
|
+
- Two rounds of independent review shaped the wait behavior. A deferred released turn stays the observed turn even if a newer turn starts. The deferral reason is cleared after a clean read, and a timeout reports it as an unavailable observation. Only the set level retries, so an actively written rollout is scanned at most three times per observation. Deferral is limited to `changed while reading`, because `incomplete final record` can be permanent. Each of these has a regression test that fails without its fix.
|
|
35
|
+
|
|
36
|
+
### Limitations
|
|
37
|
+
|
|
38
|
+
- The original race was seen once in a live Desktop send and cannot be triggered on demand; the regression tests reproduce it deterministically by appending to the rollout during the set check. A Codex Desktop or MCP restart still ends in-memory peer receipts, as before.
|
|
39
|
+
|
|
7
40
|
## [1.22.9] - 2026-10-20
|
|
8
41
|
|
|
9
42
|
### Fixed
|
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.11",
|
|
5
5
|
"description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bui Dang Minh",
|
|
@@ -24,7 +24,7 @@ import { stableInstallPath } from "../src/install-paths.mjs";
|
|
|
24
24
|
|
|
25
25
|
exitForVersionRequest(import.meta.url);
|
|
26
26
|
|
|
27
|
-
const VERSION = "1.22.
|
|
27
|
+
const VERSION = "1.22.11";
|
|
28
28
|
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
29
29
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
30
30
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
|
|
|
18
18
|
import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
|
|
19
19
|
import { resolveCodexSenderContext } from "./codex-sender-context.mjs";
|
|
20
20
|
import { ReplyForwarder } from "./reply-forwarder.mjs";
|
|
21
|
-
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
21
|
+
import { assertRoutingReload, clientReloadReason, createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
|
|
22
22
|
import { readClaudeAccountContext } from "./desktop-account-context.mjs";
|
|
23
23
|
import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
|
|
24
24
|
import { recoverClaudeDesktopSession, resolveClaudeDesktopSession, sameClaudeDesktopRecipient } from "./claude-session-router.mjs";
|
|
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
|
|
|
30
30
|
|
|
31
31
|
exitForVersionRequest(import.meta.url);
|
|
32
32
|
|
|
33
|
-
const VERSION = "1.22.
|
|
33
|
+
const VERSION = "1.22.11";
|
|
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;
|
|
@@ -811,5 +811,13 @@ try {
|
|
|
811
811
|
|
|
812
812
|
const transport = new StdioServerTransport();
|
|
813
813
|
await server.connect(transport);
|
|
814
|
+
exitWhenClientCloses({
|
|
815
|
+
prepare: () => peer.closeIntake(),
|
|
816
|
+
busy: () => peer.connections.size ? "Peer socket connections are still open"
|
|
817
|
+
: peer.requestQueues.size ? "Claude messages are still being sent"
|
|
818
|
+
: replyForwarder.forwardingReason(),
|
|
819
|
+
stop: async () => { replyForwarder.close(); peer.stop(); codex.close(); await peer.closed; },
|
|
820
|
+
log,
|
|
821
|
+
});
|
|
814
822
|
reload.listen();
|
|
815
823
|
log(`ready on ${PLATFORM_LABEL} as peer "${peer.name}" (${peer.started ? peer.socketPath : "peer endpoint down"})`);
|
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { scanRollout } from "./rollout-reader.mjs";
|
|
7
7
|
import { fileIdentity, sameFileIdentity } from "./file-identity.mjs";
|
|
8
|
-
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
|
|
8
|
+
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable, retryRolloutRead } from "./rollout-segments.mjs";
|
|
9
9
|
|
|
10
10
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
11
11
|
const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024;
|
|
@@ -84,12 +84,15 @@ function samePathBoundary(expected, current) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize, dispatchOutputs, dispatchAfterOffset = afterOffset } = {}) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readStableRolloutSet(threadId, env, readSegments) {
|
|
91
|
+
return retryRolloutRead(() => {
|
|
92
|
+
const snapshots = readSegments(findRollouts(threadId, env));
|
|
93
|
+
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
94
|
+
return snapshots;
|
|
95
|
+
});
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
|
|
@@ -346,8 +349,7 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch, o
|
|
|
346
349
|
export function captureCodexRolloutWatermark({ threadId, expectedCwd, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
|
|
347
350
|
try {
|
|
348
351
|
const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
|
|
349
|
-
const snapshots =
|
|
350
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
352
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => found.map((entry) => ({ ...entry, ...readStable(entry, maxRolloutBytes) })));
|
|
351
353
|
for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
352
354
|
const matches = snapshots.length === 1 ? snapshots : snapshots.filter((snapshot) =>
|
|
353
355
|
desktopEvidence?.latestTurnId && snapshot.latestContext?.turn_id === desktopEvidence.latestTurnId);
|
|
@@ -369,15 +371,16 @@ export function readCodexNativeCreationResponse({ threadId, turnId, expectedCwd,
|
|
|
369
371
|
if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native creation response identity is invalid");
|
|
370
372
|
if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact creation prompt is unavailable");
|
|
371
373
|
const cwd = canonicalDirectory(expectedCwd, "The created native task workspace");
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
374
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
375
|
+
let retainedBytes = 0;
|
|
376
|
+
return found.map((entry) => {
|
|
377
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId });
|
|
378
|
+
retainedBytes += snapshot.retainedBytes;
|
|
379
|
+
if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
|
|
380
|
+
validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
381
|
+
return snapshot;
|
|
382
|
+
});
|
|
379
383
|
});
|
|
380
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
381
384
|
const selected = uniqueTurn(snapshots);
|
|
382
385
|
validateSession(selected, threadId, cwd, desktopEvidence);
|
|
383
386
|
return inspectTurnRecords(selected.records, threadId, turnId, cwd, {
|
|
@@ -392,14 +395,15 @@ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd, desktopE
|
|
|
392
395
|
try {
|
|
393
396
|
if (!UUID.test(threadId) || !UUID.test(turnId)) throw new Error("The native turn identity is invalid");
|
|
394
397
|
const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
398
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
399
|
+
let retainedBytes = 0;
|
|
400
|
+
return found.map((entry) => {
|
|
401
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId });
|
|
402
|
+
retainedBytes += snapshot.retainedBytes;
|
|
403
|
+
if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
|
|
404
|
+
return snapshot;
|
|
405
|
+
});
|
|
401
406
|
});
|
|
402
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
403
407
|
for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
404
408
|
const selected = uniqueTurn(snapshots);
|
|
405
409
|
validateSession(selected, threadId, cwd, desktopEvidence);
|
|
@@ -421,20 +425,20 @@ export function resolveCodexNativeDispatchResponse(binding, { env = process.env,
|
|
|
421
425
|
if (!Array.isArray(marks) || !marks.length || marks.length > 64 || new Set(marks.map((mark) => mark.file)).size !== marks.length ||
|
|
422
426
|
!marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256) ||
|
|
423
427
|
marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
|
|
424
|
-
const found = findRollouts(threadId, env);
|
|
425
|
-
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
426
428
|
const outputs = delegationOutputs(executorThreadId, prompt);
|
|
427
|
-
const snapshots =
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
429
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
430
|
+
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
431
|
+
return found.map((entry) => {
|
|
432
|
+
const mark = marks.find(({ file }) => file === entry.file);
|
|
433
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
|
|
434
|
+
if (mark) {
|
|
435
|
+
if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
|
|
436
|
+
if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
|
|
437
|
+
}
|
|
438
|
+
validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
439
|
+
return snapshot;
|
|
440
|
+
});
|
|
436
441
|
});
|
|
437
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
438
442
|
const matches = snapshots.flatMap((snapshot) => snapshot.dispatches.map((dispatch) => ({ snapshot, dispatch })));
|
|
439
443
|
if (!matches.length) return { status: "pending", threadId, turnId: null, text: "", assistantItems: [], replySha256: null };
|
|
440
444
|
if (matches.length !== 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
|
|
@@ -464,24 +468,24 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
|
|
|
464
468
|
|| new Set(marks.map((mark) => mark.file)).size !== marks.length
|
|
465
469
|
|| !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256)) throw new Error("The pre-send segment watermarks are invalid");
|
|
466
470
|
for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
|
|
467
|
-
const found = findRollouts(threadId, env);
|
|
468
|
-
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
469
471
|
const outputs = delegationOutputs(executorThreadId, prompt);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
472
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
473
|
+
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
474
|
+
let retainedBytes = 0;
|
|
475
|
+
return found.map((entry) => {
|
|
476
|
+
const mark = marks.find(({ file }) => file === entry.file);
|
|
477
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
|
|
478
|
+
retainedBytes += snapshot.retainedBytes;
|
|
479
|
+
if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
|
|
480
|
+
if (mark) {
|
|
481
|
+
if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
|
|
482
|
+
if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
|
|
483
|
+
}
|
|
484
|
+
if (snapshot.turnBeforeWatermark) throw new Error("The observed turn was already present before dispatch");
|
|
485
|
+
validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
486
|
+
return snapshot;
|
|
487
|
+
});
|
|
483
488
|
});
|
|
484
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
485
489
|
const selected = uniqueTurn(snapshots);
|
|
486
490
|
const matchedDispatches = snapshots.flatMap((snapshot) => snapshot.dispatches);
|
|
487
491
|
if (matchedDispatches.length !== 1 || matchedDispatches[0].turnId !== turnId) throw new Error("The observed turn is not uniquely correlated to the exact native dispatch");
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { scanRollout } from "./rollout-reader.mjs";
|
|
5
|
-
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
|
|
5
|
+
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable, retryRolloutRead } from "./rollout-segments.mjs";
|
|
6
6
|
|
|
7
7
|
const METADATA_KEY = "x-codex-turn-metadata";
|
|
8
8
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
@@ -43,14 +43,9 @@ export function findRollout(sessions, threadId) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function readState(file, maxBytes) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// A live Desktop task can append its tool event during verification.
|
|
50
|
-
// Retry the entire scan, never reuse partially observed permissions.
|
|
51
|
-
if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
46
|
+
// A live Desktop task can append its tool event during verification.
|
|
47
|
+
// Retry the entire scan, never reuse partially observed permissions.
|
|
48
|
+
return retryRolloutRead(() => readStateOnce(file, maxBytes));
|
|
54
49
|
}
|
|
55
50
|
|
|
56
51
|
function readStateOnce(file, maxBytes) {
|
|
@@ -182,15 +177,18 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
|
|
|
182
177
|
const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
|
|
183
178
|
if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
|
|
184
179
|
const sessions = path.join(configuredHome, "sessions");
|
|
185
|
-
const matches =
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
180
|
+
const matches = retryRolloutRead(() => {
|
|
181
|
+
const found = [], snapshots = [];
|
|
182
|
+
for (const file of findRolloutSegments(sessions, identity.threadId)) {
|
|
183
|
+
const state = readStateOnce(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
|
|
184
|
+
if (state.session?.id !== identity.threadId) throw new Error("A rollout segment contradicts the calling task identity");
|
|
185
|
+
snapshots.push({ file, version: state.version });
|
|
186
|
+
if (state.context?.turn_id === identity.turnId || state.lifecycle?.turn_id === identity.turnId) found.push({ file, ...state });
|
|
187
|
+
if (found.length > 1) throw new Error("Multiple rollout segments match the calling turn");
|
|
188
|
+
}
|
|
189
|
+
assertRolloutSetStable(sessions, identity.threadId, snapshots);
|
|
190
|
+
return found;
|
|
191
|
+
});
|
|
194
192
|
if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollout segments match the calling turn" : "The calling turn is no longer the latest active Codex turn");
|
|
195
193
|
const { file, session, context, lifecycle } = matches[0];
|
|
196
194
|
if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
|
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.11";
|
|
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
|
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -6,11 +6,14 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
|
|
7
7
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
8
8
|
import { isMainModule, stableInstallPath } from "./install-paths.mjs";
|
|
9
|
+
import { CLIENT_CLOSE_DRAIN_MS, CLIENT_CLOSE_STOP_MS } from "./reload-control.mjs";
|
|
9
10
|
|
|
10
11
|
const WORKER_NODE = stableInstallPath(process.execPath);
|
|
11
12
|
const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
|
|
12
13
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
13
14
|
const MAX_QUEUE = 256;
|
|
15
|
+
const RETIRE_GRACE_MS = 6000;
|
|
16
|
+
const CLIENT_CLOSE_GRACE_MS = CLIENT_CLOSE_DRAIN_MS + CLIENT_CLOSE_STOP_MS + 1000;
|
|
14
17
|
const hasId = (message) => Object.hasOwn(message, "id");
|
|
15
18
|
const idKey = (id) => JSON.stringify(id);
|
|
16
19
|
const errorMessage = (id, message) => ({ jsonrpc: "2.0", id, error: { code: -32603, message } });
|
|
@@ -111,7 +114,7 @@ class Worker {
|
|
|
111
114
|
this.handlers.exit(this, error);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
async stop() {
|
|
117
|
+
async stop(graceMs = RETIRE_GRACE_MS) {
|
|
115
118
|
this.retiring = true;
|
|
116
119
|
if (this.stopping) return this.stopping;
|
|
117
120
|
this.stopping = (async () => {
|
|
@@ -123,7 +126,7 @@ class Worker {
|
|
|
123
126
|
};
|
|
124
127
|
this.process.stdin.end();
|
|
125
128
|
if (this.process.connected) this.process.disconnect();
|
|
126
|
-
if (await wait(
|
|
129
|
+
if (await wait(graceMs)) return;
|
|
127
130
|
if (this.process.exitCode === null && this.process.signalCode === null) {
|
|
128
131
|
this.process.kill("SIGKILL");
|
|
129
132
|
}
|
|
@@ -324,7 +327,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
324
327
|
if (stopping) return;
|
|
325
328
|
stopping = true;
|
|
326
329
|
clearInterval(timer);
|
|
327
|
-
await Promise.allSettled([...workers].map((worker) => worker.stop()));
|
|
330
|
+
await Promise.allSettled([...workers].map((worker) => worker.stop(CLIENT_CLOSE_GRACE_MS)));
|
|
328
331
|
process.stdin.pause();
|
|
329
332
|
};
|
|
330
333
|
const preparationStarted = performance.now();
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
|
|
29
29
|
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
30
30
|
import { assertAccountIdentity } from "./bridge-account-context.mjs";
|
|
31
|
-
import { createReloadControl } from "./reload-control.mjs";
|
|
31
|
+
import { createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
|
|
32
32
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
33
33
|
import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
|
|
34
34
|
import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
|
|
@@ -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.11";
|
|
42
42
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
43
43
|
|
|
44
44
|
function errorResponse(code, message, sent, details) {
|
|
@@ -46,6 +46,9 @@ function errorResponse(code, message, sent, details) {
|
|
|
46
46
|
return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message, ...size, ...(sent === false ? { sent: false } : {}) } };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const RELAY_RELOADING_REFUSAL = Object.freeze({ code: "RELAY_RELOADING", message: "The native relay is reloading; no message was sent" });
|
|
50
|
+
const RELAY_STOPPING_REFUSAL = Object.freeze({ code: "RELAY_STOPPING", message: "The native relay is shutting down because its MCP client closed; no message was sent" });
|
|
51
|
+
|
|
49
52
|
function boundRequest(payload) {
|
|
50
53
|
return payload?.v === ACCOUNT_RELAY_PROTOCOL_VERSION;
|
|
51
54
|
}
|
|
@@ -411,6 +414,7 @@ export class RelaySocketServer {
|
|
|
411
414
|
this.connections = new Set();
|
|
412
415
|
this.handlers = new Set();
|
|
413
416
|
this.accepting = true;
|
|
417
|
+
this.refusal = RELAY_RELOADING_REFUSAL;
|
|
414
418
|
this.closed = Promise.resolve();
|
|
415
419
|
this.processHandlers = new Map();
|
|
416
420
|
this.aclReady = !IS_WINDOWS || !strict;
|
|
@@ -558,7 +562,7 @@ export class RelaySocketServer {
|
|
|
558
562
|
}
|
|
559
563
|
if (!this.accepting) {
|
|
560
564
|
socket.on("error", () => {});
|
|
561
|
-
socket.end(`${JSON.stringify(errorResponse(
|
|
565
|
+
socket.end(`${JSON.stringify(errorResponse(this.refusal.code, this.refusal.message, false))}\n`);
|
|
562
566
|
return;
|
|
563
567
|
}
|
|
564
568
|
let buffer = Buffer.alloc(0);
|
|
@@ -712,10 +716,16 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
712
716
|
const servers = [...new Set(relays)];
|
|
713
717
|
let startups = [];
|
|
714
718
|
let ownedSockets = [];
|
|
715
|
-
|
|
719
|
+
let stopping = false;
|
|
720
|
+
const deliveryReason = () => {
|
|
716
721
|
if (servers.some((relay) => relay.handlers.size)) return "Native relay requests are still active";
|
|
717
722
|
if (servers.some((relay) => relay.connections.size)) return "Native relay clients are still connected";
|
|
718
723
|
if (nativeTools.pending.size) return "Native Desktop dispatches are still pending";
|
|
724
|
+
return null;
|
|
725
|
+
};
|
|
726
|
+
const inspect = () => {
|
|
727
|
+
const reason = deliveryReason();
|
|
728
|
+
if (reason) return reason;
|
|
719
729
|
if (nativeTools.connecting || startups.some((startup) => startup.busy)) return "Native relay startup is still pending";
|
|
720
730
|
return null;
|
|
721
731
|
};
|
|
@@ -729,6 +739,8 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
729
739
|
nativeTools.close();
|
|
730
740
|
};
|
|
731
741
|
const activate = async () => {
|
|
742
|
+
if (stopping) throw new Error("The native relay is shutting down because its MCP client closed");
|
|
743
|
+
for (const relay of servers) relay.refusal = RELAY_RELOADING_REFUSAL;
|
|
732
744
|
if (startups.length) {
|
|
733
745
|
for (const relay of servers) relay.accepting = true;
|
|
734
746
|
return;
|
|
@@ -745,8 +757,16 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
745
757
|
};
|
|
746
758
|
return {
|
|
747
759
|
inspect,
|
|
760
|
+
deliveryReason,
|
|
748
761
|
activate,
|
|
749
762
|
stop,
|
|
763
|
+
refuseNewDeliveries() {
|
|
764
|
+
stopping = true;
|
|
765
|
+
for (const relay of servers) {
|
|
766
|
+
relay.refusal = RELAY_STOPPING_REFUSAL;
|
|
767
|
+
relay.accepting = false;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
750
770
|
async quiesce() {
|
|
751
771
|
for (const relay of servers) relay.accepting = false;
|
|
752
772
|
const reason = inspect();
|
|
@@ -861,6 +881,7 @@ if (invokedDirectly) {
|
|
|
861
881
|
if (!reload.staged) void lifecycle.activate().catch((error) => log(`native relay startup failed: ${error.message}`));
|
|
862
882
|
mcp.server.onclose = () => { void lifecycle.stop(); };
|
|
863
883
|
await mcp.connect(new StdioServerTransport());
|
|
884
|
+
exitWhenClientCloses({ prepare: () => lifecycle.refuseNewDeliveries(), busy: lifecycle.deliveryReason, stop: lifecycle.stop, log });
|
|
864
885
|
reload.listen();
|
|
865
886
|
log(`ready on ${PLATFORM_LABEL} (${strictDesktop ? accountRelay.socketPath : relay.started ? relay.socketPath : "socket down"})`);
|
|
866
887
|
}
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -616,6 +616,7 @@ export class PeerEndpoint {
|
|
|
616
616
|
this.connections = new Set();
|
|
617
617
|
this.activityRevision = 0;
|
|
618
618
|
this.reloadPaused = false;
|
|
619
|
+
this.intakeClosed = false;
|
|
619
620
|
this.processHandlersRegistered = false;
|
|
620
621
|
this.strict = hardenedBridgeEnabled(env);
|
|
621
622
|
this.protectSocket = protectSocket ?? (hardenedBridgeEnabled(env) ? protectCurrentUserPipe : null);
|
|
@@ -655,6 +656,7 @@ export class PeerEndpoint {
|
|
|
655
656
|
|
|
656
657
|
start() {
|
|
657
658
|
if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
|
|
659
|
+
if (this.intakeClosed) throw new Error("The peer endpoint is shutting down because its MCP client closed");
|
|
658
660
|
if (this.started) return;
|
|
659
661
|
if (this.startPromise) return this.startPromise;
|
|
660
662
|
const starting = this.#startOnce();
|
|
@@ -1150,6 +1152,12 @@ export class PeerEndpoint {
|
|
|
1150
1152
|
if (revision !== this.activityRevision || this.reloadReason()) throw new Error("Peer activity raced with reload; the endpoint remains quiesced");
|
|
1151
1153
|
}
|
|
1152
1154
|
|
|
1155
|
+
closeIntake() {
|
|
1156
|
+
this.intakeClosed = true;
|
|
1157
|
+
this.#removeRegistration();
|
|
1158
|
+
if (this.server?.listening) this.server.close();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1153
1161
|
async resume() {
|
|
1154
1162
|
this.reloadPaused = false;
|
|
1155
1163
|
await this.start();
|
package/src/reload-control.mjs
CHANGED
|
@@ -100,3 +100,38 @@ export function createReloadControl({ entry, inspect = () => null, quiesce = ()
|
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
export const CLIENT_CLOSE_DRAIN_MS = 35000;
|
|
105
|
+
export const CLIENT_CLOSE_STOP_MS = 5000;
|
|
106
|
+
|
|
107
|
+
export function exitWhenClientCloses({ prepare = () => {}, busy = () => null, stop = () => {}, log = () => {}, drainMs = CLIENT_CLOSE_DRAIN_MS, stopMs = CLIENT_CLOSE_STOP_MS, pollMs = 50, stdin = process.stdin, channel = process, outputs = [process.stdout, process.stderr], exit = (code) => process.exit(code) } = {}) {
|
|
108
|
+
let closing = null;
|
|
109
|
+
const bounded = (promise, ms) => {
|
|
110
|
+
let timer;
|
|
111
|
+
return Promise.race([Promise.resolve(promise).then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })])
|
|
112
|
+
.finally(() => clearTimeout(timer));
|
|
113
|
+
};
|
|
114
|
+
const close = (why) => closing ??= (async () => {
|
|
115
|
+
log(`MCP client closed (${why}); draining before exit`);
|
|
116
|
+
let code = 0;
|
|
117
|
+
try {
|
|
118
|
+
await prepare();
|
|
119
|
+
const deadline = Date.now() + drainMs;
|
|
120
|
+
let reason = busy();
|
|
121
|
+
while (reason && Date.now() < deadline) {
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
123
|
+
reason = busy();
|
|
124
|
+
}
|
|
125
|
+
if (reason) log(`exiting after ${drainMs} ms with work still open: ${reason}`);
|
|
126
|
+
if (!await bounded(stop(), stopMs)) log(`listener shutdown exceeded ${stopMs} ms`);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
code = 1;
|
|
129
|
+
log(`shutdown failed: ${error?.message ?? String(error)}`);
|
|
130
|
+
}
|
|
131
|
+
exit(code);
|
|
132
|
+
})();
|
|
133
|
+
for (const output of outputs) output?.on("error", (error) => { void close(`output failed: ${error?.code ?? error?.message}`); });
|
|
134
|
+
stdin?.once("end", () => { void close("stdin ended"); });
|
|
135
|
+
channel?.once("disconnect", () => { void close("supervisor channel disconnected"); });
|
|
136
|
+
return { close, get closing() { return closing !== null; } };
|
|
137
|
+
}
|
package/src/reply-forwarder.mjs
CHANGED
|
@@ -106,8 +106,13 @@ export class ReplyForwarder {
|
|
|
106
106
|
return { ...counts, attempts, maxPerSession: this.maxPerSession, closed: this.closed };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
forwardingReason() {
|
|
110
|
+
return this.active || this.queue.length || this.timer !== null ? "Reply forwarding is still active or queued" : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
109
113
|
reloadReason() {
|
|
110
|
-
|
|
114
|
+
const forwarding = this.forwardingReason();
|
|
115
|
+
if (forwarding) return forwarding;
|
|
111
116
|
if ([...this.records.values()].some(({ receipt }) => ["queued", "sending", "unknown"].includes(receipt.status))) {
|
|
112
117
|
return "A reply forwarding outcome remains unconfirmed";
|
|
113
118
|
}
|
package/src/rollout-segments.mjs
CHANGED
|
@@ -55,6 +55,33 @@ export function isLegacyCliSession(session) {
|
|
|
55
55
|
return session?.originator === "codex-tui" && session.source === "cli";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const TRANSIENT_ROLLOUT_READ = /changed while (reading|opening)|incomplete final record/;
|
|
59
|
+
const ROLLOUT_CHANGED_DURING_READ = /changed while (reading|opening)/;
|
|
60
|
+
const ROLLOUT_READ_ATTEMPTS = 3;
|
|
61
|
+
|
|
62
|
+
function reasonText(reason) {
|
|
63
|
+
return typeof reason === "string" ? reason : reason?.message;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isTransientRolloutRead(reason) {
|
|
67
|
+
const message = reasonText(reason);
|
|
68
|
+
return typeof message === "string" && TRANSIENT_ROLLOUT_READ.test(message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isRolloutChangedDuringRead(reason) {
|
|
72
|
+
const message = reasonText(reason);
|
|
73
|
+
return typeof message === "string" && ROLLOUT_CHANGED_DURING_READ.test(message);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function retryRolloutRead(read, attempts = ROLLOUT_READ_ATTEMPTS) {
|
|
77
|
+
for (let attempt = 1; ; attempt++) {
|
|
78
|
+
try { return read(); }
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (attempt >= attempts || !isTransientRolloutRead(error)) throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
export function assertRolloutSetStable(sessions, threadId, snapshots) {
|
|
59
86
|
const files = findRolloutSegments(sessions, threadId);
|
|
60
87
|
if (files.length !== snapshots.length || files.some((file, i) => file !== snapshots[i].file)) throw new Error("The rollout segment set changed while reading");
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { creationRequestHash } from "./native-creation-receipts.mjs";
|
|
|
7
7
|
import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
|
|
8
8
|
import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse, resolveCodexNativeDispatchResponse } from "./codex-native-response.mjs";
|
|
9
9
|
import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
|
|
10
|
+
import { isRolloutChangedDuringRead } from "./rollout-segments.mjs";
|
|
10
11
|
import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -550,6 +551,8 @@ export class DesktopTaskDelivery {
|
|
|
550
551
|
let cursor;
|
|
551
552
|
let turnId = null;
|
|
552
553
|
let readSnapshots = false;
|
|
554
|
+
let deferredReason = null;
|
|
555
|
+
let deferredTurn = null;
|
|
553
556
|
const readSnapshot = async () => {
|
|
554
557
|
const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
|
|
555
558
|
const thread = result?.thread;
|
|
@@ -560,19 +563,24 @@ export class DesktopTaskDelivery {
|
|
|
560
563
|
if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
|
|
561
564
|
return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
|
|
562
565
|
};
|
|
563
|
-
const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
566
|
+
const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", ...(deferredReason ? { observationStatus: "unavailable", observationReason: deferredReason } : {}), assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
564
567
|
const deadlineError = (error) => {
|
|
565
568
|
const cause = error.cause ?? error;
|
|
566
569
|
return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
|
|
567
570
|
cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
|
|
568
571
|
};
|
|
569
|
-
const
|
|
572
|
+
const observeLater = Symbol("observe-later");
|
|
570
573
|
const observe = async (selectedTurnId, resolveDispatch = false) => {
|
|
571
574
|
try {
|
|
572
|
-
|
|
575
|
+
const observed = await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
|
|
576
|
+
deferredReason = observed?.status === "unavailable" && isRolloutChangedDuringRead(observed.reason) ? observed.reason : null;
|
|
577
|
+
return deferredReason ? observeLater : observed;
|
|
573
578
|
} catch (error) {
|
|
574
579
|
if (deadlineError(error)) return null;
|
|
575
|
-
if (error.code === WORKSPACE_UNASSIGNED)
|
|
580
|
+
if (error.code === WORKSPACE_UNASSIGNED) {
|
|
581
|
+
deferredReason = null;
|
|
582
|
+
return observeLater;
|
|
583
|
+
}
|
|
576
584
|
throw error;
|
|
577
585
|
}
|
|
578
586
|
};
|
|
@@ -611,7 +619,7 @@ export class DesktopTaskDelivery {
|
|
|
611
619
|
if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
|
|
612
620
|
const observed = await observe(null, true);
|
|
613
621
|
if (!observed) return expired();
|
|
614
|
-
if (observed ===
|
|
622
|
+
if (observed === observeLater) {
|
|
615
623
|
await pause();
|
|
616
624
|
continue;
|
|
617
625
|
}
|
|
@@ -634,17 +642,20 @@ export class DesktopTaskDelivery {
|
|
|
634
642
|
continue;
|
|
635
643
|
}
|
|
636
644
|
if (needsAttention) return attention();
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
645
|
+
const released = deferredTurn ?? turn;
|
|
646
|
+
const existingTurn = released?.id === previousTurnId;
|
|
647
|
+
if (released?.id && (!existingTurn || responseObservation?.previousTurnId === previousTurnId)) {
|
|
648
|
+
if (!existingTurn) turnId = released.id;
|
|
649
|
+
const status = released.status;
|
|
641
650
|
if (RELEASE_STATUSES.has(status)) {
|
|
642
|
-
const observed = await observe(
|
|
651
|
+
const observed = await observe(released.id);
|
|
643
652
|
if (!observed) return expired();
|
|
644
|
-
if (observed ===
|
|
653
|
+
if (observed === observeLater) {
|
|
654
|
+
deferredTurn = deferredReason ? released : null;
|
|
645
655
|
await pause();
|
|
646
656
|
continue;
|
|
647
657
|
}
|
|
658
|
+
deferredTurn = null;
|
|
648
659
|
const responseStatus = observed.status;
|
|
649
660
|
if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
|
|
650
661
|
turnId = null;
|
|
@@ -652,7 +663,7 @@ export class DesktopTaskDelivery {
|
|
|
652
663
|
await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
653
664
|
continue;
|
|
654
665
|
}
|
|
655
|
-
turnId =
|
|
666
|
+
turnId = released.id;
|
|
656
667
|
return {
|
|
657
668
|
threadId, turnId, status,
|
|
658
669
|
text: responseStatus === "completed" ? observed.text : "",
|
|
@@ -663,7 +674,7 @@ export class DesktopTaskDelivery {
|
|
|
663
674
|
replySha256: observed.replySha256 ?? null,
|
|
664
675
|
responseSource: observed.source ?? null,
|
|
665
676
|
...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
|
|
666
|
-
activity: [], errors:
|
|
677
|
+
activity: [], errors: released.error ? [released.error] : [], durationMs: released.durationMs ?? this.now() - startedAt,
|
|
667
678
|
};
|
|
668
679
|
}
|
|
669
680
|
}
|