@minhspark/codex-mcp-bridge 1.20.0 → 1.21.0

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.
Files changed (53) hide show
  1. package/.codex-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +57 -15
  4. package/assets/README.md +30 -0
  5. package/assets/agent-pets.svg +1 -0
  6. package/docs/daily-handoffs.md +106 -0
  7. package/docs/desktop-handoff-design.md +180 -0
  8. package/docs/new-machine-setup.md +112 -0
  9. package/docs/project-onboarding.md +122 -0
  10. package/package.json +8 -2
  11. package/plugin.json +10 -3
  12. package/scripts/bridge-projects.mjs +44 -0
  13. package/scripts/check.mjs +2 -1
  14. package/scripts/collect-repo-analytics.mjs +5 -2
  15. package/scripts/configure-message-automation.mjs +18 -0
  16. package/scripts/install-claude-code.mjs +64 -0
  17. package/scripts/install-claude-desktop.mjs +48 -5
  18. package/scripts/install-native-relay.mjs +6 -1
  19. package/scripts/install-npm-footer.mjs +13 -1
  20. package/scripts/publish-repo-analytics.mjs +24 -1
  21. package/src/app-server-client.mjs +5 -2
  22. package/src/bridge-readiness.mjs +23 -0
  23. package/src/card-settings-sync.mjs +125 -0
  24. package/src/claude-bridge.mjs +54 -20
  25. package/src/claude-code-registration.mjs +54 -0
  26. package/src/claude-sender-context.mjs +1 -1
  27. package/src/claude-session-creation.mjs +10 -2
  28. package/src/codex-mcp-registration.mjs +1 -1
  29. package/src/codex-native-response.mjs +139 -107
  30. package/src/codex-sender-context.mjs +62 -61
  31. package/src/desktop-reply-receipts.mjs +87 -0
  32. package/src/desktop-task-receipts.mjs +30 -7
  33. package/src/hardened-root-policy.mjs +5 -0
  34. package/src/index.mjs +97 -19
  35. package/src/mcp-supervisor.mjs +6 -2
  36. package/src/message-automation.mjs +89 -0
  37. package/src/native-creation-receipts.mjs +155 -0
  38. package/src/native-relay-companion.mjs +37 -6
  39. package/src/native-relay.mjs +12 -3
  40. package/src/peer-protocol.mjs +192 -18
  41. package/src/project-manager-registration.mjs +34 -0
  42. package/src/project-onboarding-tools.mjs +30 -0
  43. package/src/project-onboarding.mjs +178 -0
  44. package/src/project-policy.mjs +221 -0
  45. package/src/project-scope.mjs +33 -5
  46. package/src/prompt-guidance.mjs +2 -0
  47. package/src/release-snapshot.mjs +30 -5
  48. package/src/rollout-reader.mjs +58 -0
  49. package/src/rollout-segments.mjs +65 -0
  50. package/src/security-policy.mjs +4 -2
  51. package/src/sites-connector.mjs +18 -3
  52. package/src/telemetry.mjs +10 -8
  53. package/src/thread-delivery.mjs +134 -35
@@ -0,0 +1,155 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+ import { homeDir } from "./platform.mjs";
6
+
7
+ export const creationIdValid = value => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value);
8
+ export function creationRequestHash(args) {
9
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === "object"
10
+ ? Object.fromEntries(Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => [k, canonical(value[k])])) : value;
11
+ return createHash("sha256").update(JSON.stringify(canonical(args))).digest("hex");
12
+ }
13
+
14
+ // Stores only exact native creation requests and their results. A missing or
15
+ // pending receipt never permits dispatch again, including after a restart.
16
+ export class NativeCreationReceipts {
17
+ #key;
18
+ #keyBoundary;
19
+ #keyDirectoryBoundary;
20
+ #keyDirectory;
21
+ constructor({ directory = path.join(process.env.CODEX_HOME ?? path.join(homeDir(), ".codex"), "bridge-native-creations") } = {}) {
22
+ this.directory = path.resolve(directory);
23
+ this.#keyDirectory = path.join(path.dirname(this.directory), "bridge-native-creation-authority");
24
+ }
25
+ #signingKey(create = false) {
26
+ if (create) {
27
+ try {
28
+ fs.mkdirSync(this.#keyDirectory, { mode: 0o700 });
29
+ if (process.platform === "win32") this.#protectWindowsPrivate(this.#keyDirectory, true);
30
+ } catch (error) { if (error.code !== "EEXIST") throw error; }
31
+ }
32
+ const directory = fs.lstatSync(this.#keyDirectory);
33
+ const canonical = fs.realpathSync.native(this.#keyDirectory);
34
+ if (!directory.isDirectory() || directory.isSymbolicLink() ||
35
+ process.platform !== "win32" && ((directory.mode & 0o077) || directory.uid !== process.getuid()) ||
36
+ this.#keyDirectoryBoundary && (this.#keyDirectoryBoundary.path !== canonical || this.#keyDirectoryBoundary.dev !== directory.dev || this.#keyDirectoryBoundary.ino !== directory.ino)) {
37
+ throw Error("Unsafe native creation authority directory");
38
+ }
39
+ if (process.platform === "win32" && !this.#keyDirectoryBoundary) {
40
+ this.#assertWindowsPrivate(canonical, true);
41
+ }
42
+ this.#keyDirectoryBoundary ??= { path: canonical, dev: directory.dev, ino: directory.ino };
43
+ const file = path.join(canonical, "signing-key");
44
+ if (create) {
45
+ let created, createdBoundary;
46
+ try {
47
+ created = fs.openSync(file, "wx", 0o600);
48
+ createdBoundary = fs.fstatSync(created);
49
+ if (process.platform === "win32") this.#protectWindowsPrivate(file, false);
50
+ fs.writeFileSync(created, randomBytes(32));
51
+ fs.fsyncSync(created);
52
+ }
53
+ catch (error) {
54
+ if (created !== undefined) {
55
+ fs.closeSync(created);
56
+ created = undefined;
57
+ const failed = fs.lstatSync(file);
58
+ if (failed.dev === createdBoundary.dev && failed.ino === createdBoundary.ino && !failed.isSymbolicLink()) fs.unlinkSync(file);
59
+ }
60
+ if (error.code !== "EEXIST") throw error;
61
+ }
62
+ finally { if (created !== undefined) fs.closeSync(created); }
63
+ }
64
+ const before = fs.lstatSync(file);
65
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size !== 32 ||
66
+ process.platform !== "win32" && ((before.mode & 0o077) || before.uid !== process.getuid())) throw Error("Unsafe native creation signing key");
67
+ if (process.platform === "win32" && !this.#keyBoundary) this.#assertWindowsPrivate(file, false);
68
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
69
+ let key;
70
+ try {
71
+ const opened = fs.fstatSync(fd);
72
+ if (opened.dev !== before.dev || opened.ino !== before.ino) throw Error("Native creation signing key changed");
73
+ const bytes = Buffer.alloc(33);
74
+ let count = 0, read;
75
+ while (count < bytes.length && (read = fs.readSync(fd, bytes, count, bytes.length - count, count))) count += read;
76
+ key = bytes.subarray(0, count);
77
+ const after = fs.fstatSync(fd);
78
+ if (key.length !== 32 || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) throw Error("Native creation signing key changed");
79
+ } finally { fs.closeSync(fd); }
80
+ if (this.#keyBoundary && (this.#keyBoundary.dev !== before.dev || this.#keyBoundary.ino !== before.ino || !timingSafeEqual(this.#key, key))) throw Error("Native creation signing key changed");
81
+ this.#keyBoundary ??= { dev: before.dev, ino: before.ino };
82
+ this.#key ??= key;
83
+ return this.#key;
84
+ }
85
+ #protectWindowsPrivate(file, directory) {
86
+ const quoted = file.replaceAll("'", "''");
87
+ const securityType = directory ? "DirectorySecurity" : "FileSecurity";
88
+ const inheritance = directory ? "ContainerInherit,ObjectInherit" : "None";
89
+ const script = `$ErrorActionPreference='Stop';$s=[Security.Principal.WindowsIdentity]::GetCurrent().User;$a=New-Object Security.AccessControl.${securityType};$a.SetOwner($s);$a.SetAccessRuleProtection($true,$false);$r=New-Object Security.AccessControl.FileSystemAccessRule($s,'FullControl','${inheritance}','None','Allow');$a.AddAccessRule($r);Set-Acl -LiteralPath '${quoted}' -AclObject $a`;
90
+ this.#runWindowsAclScript(script);
91
+ }
92
+ #assertWindowsPrivate(file, directory) {
93
+ const quoted = file.replaceAll("'", "''");
94
+ const script = `$ErrorActionPreference='Stop';$a=Get-Acl -LiteralPath '${quoted}';$s=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;if(${directory ? "$true" : "$false"} -and !$a.AreAccessRulesProtected){throw 'Unprotected native authority'};if($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $s){throw 'Unexpected native authority owner'};foreach($r in $a.Access){if($r.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -ne $s -or $r.AccessControlType -ne 'Allow'){throw 'Unexpected native authority access'}};if($a.Access.Count -ne 1){throw 'Ambiguous native authority access'}`;
95
+ this.#runWindowsAclScript(script);
96
+ }
97
+ #runWindowsAclScript(script) {
98
+ const shellDirectory = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0");
99
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "psmodulepath"));
100
+ env.PSModulePath = path.win32.join(shellDirectory, "Modules");
101
+ execFileSync(path.win32.join(shellDirectory, "powershell.exe"), ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { env, stdio: "pipe", windowsHide: true, timeout: 10000 });
102
+ }
103
+ #signature(row, create = false) {
104
+ const { signature, ...payload } = row;
105
+ return createHmac("sha256", this.#signingKey(create)).update("native-creation-receipt-v1\0").update(creationRequestHash(payload)).digest("hex");
106
+ }
107
+ file(id) {
108
+ if (!creationIdValid(id)) throw Error("Invalid native creation receipt id");
109
+ fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
110
+ const st = fs.lstatSync(this.directory);
111
+ if (!st.isDirectory() || st.isSymbolicLink()) throw Error("Unsafe native creation receipt directory");
112
+ const canonical = fs.realpathSync.native(this.directory);
113
+ if (this.boundary && (this.boundary.path !== canonical || this.boundary.dev !== st.dev || this.boundary.ino !== st.ino)) throw Error("Native creation receipt directory changed");
114
+ this.boundary ??= { path: canonical, dev: st.dev, ino: st.ino };
115
+ this.directory = canonical;
116
+ return path.join(canonical, id + ".json");
117
+ }
118
+ read(id, accounts, hash) {
119
+ const file = this.file(id);
120
+ let fd, receiptFound = false;
121
+ try {
122
+ const before = fs.lstatSync(file);
123
+ receiptFound = true;
124
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 1024 * 1024) throw Error("Unsafe native creation receipt");
125
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
126
+ const opened = fs.fstatSync(fd);
127
+ if (opened.ino !== before.ino || opened.dev !== before.dev) throw Error("Native creation receipt changed");
128
+ const bytes = Buffer.alloc(1024 * 1024 + 1);
129
+ let count = 0, n;
130
+ while ((n = fs.readSync(fd, bytes, count, bytes.length - count, count))) { count += n; if (count > 1024 * 1024) throw Error("Oversized native creation receipt"); }
131
+ const after = fs.fstatSync(fd);
132
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || count !== opened.size) throw Error("Native creation receipt changed");
133
+ const row = JSON.parse(bytes.subarray(0, count).toString());
134
+ if (row.version !== 1 || row.id !== id || row.hash !== hash || creationRequestHash(row.args) !== hash ||
135
+ !accounts || ["claude", "codex"].some(k => !accounts[k] || row.accounts?.[k] !== accounts[k]) ||
136
+ typeof row.executorThreadId !== "string" || !["pending", "completed", "failed"].includes(row.state)) throw Error("Native creation receipt binding mismatch");
137
+ if (typeof row.signature !== "string" || !/^[a-f0-9]{64}$/.test(row.signature) ||
138
+ !timingSafeEqual(Buffer.from(row.signature, "hex"), Buffer.from(this.#signature(row), "hex"))) throw Error("Native creation receipt signature is invalid; ownership recovery is blocked");
139
+ return row;
140
+ } catch (e) { if (e.code === "ENOENT" && !receiptFound) return null; throw e; }
141
+ finally { if (fd !== undefined) fs.closeSync(fd); }
142
+ }
143
+ reserve(id, args, accounts, executorThreadId) {
144
+ const row = { version: 1, id, hash: creationRequestHash(args), args, accounts, executorThreadId, state: "pending" };
145
+ this.write(row, true); return row;
146
+ }
147
+ write(row, exclusive = false) {
148
+ const file = this.file(row.id), tmp = exclusive ? file : file + "." + randomUUID() + ".pending";
149
+ const bytes = JSON.stringify({ ...row, signature: this.#signature(row, exclusive) });
150
+ if (Buffer.byteLength(bytes) > 1024 * 1024) throw Error("Oversized native creation receipt");
151
+ const fd = fs.openSync(tmp, "wx", 0o600);
152
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
153
+ if (!exclusive) fs.renameSync(tmp, file);
154
+ }
155
+ }
@@ -32,9 +32,11 @@ import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
32
32
  import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
33
33
  import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
34
34
 
35
+ import { NativeCreationReceipts, creationIdValid } from "./native-creation-receipts.mjs";
36
+
35
37
  exitForVersionRequest(import.meta.url);
36
38
 
37
- const VERSION = "1.20.0";
39
+ const VERSION = "1.21.0";
38
40
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
39
41
 
40
42
  function errorResponse(code, message, sent) {
@@ -238,12 +240,12 @@ export function createNativeScopeAuthorizer({ dispatchDesktop, env = process.env
238
240
  */
239
241
  export async function handleRelayRequest(
240
242
  payload,
241
- { dispatch, dispatchDesktop, resolveExecutor = resolveRelayThreadId, env = process.env, assertAccount = assertAccountIdentity, authorize, strict = hardenedBridgeEnabled(env) } = {},
243
+ { dispatch, dispatchDesktop, resolveExecutor = resolveRelayThreadId, env = process.env, assertAccount = assertAccountIdentity, authorize, creationReceipts = new NativeCreationReceipts(), strict = hardenedBridgeEnabled(env) } = {},
242
244
  ) {
243
245
  if (strict && (!boundRequest(payload) || !validProtocol(payload))) return errorResponse("RELAY_BAD_REQUEST", "Hardened relay accepts only protocol 2 requests with account context", false);
244
246
  if (strict && typeof authorize !== "function") return errorResponse("NATIVE_SCOPE_UNVERIFIED", "Hardened relay requires verified current native project and task metadata; no dispatch was attempted", false);
245
247
  if (payload && typeof payload === "object" && Object.hasOwn(payload, "operation")) {
246
- return handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount, authorize, strict });
248
+ return handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount, authorize, creationReceipts, strict });
247
249
  }
248
250
  if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
249
251
  Object.keys(payload).some((key) => !["v", "targetThreadId", "message", "accountContext"].includes(key)) || !validProtocol(payload)) {
@@ -295,14 +297,15 @@ export async function handleRelayRequest(
295
297
  }
296
298
  }
297
299
 
298
- async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount, authorize, strict = hardenedBridgeEnabled(env) }) {
300
+ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor, env, assertAccount, authorize, creationReceipts, strict = hardenedBridgeEnabled(env) }) {
299
301
  if (strict && !boundRequest(payload)) return errorResponse("RELAY_BAD_REQUEST", "Hardened relay accepts only protocol 2 Desktop operations", false);
300
302
  if (Array.isArray(payload) || ![RELAY_PROTOCOL_VERSION, ACCOUNT_RELAY_PROTOCOL_VERSION].includes(payload.v) || !validProtocol(payload) ||
301
- Object.keys(payload).some((key) => !["v", "operation", "arguments", "accountContext"].includes(key))) {
303
+ Object.keys(payload).some((key) => !["v", "operation", "arguments", "accountContext", "creationReceiptId"].includes(key))) {
302
304
  return errorResponse("RELAY_BAD_REQUEST", "expected an allowlisted Desktop operation");
303
305
  }
304
306
  try {
305
307
  validateDesktopOperation(payload.operation, payload.arguments);
308
+ if (payload.creationReceiptId !== undefined && (payload.operation !== "create_thread" || !boundRequest(payload) || !creationIdValid(payload.creationReceiptId))) throw new NativeRelayError("Invalid creation receipt binding", "RELAY_BAD_REQUEST");
306
309
  let accountContext;
307
310
  try {
308
311
  accountContext = await checkAccountContext(payload, assertAccount);
@@ -313,12 +316,29 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
313
316
  return errorResponse("NATIVE_OPERATION_UNAVAILABLE", "This companion does not support Desktop operations; reload the native relay");
314
317
  }
315
318
  const executorThreadId = resolveExecutor(env).threadId;
319
+ if (payload.operation === "get_creation_receipt") {
320
+ if (!accountContext) throw new NativeRelayError("Creation receipts require bound accounts", "RELAY_BAD_REQUEST");
321
+ const row = creationReceipts.read(payload.arguments.receiptId, accountContext, payload.arguments.requestHash);
322
+ if (row && row.executorThreadId !== executorThreadId) throw new NativeRelayError("Creation receipt executor changed", "NATIVE_SCOPE_UNVERIFIED");
323
+ const original = row ? { executorThreadId, operation: "create_thread", arguments: row.args, accountContext } : { executorThreadId, operation: "list_projects", arguments: {}, accountContext };
324
+ if (strict) {
325
+ const initial = await authorize(original);
326
+ if (row?.state === "completed") await authorize({ ...original, phase: "return", expected: initial, result: row.result });
327
+ }
328
+ await assertAccount(accountContext);
329
+ return { ok: true, v: payload.v, operation: payload.operation, executorThreadId,
330
+ result: { status: row?.state ?? "missing", ...(row?.state === "completed" ? { result: row.result } : {}), ...(row?.state === "failed" ? { error: row.error } : {}) } };
331
+ }
316
332
  const request = { executorThreadId, targetThreadId: payload.arguments?.threadId, operation: payload.operation, arguments: payload.arguments, accountContext };
317
333
  const authorization = strict ? await authorize(request) : null;
318
334
  if (payload.operation === "send_message_to_thread" && payload.arguments.threadId === executorThreadId) {
319
335
  return errorResponse("RELAY_BAD_REQUEST", "The relay executor cannot receive its own relayed message");
320
336
  }
321
- const nativeResult = await dispatchDesktop({
337
+ // Reserve before dispatch. A disconnected caller must not discard a late
338
+ // result, and even identical duplicate writes must not dispatch twice.
339
+ const creation = payload.creationReceiptId ? creationReceipts.reserve(payload.creationReceiptId, payload.arguments, accountContext, executorThreadId) : null;
340
+ let nativeResult;
341
+ try { nativeResult = await dispatchDesktop({
322
342
  executorThreadId,
323
343
  operation: payload.operation,
324
344
  arguments: payload.arguments,
@@ -328,6 +348,10 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
328
348
  });
329
349
  const decoded = decodeNativeToolResult(nativeResult);
330
350
  const checked = strict ? await authorize({ ...request, phase: "return", expected: authorization, result: decoded }) : null;
351
+ if (creation) {
352
+ await assertAccount(accountContext);
353
+ creationReceipts.write({ ...creation, state: "completed", result: checked?.result ?? decoded });
354
+ }
331
355
  return {
332
356
  ok: true,
333
357
  v: boundRequest(payload) ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION,
@@ -335,6 +359,10 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
335
359
  executorThreadId,
336
360
  result: checked?.result ?? decoded,
337
361
  };
362
+ } catch (error) {
363
+ if (creation) creationReceipts.write({ ...creation, state: "failed", error: { code: errorCode(error), message: error.message } });
364
+ throw error;
365
+ }
338
366
  } catch (err) {
339
367
  return errorResponse(errorCode(err), err?.message ?? String(err), err?.sent === false && err?.reachedCompanion !== true ? false : undefined);
340
368
  }
@@ -354,6 +382,7 @@ export class RelaySocketServer {
354
382
  resolveExecutor = resolveRelayThreadId,
355
383
  assertAccount = assertAccountIdentity,
356
384
  authorize,
385
+ creationReceipts = new NativeCreationReceipts(),
357
386
  requireAccountContext = false,
358
387
  strict = hardenedBridgeEnabled(),
359
388
  protectSocket = protectCurrentUserPipe,
@@ -368,6 +397,7 @@ export class RelaySocketServer {
368
397
  this.resolveExecutor = resolveExecutor;
369
398
  this.assertAccount = assertAccount;
370
399
  this.authorize = authorize;
400
+ this.creationReceipts = creationReceipts;
371
401
  this.requireAccountContext = requireAccountContext;
372
402
  this.strict = strict;
373
403
  this.protectSocket = protectSocket;
@@ -574,6 +604,7 @@ export class RelaySocketServer {
574
604
  assertAccount: this.assertAccount,
575
605
  authorize: this.authorize,
576
606
  strict: this.strict,
607
+ creationReceipts: this.creationReceipts,
577
608
  });
578
609
  if (!response.ok) this.log(`relay refused ${payload?.targetThreadId ?? "?"}: ${response.error.message}`);
579
610
  else this.log(response.operation ? `completed Desktop operation ${response.operation}` : `relayed a message into thread ${response.targetThreadId}`);
@@ -177,6 +177,8 @@ export function nativeDispatchParams({ executorThreadId, targetThreadId, message
177
177
  };
178
178
  }
179
179
 
180
+ import { creationIdValid } from "./native-creation-receipts.mjs";
181
+
180
182
  const DESKTOP_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
181
183
 
182
184
  function exactObject(value, keys) {
@@ -201,6 +203,9 @@ export function validateDesktopOperation(operation, args) {
201
203
  const modelSettings = () => optionalText(args.model) &&
202
204
  (args.thinking === undefined || DESKTOP_EFFORTS.has(args.thinking));
203
205
  switch (operation) {
206
+ case "get_creation_receipt":
207
+ valid = exactObject(args, ["receiptId", "requestHash"]) && creationIdValid(args.receiptId) && /^[a-f0-9]{64}$/.test(args.requestHash);
208
+ break;
204
209
  case "list_projects":
205
210
  valid = exactObject(args, []);
206
211
  break;
@@ -540,7 +545,10 @@ export async function resolveNativeToolsPipePath({
540
545
  const remaining = deadline - Date.now();
541
546
  if (remaining <= 0) break;
542
547
  try {
543
- if (await probeWindowsPipe(candidate, { env, timeoutMs: Math.min(750, remaining) })) return candidate;
548
+ // Cold Desktop metadata reads can take over a second. Keep the shared
549
+ // seven-second discovery budget, but do not reject a valid endpoint
550
+ // merely because its first read exceeds the old 750ms probe window.
551
+ if (await probeWindowsPipe(candidate, { env, timeoutMs: Math.min(3000, remaining) })) return candidate;
544
552
  } catch {}
545
553
  }
546
554
  return null;
@@ -801,11 +809,12 @@ export class NativeDesktopRelay {
801
809
  return this.#request(request, timeoutMs, beforeSend);
802
810
  }
803
811
 
804
- async requestDesktop(operation, args, { timeoutMs = this.timeoutMs, beforeSend, accountContext } = {}) {
812
+ async requestDesktop(operation, args, { timeoutMs = this.timeoutMs, beforeSend, accountContext, creationReceiptId } = {}) {
805
813
  validateDesktopOperation(operation, args);
806
814
  const accounts = relayAccountContext(accountContext);
807
815
  if (hardenedBridgeEnabled(this.env) && !accounts) throw beforeWriteError(new NativeRelayError("Hardened relay requires protocol 2 account context", "RELAY_BAD_REQUEST"));
808
- return this.#request({ v: accounts ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, operation, arguments: args, ...(accounts ? { accountContext: accounts } : {}) }, timeoutMs, beforeSend);
816
+ if (creationReceiptId !== undefined && (operation !== "create_thread" || !accounts || !creationIdValid(creationReceiptId))) throw beforeWriteError(new NativeRelayError("Invalid creation receipt binding", "RELAY_BAD_REQUEST"));
817
+ return this.#request({ v: accounts ? ACCOUNT_RELAY_PROTOCOL_VERSION : RELAY_PROTOCOL_VERSION, operation, arguments: args, ...(accounts ? { accountContext: accounts } : {}), ...(creationReceiptId ? { creationReceiptId } : {}) }, timeoutMs, beforeSend);
809
818
  }
810
819
 
811
820
  async #request(request, timeoutMs, beforeSend) {
@@ -167,6 +167,7 @@ function isProcessAlive(pid) {
167
167
  const SESSION_READ_ATTEMPTS = 3;
168
168
  const PEER_SEND_ATTEMPTS = 3;
169
169
  const PEER_CONNECT_TIMEOUT_MS = 2000;
170
+ const PEER_VERIFY_TIMEOUT_MS = 30000;
170
171
  const PEER_RETRY_DELAY_MS = 75;
171
172
  const RETRYABLE_PEER_ERRORS = new Set(["ECONNREFUSED", "ECONNRESET", "ENOENT", "EPIPE", "ENOTFOUND", "ETIMEDOUT"]);
172
173
 
@@ -364,36 +365,179 @@ function injectedMessageRoot(entry, msgId) {
364
365
  return null;
365
366
  }
366
367
 
368
+ function isInterruptedDelivery(receipt) {
369
+ return receipt?.status === "interrupted" && receipt.source === "transcript"
370
+ && receipt.reasonCode === "CLAUDE_REPLY_INTERRUPTED"
371
+ && typeof receipt.inReplyTo === "string" && receipt.inReplyTo.length > 0
372
+ && Number.isFinite(receipt.observedAt)
373
+ && Array.isArray(receipt.interruptedBy) && receipt.interruptedBy.length > 0
374
+ && receipt.interruptedBy.every((id) => typeof id === "string" && id.trim())
375
+ && Array.isArray(receipt.backgroundTaskIds)
376
+ && receipt.backgroundTaskIds.every((id) => typeof id === "string" && id.trim());
377
+ }
378
+
379
+ function isTerminalDelivery(receipt) {
380
+ return ["refused", "denied", "expired", "dropped"].includes(receipt?.status) || isInterruptedDelivery(receipt);
381
+ }
382
+
367
383
  export function readTranscriptReply(sessionId, cwd, msgId) {
384
+ return readTranscriptOutcome(sessionId, cwd, msgId)?.reply ?? null;
385
+ }
386
+
387
+ // Read(image) adds a native user-role companion after its tool result (and
388
+ // sometimes hooks). It describes image scaling, not a new user command.
389
+ // Require the actual image-result ancestry; matching text alone is not proof.
390
+ function isImageToolCompanion(entry, entriesById) {
391
+ if (entry.type !== "user" || entry.message?.role !== "user"
392
+ || entry.isMeta !== true || entry.turnCompanion !== true
393
+ || entry.origin != null || entry.promptSource != null || entry.turnOrigin != null
394
+ || typeof entry.promptId !== "string" || !entry.promptId
395
+ || typeof entry.sessionId !== "string" || !entry.sessionId
396
+ || typeof entry.message.content !== "string") return false;
397
+ let result = entriesById.get(entry.parentUuid);
398
+ for (let i = 0; i < 8 && result?.type === "attachment"
399
+ && ["hook_success", "total_tokens_reminder"].includes(result.attachment?.type); i++) {
400
+ result = entriesById.get(result.parentUuid);
401
+ }
402
+ const call = entriesById.get(result?.parentUuid);
403
+ return result?.type === "user" && result.message?.role === "user"
404
+ && result.promptId === entry.promptId && result.sessionId === entry.sessionId
405
+ && result.toolUseResult?.type === "image"
406
+ && result.sourceToolAssistantUUID === call?.uuid
407
+ && call?.message?.role === "assistant" && call.sessionId === entry.sessionId
408
+ && Array.isArray(result.message.content) && result.message.content.length === 1
409
+ && result.message.content.every(part => part?.type === "tool_result" && !part.is_error
410
+ && Array.isArray(part.content) && part.content.some(block => block?.type === "image")
411
+ && Array.isArray(call.message.content) && call.message.content.some(tool =>
412
+ tool?.type === "tool_use" && tool.name === "Read"
413
+ && typeof tool.id === "string" && tool.id === part.tool_use_id));
414
+ }
415
+
416
+ // Interruption closes reply observation, not execution. Never attach a later
417
+ // user turn's answer to the original request or claim its side effects stopped.
418
+ export function readTranscriptOutcome(sessionId, cwd, msgId) {
368
419
  const file = findTranscriptFile(sessionId, cwd);
369
420
  let lines;
370
421
  try { lines = fs.readFileSync(file, "utf8").split("\n"); }
371
422
  catch { return null; }
372
423
  const entries = [];
373
424
  const roots = [];
425
+ const ids = new Set();
426
+ let ambiguous = false;
374
427
  for (const line of lines) {
428
+ if (!line.trim()) continue;
375
429
  let entry;
376
- try { entry = JSON.parse(line); } catch { continue; }
430
+ try { entry = JSON.parse(line); } catch { ambiguous = true; continue; }
377
431
  if (!entry || typeof entry !== "object" || Array.isArray(entry) || entry.isSidechain) continue;
432
+ if (typeof entry.uuid === "string") {
433
+ if (ids.has(entry.uuid)) ambiguous = true;
434
+ ids.add(entry.uuid);
435
+ }
378
436
  entries.push(entry);
379
437
  const root = injectedMessageRoot(entry, msgId);
380
438
  if (root) roots.push({ entry, absorbed: root === "absorbed" });
381
439
  }
382
440
  if (roots.length !== 1) return null;
383
441
  const { entry: root, absorbed } = roots[0];
384
- const descendants = new Set();
442
+ const entriesById = new Map(entries.map((entry) => [entry.uuid, entry]));
443
+ const descendants = new Map();
444
+ const backgroundCalls = new Map();
445
+ const openLeaves = new Set();
446
+ const passiveLeaves = new Set();
447
+ const interruptions = [];
448
+ const interrupt = (entry, outstanding) => {
449
+ if (typeof entry.uuid !== "string" || !entry.uuid.trim()) return;
450
+ openLeaves.delete(entry.parentUuid);
451
+ interruptions.push({ entryId: entry.uuid, backgroundTaskIds: [...outstanding.keys()] });
452
+ };
385
453
  for (const entry of entries) {
386
- if (entry === root) { descendants.add(entry.uuid); continue; }
454
+ if (entry === root) { descendants.set(entry.uuid, new Map()); openLeaves.add(entry.uuid); continue; }
387
455
  if (!descendants.has(entry.parentUuid)) continue;
388
- if (entry.type === "attachment" && entry.attachment?.type === "queued_command") continue;
456
+ if (entry.type === "attachment" && entry.attachment?.type === "queued_command") {
457
+ // Only a native command record is a boundary. Its text is never used as
458
+ // proof of authorization, cancellation, or successful task completion.
459
+ if (entry.attachment.commandMode === "prompt") {
460
+ interrupt(entry, descendants.get(entry.parentUuid));
461
+ continue;
462
+ }
463
+ // An inline native task notification is not a new prompt. Preserve the
464
+ // active reply chain even when the notification belongs to another task;
465
+ // its text alone never completes any outstanding background command.
466
+ if (entry.attachment.commandMode !== "task-notification"
467
+ || entry.attachment.origin?.kind !== "task-notification"
468
+ || entry.attachment.origin?.producer !== "session-task") continue;
469
+ }
389
470
  const role = entry.message?.role;
390
471
  const content = entry.message?.content;
391
- if (role === "user" && !(Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "tool_result"))) continue;
472
+ const outstanding = new Map(descendants.get(entry.parentUuid));
473
+ const toolResults = Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "tool_result");
474
+ const imageCompanion = isImageToolCompanion(entry, entriesById);
475
+ if (role === "user" && !toolResults && !imageCompanion) {
476
+ const notificationText = typeof content === "string" ? content
477
+ : Array.isArray(content) ? content.filter((part) => typeof part?.text === "string").map((part) => part.text).join("\n") : "";
478
+ const nativeHuman = entry.origin?.kind === "human" && entry.promptSource === "sdk" && entry.turnOrigin === "human";
479
+ const legacyInput = entry.origin == null && entry.promptSource == null && entry.turnOrigin == null;
480
+ const ordinaryInput = !entry.isMeta && (nativeHuman || legacyInput)
481
+ && !notificationText.includes("<task-notification>")
482
+ && (typeof content === "string" && content.trim()
483
+ || Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "text" && typeof part.text === "string")
484
+ && content.some((part) => part.text.trim()));
485
+ if (ordinaryInput) {
486
+ interrupt(entry, outstanding);
487
+ continue;
488
+ }
489
+ // A background command can end one model turn, then resume it through a
490
+ // native task notification. Only that correlated system event may cross
491
+ // the user-message boundary; pasted notification text is never enough.
492
+ if (entry.origin?.kind !== "task-notification" || entry.origin.producer !== "session-task"
493
+ || entry.promptSource !== "system" || entry.turnOrigin !== "task_notification"
494
+ || typeof content !== "string" || !/^<task-notification>\s[\s\S]*<\/task-notification>$/.test(content.trim())) continue;
495
+ const field = (name) => {
496
+ const matches = [...content.matchAll(new RegExp(`<${name}>([^<>]+)</${name}>`, "g"))];
497
+ return matches.length === 1 ? matches[0][1] : null;
498
+ };
499
+ const taskId = field("task-id");
500
+ if (!outstanding.has(taskId) || outstanding.get(taskId) !== field("tool-use-id")
501
+ || !["completed", "failed", "killed"].includes(field("status"))) continue;
502
+ outstanding.delete(taskId);
503
+ }
392
504
  if (typeof entry.uuid !== "string" || !entry.uuid.trim()) continue;
393
- descendants.add(entry.uuid);
505
+ if (role === "assistant" && Array.isArray(content)) {
506
+ for (const part of content) {
507
+ if (part?.type === "tool_use" && ["Bash", "PowerShell"].includes(part.name)
508
+ && typeof part.id === "string") backgroundCalls.set(part.id, entry.uuid);
509
+ }
510
+ }
511
+ const taskId = entry.toolUseResult?.backgroundTaskId;
512
+ if (role === "user" && toolResults && typeof taskId === "string" && /^[\w-]{1,128}$/.test(taskId)) {
513
+ const matches = content.filter((part) => !part.is_error && backgroundCalls.get(part.tool_use_id) === entry.parentUuid);
514
+ if (matches.length === 1) outstanding.set(taskId, matches[0].tool_use_id);
515
+ }
516
+ descendants.set(entry.uuid, outstanding);
517
+ // Parallel tool results and hook records can be leaf siblings of the real
518
+ // continuation. They are completed observations, not separate reply turns.
519
+ const parentEntry = entriesById.get(entry.parentUuid);
520
+ const matchedResults = toolResults && !entry.toolUseResult?.backgroundTaskId
521
+ && content.every((part) => Array.isArray(parentEntry?.message?.content)
522
+ && parentEntry.message.content.some((call) => call.type === "tool_use" && call.id === part.tool_use_id));
523
+ if (matchedResults || imageCompanion || entry.type === "attachment"
524
+ && ["hook_success", "total_tokens_reminder", "deferred_tools_record"].includes(entry.attachment?.type)) {
525
+ passiveLeaves.add(entry.uuid);
526
+ }
527
+ openLeaves.delete(entry.parentUuid);
528
+ openLeaves.add(entry.uuid);
394
529
  if (role !== "assistant" || !["end_turn", "stop_sequence"].includes(entry.message.stop_reason)) continue;
530
+ if (outstanding.size > 0) continue;
395
531
  const text = Array.isArray(content) ? content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n") : typeof content === "string" ? content : "";
396
- if (text.trim()) return { text: text.trim(), msgId: entry.uuid, source: "transcript", inReplyTo: msgId, absorbed };
532
+ if (text.trim()) return { status: "reply_received", reply: { text: text.trim(), msgId: entry.uuid, source: "transcript", inReplyTo: msgId, absorbed } };
533
+ }
534
+ if (!ambiguous && interruptions.length && [...openLeaves].every((id) => passiveLeaves.has(id))) {
535
+ return { status: "interrupted", source: "transcript", inReplyTo: msgId,
536
+ reasonCode: "CLAUDE_REPLY_INTERRUPTED",
537
+ reason: "A new conversation command separated this request from later replies. Reply waiting ended; the original task outcome is unknown and any background work may still be running. Inspect the existing task before sending a follow-up; do not resend the original action.",
538
+ interruptedBy: interruptions.map((entry) => entry.entryId),
539
+ backgroundTaskIds: [...new Set(interruptions.flatMap((entry) => entry.backgroundTaskIds))],
540
+ };
397
541
  }
398
542
  return null;
399
543
  }
@@ -611,6 +755,9 @@ export class PeerEndpoint {
611
755
  reason: typeof frame.reason === "string" ? frame.reason : "",
612
756
  fromSocket: decodePeerAddress(frame.from),
613
757
  };
758
+ // This status is derived locally from transcript ancestry, never
759
+ // accepted as a peer assertion that releases pending ownership.
760
+ if (receipt.status === "interrupted") continue;
614
761
  const pending = this.pendingMessages.get(frame.orig_msg_id);
615
762
  if (pending?.targetSocket === receipt.fromSocket) {
616
763
  this.deliveryReceipts.set(frame.orig_msg_id, receipt);
@@ -673,8 +820,14 @@ export class PeerEndpoint {
673
820
  catch { continue; }
674
821
  if (stamp === pending.transcriptStamp) continue;
675
822
  pending.transcriptStamp = stamp;
676
- const reply = readTranscriptReply(sessionId, cwd, msgId);
677
- if (reply) this.#receiveMessage({ ...reply, fromSocket: pending.targetSocket });
823
+ const outcome = readTranscriptOutcome(sessionId, cwd, msgId);
824
+ if (outcome?.reply) this.#receiveMessage({ ...outcome.reply, fromSocket: pending.targetSocket });
825
+ else if (outcome?.status === "interrupted") {
826
+ this.activityRevision += 1;
827
+ this.deliveryReceipts.set(msgId, { ...outcome, fromSocket: pending.targetSocket, observedAt: Date.now() });
828
+ this.#removePendingReply(pending.targetSocket, msgId);
829
+ this.log(`reply observation interrupted for ${msgId}; task outcome remains unknown`);
830
+ }
678
831
  }
679
832
  if (![...this.pendingMessages.values()].some((entry) => entry.transcriptSession)) {
680
833
  globalThis.clearInterval(this.responsePoll);
@@ -719,12 +872,16 @@ export class PeerEndpoint {
719
872
  let connected = false;
720
873
  let writeStarted = false;
721
874
  let settled = false;
722
- const timer = globalThis.setTimeout(() => {
723
- const error = new Error("timed out connecting to " + targetSocket);
724
- error.code = "ETIMEDOUT";
725
- finish(error);
726
- client.destroy();
727
- }, PEER_CONNECT_TIMEOUT_MS);
875
+ let timer;
876
+ const armTimeout = (phase, duration) => {
877
+ globalThis.clearTimeout(timer);
878
+ timer = globalThis.setTimeout(() => {
879
+ const error = new Error(`timed out ${phase} ${targetSocket}`);
880
+ error.code = phase === "verifying sender for" ? "PEER_VERIFY_TIMEOUT" : "ETIMEDOUT";
881
+ finish(error);
882
+ client.destroy();
883
+ }, duration);
884
+ };
728
885
  const finish = (error) => {
729
886
  if (settled) return;
730
887
  settled = true;
@@ -736,11 +893,17 @@ export class PeerEndpoint {
736
893
  resolve();
737
894
  }
738
895
  };
896
+ armTimeout("connecting to", PEER_CONNECT_TIMEOUT_MS);
739
897
  client.once("connect", async () => {
740
898
  connected = true;
899
+ // An established connection must not time out while a long rollout
900
+ // and native ownership are being verified. Keep a separate bound;
901
+ // a late verification result still cannot write after settlement.
902
+ armTimeout("verifying sender for", PEER_VERIFY_TIMEOUT_MS);
741
903
  try {
742
904
  await beforeSend?.();
743
905
  if (settled) return;
906
+ armTimeout("writing to", PEER_CONNECT_TIMEOUT_MS);
744
907
  writeStarted = true;
745
908
  client.write(line, (error) => {
746
909
  if (error) {
@@ -773,6 +936,7 @@ export class PeerEndpoint {
773
936
  const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
774
937
  const pending = previous.catch(() => {}).then(async () => {
775
938
  await beforeSend?.();
939
+ this.#refreshTranscriptReplies();
776
940
  const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
777
941
  if (unconfirmed > 0) {
778
942
  const error = new Error(
@@ -864,7 +1028,7 @@ export class PeerEndpoint {
864
1028
  });
865
1029
  const poll = msgId ? globalThis.setInterval(() => {
866
1030
  const receipt = this.deliveryReceipts.get(msgId);
867
- if (receipt?.fromSocket === fromSocket && ["held", "refused", "denied", "expired", "dropped"].includes(receipt.status)) { finish(null); return; }
1031
+ if (receipt?.fromSocket === fromSocket && (receipt.status === "held" || isTerminalDelivery(receipt))) { finish(null); return; }
868
1032
  }, 250) : null;
869
1033
  });
870
1034
  }
@@ -881,6 +1045,7 @@ export class PeerEndpoint {
881
1045
  }
882
1046
 
883
1047
  readDelivery(msgId) {
1048
+ this.#refreshTranscriptReplies();
884
1049
  const sent = this.sentMessages.get(msgId);
885
1050
  if (!sent) return null;
886
1051
  const delivery = this.deliveryReceipts.get(msgId);
@@ -903,15 +1068,23 @@ export class PeerEndpoint {
903
1068
  ...(sent.accountContext ? { accountContext: { ...sent.accountContext } } : {}),
904
1069
  ...(sent.scopeBindings ? { scopeBindings: structuredClone(sent.scopeBindings), senderCwd: sent.scopeBindings.sender?.path ?? null } : {}),
905
1070
  pending: this.pendingMessages.has(msgId),
1071
+ ...(isInterruptedDelivery(delivery) ? {
1072
+ outcome: "unknown", waitEnded: true, taskCancelled: false, retrySafe: false,
1073
+ nextAction: "inspect_existing_task_then_follow_up",
1074
+ interruption: { reasonCode: delivery.reasonCode, source: delivery.source,
1075
+ observedAt: delivery.observedAt, entryIds: delivery.interruptedBy,
1076
+ backgroundTaskIds: delivery.backgroundTaskIds },
1077
+ } : {}),
906
1078
  ...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.absorbed ? { replyAbsorbed: true } : {}), ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
907
1079
  };
908
1080
  }
909
1081
 
910
1082
  reloadReason() {
1083
+ this.#refreshTranscriptReplies();
911
1084
  if (this.connections.size) return "Peer socket connections are still open";
912
1085
  if (this.requestQueues.size || this.pendingMessages.size || this.unconfirmedReplies.size) return "Claude messages still have pending or unconfirmed delivery";
913
1086
  for (const [id, sent] of this.sentMessages) {
914
- if (!sent.reply && !sent.failed && !["refused", "denied", "expired", "dropped"].includes(this.deliveryReceipts.get(id)?.status)) {
1087
+ if (!sent.reply && !sent.failed && !isTerminalDelivery(this.deliveryReceipts.get(id))) {
915
1088
  return "A Claude delivery outcome remains unconfirmed";
916
1089
  }
917
1090
  }
@@ -968,7 +1141,8 @@ export class PeerEndpoint {
968
1141
  for (const [id, sent] of sentMessages) {
969
1142
  if (typeof sent.targetSocket !== "string" || !sent.targetSocket || !Number.isFinite(sent.sentAt)
970
1143
  || sent.reply && (typeof sent.reply.text !== "string" || sent.reply.inReplyTo !== id)
971
- || !sent.reply && !sent.failed && !["refused", "denied", "expired", "dropped"].includes(deliveryReceipts.get(id)?.status)) {
1144
+ || !sent.reply && !sent.failed && !isTerminalDelivery(deliveryReceipts.get(id))
1145
+ || isInterruptedDelivery(deliveryReceipts.get(id)) && deliveryReceipts.get(id).inReplyTo !== id) {
972
1146
  throw new Error("Invalid or unconfirmed Claude delivery reload record");
973
1147
  }
974
1148
  }