@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.
- package/.codex-plugin/plugin.json +7 -2
- package/CHANGELOG.md +32 -0
- package/README.md +57 -15
- package/assets/README.md +30 -0
- package/assets/agent-pets.svg +1 -0
- package/docs/daily-handoffs.md +106 -0
- package/docs/desktop-handoff-design.md +180 -0
- package/docs/new-machine-setup.md +112 -0
- package/docs/project-onboarding.md +122 -0
- package/package.json +8 -2
- package/plugin.json +10 -3
- package/scripts/bridge-projects.mjs +44 -0
- package/scripts/check.mjs +2 -1
- package/scripts/collect-repo-analytics.mjs +5 -2
- package/scripts/configure-message-automation.mjs +18 -0
- package/scripts/install-claude-code.mjs +64 -0
- package/scripts/install-claude-desktop.mjs +48 -5
- package/scripts/install-native-relay.mjs +6 -1
- package/scripts/install-npm-footer.mjs +13 -1
- package/scripts/publish-repo-analytics.mjs +24 -1
- package/src/app-server-client.mjs +5 -2
- package/src/bridge-readiness.mjs +23 -0
- package/src/card-settings-sync.mjs +125 -0
- package/src/claude-bridge.mjs +54 -20
- package/src/claude-code-registration.mjs +54 -0
- package/src/claude-sender-context.mjs +1 -1
- package/src/claude-session-creation.mjs +10 -2
- package/src/codex-mcp-registration.mjs +1 -1
- package/src/codex-native-response.mjs +139 -107
- package/src/codex-sender-context.mjs +62 -61
- package/src/desktop-reply-receipts.mjs +87 -0
- package/src/desktop-task-receipts.mjs +30 -7
- package/src/hardened-root-policy.mjs +5 -0
- package/src/index.mjs +97 -19
- package/src/mcp-supervisor.mjs +6 -2
- package/src/message-automation.mjs +89 -0
- package/src/native-creation-receipts.mjs +155 -0
- package/src/native-relay-companion.mjs +37 -6
- package/src/native-relay.mjs +12 -3
- package/src/peer-protocol.mjs +192 -18
- package/src/project-manager-registration.mjs +34 -0
- package/src/project-onboarding-tools.mjs +30 -0
- package/src/project-onboarding.mjs +178 -0
- package/src/project-policy.mjs +221 -0
- package/src/project-scope.mjs +33 -5
- package/src/prompt-guidance.mjs +2 -0
- package/src/release-snapshot.mjs +30 -5
- package/src/rollout-reader.mjs +58 -0
- package/src/rollout-segments.mjs +65 -0
- package/src/security-policy.mjs +4 -2
- package/src/sites-connector.mjs +18 -3
- package/src/telemetry.mjs +10 -8
- 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.
|
|
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
|
-
|
|
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}`);
|
package/src/native-relay.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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 &&
|
|
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 && !
|
|
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 && !
|
|
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
|
}
|