relay-companion 0.1.577 → 0.1.579

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.
@@ -151,7 +151,11 @@ async function handoffApplication({ activationEnabled = false, envelope, version
151
151
  const reconciled = await reconcile({ homeDir, acquireLock: sharedLock, health });
152
152
  if (!reconciled.ok) throw new Error("Runtime recovery must finish before resuming native handoff");
153
153
  }
154
- const result = await activate({ homeDir, resourcesDir: location.resourcesDir, applicationRoot: location.root,
154
+ // A background app update never runs the destructive repair: it would end
155
+ // every process in the recovery folder, including this worker, and leave
156
+ // Relay stopped with no recovery task (Shane's laptop, 2026-10-02). An
157
+ // unhealthy Relay makes this step fail, and restorePrevious repairs it.
158
+ const result = await activate({ homeDir, allowRecovery: false, resourcesDir: location.resourcesDir, applicationRoot: location.root,
155
159
  executable: location.executable, activationEnabled: true, acquireLock: sharedLock, drain: async () => () => {} });
156
160
  return { ok: result.ok === true };
157
161
  },
@@ -33,7 +33,10 @@ async function pauseRecoveryChecks({ homeDir, timeoutMs = 3 * 60_000, pollMs = 2
33
33
  sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
34
34
  const intent = require("./recovery-intent.cjs");
35
35
  const wasStopped = intent.stopped(homeDir);
36
- intent.setStopped(true, homeDir);
36
+ // Recorded as held by this setup, so Relay's own setup and pill launch during
37
+ // activation leave it paused; a dead holder no longer counts.
38
+ const hold = () => intent.setStopped(true, homeDir, { heldBy: "setup", pid: process.pid });
39
+ hold();
37
40
  const runLock = path.join(homeDir, ".relay", "recovery", "run.lock");
38
41
  for (const deadline = Date.now() + timeoutMs; ;) {
39
42
  try { bootstrap.acquireCanonicalLock(runLock).release(); break; }
@@ -44,7 +47,7 @@ async function pauseRecoveryChecks({ homeDir, timeoutMs = 3 * 60_000, pollMs = 2
44
47
  }
45
48
  }
46
49
  return {
47
- repause: () => intent.setStopped(true, homeDir),
50
+ repause: hold,
48
51
  resume: succeeded => intent.setStopped(succeeded ? false : wasStopped, homeDir),
49
52
  };
50
53
  }
@@ -107,6 +110,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
107
110
  homeDir = os.homedir(), verify = verifyBundle, extract = extractBundle, activate = bootstrap.activateRuntime,
108
111
  acquireLock = bootstrap.acquireCanonicalLock, health = require("./runtime-health.cjs").exactRuntimeHealth,
109
112
  recover = recovery.recoverWithCandidate, pauseRecovery = pauseRecoveryChecks,
113
+ hidePill = require("./previous-pill.cjs").hidePreviousPill,
110
114
  drain = require("./update-activity.cjs").drainCalls, download = bootstrap.downloadVerifiedArtifact,
111
115
  onProgress = () => {}, signal } = {}) {
112
116
  onProgress({ phase: "verifying", canCancel: false });
@@ -128,7 +132,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
128
132
  const journalPath = path.join(homeDir, ".relay", "application-migration.json");
129
133
  let releaseDrain;
130
134
  let downloadDirectory;
131
- let pausedRecovery, succeeded = false;
135
+ let pausedRecovery, previousPill, succeeded = false;
132
136
  try {
133
137
  const recordedCurrent = recovery.marker(path.join(runtimeRoot, "current.json"));
134
138
  const removed = recovery.marker(path.join(homeDir, ".relay", "application-uninstall.json"));
@@ -162,6 +166,10 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
162
166
  const newerRuntime = Boolean(current && /^\d+\.\d+\.\d+$/.test(current.version || "")
163
167
  && versionCompare(bundle.receipt.version, current.version) < 0);
164
168
  if (newerRuntime && recovery.marker(ownerPath)?.appId !== APPLICATION_ID) throw new Error("An older installer cannot downgrade Relay");
169
+ // The same runtime version is the same signed runtime: an installed app whose
170
+ // Relay is already current only changes owner, without touching services.
171
+ const currentRuntime = Boolean(current && /^\d+\.\d+\.\d+$/.test(current.version || "")
172
+ && versionCompare(bundle.receipt.version, current.version) === 0 && recovery.marker(ownerPath)?.appId === APPLICATION_ID);
165
173
  if (current && /^\d+\.\d+\.\d+$/.test(current.version || "") && versionCompare(current.version, "0.1.500") < 0) needsRecovery = true;
166
174
  // Never execute an old CLI as a prerequisite for rescuing it.
167
175
  if (current && !needsRecovery && !(await health(current, { platform: process.platform })).ok) needsRecovery = true;
@@ -182,9 +190,10 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
182
190
  onProgress({ phase: "ready", canCancel: false });
183
191
  return { ok: true, alreadyInstalled: true, owner: installedOwner, runtime: current, updateOwner: "canonical-runtime" };
184
192
  }
185
- if (newerRuntime) {
193
+ if (newerRuntime || (currentRuntime && !needsRecovery)) {
186
194
  // Repairing would install this older runtime over the newer one; the
187
- // runtime's own recovery owns that. A healthy newer runtime keeps running.
195
+ // runtime's own recovery owns that. A healthy current or newer runtime
196
+ // keeps running; an unhealthy same-version one is repaired below.
188
197
  if (needsRecovery) throw new Error("An older installer cannot downgrade Relay");
189
198
  const owner = { schema: 1, appId: APPLICATION_ID, platform: process.platform,
190
199
  root, executable, receipt: receiptPath, version: bundle.receipt.version,
@@ -199,6 +208,8 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
199
208
  return { ok: true, applicationOnly: true, owner, runtime: current, updateOwner: "canonical-runtime" };
200
209
  }
201
210
  pausedRecovery = await pauseRecovery({ homeDir });
211
+ // Only the old pill goes now; its background service runs until the switch.
212
+ if (current?.active === true) previousPill = hidePill({ current });
202
213
  if (bundle.receipt.runtimeDelivery === "download") {
203
214
  signal?.throwIfAborted();
204
215
  // Stage beside releases: tar requires archive and destination on one volume.
@@ -275,6 +286,7 @@ async function installFromApplication({ resourcesDir, applicationRoot, executabl
275
286
  try { releaseDrain?.(); }
276
287
  finally {
277
288
  try { pausedRecovery?.resume(succeeded); } catch { /* recovery reads a missing intent as running */ }
289
+ if (!succeeded) try { previousPill?.restore(); } catch { /* recovery restores a missing pill */ }
278
290
  lock.release();
279
291
  if (downloadDirectory) fs.rmSync(downloadDirectory, { recursive: true, force: true });
280
292
  }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ // The installer's setup leaves the existing Relay running while it downloads,
3
+ // so a failed download still leaves a working Relay. Its pill, though, stayed
4
+ // on screen beside the setup window looking active (Shane, 2026-10-02). Hide
5
+ // only the old pill when setup starts; the background service keeps running
6
+ // until activation switches over and starts the new pill. If setup fails, the
7
+ // previous Relay opens its pill again.
8
+ const { spawnSync, spawn } = require("node:child_process");
9
+
10
+ const MAC_PILL_LABEL = "work.relay.companion.pill";
11
+ // Every stock pill runs relay-companion's overlay/main.cjs under Electron.
12
+ const WINDOWS_STOP_PILL_PS = [
13
+ "$p=Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
14
+ " $_.CommandLine -and ($_.CommandLine -match '[\\\\/]relay-companion[\\\\/]') -and ($_.CommandLine -match '[\\\\/]overlay[\\\\/]main')",
15
+ "}; foreach($x in $p){ try { Invoke-CimMethod -InputObject $x -MethodName Terminate -ErrorAction Stop | Out-Null } catch {} }",
16
+ ].join(" ");
17
+
18
+ function hidePreviousPill({ current, platform = process.platform, uid = process.getuid?.(), run = spawnSync, launch = spawn } = {}) {
19
+ let hidden = false;
20
+ try {
21
+ if (platform === "win32") {
22
+ const result = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_STOP_PILL_PS], { windowsHide: true, timeout: 60_000 });
23
+ hidden = !result?.error && result?.status === 0;
24
+ } else if (platform === "darwin") {
25
+ // launchd keeps the pill alive, so stop its job rather than its process.
26
+ const result = run("/bin/launchctl", ["bootout", `gui/${uid}/${MAC_PILL_LABEL}`], { timeout: 30_000 });
27
+ hidden = !result?.error && result?.status === 0;
28
+ } else {
29
+ const result = run("pkill", ["-u", String(uid), "-f", "relay-companion/overlay/main"], { timeout: 30_000 });
30
+ hidden = !result?.error && result?.status === 0;
31
+ }
32
+ } catch { hidden = false; }
33
+ return {
34
+ hidden,
35
+ // Only an active previous runtime can reopen its own pill.
36
+ restore() {
37
+ if (!hidden || current?.active !== true || typeof current.node !== "string" || typeof current.bin !== "string") return false;
38
+ try {
39
+ const child = launch(current.node, [current.bin, "pill"], { detached: true, stdio: "ignore", windowsHide: true });
40
+ child.unref?.();
41
+ return true;
42
+ } catch { return false; }
43
+ },
44
+ };
45
+ }
46
+
47
+ module.exports = { hidePreviousPill, WINDOWS_STOP_PILL_PS, MAC_PILL_LABEL };
@@ -271,7 +271,11 @@ function windowsStopRecoveryScript(root) {
271
271
  `$root=${quoted};`,
272
272
  "$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value;",
273
273
  "$inside={ param($text) $text -and $text.IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0 };",
274
- "$find={ @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.ProcessId -ne $PID -and ((& $inside $_.ExecutablePath) -or (& $inside $_.CommandLine)) } | Where-Object { try { (Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction Stop).Sid -eq $sid } catch { $false } }) };",
274
+ // Never end the process that asked for this sweep or anything above it: a
275
+ // Relay worker running on the recovery folder's Node would end itself.
276
+ "$all=@(Get-CimInstance Win32_Process -ErrorAction Stop); $up=@{}; $cur=($all | Where-Object ProcessId -eq $PID).ParentProcessId;",
277
+ "for($i=0;$i -lt 32 -and $cur -and -not $up.ContainsKey([int]$cur);$i++){ $up[[int]$cur]=$true; $cur=($all | Where-Object ProcessId -eq $cur).ParentProcessId };",
278
+ "$find={ @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.ProcessId -ne $PID -and -not $up.ContainsKey([int]$_.ProcessId) -and ((& $inside $_.ExecutablePath) -or (& $inside $_.CommandLine)) } | Where-Object { try { (Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction Stop).Sid -eq $sid } catch { $false } }) };",
275
279
  "$p=@(&$find); for($i=0;$i -lt 10 -and $p.Count;$i++){ foreach($x in $p){ try { Invoke-CimMethod -InputObject $x -MethodName Terminate -ErrorAction Stop | Out-Null } catch {} }; Start-Sleep -Milliseconds 200; $p=@(&$find) };",
276
280
  "if($p.Count){ Write-Error ('Relay recovery processes did not stop: '+(($p | ForEach-Object ProcessId) -join ', ')); exit 1 }",
277
281
  ].join(" ");
@@ -1,11 +1,33 @@
1
1
  "use strict";
2
2
  const fs = require("node:fs"), os = require("node:os"), path = require("node:path");
3
3
  const file = homeDir => path.join(homeDir, ".relay", "recovery", "intent.json");
4
+ function read(homeDir) {
5
+ try { return { value: JSON.parse(fs.readFileSync(file(homeDir), "utf8")) }; }
6
+ catch (error) { return { missing: error.code === "ENOENT" }; }
7
+ }
8
+ function alive(pid) {
9
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
10
+ }
11
+ // The installer's setup pauses recovery while it replaces Relay and records
12
+ // itself as the holder. A hold whose setup process is gone no longer counts,
13
+ // so a killed setup can never leave recovery paused for good.
14
+ function heldBySetup(value) {
15
+ return value?.stopped === true && value.heldBy === "setup" && Number.isInteger(value.pid) && alive(value.pid);
16
+ }
4
17
  function stopped(homeDir = os.homedir()) {
5
- try { return JSON.parse(fs.readFileSync(file(homeDir), "utf8")).stopped === true; }
6
- catch (error) { return error.code !== "ENOENT"; }
18
+ const { value, missing } = read(homeDir);
19
+ if (!value) return !missing;
20
+ if (value.stopped === true && value.heldBy === "setup") return heldBySetup(value);
21
+ return value.stopped === true;
22
+ }
23
+ function setStopped(value, homeDir = os.homedir(), { heldBy, pid } = {}) {
24
+ require("./recovery-launcher.cjs").write(file(homeDir), { schema: 1, stopped: value, at: Date.now(), ...(value && heldBy ? { heldBy, pid } : {}) });
7
25
  }
8
- function setStopped(value, homeDir = os.homedir()) {
9
- require("./recovery-launcher.cjs").write(file(homeDir), { schema: 1, stopped: value, at: Date.now() });
26
+ // Starting Relay (setup, the pill) resumes recovery, except while an
27
+ // installer's setup holds the pause; that setup lifts it when it ends.
28
+ function resumeUnlessHeld(homeDir = os.homedir()) {
29
+ if (heldBySetup(read(homeDir).value)) return false;
30
+ setStopped(false, homeDir);
31
+ return true;
10
32
  }
11
- module.exports = { stopped, setStopped };
33
+ module.exports = { stopped, setStopped, resumeUnlessHeld };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.577",
3
+ "version": "0.1.579",
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": {