@minhspark/codex-mcp-bridge 1.22.3 → 1.22.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +38 -0
- package/README.md +77 -380
- package/docs/daily-handoffs.md +32 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +30 -19
- package/src/claude-project-discovery.mjs +25 -0
- package/src/claude-session-creation.mjs +43 -7
- package/src/codex-native-response.mjs +96 -15
- package/src/desktop-reply-receipts.mjs +28 -0
- package/src/index.mjs +6 -4
- package/src/native-relay-companion.mjs +1 -1
- package/src/peer-protocol.mjs +51 -7
- package/src/platform.mjs +7 -3
- package/src/project-policy.mjs +9 -0
- package/src/thread-delivery.mjs +59 -14
package/docs/daily-handoffs.md
CHANGED
|
@@ -27,6 +27,32 @@ session identity, Git root if applicable, and Remote Control state. Compare
|
|
|
27
27
|
Pin `expectedCwd` and `expectedTaskId` before dispatching production work.
|
|
28
28
|
Reuse this verified parent for subsequent handoffs.
|
|
29
29
|
|
|
30
|
+
If a saved Claude project's directory has moved, discovery reports a separate
|
|
31
|
+
`relocatedProjects` candidate when the old directory is missing and the workspace
|
|
32
|
+
resolver finds one writable replacement authorized by the shared project policy.
|
|
33
|
+
Revoked original paths, revoked targets, ambiguous replacements and existing old
|
|
34
|
+
directories do not qualify. Hardened deployments do not remap projects.
|
|
35
|
+
|
|
36
|
+
The candidate can establish the project for an explicitly authorized new Code
|
|
37
|
+
conversation. It cannot resume the old task or authorize a live session: the
|
|
38
|
+
new native task, account, process, actual cwd and initial message must still match.
|
|
39
|
+
Discovery does not rewrite Claude metadata, create grants or clear revocations.
|
|
40
|
+
The saved source and its authorization are rechecked before opening, trusting and
|
|
41
|
+
submitting, including after a pending request is restored.
|
|
42
|
+
|
|
43
|
+
Native composer creation submits an initial user prompt; it is not a peer
|
|
44
|
+
message and does not establish a native sender label. If a handoff requires the
|
|
45
|
+
recipient's peer sender attribution, use `send_to_claude_session` with a verified
|
|
46
|
+
live task. Do not substitute composer submission or a text header for that route.
|
|
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
|
+
|
|
30
56
|
If a link was already opened, retain its requestId and inspect its receipt.
|
|
31
57
|
`promptSubmitted=false` with `submissionStatus=not_observed` means submission
|
|
32
58
|
was not verified, not that nothing was delivered. A wrong-directory task can
|
|
@@ -50,6 +76,12 @@ new task, account, process, prompt and directory to match.
|
|
|
50
76
|
deliveryId. For Codex-to-Claude, retain the original msgId and inspect its
|
|
51
77
|
receipt. An unconfirmed acknowledgement is not proof of delivery; observe
|
|
52
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.
|
|
53
85
|
5. An `interrupted` receipt ends observation only: task outcome is unknown and
|
|
54
86
|
background work may continue. Inspect the existing task before a scoped
|
|
55
87
|
follow-up; do not repeat card creation or file operations.
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "codex-mcp-bridge",
|
|
4
|
-
"version": "1.22.
|
|
4
|
+
"version": "1.22.5",
|
|
5
5
|
"description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bui Dang Minh",
|
|
@@ -23,7 +23,7 @@ import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-acce
|
|
|
23
23
|
|
|
24
24
|
exitForVersionRequest(import.meta.url);
|
|
25
25
|
|
|
26
|
-
const VERSION = "1.22.
|
|
26
|
+
const VERSION = "1.22.5";
|
|
27
27
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
28
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
29
29
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -26,10 +26,11 @@ import { submitClaudeCodeComposer, trustClaudeCodeComposer } from "./claude-comp
|
|
|
26
26
|
import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
27
27
|
import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
|
|
28
28
|
import { registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
|
|
29
|
+
import { discoverClaudeProject } from "./claude-project-discovery.mjs";
|
|
29
30
|
|
|
30
31
|
exitForVersionRequest(import.meta.url);
|
|
31
32
|
|
|
32
|
-
const VERSION = "1.22.
|
|
33
|
+
const VERSION = "1.22.5";
|
|
33
34
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
34
35
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
35
36
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -71,7 +72,8 @@ const creations = new ClaudeSessionCreation({
|
|
|
71
72
|
submit: submitClaudeCodeComposer,
|
|
72
73
|
trust: trustClaudeCodeComposer,
|
|
73
74
|
settle: () => delay(2500),
|
|
74
|
-
listTasks: (account) => readClaudeDesktopTasks({ account })
|
|
75
|
+
listTasks: (account) => readClaudeDesktopTasks({ account }),
|
|
76
|
+
resolveProject: (task) => discoverClaudeProject(task, { policy: rootPolicy })?.cwd ?? null,
|
|
75
77
|
listSessions: () => listClaudeSessions().filter((session) => rootPolicy.allows(session.cwd)),
|
|
76
78
|
readContext: (session, account) => readClaudeDesktopContext(session, { account }),
|
|
77
79
|
readTranscript: (...args) => {
|
|
@@ -254,21 +256,25 @@ async function startClaudeCreation({ requestId, cwd, prompt, autoSubmit = proces
|
|
|
254
256
|
sender: rootPolicy.capture(sender.cwd, "Codex sender working directory"),
|
|
255
257
|
recipient: rootPolicy.capture(cwd, "Claude creation working directory"),
|
|
256
258
|
} : null;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
259
|
+
let receipt;
|
|
260
|
+
try {
|
|
261
|
+
receipt = await creations.start({ requestId, cwd, prompt, autoSubmit, trustProject, account: accounts.claude, senderThreadId: sender.threadId,
|
|
262
|
+
beforeOpen: async () => {
|
|
263
|
+
runtime.assertCurrent();
|
|
264
|
+
assertAccountIdentity(selectedAccounts);
|
|
265
|
+
recheckScopeBindings(scopeBindings);
|
|
266
|
+
if (rootPolicy.enabled && !rootPolicy.same(cwd, scopeBindings.recipient.path)) throw new Error("The Claude creation working directory changed before opening Desktop");
|
|
267
|
+
const current = await assertSender(meta);
|
|
268
|
+
if (JSON.stringify(current) !== JSON.stringify(sender)) throw new Error("The calling Codex turn changed before opening Claude Desktop");
|
|
269
|
+
runtime.assertCurrent();
|
|
270
|
+
assertAccountIdentity(selectedAccounts);
|
|
271
|
+
recheckScopeBindings(scopeBindings);
|
|
272
|
+
if (rootPolicy.enabled && !rootPolicy.same(cwd, scopeBindings.recipient.path)) throw new Error("The Claude creation working directory changed before opening Desktop");
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
} finally {
|
|
276
|
+
if (rootPolicy.enabled && creations.requests.has(requestId) && !creationRootBindings.has(requestId)) creationRootBindings.set(requestId, scopeBindings);
|
|
277
|
+
}
|
|
272
278
|
return { ...textResult(JSON.stringify(receipt, null, 2)), structuredContent: { creation: receipt } };
|
|
273
279
|
}
|
|
274
280
|
|
|
@@ -414,13 +420,18 @@ registerTool(
|
|
|
414
420
|
if (expectedCwd !== undefined) { try { assertClaudeSessionCwd(task, expectedCwd); } catch { return false; } }
|
|
415
421
|
return true;
|
|
416
422
|
}).map(({ taskId, title, cwd }) => ({ taskId, title, cwd, alive: sessions.some((session) => session.desktop?.taskId === taskId) }));
|
|
423
|
+
const relocatedProjects = saved.flatMap(task => {
|
|
424
|
+
const candidate = discoverClaudeProject(task, { policy: rootPolicy });
|
|
425
|
+
if (!candidate?.relocated || (expectedCwd !== undefined && !rootPolicy.same(candidate.cwd, expectedCwd))) return [];
|
|
426
|
+
return [{ taskId: task.taskId, originalCwd: candidate.originalCwd, resolvedCwd: candidate.cwd, liveVerified: false, resumable: false }];
|
|
427
|
+
});
|
|
417
428
|
const summary = sessions.length
|
|
418
429
|
? `${sessions.length} Claude${desktopOnly ? " Desktop" : ""} session(s):\n\n${sessions.map(formatSessionRow).join("\n")}`
|
|
419
430
|
: discovery.excluded.length
|
|
420
431
|
? `Found ${discovery.candidateCount} registered Claude Desktop candidate(s) with messaging endpoints, but their Desktop task identities could not be verified. Delivery remains blocked.`
|
|
421
432
|
: desktopOnly ? missingDesktopSession : "No live Claude Code session found.";
|
|
422
|
-
return { ...textResult([summary, ...discoveryDetails(discovery), ...(savedTasks.length ? [`Saved Desktop tasks (resume unfinished work with target auto and expectedTaskId):\n${JSON.stringify(savedTasks, null, 2)}`] : [])].join("\n")),
|
|
423
|
-
structuredContent: { account: publicAccountState(account), sessions: sessions.map(({ socket, bridgeSessionId, ...session }) => session), savedTasks, discovery } };
|
|
433
|
+
return { ...textResult([summary, ...discoveryDetails(discovery), ...(savedTasks.length ? [`Saved Desktop tasks (resume unfinished work with target auto and expectedTaskId):\n${JSON.stringify(savedTasks, null, 2)}`] : []), ...(relocatedProjects.length ? [`Relocated project candidates (new conversation only when authorized; old tasks are not verified or resumable at the new path):\n${JSON.stringify(relocatedProjects, null, 2)}`] : [])].join("\n")),
|
|
434
|
+
structuredContent: { account: publicAccountState(account), sessions: sessions.map(({ socket, bridgeSessionId, ...session }) => session), savedTasks, relocatedProjects, discovery } };
|
|
424
435
|
} catch (err) {
|
|
425
436
|
return failure(err);
|
|
426
437
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveWorkspacePath } from "./platform.mjs";
|
|
4
|
+
|
|
5
|
+
export function discoverClaudeProject(task, { policy, resolveWorkspace = resolveWorkspacePath }) {
|
|
6
|
+
if (!task || task.isArchived !== false || typeof task.taskId !== "string" || !task.taskId || task.fileTaskId !== task.taskId ||
|
|
7
|
+
typeof task.cwd !== "string" || !path.isAbsolute(task.cwd) || task.cwd.includes("\0")) return null;
|
|
8
|
+
try {
|
|
9
|
+
let cwd;
|
|
10
|
+
let relocated = false;
|
|
11
|
+
try {
|
|
12
|
+
cwd = fs.realpathSync.native(task.cwd);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error.code !== "ENOENT" || policy.mode !== "project-policy") return null;
|
|
15
|
+
try { fs.lstatSync(task.cwd); return null; } catch (missing) { if (missing.code !== "ENOENT") return null; }
|
|
16
|
+
policy.assertDiscoverySource(task.cwd);
|
|
17
|
+
const candidate = resolveWorkspace(task.cwd, { requireUnique: true });
|
|
18
|
+
if (!candidate.remapped || !candidate.writable) return null;
|
|
19
|
+
cwd = fs.realpathSync.native(candidate.path);
|
|
20
|
+
relocated = true;
|
|
21
|
+
}
|
|
22
|
+
const binding = policy.capture(cwd, "Discovered Claude project directory");
|
|
23
|
+
return { cwd: binding.path, originalCwd: task.cwd, relocated, binding };
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
@@ -60,7 +60,7 @@ function receipt(request) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export class ClaudeSessionCreation {
|
|
63
|
-
constructor({ open, submit, trust, settle = async () => {}, listTasks, listSessions, readContext, readTranscript, readAccount, assertProcess, realpath = (directory) => {
|
|
63
|
+
constructor({ open, submit, trust, settle = async () => {}, listTasks, listSessions, readContext, readTranscript, readAccount, assertProcess, resolveProject, realpath = (directory) => {
|
|
64
64
|
const canonical = fs.realpathSync.native(directory);
|
|
65
65
|
if (!fs.statSync(canonical).isDirectory()) throw new Error("Not a directory");
|
|
66
66
|
return canonical;
|
|
@@ -68,7 +68,7 @@ export class ClaudeSessionCreation {
|
|
|
68
68
|
for (const [name, dependency] of Object.entries({ open, listTasks, listSessions, readContext, readTranscript, readAccount, assertProcess, realpath })) {
|
|
69
69
|
if (typeof dependency !== "function") throw new TypeError(`${name} must be a function`);
|
|
70
70
|
}
|
|
71
|
-
Object.assign(this, { open, submit, trust, settle, listTasks, listSessions, readContext, readTranscript, readAccount, assertProcess, realpath, platform });
|
|
71
|
+
Object.assign(this, { open, submit, trust, settle, listTasks, listSessions, readContext, readTranscript, readAccount, assertProcess, resolveProject, realpath, platform });
|
|
72
72
|
this.requests = new Map();
|
|
73
73
|
this.queue = Promise.resolve();
|
|
74
74
|
}
|
|
@@ -83,6 +83,7 @@ export class ClaudeSessionCreation {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
sameCwd(left, right) {
|
|
86
|
+
if (typeof left !== "string" || typeof right !== "string") return false;
|
|
86
87
|
return this.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -123,11 +124,14 @@ export class ClaudeSessionCreation {
|
|
|
123
124
|
if (sameAccount(existing.account, identity) && PENDING.has(existing.status) && !composerReleased) fail("CLAUDE_CREATION_PENDING", "A creation request is already pending for this account; inspect or explicitly abandon that request before opening another project in the shared composer.");
|
|
124
125
|
}
|
|
125
126
|
const tasks = await this.listTasks(account);
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
const validTasks = value => Array.isArray(value) && value.length <= MAX_TASKS && value.every(task => string(task?.taskId, 256) && (task.fileTaskId === undefined || task.fileTaskId === task.taskId)) && new Set(value.map(task => task.taskId)).size === value.length;
|
|
128
|
+
if (!validTasks(tasks)) fail("CLAUDE_CREATION_TASKS_INVALID", "The existing native task baseline cannot be verified.");
|
|
129
|
+
const baseline = new Set(tasks.map(task => task.taskId));
|
|
130
|
+
const projectCwd = async task => this.resolveProject ? this.resolveProject(task) : this.canonical(task.cwd);
|
|
131
|
+
let existingProject;
|
|
128
132
|
for (const task of tasks) {
|
|
129
133
|
if (task.isArchived !== false) continue;
|
|
130
|
-
try { if (this.sameCwd(await
|
|
134
|
+
try { if (this.sameCwd(await projectCwd(task), canonical)) { existingProject = task; break; } } catch {}
|
|
131
135
|
}
|
|
132
136
|
if (!existingProject) fail("CLAUDE_CREATION_PROJECT_NOT_FOUND", "The canonical directory must already belong to a nonarchived Claude Desktop task.");
|
|
133
137
|
if (autoSubmit && typeof this.submit !== "function") fail("CLAUDE_CREATION_SUBMIT_UNAVAILABLE", "Native composer submission is not available.");
|
|
@@ -139,7 +143,20 @@ export class ClaudeSessionCreation {
|
|
|
139
143
|
if (typeof beforeOpen !== "function") fail("CLAUDE_CREATION_PREFLIGHT_INVALID", "beforeOpen must be a function.");
|
|
140
144
|
await beforeOpen();
|
|
141
145
|
}
|
|
142
|
-
|
|
146
|
+
if (this.resolveProject) {
|
|
147
|
+
const currentTasks = await this.listTasks(account);
|
|
148
|
+
if (!validTasks(currentTasks)) fail("CLAUDE_CREATION_TASKS_INVALID", "The current native task baseline cannot be verified.");
|
|
149
|
+
const matches = currentTasks.filter(task => task.taskId === existingProject.taskId && task.fileTaskId === existingProject.fileTaskId && task.cwd === existingProject.cwd && task.isArchived === false);
|
|
150
|
+
if (matches.length !== 1 || !this.sameCwd(await projectCwd(matches[0]), canonical)) {
|
|
151
|
+
fail("CLAUDE_CREATION_PROJECT_NOT_FOUND", "The saved project or its authorized current directory changed before opening Desktop.");
|
|
152
|
+
}
|
|
153
|
+
for (const task of currentTasks) baseline.add(task.taskId);
|
|
154
|
+
if (baseline.size > MAX_TASKS) fail("CLAUDE_CREATION_TASKS_INVALID", "The native task baseline exceeds the creation limit.");
|
|
155
|
+
if (beforeOpen) await beforeOpen();
|
|
156
|
+
}
|
|
157
|
+
const projectSource = this.resolveProject ? { taskId: existingProject.taskId, fileTaskId: existingProject.fileTaskId, cwd: existingProject.cwd } : undefined;
|
|
158
|
+
const request = { requestId, cwd: canonical, prompt, initialMessage, account: identity, senderThreadId, baseline: [...baseline], ...(projectSource ? { projectSource } : {}), status: "launch_uncertain", reason: "The Desktop launch outcome is uncertain; inspect this request before taking further action." };
|
|
159
|
+
await this.revalidateProjectSource(request, account);
|
|
143
160
|
this.requests.set(requestId, request);
|
|
144
161
|
try {
|
|
145
162
|
await this.open(url);
|
|
@@ -155,11 +172,25 @@ export class ClaudeSessionCreation {
|
|
|
155
172
|
return receipt(request);
|
|
156
173
|
}
|
|
157
174
|
|
|
175
|
+
async revalidateProjectSource(request, account) {
|
|
176
|
+
if (!this.resolveProject) return;
|
|
177
|
+
const source = request.projectSource;
|
|
178
|
+
if (!source) fail("CLAUDE_CREATION_PROJECT_NOT_FOUND", "The original saved project source is unavailable; submission is not authorized.");
|
|
179
|
+
const tasks = await this.listTasks(account);
|
|
180
|
+
if (!Array.isArray(tasks) || tasks.length > MAX_TASKS) fail("CLAUDE_CREATION_TASKS_INVALID", "The saved project source cannot be verified.");
|
|
181
|
+
const matches = tasks.filter(task => task?.taskId === source.taskId);
|
|
182
|
+
if (matches.length !== 1 || matches[0].fileTaskId !== source.fileTaskId || matches[0].cwd !== source.cwd || matches[0].isArchived !== false ||
|
|
183
|
+
!this.sameCwd(await this.resolveProject(matches[0]), request.cwd)) {
|
|
184
|
+
fail("CLAUDE_CREATION_PROJECT_NOT_FOUND", "The original saved project or its authorization changed; submission is not authorized.");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
158
188
|
async completeRequest(request, { account, senderThreadId, beforeOpen, trustProject }) {
|
|
159
189
|
if (typeof this.submit !== "function") fail("CLAUDE_CREATION_SUBMIT_UNAVAILABLE", "Native composer submission is not available.");
|
|
160
190
|
if (trustProject && typeof this.trust !== "function") fail("CLAUDE_CREATION_TRUST_UNAVAILABLE", "Authorized project trust preparation is not available.");
|
|
161
191
|
await this.checkAccount(account, request.account);
|
|
162
192
|
if (beforeOpen) await beforeOpen();
|
|
193
|
+
await this.revalidateProjectSource(request, account);
|
|
163
194
|
if (trustProject) {
|
|
164
195
|
const trusted = await this.trust({ cwd: request.cwd, prompt: request.initialMessage });
|
|
165
196
|
if (!["trusted", "absent"].includes(trusted?.status)) {
|
|
@@ -171,6 +202,7 @@ export class ClaudeSessionCreation {
|
|
|
171
202
|
}
|
|
172
203
|
await this.checkAccount(account, request.account);
|
|
173
204
|
if (beforeOpen) await beforeOpen();
|
|
205
|
+
await this.revalidateProjectSource(request, account);
|
|
174
206
|
request.submissionAttempted = true;
|
|
175
207
|
request.status = "submission_uncertain";
|
|
176
208
|
request.reason = "Native submission was attempted; inspect this request before retrying. The bridge will not press Send again.";
|
|
@@ -178,6 +210,7 @@ export class ClaudeSessionCreation {
|
|
|
178
210
|
const submission = await this.submit({ prompt: request.initialMessage, cwd: request.cwd, beforeSubmit: async () => {
|
|
179
211
|
await this.checkAccount(account, request.account);
|
|
180
212
|
if (beforeOpen) await beforeOpen();
|
|
213
|
+
await this.revalidateProjectSource(request, account);
|
|
181
214
|
} });
|
|
182
215
|
if (submission?.status === "blocked" && submission.submissionAttempted === false) delete request.submissionAttempted;
|
|
183
216
|
request.status = submission?.status === "submitted" ? "awaiting_session" : submission?.status === "blocked" ? "awaiting_user" : "submission_uncertain";
|
|
@@ -263,7 +296,10 @@ export class ClaudeSessionCreation {
|
|
|
263
296
|
if ((request.status === "awaiting_project_confirmation" || request.observedCwd !== undefined) && (!string(request.observedCwd, 8192) || !paths.isAbsolute(request.observedCwd))) fail("CLAUDE_CREATION_STATE_INVALID", "A retained observed directory is invalid.");
|
|
264
297
|
if (request.status === "awaiting_project_confirmation" && (request.submissionObserved !== true || this.sameCwd(request.cwd, request.observedCwd))) fail("CLAUDE_CREATION_STATE_INVALID", "A pending project confirmation is invalid.");
|
|
265
298
|
if (request.ambiguitySeen !== undefined && typeof request.ambiguitySeen !== "boolean") fail("CLAUDE_CREATION_STATE_INVALID", "A retained ambiguity marker is invalid.");
|
|
266
|
-
|
|
299
|
+
if (request.projectSource !== undefined && (!string(request.projectSource?.taskId, 256) || !request.baseline.includes(request.projectSource.taskId) ||
|
|
300
|
+
(request.projectSource.fileTaskId !== undefined && request.projectSource.fileTaskId !== request.projectSource.taskId) ||
|
|
301
|
+
!string(request.projectSource.cwd, 8192) || !paths.isAbsolute(request.projectSource.cwd))) fail("CLAUDE_CREATION_STATE_INVALID", "A retained project source is invalid.");
|
|
302
|
+
restored.set(request.requestId, structuredClone({ requestId: request.requestId, cwd: request.cwd, prompt: request.prompt, initialMessage: request.initialMessage, account: { fingerprint: request.account.fingerprint, root: request.account.root }, senderThreadId: request.senderThreadId, baseline: request.baseline, ...(request.projectSource ? { projectSource: { taskId: request.projectSource.taskId, fileTaskId: request.projectSource.fileTaskId, cwd: request.projectSource.cwd } } : {}), status: request.status, reason: request.reason, ...(request.submissionAttempted ? { submissionAttempted: true } : {}), ...(request.ambiguitySeen || request.status === "ambiguous" ? { ambiguitySeen: true } : {}), ...(request.submissionObserved ? { submissionObserved: true } : {}), ...(request.observedCwd ? { observedCwd: request.observedCwd } : {}), ...(hasSession ? { sessionId: request.sessionId, taskId: request.taskId, title: request.title } : {}) }));
|
|
267
303
|
}
|
|
268
304
|
this.requests = restored;
|
|
269
305
|
}
|
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { scanRollout } from "./rollout-reader.mjs";
|
|
6
7
|
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
|
|
7
8
|
|
|
@@ -81,20 +82,22 @@ function samePathBoundary(expected, current) {
|
|
|
81
82
|
entry.path === current.ancestors[index].path && entry.dev === current.ancestors[index].dev && entry.ino === current.ancestors[index].ino);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize } = {}) {
|
|
85
|
+
function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize, dispatchOutputs, dispatchAfterOffset = afterOffset } = {}) {
|
|
85
86
|
for (let attempt = 0; ; attempt++) {
|
|
86
|
-
try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }); }
|
|
87
|
+
try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }); }
|
|
87
88
|
catch (error) {
|
|
88
89
|
if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }) {
|
|
94
|
+
function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
|
|
94
95
|
const { file, sessions } = found;
|
|
95
96
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ROLLOUT_BYTES) throw new Error("The Codex rollout read limit is invalid");
|
|
96
97
|
const beforeBoundary = capturePathBoundary(file, sessions);
|
|
97
98
|
const records = [];
|
|
99
|
+
const dispatches = [];
|
|
100
|
+
const otherTurnStarts = [];
|
|
98
101
|
const digest = crypto.createHash("sha256");
|
|
99
102
|
let session, latestContext, retainedBytes = 0, turnBeforeWatermark = false;
|
|
100
103
|
const snapshot = scanRollout(file, {
|
|
@@ -111,6 +114,19 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
|
|
|
111
114
|
}
|
|
112
115
|
if (record.type === "turn_context") latestContext = record.payload;
|
|
113
116
|
const entry = { record, start };
|
|
117
|
+
if (start >= dispatchAfterOffset && dispatchOutputs && record.type === "response_item" && record.payload.type === "function_call_output" &&
|
|
118
|
+
record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread" && dispatchOutputs.includes(record.payload.output)) {
|
|
119
|
+
const metadata = record.payload.internal_chat_message_metadata_passthrough;
|
|
120
|
+
const selectedTurnId = record.payload.turn_id ?? record.payload.root_turn_id ?? metadata?.turn_id ?? metadata?.root_turn_id;
|
|
121
|
+
if (!UUID.test(selectedTurnId) || !recordBelongsToTurn(entry, threadId, selectedTurnId)) throw new Error("The native dispatch has no consistent turn identity");
|
|
122
|
+
dispatches.push({ turnId: selectedTurnId, start });
|
|
123
|
+
if (dispatches.length > 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
|
|
124
|
+
}
|
|
125
|
+
if (turnId && start >= afterOffset && (record.type === "turn_context" || record.type === "event_msg" && STARTED.has(record.payload.type)) &&
|
|
126
|
+
!recordBelongsToTurn(entry, threadId, turnId)) {
|
|
127
|
+
otherTurnStarts.push(start);
|
|
128
|
+
if (otherTurnStarts.length > 65536) throw new Error("The native turn boundary count exceeds its read limit");
|
|
129
|
+
}
|
|
114
130
|
if (turnId && recordBelongsToTurn(entry, threadId, turnId)) {
|
|
115
131
|
if (start < afterOffset) { turnBeforeWatermark = true; return; }
|
|
116
132
|
retainedBytes += bytes;
|
|
@@ -123,7 +139,7 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
|
|
|
123
139
|
if (!samePathBoundary(beforeBoundary, afterBoundary) || snapshot.identity.dev !== beforeBoundary.file.dev || snapshot.identity.ino !== beforeBoundary.file.ino) {
|
|
124
140
|
throw new Error("The Codex rollout changed while reading");
|
|
125
141
|
}
|
|
126
|
-
return { ...snapshot, file, sessions, records, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
|
|
142
|
+
return { ...snapshot, file, sessions, records, dispatches, otherTurnStarts, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
function canonicalDirectory(value, label) {
|
|
@@ -209,10 +225,11 @@ function completedMemoryCitation(item) {
|
|
|
209
225
|
!Number.isSafeInteger(entry.lineStart) || entry.lineStart < 1 || !Number.isSafeInteger(entry.lineEnd) || entry.lineEnd < entry.lineStart) ||
|
|
210
226
|
citation.rolloutIds.some((id) => typeof id !== "string" || !UUID.test(id))) return null;
|
|
211
227
|
const entries = citation.entries.map((entry) => `${entry.path}:${entry.lineStart}-${entry.lineEnd}|note=[${entry.note}]`).join("\n");
|
|
212
|
-
|
|
228
|
+
const rolloutIds = citation.rolloutIds.join("\n");
|
|
229
|
+
return `<oai-mem-citation>\n<citation_entries>\n${entries ? `${entries}\n` : ""}</citation_entries>\n<rollout_ids>\n${rolloutIds ? `${rolloutIds}\n` : ""}</rollout_ids>\n</oai-mem-citation>`;
|
|
213
230
|
}
|
|
214
231
|
|
|
215
|
-
function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch } = {}) {
|
|
232
|
+
function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch, otherTurnStarts = [] } = {}) {
|
|
216
233
|
const turns = records.filter((entry) => recordBelongsToTurn(entry, threadId, turnId));
|
|
217
234
|
const starts = turns.filter(({ record }) => record.type === "event_msg" && STARTED.has(record.payload.type));
|
|
218
235
|
const contexts = turns.filter(({ record }) => record.type === "turn_context");
|
|
@@ -222,26 +239,39 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
|
|
|
222
239
|
const lifecycleError = (check, fields) => validationError("The native turn lifecycle is missing, ambiguous, failed, or out of order", check, fields);
|
|
223
240
|
if (failures.length) throw lifecycleError("lifecycle_failure", { count: failures.length, eventBytes: recordOffsets(failures) });
|
|
224
241
|
for (const [kind, entries] of [["start", starts], ["context", contexts], ["completion", completions]]) {
|
|
225
|
-
if (entries.length !== 1) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
|
|
242
|
+
if (entries.length !== 1 && (kind !== "context" || !entries.length)) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
|
|
243
|
+
}
|
|
244
|
+
if (contexts.some((entry) => !isDeepStrictEqual(entry.record.payload, contexts[0].record.payload))) {
|
|
245
|
+
throw lifecycleError("lifecycle_context_changed", { count: contexts.length, recordBytes: recordOffsets(contexts) });
|
|
226
246
|
}
|
|
227
247
|
if (canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd) {
|
|
228
248
|
throw lifecycleError("lifecycle_workspace", { contextByte: contexts[0].start });
|
|
229
249
|
}
|
|
230
|
-
if (starts[0].start >=
|
|
250
|
+
if (contexts.some((entry) => starts[0].start >= entry.start || entry.start >= completions[0].start)) {
|
|
231
251
|
throw lifecycleError("lifecycle_order", { startByte: starts[0].start, contextByte: contexts[0].start, completionByte: completions[0].start });
|
|
232
252
|
}
|
|
233
253
|
|
|
234
254
|
let dispatch = null;
|
|
235
255
|
if (requireDispatch) {
|
|
236
256
|
const dispatches = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "function_call_output" &&
|
|
237
|
-
record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread")
|
|
238
|
-
|
|
257
|
+
record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread") &&
|
|
258
|
+
(!requireDispatch.existingTurn || requireDispatch.outputs.includes(record.payload.output)));
|
|
259
|
+
if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) || dispatches[0].start < (requireDispatch.afterOffset ?? 0) ||
|
|
260
|
+
(requireDispatch.existingTurn && contexts[0].start >= requireDispatch.afterOffset) ||
|
|
239
261
|
contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
|
|
240
262
|
throw validationError("The newly observed turn is not correlated to the exact native dispatch", "dispatch_correlation", {
|
|
241
263
|
count: dispatches.length, dispatchBytes: recordOffsets(dispatches), contextByte: contexts[0].start, completionByte: completions[0].start,
|
|
242
264
|
});
|
|
243
265
|
}
|
|
244
266
|
dispatch = dispatches[0];
|
|
267
|
+
if (otherTurnStarts.some((start) => start > dispatch.start && start < completions[0].start)) {
|
|
268
|
+
throw validationError("Another native turn superseded the dispatch before completion", "dispatch_superseded");
|
|
269
|
+
}
|
|
270
|
+
if (requireDispatch.existingTurn && turns.some(({ record, start }) => start > dispatch.start &&
|
|
271
|
+
(record.type === "response_item" && record.payload.type === "message" && record.payload.role === "user" ||
|
|
272
|
+
record.type === "event_msg" && record.payload.type === "user_message"))) {
|
|
273
|
+
throw validationError("New user input superseded the native dispatch in the active turn", "dispatch_superseded");
|
|
274
|
+
}
|
|
245
275
|
}
|
|
246
276
|
|
|
247
277
|
const allAssistantEntries = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "message" && record.payload.role === "assistant");
|
|
@@ -378,10 +408,52 @@ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd, desktopE
|
|
|
378
408
|
}
|
|
379
409
|
}
|
|
380
410
|
|
|
411
|
+
export function resolveCodexNativeDispatchResponse(binding, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
|
|
412
|
+
const { threadId, expectedCwd, executorThreadId, prompt, watermark, desktopEvidence } = binding;
|
|
413
|
+
let turnId = null;
|
|
414
|
+
try {
|
|
415
|
+
if (!UUID.test(threadId) || !UUID.test(executorThreadId) || typeof prompt !== "string" || !prompt.length) throw new Error("The native dispatch identity is invalid");
|
|
416
|
+
if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
|
|
417
|
+
const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
|
|
418
|
+
if (watermark.cwd !== cwd) throw new Error("The selected native task workspace changed after dispatch");
|
|
419
|
+
const marks = watermark.segments ?? [watermark];
|
|
420
|
+
if (!Array.isArray(marks) || !marks.length || marks.length > 64 || new Set(marks.map((mark) => mark.file)).size !== marks.length ||
|
|
421
|
+
!marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256) ||
|
|
422
|
+
marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
|
|
423
|
+
const found = findRollouts(threadId, env);
|
|
424
|
+
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
425
|
+
const outputs = delegationOutputs(executorThreadId, prompt);
|
|
426
|
+
const snapshots = found.map((entry) => {
|
|
427
|
+
const mark = marks.find(({ file }) => file === entry.file);
|
|
428
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
|
|
429
|
+
if (mark) {
|
|
430
|
+
if (snapshot.identity.dev !== mark.identity?.dev || snapshot.identity.ino !== mark.identity?.ino || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
|
|
431
|
+
if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
|
|
432
|
+
}
|
|
433
|
+
validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
434
|
+
return snapshot;
|
|
435
|
+
});
|
|
436
|
+
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
437
|
+
const matches = snapshots.flatMap((snapshot) => snapshot.dispatches.map((dispatch) => ({ snapshot, dispatch })));
|
|
438
|
+
if (!matches.length) return { status: "pending", threadId, turnId: null, text: "", assistantItems: [], replySha256: null };
|
|
439
|
+
if (matches.length !== 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
|
|
440
|
+
validateSession(matches[0].snapshot, threadId, cwd, desktopEvidence);
|
|
441
|
+
turnId = matches[0].dispatch.turnId;
|
|
442
|
+
const result = readCodexNativeTurnResponse({ ...binding, turnId }, { env, maxRolloutBytes });
|
|
443
|
+
if (result.status === "unavailable" && result.dispatchObserved && /check=lifecycle_completion_count; count=0;/.test(result.reason)) {
|
|
444
|
+
return { status: "pending", threadId, turnId, dispatchObserved: true, text: "", assistantItems: [], replySha256: null };
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
381
452
|
export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId, expectedCwd, executorThreadId, prompt, watermark, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
|
|
453
|
+
let dispatchObserved = false;
|
|
382
454
|
try {
|
|
383
455
|
if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native response identity is invalid");
|
|
384
|
-
|
|
456
|
+
const existingTurn = turnId === previousTurnId;
|
|
385
457
|
if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact dispatched prompt is unavailable");
|
|
386
458
|
if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
|
|
387
459
|
const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
|
|
@@ -393,10 +465,11 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
|
|
|
393
465
|
for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
|
|
394
466
|
const found = findRollouts(threadId, env);
|
|
395
467
|
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
468
|
+
const outputs = delegationOutputs(executorThreadId, prompt);
|
|
396
469
|
let retainedBytes = 0;
|
|
397
470
|
const snapshots = found.map((entry) => {
|
|
398
471
|
const mark = marks.find(({ file }) => file === entry.file);
|
|
399
|
-
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size });
|
|
472
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
|
|
400
473
|
retainedBytes += snapshot.retainedBytes;
|
|
401
474
|
if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
|
|
402
475
|
if (mark) {
|
|
@@ -409,12 +482,20 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
|
|
|
409
482
|
});
|
|
410
483
|
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
411
484
|
const selected = uniqueTurn(snapshots);
|
|
485
|
+
const matchedDispatches = snapshots.flatMap((snapshot) => snapshot.dispatches);
|
|
486
|
+
if (matchedDispatches.length !== 1 || matchedDispatches[0].turnId !== turnId) throw new Error("The observed turn is not uniquely correlated to the exact native dispatch");
|
|
412
487
|
validateSession(selected, threadId, cwd, desktopEvidence);
|
|
488
|
+
const selectedMark = marks.find(({ file }) => file === selected.file);
|
|
489
|
+
if (existingTurn && !selectedMark) throw new Error("The active native turn has no pre-send rollout watermark");
|
|
490
|
+
dispatchObserved = selected.records.some(({ record, start }) => start >= (selectedMark?.size ?? 0) &&
|
|
491
|
+
record.type === "response_item" && record.payload.type === "function_call_output" &&
|
|
492
|
+
record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread" && outputs.includes(record.payload.output));
|
|
413
493
|
const inspected = inspectTurnRecords(selected.records, threadId, turnId, cwd, {
|
|
414
|
-
requireDispatch: { outputs:
|
|
494
|
+
requireDispatch: { outputs, afterOffset: selectedMark?.size ?? 0, existingTurn },
|
|
495
|
+
otherTurnStarts: selected.otherTurnStarts,
|
|
415
496
|
});
|
|
416
|
-
return { ...inspected, observationStatus: inspected.status };
|
|
497
|
+
return { ...inspected, observationStatus: inspected.status, ...(existingTurn ? { responseScope: "same_turn_final" } : {}) };
|
|
417
498
|
} catch (error) {
|
|
418
|
-
return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
|
|
499
|
+
return { ...unavailable(error), ...(dispatchObserved ? { dispatchObserved: true } : {}), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
|
|
419
500
|
}
|
|
420
501
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
5
|
import { creationRequestHash, creationIdValid } from "./native-creation-receipts.mjs";
|
|
5
6
|
import { homeDir } from "./platform.mjs";
|
|
6
7
|
|
|
@@ -52,6 +53,33 @@ export class DesktopReplyReceipts {
|
|
|
52
53
|
return id;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
acknowledge(id, delivered, context) {
|
|
57
|
+
const previous = this.read(id, context);
|
|
58
|
+
const acknowledgement = JSON.parse(JSON.stringify(delivered));
|
|
59
|
+
if (acknowledgement.deliveryStatus !== "accepted" || previous.creationObservation ||
|
|
60
|
+
!["unconfirmed", "accepted"].includes(previous.deliveryStatus) ||
|
|
61
|
+
!isDeepStrictEqual({ ...acknowledgement, deliveryStatus: previous.deliveryStatus }, previous)) {
|
|
62
|
+
throw new Error("Delivery acknowledgement does not match the original reply binding");
|
|
63
|
+
}
|
|
64
|
+
if (previous.deliveryStatus === "accepted") return;
|
|
65
|
+
const file = this.file(id);
|
|
66
|
+
const bytes = Buffer.from(JSON.stringify({ version: 1, deliveryId: id, owner: owner(context), delivered: acknowledgement }));
|
|
67
|
+
if (bytes.length > LIMIT) throw new Error("Reply observation binding exceeds the receipt limit");
|
|
68
|
+
const temporary = `${file}.${randomUUID()}.pending`;
|
|
69
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
70
|
+
try {
|
|
71
|
+
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); }
|
|
72
|
+
finally { fs.closeSync(fd); }
|
|
73
|
+
if (!isDeepStrictEqual(this.read(id, context), previous) || this.file(id) !== file) {
|
|
74
|
+
throw new Error("Reply receipt changed before acknowledgement");
|
|
75
|
+
}
|
|
76
|
+
fs.renameSync(temporary, file);
|
|
77
|
+
} finally {
|
|
78
|
+
try { fs.unlinkSync(temporary); }
|
|
79
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
read(id, context) {
|
|
56
84
|
const file = this.file(id);
|
|
57
85
|
const before = fs.lstatSync(file);
|
package/src/index.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
|
|
|
40
40
|
|
|
41
41
|
exitForVersionRequest(import.meta.url);
|
|
42
42
|
|
|
43
|
-
const VERSION = "1.22.
|
|
43
|
+
const VERSION = "1.22.5";
|
|
44
44
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
45
45
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
46
46
|
|
|
@@ -151,11 +151,12 @@ const failure = (err) => {
|
|
|
151
151
|
|
|
152
152
|
function replyResult(delivered, deliveryId, result, notes = []) {
|
|
153
153
|
result = { activity: [], errors: [], ...result };
|
|
154
|
+
const deliveryStatus = ["completed", "completed_no_reply"].includes(result.responseStatus) ? "accepted" : delivered.deliveryStatus ?? "accepted";
|
|
154
155
|
const pending = result.status === "timeout";
|
|
155
156
|
const continuation = deliveryId ? `deliveryId: ${deliveryId}\n${pending ? "Continue automatically with wait_codex_reply and this deliveryId; it only observes the original send. Do not resend the prompt." : "Keep this deliveryId to inspect the same send after reconnecting."}` : "";
|
|
156
157
|
return {
|
|
157
|
-
...textResult([...notes, formatTurn(result, { desktop: true }), continuation].filter(Boolean).join("\n"), result.status === "failed" || result.status === "systemError"),
|
|
158
|
-
structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus
|
|
158
|
+
...textResult([...notes, `deliveryStatus: ${deliveryStatus}`, formatTurn(result, { desktop: true }), continuation].filter(Boolean).join("\n"), result.status === "failed" || result.status === "systemError"),
|
|
159
|
+
structuredContent: { ...result, threadId: delivered.threadId, deliveryStatus, ...(deliveryId ? { deliveryId } : {}), nextAction: pending && deliveryId ? "wait_codex_reply" : "none" },
|
|
159
160
|
};
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -625,6 +626,7 @@ registerTool(
|
|
|
625
626
|
acceptedDelivery = prepared;
|
|
626
627
|
} });
|
|
627
628
|
acceptedDelivery = delivered;
|
|
629
|
+
if (delivered.deliveryStatus === "accepted") replyReceipts.acknowledge(deliveryId, delivered, desktopOperation.getStore());
|
|
628
630
|
notes.push(delivered.deliveryStatus === "unconfirmed" ? "Desktop delivery acknowledgement timed out; delivery is unconfirmed. Continue observing this receipt; do not resend." : "sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
|
|
629
631
|
if (delivered.deliveryStatus === "unconfirmed") return replyResult(delivered, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" }, notes);
|
|
630
632
|
if (shouldOpen) {
|
|
@@ -694,7 +696,7 @@ registerTool(
|
|
|
694
696
|
const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
|
|
695
697
|
return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
|
|
696
698
|
} catch (err) {
|
|
697
|
-
if (acceptedDelivery?.deliveryStatus === "accepted") return textResult(`threadId: ${threadId}\nTask was accepted, but its reply receipt could not be
|
|
699
|
+
if (acceptedDelivery?.deliveryStatus === "accepted") return textResult(`threadId: ${threadId}\ndeliveryId: ${deliveryId}\nTask was accepted, but its reply receipt could not be updated: ${err.message}. Inspect the existing task; do not resend.`, true);
|
|
698
700
|
return failure(err);
|
|
699
701
|
}
|
|
700
702
|
}, {
|
|
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
|
|
|
38
38
|
|
|
39
39
|
exitForVersionRequest(import.meta.url);
|
|
40
40
|
|
|
41
|
-
const VERSION = "1.22.
|
|
41
|
+
const VERSION = "1.22.5";
|
|
42
42
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
43
43
|
|
|
44
44
|
function errorResponse(code, message, sent, details) {
|