relay-companion 0.1.511 → 0.1.513

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.
@@ -9,6 +9,23 @@ const defaultRun = (cmd, args, options = {}) => spawnSync(cmd, args, { encoding:
9
9
  const snapshotPath = homeDir => path.join(homeDir, ".relay", "runtime", "mac-registrations.json");
10
10
  const ok = r => Boolean(r && !r.error && r.status === 0);
11
11
 
12
+ // Windows antivirus and indexing hold a handle on a file for a moment after it
13
+ // is written, and rename fails with EPERM/EBUSY for exactly that moment. Retry
14
+ // briefly instead of failing an entire repair on a hold that clears by itself.
15
+ const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
16
+ function renameWithRetry(fsImpl, from, to, { totalMs = 3000, sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
17
+ const started = Date.now();
18
+ let wait = 50;
19
+ for (;;) {
20
+ try { fsImpl.renameSync(from, to); return; }
21
+ catch (error) {
22
+ if (!RENAME_RETRY_CODES.has(error?.code) || Date.now() - started + wait > totalMs) throw error;
23
+ sleep(wait);
24
+ wait = Math.min(500, wait * 2);
25
+ }
26
+ }
27
+ }
28
+
12
29
  function atomicFile(file, bytes, fsImpl = fs) {
13
30
  fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
14
31
  const temporary = `${file}.${crypto.randomUUID()}.tmp`;
@@ -16,7 +33,7 @@ function atomicFile(file, bytes, fsImpl = fs) {
16
33
  try {
17
34
  fd = fsImpl.openSync(temporary, "wx", 0o600);
18
35
  fsImpl.writeFileSync(fd, bytes); fsImpl.fsyncSync(fd); fsImpl.closeSync(fd); fd = undefined;
19
- fsImpl.renameSync(temporary, file);
36
+ renameWithRetry(fsImpl, temporary, file);
20
37
  // macOS can flush a directory after rename. Keep this conditional for test
21
38
  // hosts (Windows cannot open directory handles through this API).
22
39
  if (process.platform !== "win32") {
@@ -162,4 +179,4 @@ async function restoreSnapshot({ homeDir = os.homedir(), fsImpl = fs, run = defa
162
179
  }
163
180
  }
164
181
 
165
- module.exports = { atomicFile, snapshotPath, readSnapshot, clearSnapshot, prepareSnapshot, restoreSnapshot };
182
+ module.exports = { atomicFile, renameWithRetry, snapshotPath, readSnapshot, clearSnapshot, prepareSnapshot, restoreSnapshot };
@@ -13,6 +13,32 @@ const ok = (r) => r?.ok === true || (!r?.error && r?.status === 0);
13
13
  const xml = (s) => String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
14
14
  const unit = (s) => '"' + String(s).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%") + '"';
15
15
  function run(command, args) { return spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 30_000 }); }
16
+ // Why a command failed, in one line: the spawn error first, then whatever the
17
+ // child said. Callers embed this in their own error so update.log names the step.
18
+ function commandDetail(r) {
19
+ const text = r?.error?.message || String(r?.stderr || r?.out || r?.stdout || "").trim();
20
+ return `${r?.error ? "" : `exit ${r?.status ?? "unknown"}`}${text ? `${r?.error ? "" : ": "}${text}` : ""}`.slice(0, 600);
21
+ }
22
+ // Windows refuses to rename a directory or file while an antivirus scanner or
23
+ // indexer still holds a handle on something it just wrote. Those holds last
24
+ // milliseconds to a couple of seconds and then clear on their own, so a short
25
+ // bounded retry turns a spurious EPERM into the rename that was asked for. The
26
+ // same install step, run by hand a minute later, succeeded on the first try on
27
+ // Dino's machine (2026-09-13) after eight consecutive worker failures.
28
+ const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOTEMPTY"]);
29
+ const RENAME_RETRY_TOTAL_MS = 3000;
30
+ function renameWithRetry(from, to, { fsImpl = fs, totalMs = RENAME_RETRY_TOTAL_MS, sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
31
+ const started = Date.now();
32
+ let wait = 50;
33
+ for (;;) {
34
+ try { fsImpl.renameSync(from, to); return; }
35
+ catch (error) {
36
+ if (!RENAME_RETRY_CODES.has(error?.code) || Date.now() - started + wait > totalMs) throw error;
37
+ sleep(wait);
38
+ wait = Math.min(500, wait * 2);
39
+ }
40
+ }
41
+ }
16
42
 
17
43
  // Task Scheduler reads this XML as UTF-16 (the caller adds the BOM). The settings
18
44
  // that differ from schtasks' `/SC MINUTE` defaults are the load-bearing ones: both
@@ -84,16 +110,23 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
84
110
  };
85
111
  // A live runner may still import these files. Never repair them in place.
86
112
  if (fs.existsSync(bundle) && !matches(bundle)) bundle = path.join(root, "versions", crypto.createHash("sha256").update(digest + crypto.randomUUID()).digest("hex"));
87
- const runtimeNode = preserveNode(node, { platform, runtimeRoot: root, isTemporary: () => true });
113
+ let runtimeNode;
114
+ try { runtimeNode = preserveNode(node, { platform, runtimeRoot: root, isTemporary: () => true }); }
115
+ catch (error) { throw Error(`recovery-node-preservation-failed: ${error.message}`); }
88
116
  if (!fs.existsSync(bundle)) {
89
117
  const pending = path.join(root, "versions", '.pending-' + crypto.randomUUID());
90
118
  fs.mkdirSync(path.join(pending, "bootstrap"), { recursive: true, mode: 0o700 });
119
+ // Each step names itself when it fails. The update worker records only the
120
+ // message, and "recovery-install-failed" alone cost a day of guessing.
91
121
  try {
92
- for (const name of names) atomicFile(path.join(pending, "bootstrap", name), fs.readFileSync(path.join(source, name)));
93
- atomicFile(path.join(pending, "package.json"), fs.readFileSync(path.join(packageRoot, "package.json")));
94
- if (!ok(runCommand(runtimeNode, [path.join(pending, "bootstrap", "recovery-runner.cjs"), "--self-check"]))) throw Error("recovery-bundle-verification-failed");
95
- try { fs.renameSync(pending, bundle); }
96
- catch (error) { if (!matches(bundle)) throw error; }
122
+ try {
123
+ for (const name of names) atomicFile(path.join(pending, "bootstrap", name), fs.readFileSync(path.join(source, name)));
124
+ atomicFile(path.join(pending, "package.json"), fs.readFileSync(path.join(packageRoot, "package.json")));
125
+ } catch (error) { throw Error(`recovery-bundle-copy-failed: ${error.message}`); }
126
+ const pendingCheck = runCommand(runtimeNode, [path.join(pending, "bootstrap", "recovery-runner.cjs"), "--self-check"]);
127
+ if (!ok(pendingCheck)) throw Error(`recovery-bundle-verification-failed: ${commandDetail(pendingCheck)}`);
128
+ try { renameWithRetry(pending, bundle); }
129
+ catch (error) { if (!matches(bundle)) throw Error(`recovery-bundle-publish-failed: ${error.message}`); }
97
130
  } finally { fs.rmSync(pending, { recursive: true, force: true }); }
98
131
  }
99
132
  const pointer = path.join(root, "current.json");
@@ -106,7 +139,7 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
106
139
  write(path.join(root, "known-good.json"), current);
107
140
  }
108
141
  const checked = runCommand(runtimeNode, [path.join(bundle, "bootstrap", "recovery-runner.cjs"), "--self-check"]);
109
- if (!ok(checked)) throw Error("recovery-bundle-verification-failed");
142
+ if (!ok(checked)) throw Error(`recovery-bundle-verification-failed: ${commandDetail(checked)}`);
110
143
  atomicFile(pointer, JSON.stringify({ schema: 1, version: incoming, node: runtimeNode, bundle }));
111
144
  const launcher = path.join(root, "launch.cjs");
112
145
  // Static launcher dispatches through a replaceable pointer. Never overwrite
@@ -201,4 +234,4 @@ function uninstallRecovery({ homeDir = os.homedir(), platform = process.platform
201
234
  for (const file of files) fs.rmSync(file, { force: true });
202
235
  return { ok: true };
203
236
  }
204
- module.exports = { installRecovery, uninstallRecovery, windowsRecoveryTaskXml, LABEL, TASK };
237
+ module.exports = { installRecovery, uninstallRecovery, windowsRecoveryTaskXml, renameWithRetry, commandDetail, LABEL, TASK };
@@ -1051,6 +1051,19 @@ async function activateRuntime(layout, runtime, version, {
1051
1051
  return { candidate, cliLauncher };
1052
1052
  }
1053
1053
 
1054
+ // The setup-intent marker, read by the pill (overlay/main.cjs readSetupIntent).
1055
+ // Plain setup opens the pill signed out for a person who will sign in there
1056
+ // (sendrelays.com's Get started); the marker is what lets the pill start that
1057
+ // sign-in without a click. The --code and --agent-protocol paths pair without
1058
+ // a pill sign-in, so they must not leave one behind. Only the fact, the time
1059
+ // and the version are recorded: never a credential.
1060
+ const SETUP_INTENT_FILE = "setup-intent.json";
1061
+ function writeSetupIntent(configDir, version, setupCompatibilityArgs = [], { now = new Date(), write = atomicWriteJson } = {}) {
1062
+ if (setupCompatibilityArgs.includes("--code") || setupCompatibilityArgs.includes("--agent-protocol")) return false;
1063
+ write(path.join(configDir, SETUP_INTENT_FILE), { agentInstalled: true, at: now.toISOString(), version });
1064
+ return true;
1065
+ }
1066
+
1054
1067
  async function setup(argv = []) {
1055
1068
  assertCompatibleNode();
1056
1069
  const setupCompatibilityArgs = validateSetupCompatibilityArgs(argv);
@@ -1068,6 +1081,12 @@ async function setup(argv = []) {
1068
1081
  if (setupCompatibilityArgs.includes("--agent-protocol") && process.env.RELAY_BACKGROUND_INSTALL_WORKER === "1") {
1069
1082
  await require("./relay-background-install.cjs").waitForAgentAuthorization();
1070
1083
  }
1084
+ // Before activation, so the pill it opens reads the marker on first paint.
1085
+ // A marker that cannot be written costs the person one click, not the
1086
+ // install, so it never fails setup.
1087
+ try {
1088
+ writeSetupIntent(process.env.RELAY_CONFIG_DIR || path.join(os.homedir(), ".relay"), version, setupCompatibilityArgs);
1089
+ } catch {}
1071
1090
  const activated = await activateRuntime(layout, runtime, version, { setupCompatibilityArgs });
1072
1091
  if (setupCompatibilityArgs.includes("--code")) {
1073
1092
  console.log(`Relay ${version} is installed and paired. The Relay pill is open.`);
@@ -1203,6 +1222,7 @@ module.exports = {
1203
1222
  validateArchiveEntries,
1204
1223
  verifyExtractedRuntime,
1205
1224
  waitForRuntimeHealth,
1225
+ writeSetupIntent,
1206
1226
  };
1207
1227
 
1208
1228
  if (require.main === module) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.511",
3
+ "version": "0.1.513",
4
4
  "description": "Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.",
5
5
  "homepage": "https://sendrelays.com/get-started",
6
6
  "repository": {