@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.3",
3
+ "version": "1.22.5",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
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.3",
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.3";
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"];
@@ -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.3";
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 }).filter((task) => rootPolicy.allows(task.cwd)),
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
- const receipt = await creations.start({ requestId, cwd, prompt, autoSubmit, trustProject, account: accounts.claude, senderThreadId: sender.threadId,
258
- beforeOpen: async () => {
259
- runtime.assertCurrent();
260
- assertAccountIdentity(selectedAccounts);
261
- recheckScopeBindings(scopeBindings);
262
- if (rootPolicy.enabled && !rootPolicy.same(cwd, scopeBindings.recipient.path)) throw new Error("The Claude creation working directory changed before opening Desktop");
263
- const current = await assertSender(meta);
264
- if (JSON.stringify(current) !== JSON.stringify(sender)) throw new Error("The calling Codex turn changed before opening Claude Desktop");
265
- runtime.assertCurrent();
266
- assertAccountIdentity(selectedAccounts);
267
- recheckScopeBindings(scopeBindings);
268
- if (rootPolicy.enabled && !rootPolicy.same(cwd, scopeBindings.recipient.path)) throw new Error("The Claude creation working directory changed before opening Desktop");
269
- },
270
- });
271
- if (rootPolicy.enabled) creationRootBindings.set(requestId, scopeBindings);
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
- if (!Array.isArray(tasks) || tasks.length > MAX_TASKS || tasks.some((task) => !string(task?.taskId, 256) || (task.fileTaskId !== undefined && task.fileTaskId !== task.taskId))) fail("CLAUDE_CREATION_TASKS_INVALID", "The existing native task baseline cannot be verified.");
127
- let existingProject = false;
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 this.canonical(task.cwd), canonical)) existingProject = true; } catch {}
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
- const request = { requestId, cwd: canonical, prompt, initialMessage, account: identity, senderThreadId, baseline: [...new Set(tasks.map((task) => task.taskId))], status: "launch_uncertain", reason: "The Desktop launch outcome is uncertain; inspect this request before taking further action." };
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
- 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, 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 } : {}) }));
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
- return `<oai-mem-citation>\n<citation_entries>\n${entries}\n</citation_entries>\n<rollout_ids>\n${citation.rolloutIds.join("\n")}\n</rollout_ids>\n</oai-mem-citation>`;
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 >= contexts[0].start || contexts[0].start >= completions[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
- if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
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
- if (turnId === previousTurnId) throw new Error("The observed turn was already present before dispatch");
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: delegationOutputs(executorThreadId, prompt) },
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.3";
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: result.responseStatus === "completed" || result.responseStatus === "completed_no_reply" ? "accepted" : delivered.deliveryStatus ?? "accepted", ...(deliveryId ? { deliveryId } : {}), nextAction: pending && deliveryId ? "wait_codex_reply" : "none" },
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 prepared: ${err.message}. Inspect the existing task; do not resend.`, true);
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.3";
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) {