@minhspark/codex-mcp-bridge 1.13.2 → 1.13.4
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/CHANGELOG.md +35 -0
- package/README.md +47 -10
- package/package.json +5 -2
- package/scripts/check-claude-bridge.mjs +2 -1
- package/scripts/install-claude-desktop.mjs +3 -0
- package/scripts/install-codex-mcp.mjs +15 -15
- package/scripts/install-native-relay.mjs +38 -3
- package/scripts/install-npm-footer.mjs +99 -0
- package/scripts/npm-footer.ps1 +140 -0
- package/scripts/npm-footer.sh +81 -0
- package/src/claude-bridge.mjs +69 -19
- package/src/cli-version.mjs +20 -0
- package/src/codex-mcp-registration.mjs +25 -0
- package/src/index.mjs +46 -15
- package/src/native-relay-companion.mjs +4 -1
- package/src/peer-protocol.mjs +78 -11
- package/src/platform.mjs +55 -1
- package/src/runtime-state.mjs +34 -0
- package/src/thread-delivery.mjs +45 -3
package/src/peer-protocol.mjs
CHANGED
|
@@ -187,9 +187,21 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
|
|
|
187
187
|
return rows.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
export function findClaudeSession(target) {
|
|
190
|
+
export function findClaudeSession(target, { desktopOnly = false, expectedCwd } = {}) {
|
|
191
191
|
const sessions = listClaudeSessions();
|
|
192
192
|
const needle = String(target).trim();
|
|
193
|
+
if (desktopOnly) {
|
|
194
|
+
const byId = sessions.filter((session) => String(session.pid) === needle || session.sessionId === needle);
|
|
195
|
+
const matches = byId.length ? byId : sessions.filter((session) => session.name === needle);
|
|
196
|
+
if (!needle || matches.length === 0) return null;
|
|
197
|
+
if (matches.length !== 1) throw new Error(`Desktop-only mode requires an unambiguous sessionId or pid; "${needle}" matches multiple sessions. No message was sent.`);
|
|
198
|
+
const session = matches[0];
|
|
199
|
+
if (session.entrypoint !== "claude-desktop") {
|
|
200
|
+
throw new Error(`Desktop-only mode refuses Claude session ${session.sessionId ?? session.pid} with entrypoint ${session.entrypoint ?? "unknown"}. Open or reconnect an existing Code session in Claude Desktop for the intended project. Do not launch a replacement CLI session. No message was sent.`);
|
|
201
|
+
}
|
|
202
|
+
if (expectedCwd !== undefined) assertClaudeSessionCwd(session, expectedCwd);
|
|
203
|
+
return session;
|
|
204
|
+
}
|
|
193
205
|
return (
|
|
194
206
|
sessions.find((s) => String(s.pid) === needle) ??
|
|
195
207
|
sessions.find((s) => s.sessionId === needle) ??
|
|
@@ -199,6 +211,24 @@ export function findClaudeSession(target) {
|
|
|
199
211
|
);
|
|
200
212
|
}
|
|
201
213
|
|
|
214
|
+
export function assertClaudeSessionCwd(session, expectedCwd) {
|
|
215
|
+
if (typeof expectedCwd !== "string" || !path.isAbsolute(expectedCwd) || !session.cwd || !path.isAbsolute(session.cwd)) {
|
|
216
|
+
throw new Error("Desktop delivery requires an explicit absolute expectedCwd and an absolute session cwd. No message was sent.");
|
|
217
|
+
}
|
|
218
|
+
let expected;
|
|
219
|
+
let actual;
|
|
220
|
+
try {
|
|
221
|
+
expected = fs.realpathSync.native(expectedCwd);
|
|
222
|
+
actual = fs.realpathSync.native(session.cwd);
|
|
223
|
+
} catch {
|
|
224
|
+
throw new Error("The intended project directory or the Claude session cwd no longer exists. No message was sent.");
|
|
225
|
+
}
|
|
226
|
+
const normalize = (value) => IS_WINDOWS ? value.toLowerCase() : value;
|
|
227
|
+
if (normalize(expected) !== normalize(actual)) {
|
|
228
|
+
throw new Error(`Claude session cwd ${actual} does not match expectedCwd ${expected}. No message was sent.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
202
232
|
/**
|
|
203
233
|
* Claude Code stores a transcript at ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
204
234
|
* where the slug rewrites more than just path separators (/mnt/dev_disk ->
|
|
@@ -292,6 +322,7 @@ export class PeerEndpoint {
|
|
|
292
322
|
this.peerToken = null;
|
|
293
323
|
this.permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
|
|
294
324
|
this.deliveryReceipts = new Map();
|
|
325
|
+
this.sentMessages = new Map();
|
|
295
326
|
this.pendingMessages = new Map();
|
|
296
327
|
this.responsePoll = null;
|
|
297
328
|
}
|
|
@@ -432,7 +463,12 @@ export class PeerEndpoint {
|
|
|
432
463
|
#receiveMessage(message) {
|
|
433
464
|
const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
|
|
434
465
|
this.inbox.push(record);
|
|
435
|
-
this
|
|
466
|
+
const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
|
|
467
|
+
if (key && this.pendingMessages.get(key)?.targetSocket === record.fromSocket) {
|
|
468
|
+
const sent = this.sentMessages.get(key);
|
|
469
|
+
if (sent) sent.reply = record;
|
|
470
|
+
this.#removePendingReply(record.fromSocket, key);
|
|
471
|
+
}
|
|
436
472
|
this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
|
|
437
473
|
for (const listener of [...this.listeners]) {
|
|
438
474
|
try { listener(record); }
|
|
@@ -503,6 +539,7 @@ export class PeerEndpoint {
|
|
|
503
539
|
settled = true;
|
|
504
540
|
globalThis.clearTimeout(timer);
|
|
505
541
|
if (error) {
|
|
542
|
+
if (connected || writeStarted) error.deliveryUncertain = true;
|
|
506
543
|
reject({ error, retryable: !connected && !writeStarted });
|
|
507
544
|
} else {
|
|
508
545
|
resolve();
|
|
@@ -534,14 +571,15 @@ export class PeerEndpoint {
|
|
|
534
571
|
return frame.msg_id;
|
|
535
572
|
}
|
|
536
573
|
|
|
537
|
-
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession } = {}) {
|
|
574
|
+
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend } = {}) {
|
|
538
575
|
const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
|
|
539
576
|
const pending = previous.catch(() => {}).then(async () => {
|
|
577
|
+
beforeSend?.();
|
|
540
578
|
const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
|
|
541
|
-
if (
|
|
579
|
+
if (unconfirmed > 0) {
|
|
542
580
|
const error = new Error(
|
|
543
581
|
`${unconfirmed} earlier message(s) to ${targetSocket} still await a reply; this message was not sent. `
|
|
544
|
-
+ "
|
|
582
|
+
+ "Inspect read_claude_delivery and wait for Claude's outstanding replies. Changing waitSec or starting another bridge must not be used to bypass a pending message.",
|
|
545
583
|
);
|
|
546
584
|
error.code = "PEER_REPLY_PENDING";
|
|
547
585
|
throw error;
|
|
@@ -551,22 +589,31 @@ export class PeerEndpoint {
|
|
|
551
589
|
this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
|
|
552
590
|
let msgId = crypto.randomUUID();
|
|
553
591
|
this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
|
|
592
|
+
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since });
|
|
593
|
+
if (transcriptSession && !this.responsePoll) {
|
|
594
|
+
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
595
|
+
this.responsePoll.unref();
|
|
596
|
+
}
|
|
554
597
|
try {
|
|
555
598
|
const sentId = await this.send(targetSocket, text, { priority, msgId });
|
|
556
599
|
if (sentId !== msgId) {
|
|
557
600
|
const pendingMessage = this.pendingMessages.get(msgId);
|
|
558
601
|
this.pendingMessages.delete(msgId);
|
|
602
|
+
this.sentMessages.set(sentId, this.sentMessages.get(msgId));
|
|
603
|
+
this.sentMessages.delete(msgId);
|
|
559
604
|
msgId = sentId;
|
|
560
605
|
if (pendingMessage) this.pendingMessages.set(msgId, pendingMessage);
|
|
561
606
|
}
|
|
562
607
|
} catch (err) {
|
|
563
|
-
this
|
|
608
|
+
const sent = this.sentMessages.get(msgId);
|
|
609
|
+
if (sent) sent.error = err.message;
|
|
610
|
+
if (!err.deliveryUncertain) {
|
|
611
|
+
this.#removePendingReply(targetSocket, msgId);
|
|
612
|
+
if (sent) sent.failed = true;
|
|
613
|
+
}
|
|
614
|
+
err.msgId = msgId;
|
|
564
615
|
throw err;
|
|
565
616
|
}
|
|
566
|
-
if (transcriptSession && !this.responsePoll && this.pendingMessages.has(msgId)) {
|
|
567
|
-
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
568
|
-
this.responsePoll.unref();
|
|
569
|
-
}
|
|
570
617
|
const reply = timeoutMs > 0
|
|
571
618
|
? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence, msgId })
|
|
572
619
|
: null;
|
|
@@ -584,7 +631,8 @@ export class PeerEndpoint {
|
|
|
584
631
|
|
|
585
632
|
#removePendingReply(fromSocket, msgId) {
|
|
586
633
|
const key = msgId ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === fromSocket)?.[0];
|
|
587
|
-
if (key
|
|
634
|
+
if (!key || this.pendingMessages.get(key)?.targetSocket !== fromSocket) return;
|
|
635
|
+
this.pendingMessages.delete(key);
|
|
588
636
|
const pending = this.unconfirmedReplies.get(fromSocket) ?? 0;
|
|
589
637
|
if (pending > 1) this.unconfirmedReplies.set(fromSocket, pending - 1);
|
|
590
638
|
else this.unconfirmedReplies.delete(fromSocket);
|
|
@@ -596,6 +644,7 @@ export class PeerEndpoint {
|
|
|
596
644
|
*/
|
|
597
645
|
waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null, msgId } = {}) {
|
|
598
646
|
const matches = (record) => record.fromSocket === fromSocket
|
|
647
|
+
&& (!record.inReplyTo || !msgId || record.inReplyTo === msgId)
|
|
599
648
|
&& (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
|
|
600
649
|
const existing = this.inbox.find(matches);
|
|
601
650
|
if (existing) return Promise.resolve(existing);
|
|
@@ -624,6 +673,24 @@ export class PeerEndpoint {
|
|
|
624
673
|
return messages;
|
|
625
674
|
}
|
|
626
675
|
|
|
676
|
+
readDelivery(msgId) {
|
|
677
|
+
const sent = this.sentMessages.get(msgId);
|
|
678
|
+
if (!sent) return null;
|
|
679
|
+
const delivery = this.deliveryReceipts.get(msgId);
|
|
680
|
+
const session = sent.transcriptSession;
|
|
681
|
+
return {
|
|
682
|
+
msgId,
|
|
683
|
+
status: sent.reply ? "reply_received" : sent.failed ? "send_failed" : delivery?.status ?? "sent_unconfirmed",
|
|
684
|
+
reason: delivery?.reason ?? sent.error ?? null,
|
|
685
|
+
sentAt: sent.sentAt,
|
|
686
|
+
sessionId: session?.sessionId ?? null,
|
|
687
|
+
cwd: session?.cwd ?? null,
|
|
688
|
+
entrypoint: session?.entrypoint ?? null,
|
|
689
|
+
pending: this.pendingMessages.has(msgId),
|
|
690
|
+
...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer" } : {}),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
627
694
|
stop() {
|
|
628
695
|
globalThis.clearInterval(this.responsePoll);
|
|
629
696
|
this.responsePoll = null;
|
package/src/platform.mjs
CHANGED
|
@@ -20,7 +20,8 @@ export const PLATFORM_LABEL = IS_MACOS
|
|
|
20
20
|
: process.platform;
|
|
21
21
|
|
|
22
22
|
const CODEX_DESKTOP_APP_MACOS = "/Applications/ChatGPT.app";
|
|
23
|
-
const
|
|
23
|
+
const CODEX_DESKTOP_RESOURCES_MACOS = `${CODEX_DESKTOP_APP_MACOS}/Contents/Resources`;
|
|
24
|
+
const CODEX_DESKTOP_BIN_MACOS = `${CODEX_DESKTOP_RESOURCES_MACOS}/codex`;
|
|
24
25
|
const CODEX_THREAD_URL_PREFIX = "codex://threads/";
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -176,6 +177,59 @@ export function resolveCodexBin(explicit) {
|
|
|
176
177
|
return "codex";
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Codex Desktop authenticates the code-signing identity of whatever process
|
|
182
|
+
* connects to its native tools pipe, and closes the connection before reading
|
|
183
|
+
* a single byte when that identity is not the vendor's - the app records
|
|
184
|
+
* `dynamic_app_tools_peer_rejected`. A companion launched by the user's own
|
|
185
|
+
* Node build carries the Node.js Foundation signature rather than OpenAI's,
|
|
186
|
+
* so the relay still reports itself installed and still creates its socket
|
|
187
|
+
* while every delivery fails; the symptom surfaces nowhere near the cause.
|
|
188
|
+
* The runtime therefore has to be the one the app ships, which is also the
|
|
189
|
+
* one the app hands its own bundled plugin through CODEX_MCP_NODE_PATH.
|
|
190
|
+
*
|
|
191
|
+
* The vendor's launcher additionally falls back to a cached runtime under
|
|
192
|
+
* ~/.cache/codex-runtimes and to a bare PATH lookup. Both are deliberately
|
|
193
|
+
* absent here: the cached binary measured on a real install is the same
|
|
194
|
+
* version and within 1.3 KB of the bundled one, yet carries the Node.js
|
|
195
|
+
* Foundation signature, so copying that list wholesale would reproduce the
|
|
196
|
+
* rejection this resolves under a different file name. For the same reason
|
|
197
|
+
* the runtime is never taken from PATH or from a version match.
|
|
198
|
+
*
|
|
199
|
+
* Only macOS ships this bundle and only macOS was measured to enforce the
|
|
200
|
+
* check, so the vendor-owned rungs are gated by platform. Every other
|
|
201
|
+
* platform keeps the runtime it has always used, and the last rung is an
|
|
202
|
+
* unconditional real path rather than a bare command name because the caller
|
|
203
|
+
* writes the result straight into client configuration.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveCodexDesktopNodeBin(
|
|
206
|
+
explicit,
|
|
207
|
+
{ env = process.env, platform = process.platform, resourcesDir = CODEX_DESKTOP_RESOURCES_MACOS } = {},
|
|
208
|
+
) {
|
|
209
|
+
const bundledNode = (dir) => path.join(dir, "cua_node", "bin", platform === "win32" ? "node.exe" : "node");
|
|
210
|
+
const vendorRungs =
|
|
211
|
+
platform === "darwin"
|
|
212
|
+
? [
|
|
213
|
+
[env.CODEX_MCP_NODE_PATH, "CODEX_MCP_NODE_PATH"],
|
|
214
|
+
[env.CODEX_BROWSER_USE_NODE_PATH, "CODEX_BROWSER_USE_NODE_PATH"],
|
|
215
|
+
[
|
|
216
|
+
env.CODEX_ELECTRON_RESOURCES_PATH && bundledNode(env.CODEX_ELECTRON_RESOURCES_PATH),
|
|
217
|
+
"CODEX_ELECTRON_RESOURCES_PATH",
|
|
218
|
+
],
|
|
219
|
+
[bundledNode(resourcesDir), "Codex Desktop bundle"],
|
|
220
|
+
]
|
|
221
|
+
: [];
|
|
222
|
+
|
|
223
|
+
for (const [candidate, source] of [
|
|
224
|
+
[explicit, "explicit"],
|
|
225
|
+
[env.CODEX_NATIVE_RELAY_NODE, "CODEX_NATIVE_RELAY_NODE"],
|
|
226
|
+
...vendorRungs,
|
|
227
|
+
]) {
|
|
228
|
+
if (candidate && isRunnable(candidate)) return { path: candidate, source };
|
|
229
|
+
}
|
|
230
|
+
return { path: process.execPath, source: "process.execPath" };
|
|
231
|
+
}
|
|
232
|
+
|
|
179
233
|
/**
|
|
180
234
|
* The macOS and Linux `codex` launcher is a Node script with a
|
|
181
235
|
* `#!/usr/bin/env node` shebang, so the spawned child needs a PATH that
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export function createRuntimeState({ directory = path.dirname(fileURLToPath(import.meta.url)), configuration = () => null } = {}) {
|
|
7
|
+
const fingerprint = () => {
|
|
8
|
+
const hash = createHash("sha256");
|
|
9
|
+
for (const file of ["../package.json", ...readdirSync(directory).filter((file) => file.endsWith(".mjs")).sort()]) {
|
|
10
|
+
hash.update(file).update("\0").update(readFileSync(path.join(directory, file))).update("\0");
|
|
11
|
+
}
|
|
12
|
+
return hash.digest("hex");
|
|
13
|
+
};
|
|
14
|
+
const revision = fingerprint();
|
|
15
|
+
const configured = JSON.stringify(configuration());
|
|
16
|
+
const startedAt = new Date().toISOString();
|
|
17
|
+
const status = () => {
|
|
18
|
+
let diskRevision = null;
|
|
19
|
+
let reason = null;
|
|
20
|
+
try {
|
|
21
|
+
diskRevision = fingerprint();
|
|
22
|
+
if (diskRevision !== revision) reason = "Bridge source changed after this MCP process started";
|
|
23
|
+
else if (JSON.stringify(configuration()) !== configured) reason = "Bridge routing configuration changed after this MCP process started";
|
|
24
|
+
} catch {
|
|
25
|
+
reason = "Bridge source or routing configuration can no longer be read";
|
|
26
|
+
}
|
|
27
|
+
return { pid: process.pid, startedAt, revision, diskRevision, current: reason === null, reason };
|
|
28
|
+
};
|
|
29
|
+
const assertCurrent = () => {
|
|
30
|
+
const state = status();
|
|
31
|
+
if (!state.current) throw new Error(`${state.reason}. Reconnect this MCP server in the existing Desktop task before sending. No message was sent; do not create a replacement task or use an external app-server.`);
|
|
32
|
+
};
|
|
33
|
+
return { status, assertCurrent };
|
|
34
|
+
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -64,8 +64,9 @@ export class DesktopTaskDelivery {
|
|
|
64
64
|
|
|
65
65
|
async withThread(threadId, operation, { deadline } = {}) {
|
|
66
66
|
const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
|
|
67
|
+
let expired = false;
|
|
67
68
|
const current = previous.catch(() => {}).then(() => {
|
|
68
|
-
if (deadline !== undefined && this.now() >= deadline) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
|
|
69
|
+
if (expired || (deadline !== undefined && this.now() >= deadline)) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
|
|
69
70
|
return operation();
|
|
70
71
|
});
|
|
71
72
|
this.threadOperations.set(threadId, current);
|
|
@@ -75,7 +76,10 @@ export class DesktopTaskDelivery {
|
|
|
75
76
|
let timer;
|
|
76
77
|
try {
|
|
77
78
|
return await Promise.race([current, new Promise((_, reject) => {
|
|
78
|
-
timer = setTimeout(() =>
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
expired = true;
|
|
81
|
+
reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again."));
|
|
82
|
+
}, Math.max(1, deadline - this.now()));
|
|
79
83
|
})]);
|
|
80
84
|
} finally {
|
|
81
85
|
clearTimeout(timer);
|
|
@@ -129,7 +133,45 @@ export class DesktopTaskDelivery {
|
|
|
129
133
|
if (thread?.id !== receipt.threadId || thread.hostId !== "local" || !thread.cwd || path.relative(cwd, realpathSync.native(thread.cwd))) throw new Error(`Existing Desktop task ${receipt.threadId} did not confirm the requested local workspace. No prompt was sent.`);
|
|
130
134
|
this.security.assertCwd(thread.cwd);
|
|
131
135
|
this.security.assertThread(thread.id, thread.cwd);
|
|
132
|
-
|
|
136
|
+
const assignment = await this.receiptProjectAssignment(receipt, cwd, { deadline });
|
|
137
|
+
return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, ...assignment, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async receiptProjectAssignment(receipt, cwd, { deadline }) {
|
|
141
|
+
let project;
|
|
142
|
+
try {
|
|
143
|
+
const listed = await this.request("list_projects", {}, { deadline });
|
|
144
|
+
if (!Array.isArray(listed?.projects)) throw new Error("Desktop returned no project list");
|
|
145
|
+
project = matchDesktopProject(listed.projects, cwd);
|
|
146
|
+
if (receipt.projectId && receipt.projectId !== project.projectId) throw new Error("The saved project identity changed for this directory");
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new Error(`Existing Desktop task ${receipt.threadId}'s saved project could not be verified: ${err.message}. No prompt was resent and no duplicate task was created. Inspect this existing task and its project settings.`, { cause: err });
|
|
149
|
+
}
|
|
150
|
+
const unverified = (reason) => ({
|
|
151
|
+
expectedProjectId: project.projectId,
|
|
152
|
+
expectedProjectName: project.label,
|
|
153
|
+
projectAssignmentStatus: "unverified",
|
|
154
|
+
projectAssignmentNote: `${reason} The task's current project assignment is unverified. Its workspace and existing ID were retained; no prompt was resent and no duplicate task was created. Inspect this task in Codex Desktop.`,
|
|
155
|
+
});
|
|
156
|
+
let snapshot;
|
|
157
|
+
try {
|
|
158
|
+
snapshot = await this.request("list_threads", { limit: 50 }, { deadline });
|
|
159
|
+
if (!Array.isArray(snapshot?.threads) || !Array.isArray(snapshot?.pinnedThreads)) throw new Error("Desktop returned an invalid thread list");
|
|
160
|
+
if (snapshot.unavailableHosts?.some((host) => host === "local" || host?.hostId === "local")) throw new Error("The local Desktop host is unavailable");
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return unverified(`Desktop's recent/pinned listing could not be confirmed: ${err.message}.`);
|
|
163
|
+
}
|
|
164
|
+
const observed = [...snapshot.pinnedThreads, ...snapshot.threads].filter((thread) => thread?.id === receipt.threadId && thread.kind === "codex" && thread.hostId === "local");
|
|
165
|
+
if (observed.length === 0) return unverified("This task is absent from Desktop's recent/pinned listing.");
|
|
166
|
+
for (const thread of observed) {
|
|
167
|
+
if (thread.projectId !== undefined && thread.projectId !== project.projectId) throw new Error(`Existing Desktop task ${receipt.threadId}'s project assignment changed. No prompt was resent and no duplicate task was created. Inspect its assignment in Codex Desktop.`);
|
|
168
|
+
if (thread.cwd) {
|
|
169
|
+
this.security.assertCwd(thread.cwd);
|
|
170
|
+
if (path.relative(cwd, realpathSync.native(thread.cwd))) throw new Error(`Existing Desktop task ${receipt.threadId}'s listed workspace changed. No prompt was resent and no duplicate task was created.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (observed.some((thread) => thread.projectId === undefined || !thread.cwd)) return unverified("Desktop's listing omitted this task's project or workspace metadata.");
|
|
174
|
+
return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
|
|
133
175
|
}
|
|
134
176
|
|
|
135
177
|
async create({ cwd, prompt, name, dedupeName = name, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|