relay-companion 0.1.40 → 0.1.41

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/bin/relay.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { createRequire } from "node:module";
6
7
  import { spawnSync, spawn } from "node:child_process";
@@ -10,8 +11,9 @@ import { RelayClient } from "../src/client.js";
10
11
  import { writeConfig, readConfig, apiUrl, DEFAULT_API_URL, DEFAULT_WEB_URL } from "../src/config.js";
11
12
  import { runTaskDaemon, pollTaskRuntimeOnce } from "../src/task-daemon.js";
12
13
  import { runMcpServer } from "../src/mcp.js";
13
- import { runSetupInstall, runUninstall } from "../src/install.js";
14
+ import { repairDesktopSurfaces, runSetupInstall, runUninstall } from "../src/install.js";
14
15
  import { runUpdateOnce } from "../src/auto-update.js";
16
+ import { pillStatusPath, waitForPillReady } from "../src/pill-control.js";
15
17
  import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
16
18
  import { openRelay, openTask } from "../src/materializer.js";
17
19
  import { resetCompanionStateForAccount } from "../src/notifications.js";
@@ -35,6 +37,16 @@ function parseFlags(argv) {
35
37
  return { flags, positional };
36
38
  }
37
39
 
40
+ function companionVersion() {
41
+ try {
42
+ const require = createRequire(import.meta.url);
43
+ const pkg = require("../package.json");
44
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
45
+ } catch {
46
+ return "unknown";
47
+ }
48
+ }
49
+
38
50
  async function prompt(question, fallback = "") {
39
51
  const rl = readline.createInterface({ input: stdin, output: stdout });
40
52
  const answer = (await rl.question(question)).trim();
@@ -97,7 +109,7 @@ function printActivationStatus(activation) {
97
109
  }
98
110
 
99
111
  function statusAreaReopenText() {
100
- if (process.platform === "darwin") return "the Relay icon in the menu bar";
112
+ if (process.platform === "darwin") return "the Relay app in Spotlight or the Relay icon in the menu bar";
101
113
  if (process.platform === "win32") return "the Relay icon in the system tray near the clock";
102
114
  return "the Relay icon in the system tray";
103
115
  }
@@ -180,6 +192,22 @@ async function cmdInstall() {
180
192
  await applyInstall({ requireLiveTools: shouldRequireLiveTools(), requiredHosts: requiredLiveHosts() });
181
193
  }
182
194
 
195
+ function cmdRepairDesktop(flags = {}) {
196
+ const reload = !flags["no-restart"];
197
+ const repaired = repairDesktopSurfaces({ reload });
198
+ if (!repaired.ok) {
199
+ const failures = [
200
+ !repaired.daemon?.ok && `daemon: ${repaired.daemon?.reason || "install failed"}`,
201
+ !repaired.pill?.ok && `pill: ${repaired.pill?.reason || "install failed"}`,
202
+ ].filter(Boolean);
203
+ throw new Error(`Could not repair Relay desktop services (${failures.join(", ")}).`);
204
+ }
205
+ const appText = repaired.pill?.appPath ? ` Relay.app is installed at ${repaired.pill.appPath}.` : "";
206
+ const restartText = reload ? "Relay background services were reloaded." : "Relay background service files were refreshed without restarting them.";
207
+ console.log(`${restartText}${appText}`);
208
+ return repaired;
209
+ }
210
+
183
211
  /** Remove the Relay tools from both agents and stop the background daemon. */
184
212
  async function cmdUninstall() {
185
213
  runUninstall();
@@ -274,8 +302,8 @@ async function cmdAnswerQuestion(flags) {
274
302
  console.log("Sent answer to Relay. The waiting task agent will resume on the next daemon poll.");
275
303
  }
276
304
 
277
- /** Launch the desktop Relay companion pill (Electron overlay). */
278
- function cmdPill() {
305
+ /** Launch (or signal) the desktop Relay companion pill and verify it is visible. */
306
+ async function cmdPill() {
279
307
  const here = path.dirname(fileURLToPath(import.meta.url));
280
308
  const overlayMain = path.resolve(here, "../overlay/main.cjs");
281
309
  // require("electron") returns the path to the electron binary when resolved under node.
@@ -293,25 +321,44 @@ function cmdPill() {
293
321
  if (typeof electronPath !== "string") {
294
322
  throw new Error("Could not resolve the Electron binary. Run `npm install` in packages/companion.");
295
323
  }
296
- const child = spawn(electronPath, [overlayMain], {
324
+ const reopenNonce = `cli-${process.pid}-${randomUUID()}`;
325
+ let spawnError = null;
326
+ const child = spawn(electronPath, [overlayMain, "--relay-reopen", reopenNonce], {
297
327
  detached: true,
298
328
  stdio: "ignore",
299
329
  env: { ...process.env },
300
330
  });
331
+ child.on("error", (error) => {
332
+ spawnError = error;
333
+ });
301
334
  child.unref();
302
- console.log(`Relay pill launched. It shows when Claude Code or Codex is running; reopen it from ${statusAreaReopenText()} after hiding it.`);
335
+ const result = await waitForPillReady(reopenNonce);
336
+ if (!result.ok) {
337
+ const detail = spawnError ? ` ${spawnError.message || spawnError}` : "";
338
+ throw new Error(
339
+ `Relay was started but did not confirm that its pill became visible.${detail} Check ${pillStatusPath()} and ~/.relay/pill.log.`,
340
+ );
341
+ }
342
+ console.log(`Relay pill is visible. Reopen it from ${statusAreaReopenText()} after hiding it.`);
303
343
  }
304
344
 
305
345
  async function main() {
306
346
  const [command, ...rest] = process.argv.slice(2);
307
347
  const { flags, positional } = parseFlags(rest);
308
348
  switch (command) {
349
+ case "version":
350
+ case "--version":
351
+ case "-v":
352
+ console.log(companionVersion());
353
+ return;
309
354
  case "setup":
310
355
  return cmdSetup(flags);
311
356
  case "pair":
312
357
  return cmdPair(flags);
313
358
  case "install":
314
359
  return cmdInstall();
360
+ case "repair-desktop":
361
+ return cmdRepairDesktop(flags);
315
362
  case "uninstall":
316
363
  return cmdUninstall();
317
364
  case "whoami":
@@ -341,10 +388,12 @@ async function main() {
341
388
  "",
342
389
  "Usage:",
343
390
  " relay setup [--code CODE] [--name NAME] Pair this machine and add Relay to Claude Code + Codex",
391
+ " relay version Print the installed Relay companion version",
344
392
  " relay setup --code CODE --open-relay TOKEN --host codex|claude",
345
393
  " relay setup --interactive Prompt for API URL and device name during setup",
346
394
  " relay install Add Relay to your agents (device already paired)",
347
395
  " relay uninstall Remove Relay from your agents and stop the daemon",
396
+ " relay repair-desktop [--no-restart] Repair Relay.app and background services without changing MCP/account state",
348
397
  " relay pair [--api URL] [--web URL] [--code CODE] Pair this machine only (no agent install)",
349
398
  " relay tasks Pull and process task agent runtime once",
350
399
  " relay open <id> --host claude|codex Materialize a staged Relay row into a native agent session",
@@ -11,12 +11,22 @@
11
11
  // desktop it breaks"). The running-process signal (from `ps`, which is Space-agnostic)
12
12
  // is the reliable tiebreaker, so it now sits ahead of the installed-preference default.
13
13
 
14
+ const CLAUDE_DESKTOP_BUNDLE = "com.anthropic.claudefordesktop";
15
+ const CODEX_DESKTOP_BUNDLES = ["com.openai.codex", "com.openai.chat"];
16
+
14
17
  function hostFromBundle(bundle) {
15
- if (bundle === "com.anthropic.claudefordesktop") return "claude";
16
- if (bundle === "com.openai.codex") return "codex";
18
+ if (bundle === CLAUDE_DESKTOP_BUNDLE) return "claude";
19
+ if (CODEX_DESKTOP_BUNDLES.includes(bundle)) return "codex";
17
20
  return null;
18
21
  }
19
22
 
23
+ function activationBundleCandidates(host, observedBundle = null) {
24
+ if (host === "claude") return [CLAUDE_DESKTOP_BUNDLE];
25
+ if (host !== "codex") return [];
26
+ const observed = CODEX_DESKTOP_BUNDLES.includes(observedBundle) ? observedBundle : null;
27
+ return [...new Set([observed, ...CODEX_DESKTOP_BUNDLES].filter(Boolean))];
28
+ }
29
+
20
30
  function isRelayBundle(bundle) {
21
31
  const clean = String(bundle || "").toLowerCase();
22
32
  return clean.includes("relay") || clean.includes("electron");
@@ -103,4 +113,31 @@ function runningHostsFromProcessList(text, platform = process.platform) {
103
113
  };
104
114
  }
105
115
 
106
- module.exports = { hostFromBundle, isRelayBundle, defaultClickHost, chooseClickHost, runningHostsFromProcessList };
116
+ // Terminal Claude Code is useful diagnostic state, but it is not a desktop surface
117
+ // Relay can foreground: Relay's Claude materializer opens a claude:// resume link in
118
+ // Claude Desktop. Keep this separate from runningHostsFromProcessList so a terminal-
119
+ // only process never diverts a click away from a usable Codex/ChatGPT desktop window.
120
+ function terminalClaudeCodeRunningFromProcessList(text, platform = process.platform) {
121
+ const lines = String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
122
+ if (platform === "darwin") {
123
+ return lines.some(
124
+ (line) =>
125
+ !/\.app\/Contents\//i.test(line) &&
126
+ /(?:^|\/)claude(?:-code)?(?:\.exe)?$/i.test(line),
127
+ );
128
+ }
129
+ if (platform === "win32") {
130
+ return lines.some((line) => /(?:^|[\\/\"])(?:claude-code|claude)(?:\.exe)?(?:\"|$)/i.test(line));
131
+ }
132
+ return lines.some((line) => /(?:^|\/)claude(?:-code)?$/i.test(line));
133
+ }
134
+
135
+ module.exports = {
136
+ hostFromBundle,
137
+ activationBundleCandidates,
138
+ isRelayBundle,
139
+ defaultClickHost,
140
+ chooseClickHost,
141
+ runningHostsFromProcessList,
142
+ terminalClaudeCodeRunningFromProcessList,
143
+ };
package/overlay/main.cjs CHANGED
@@ -29,8 +29,20 @@ const { execFile, execFileSync, spawn, pathToFileURL } = (() => {
29
29
  const url = require("node:url");
30
30
  return { execFile: cp.execFile, execFileSync: cp.execFileSync, spawn: cp.spawn, pathToFileURL: url.pathToFileURL };
31
31
  })();
32
- const { hostFromBundle, chooseClickHost, runningHostsFromProcessList } = require("./host-select.cjs");
33
- const { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss, boundedPresentedRelayIds } = require("./visibility.cjs");
32
+ const {
33
+ hostFromBundle,
34
+ activationBundleCandidates,
35
+ chooseClickHost,
36
+ runningHostsFromProcessList,
37
+ terminalClaudeCodeRunningFromProcessList,
38
+ } = require("./host-select.cjs");
39
+ const {
40
+ overlayWanted,
41
+ createHostRunningTracker,
42
+ shouldIgnoreDismiss,
43
+ boundedPresentedRelayIds,
44
+ recoverInterruptedAttentionPrefs,
45
+ } = require("./visibility.cjs");
34
46
  const { withJsonLock } = require("../src/state-lock.cjs");
35
47
  const {
36
48
  reinforceSpacePresence,
@@ -41,6 +53,7 @@ const {
41
53
 
42
54
  const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
43
55
  const STATE_PATH = path.join(RELAY_HOME, "state.json");
56
+ const PILL_STATUS_PATH = path.join(RELAY_HOME, "pill-status.json");
44
57
  // The companion CLI entrypoint (same path the overlay resolves its ESM modules
45
58
  // from). Spawned with ELECTRON_RUN_AS_NODE so Electron runs it as plain Node.
46
59
  const RELAY_CLI = path.resolve(__dirname, "..", "bin", "relay.js");
@@ -56,6 +69,7 @@ let trayAvailable = false;
56
69
  let hostRunning = false; // Claude or Codex desktop app is running (either)
57
70
  let claudeRunning = false; // Claude Desktop process is running (any macOS Space)
58
71
  let codexRunning = false; // Codex process is running (any macOS Space)
72
+ let terminalClaudeCodeRunning = false; // diagnostic only; not a foregroundable desktop host
59
73
  let lastHost = null; // most-recently foregrounded host: "claude" | "codex"
60
74
  let lastHostSeenAt = 0;
61
75
  let lastSig = "";
@@ -69,29 +83,71 @@ let overlayPrefs = {};
69
83
  try {
70
84
  overlayPrefs = JSON.parse(fs.readFileSync(OVERLAY_PREFS_PATH, "utf8")) || {};
71
85
  } catch {}
86
+ const recoveredAttentionPrefs = recoverInterruptedAttentionPrefs(overlayPrefs);
87
+ overlayPrefs = recoveredAttentionPrefs.prefs;
72
88
  let dismissed = overlayPrefs.dismissed === true;
73
89
  let attentionLatched = overlayPrefs.attentionLatched === true;
74
90
  const presentedRelayIds = new Set(
75
91
  Array.isArray(overlayPrefs.presentedRelayIds) ? overlayPrefs.presentedRelayIds.filter(Boolean).map(String) : [],
76
92
  );
93
+ let activeAttentionIds = new Set();
77
94
  let ghostActive = false; // a ghost notification is on screen while dismissed
78
95
  let trayForcedVisible = false; // tray click while no host is running still shows the card
96
+ let pillReady = false;
97
+ let pendingReopenNonce = "";
98
+ let lastReopenNonce = "";
99
+ let lastPillStatusSig = "";
79
100
  const PRESENTED_RELAY_CAP = 500;
80
101
  function writeOverlayPrefs() {
81
102
  try {
82
103
  fs.mkdirSync(RELAY_HOME, { recursive: true });
83
104
  fs.writeFileSync(
84
105
  OVERLAY_PREFS_PATH,
85
- `${JSON.stringify({ dismissed, attentionLatched, presentedRelayIds: [...presentedRelayIds] }, null, 2)}\n`,
106
+ `${JSON.stringify({ dismissed, attentionLatched, presentedRelayIds: [...presentedRelayIds], activeAttentionIds: [...activeAttentionIds] }, null, 2)}\n`,
86
107
  );
87
108
  } catch (error) {
88
109
  console.error("[overlay] prefs write failed:", error && error.message);
89
110
  }
90
111
  }
112
+ // Commit startup recovery before Electron is ready. A second crash before the
113
+ // recovered stack is rendered must still leave those ids eligible for replay.
114
+ if (recoveredAttentionPrefs.interruptedAttentionIds.length) writeOverlayPrefs();
91
115
  function setDismissed(value) {
92
116
  dismissed = Boolean(value);
93
117
  writeOverlayPrefs();
94
118
  }
119
+
120
+ function writePillStatus(reopenNonce = "") {
121
+ if (reopenNonce) lastReopenNonce = String(reopenNonce);
122
+ const stableStatus = {
123
+ pid: process.pid,
124
+ ready: pillReady,
125
+ visible: Boolean(win && !win.isDestroyed() && win.isVisible()),
126
+ dismissed: Boolean(dismissed),
127
+ reopenNonce: lastReopenNonce,
128
+ terminalClaudeCodeRunning,
129
+ };
130
+ const sig = JSON.stringify(stableStatus);
131
+ if (sig === lastPillStatusSig) return stableStatus;
132
+ const status = {
133
+ ...stableStatus,
134
+ updatedAt: new Date().toISOString(),
135
+ };
136
+ let tmp = "";
137
+ try {
138
+ fs.mkdirSync(RELAY_HOME, { recursive: true });
139
+ tmp = `${PILL_STATUS_PATH}.${process.pid}.${Date.now()}.tmp`;
140
+ fs.writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
141
+ fs.renameSync(tmp, PILL_STATUS_PATH);
142
+ lastPillStatusSig = sig;
143
+ } catch (error) {
144
+ try {
145
+ if (tmp) fs.rmSync(tmp, { force: true });
146
+ } catch {}
147
+ console.error("[overlay] status write failed:", error && error.message);
148
+ }
149
+ return status;
150
+ }
95
151
  function markRelaysPresented(ids, retainedUnreadIds = []) {
96
152
  const current = [...presentedRelayIds];
97
153
  const bounded = boundedPresentedRelayIds(
@@ -155,6 +211,15 @@ async function relayClient() {
155
211
  }
156
212
 
157
213
  app.setName("Relay");
214
+ function reopenNonceFromArgs(argv) {
215
+ const args = Array.isArray(argv) ? argv.map(String) : [];
216
+ for (let i = 0; i < args.length; i += 1) {
217
+ if (args[i] === "--relay-reopen") return args[i + 1] || "";
218
+ if (args[i].startsWith("--relay-reopen=")) return args[i].slice("--relay-reopen=".length);
219
+ }
220
+ return "";
221
+ }
222
+ pendingReopenNonce = reopenNonceFromArgs(process.argv);
158
223
  // Test seam: an isolated userData dir gives the e2e harness its own single-instance
159
224
  // lock, so a sandboxed overlay can run beside the real pill without fighting it.
160
225
  if (process.env.RELAY_OVERLAY_USER_DATA) {
@@ -164,7 +229,7 @@ if (process.env.RELAY_OVERLAY_USER_DATA) {
164
229
  console.error("[overlay] userData override failed:", error && error.message);
165
230
  }
166
231
  }
167
- const gotSingleInstanceLock = app.requestSingleInstanceLock();
232
+ const gotSingleInstanceLock = app.requestSingleInstanceLock({ relayReopenNonce: pendingReopenNonce });
168
233
 
169
234
  // ---- config / account ----------------------------------------------------
170
235
 
@@ -446,7 +511,6 @@ let systemSuspended = false;
446
511
  let screenLocked = false;
447
512
  let loginSessionActive = true;
448
513
  let deferredAttention = false;
449
- let activeAttentionIds = new Set();
450
514
  let testAwayOverride = null;
451
515
 
452
516
  function userIsAway() {
@@ -499,9 +563,12 @@ function presentUnopenedStack(payload, unpresented) {
499
563
  trayForcedVisible = false;
500
564
  deferredAttention = false;
501
565
  maybeShow({ force: true });
502
- win.webContents.send("newRelay", stack, { ghost: false, stacked: true });
503
566
  activeAttentionIds = new Set(stack.map((row) => row.id));
504
567
  markRelaysPresented(unpresented.map((row) => row.id), stack.map((row) => row.id));
568
+ // Persist the in-flight marker before renderer delivery. If the process dies at
569
+ // any point during the animation, startup recovery can replay the whole stack.
570
+ writeOverlayPrefs();
571
+ win.webContents.send("newRelay", stack, { ghost: false, stacked: true });
505
572
  syncTray();
506
573
  return true;
507
574
  }
@@ -733,14 +800,20 @@ function rememberForegroundHost(host) {
733
800
  // (good) but didnt foreground claude"). `open -b <bundle>` activates the app — switching
734
801
  // to its Space and launching it if it wasn't running. `-g` is intentionally NOT passed,
735
802
  // because raising the app is exactly the point.
736
- const HOST_BUNDLE_ID = { claude: "com.anthropic.claudefordesktop", codex: "com.openai.codex" };
737
- function activateHost(host) {
803
+ function activateHost(host, observedBundle = null) {
738
804
  if (process.platform !== "darwin") return; // Windows foregrounding is handled by its own open path
739
- const bundle = HOST_BUNDLE_ID[host];
740
- if (!bundle) return;
741
- execFile("/usr/bin/open", ["-b", bundle], (error) => {
742
- if (error) console.error("[overlay] activateHost failed:", host, error && error.message);
743
- });
805
+ const bundles = activationBundleCandidates(host, observedBundle);
806
+ const tryBundle = (index, lastError = null) => {
807
+ const bundle = bundles[index];
808
+ if (!bundle) {
809
+ if (lastError) console.error("[overlay] activateHost failed:", host, lastError && lastError.message);
810
+ return;
811
+ }
812
+ execFile("/usr/bin/open", ["-b", bundle], (error) => {
813
+ if (error) tryBundle(index + 1, error);
814
+ });
815
+ };
816
+ tryBundle(0);
744
817
  }
745
818
 
746
819
  function appInstalled(appName) {
@@ -1024,7 +1097,7 @@ async function openPacket(packetId, { sent = false } = {}) {
1024
1097
  // Raise the host so the relay is visible, whichever open path ran (Claude deep
1025
1098
  // link, Codex bridge, or a plain url). This is the fix for "opened in the right
1026
1099
  // host but didn't foreground it".
1027
- activateHost(host);
1100
+ activateHost(host, bundle);
1028
1101
  if (url && !skipExternalOpen) {
1029
1102
  const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
1030
1103
  if (claudeSessionIdFromUrl(url)) {
@@ -1099,7 +1172,7 @@ function openTaskDetail(taskId) {
1099
1172
  freshlyForged = Boolean(parsed.claudeFreshlyForged);
1100
1173
  } catch {}
1101
1174
  if (url || skipExternalOpen) {
1102
- activateHost(host); // raise the host so the opened task is visible
1175
+ activateHost(host, bundle); // raise the host so the opened task is visible
1103
1176
  if (url && !skipExternalOpen) {
1104
1177
  if (claudeSessionIdFromUrl(url)) {
1105
1178
  openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
@@ -1193,6 +1266,7 @@ function maybeShow({ force = false, reposition = true } = {}) {
1193
1266
  win.hide();
1194
1267
  }
1195
1268
  syncTray(); // keep the tray tooltip in step with the pill's actual visibility
1269
+ writePillStatus();
1196
1270
  }
1197
1271
 
1198
1272
  function refreshOverlayForActiveSpace({ force = false } = {}) {
@@ -1222,6 +1296,7 @@ function updateHostRunningFromProcesses(text) {
1222
1296
  // not the frontmost app — the signal click routing uses to beat frontmost's
1223
1297
  // Space-blindness.
1224
1298
  const running = runningHostsFromProcessList(text, process.platform);
1299
+ terminalClaudeCodeRunning = terminalClaudeCodeRunningFromProcessList(text, process.platform);
1225
1300
  claudeRunning = running.claudeRunning;
1226
1301
  codexRunning = running.codexRunning;
1227
1302
  hostRunning = hostTracker.update(claudeRunning, codexRunning);
@@ -1309,7 +1384,13 @@ function createWindow() {
1309
1384
  // First paint is instant: relays come from local state.json; the sent + contacts
1310
1385
  // background loads kicked off here each re-push when they land (buildPayload).
1311
1386
  pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
1387
+ pillReady = true;
1388
+ // Relay is independently useful and searchable even when no host app happens to
1389
+ // be open. A normal login launch still respects the persisted dismissed preference.
1390
+ maybeShow({ force: true });
1312
1391
  pollHosts();
1392
+ if (pendingReopenNonce) requestExternalReopen(pendingReopenNonce);
1393
+ else writePillStatus();
1313
1394
  });
1314
1395
 
1315
1396
  try {
@@ -1421,6 +1502,20 @@ function showFromTray() {
1421
1502
  syncTray();
1422
1503
  }
1423
1504
 
1505
+ // Spotlight's Relay.app and `relay pill` both launch another copy of the overlay.
1506
+ // Electron forwards that launch to this single-instance owner. Treat it exactly like
1507
+ // an explicit "Show Relay" action: revoke any old dismissal, open the full card, and
1508
+ // acknowledge the caller only after the OS window reports visible.
1509
+ function requestExternalReopen(reopenNonce = "") {
1510
+ if (reopenNonce) pendingReopenNonce = String(reopenNonce);
1511
+ if (!pillReady || !win || win.isDestroyed()) return false;
1512
+ const nonce = pendingReopenNonce;
1513
+ pendingReopenNonce = "";
1514
+ showFromTray();
1515
+ writePillStatus(nonce);
1516
+ return pillIsOnScreen();
1517
+ }
1518
+
1424
1519
  // Whether the pill card is actually on screen right now.
1425
1520
  function pillIsOnScreen() {
1426
1521
  return Boolean(win && !win.isDestroyed() && win.isVisible() && !dismissed);
@@ -1438,6 +1533,7 @@ function hideFromTray() {
1438
1533
  trayForcedVisible = false;
1439
1534
  if (win && !win.isDestroyed() && win.isVisible()) win.hide();
1440
1535
  syncTray();
1536
+ writePillStatus();
1441
1537
  }
1442
1538
 
1443
1539
  let lastTrayToggleAt = 0;
@@ -1566,7 +1662,9 @@ ipcMain.on("relay:dismiss", () => {
1566
1662
  if (shouldIgnoreDismiss({ now: Date.now(), lastTrayShowAt })) return;
1567
1663
  lastTrayShowAt = 0; // honored: clear the guard so it can't linger
1568
1664
  if (!trayAvailable) {
1569
- setDismissed(false);
1665
+ dismissed = false;
1666
+ activeAttentionIds = new Set();
1667
+ writeOverlayPrefs();
1570
1668
  ghostActive = false;
1571
1669
  trayForcedVisible = true;
1572
1670
  maybeShow();
@@ -1580,6 +1678,7 @@ ipcMain.on("relay:dismiss", () => {
1580
1678
  ghostActive = false;
1581
1679
  trayForcedVisible = false;
1582
1680
  if (win && !win.isDestroyed() && win.isVisible()) win.hide();
1681
+ writePillStatus();
1583
1682
  });
1584
1683
  // The user engaged with a ghost notification (tapped the lockup to expand it):
1585
1684
  // the card is fully open again, so leave dismissed mode.
@@ -1593,6 +1692,7 @@ ipcMain.on("relay:undismiss", () => {
1593
1692
  ipcMain.on("relay:notifDone", () => {
1594
1693
  ghostActive = false;
1595
1694
  if (dismissed && win && !win.isDestroyed() && win.isVisible()) win.hide();
1695
+ writePillStatus();
1596
1696
  });
1597
1697
  // The notification stack completed its visible interval and folded into the pill.
1598
1698
  // Main tracks this boundary so a later lock does not replay a stack the user already
@@ -1605,7 +1705,10 @@ ipcMain.on("relay:attentionDone", (_event, ids) => {
1605
1705
  // it pending for their next input. This covers walking away just before an arrival,
1606
1706
  // not only explicit sleep/lock events.
1607
1707
  if (userIsAway()) requeueActiveAttention();
1608
- else activeAttentionIds = new Set();
1708
+ else {
1709
+ activeAttentionIds = new Set();
1710
+ writeOverlayPrefs();
1711
+ }
1609
1712
  });
1610
1713
  ipcMain.handle("relay:soundBytes", (_e, name) => {
1611
1714
  const clean = String(name).replace(/[^A-Za-z]/g, "");
@@ -1625,12 +1728,18 @@ ipcMain.handle("relay:soundBytes", (_e, name) => {
1625
1728
  if (!gotSingleInstanceLock) {
1626
1729
  app.quit();
1627
1730
  } else {
1628
- app.on("second-instance", () => {
1629
- if (!win || win.isDestroyed()) return;
1731
+ app.on("second-instance", (_event, argv, _workingDirectory, additionalData = {}) => {
1732
+ const nonce =
1733
+ (additionalData && typeof additionalData.relayReopenNonce === "string" && additionalData.relayReopenNonce) ||
1734
+ reopenNonceFromArgs(argv);
1630
1735
  pollHosts();
1631
- maybeShow();
1736
+ requestExternalReopen(nonce);
1632
1737
  });
1633
1738
 
1739
+ // LaunchServices may reactivate the existing process instead of executing the
1740
+ // launcher again. Activation is still an explicit user request to restore Relay.
1741
+ app.on("activate", () => requestExternalReopen());
1742
+
1634
1743
  app.whenReady().then(() => {
1635
1744
  if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
1636
1745
  createWindow();
@@ -1642,6 +1751,8 @@ if (!gotSingleInstanceLock) {
1642
1751
  if (process.env.RELAY_OVERLAY_TEST === "1") {
1643
1752
  global.__relayTest = {
1644
1753
  showFromTray,
1754
+ requestExternalReopen,
1755
+ writePillStatus,
1645
1756
  toggleFromTray,
1646
1757
  pillIsOnScreen,
1647
1758
  maybeShow,
@@ -1663,6 +1774,7 @@ if (!gotSingleInstanceLock) {
1663
1774
  ghostActive,
1664
1775
  trayForcedVisible,
1665
1776
  hostRunning,
1777
+ terminalClaudeCodeRunning,
1666
1778
  deferredAttention,
1667
1779
  presentedRelayIds: [...presentedRelayIds],
1668
1780
  activeAttentionIds: [...activeAttentionIds],
@@ -0,0 +1,27 @@
1
+ <svg width="1024" height="1024" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="relayGradient" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" stop-color="#C1C1FA"/>
5
+ <stop offset="16%" stop-color="#C0B1F9"/>
6
+ <stop offset="38%" stop-color="#7790F7"/>
7
+ <stop offset="58%" stop-color="#5F74F6"/>
8
+ <stop offset="82%" stop-color="#342CF5"/>
9
+ <stop offset="100%" stop-color="#3229F5"/>
10
+ </linearGradient>
11
+ <radialGradient id="relayHighlight" cx="28%" cy="4%" r="50%">
12
+ <stop offset="0%" stop-color="#C1C1FA"/>
13
+ <stop offset="36%" stop-color="#C0B1F9" stop-opacity="0.82"/>
14
+ <stop offset="100%" stop-color="#C0B1F9" stop-opacity="0"/>
15
+ </radialGradient>
16
+ <radialGradient id="relayGlow" cx="72%" cy="36%" r="58%">
17
+ <stop offset="0%" stop-color="#7590F7" stop-opacity="0.82"/>
18
+ <stop offset="100%" stop-color="#7590F7" stop-opacity="0"/>
19
+ </radialGradient>
20
+ </defs>
21
+ <rect width="32" height="32" rx="7" fill="#f7f4ef"/>
22
+ <rect x="5.5" y="13.5" width="5" height="5" rx="0.5" fill="#D9704E"/>
23
+ <rect x="13.5" y="13.5" width="5" height="5" rx="0.5" fill="#111111"/>
24
+ <rect x="21.5" y="13.5" width="5" height="5" rx="0.5" fill="url(#relayGradient)"/>
25
+ <rect x="21.5" y="13.5" width="5" height="5" rx="0.5" fill="url(#relayHighlight)"/>
26
+ <rect x="21.5" y="13.5" width="5" height="5" rx="0.5" fill="url(#relayGlow)"/>
27
+ </svg>
@@ -1,11 +1,13 @@
1
1
  // Pure overlay-visibility logic, extracted from main.cjs so the state machine that
2
2
  // decides when the pill window is on screen is unit-testable without Electron.
3
3
  //
4
- // The window is wanted when a host app is running (or the user explicitly clicked the
5
- // status-area icon), unless the user dismissed the pill — with a ghost notification
6
- // temporarily overriding a dismissal.
4
+ // Relay is an independently installed desktop surface, not a child of Claude/Codex.
5
+ // Keep it available whenever its background process runs; only an explicit dismissal
6
+ // hides it. Host-running remains an input for click routing and backwards-compatible
7
+ // callers, but closing an agent terminal/app must never make Relay disappear.
7
8
 
8
9
  function overlayWanted({
10
+ persistent = true,
9
11
  hostRunning = false,
10
12
  trayForcedVisible = false,
11
13
  trayAvailable = false,
@@ -14,7 +16,7 @@ function overlayWanted({
14
16
  attentionLatched = false,
15
17
  } = {}) {
16
18
  const dismissedEffective = trayAvailable && dismissed;
17
- return (hostRunning || trayForcedVisible || attentionLatched) && (!dismissedEffective || ghostActive);
19
+ return (persistent || hostRunning || trayForcedVisible || attentionLatched) && (!dismissedEffective || ghostActive);
18
20
  }
19
21
 
20
22
  // Debounced "a host is running" tracker. The OFF transition requires `missThreshold`
@@ -72,4 +74,36 @@ function boundedPresentedRelayIds(currentIds, newlyPresentedIds, retainedUnreadI
72
74
  return [...ids];
73
75
  }
74
76
 
75
- module.exports = { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss, boundedPresentedRelayIds };
77
+ // An attention stack is recorded as presented before its seven-second animation
78
+ // starts. If the process dies during that interval, the renderer never gets to send
79
+ // `attentionDone`, so those ids must be put back into the unpresented pool on the next
80
+ // launch. Clear the in-flight marker at the same time: if the new process also dies
81
+ // before it can present the recovered stack, the ids are already absent from history
82
+ // and remain eligible on the following launch.
83
+ function recoverInterruptedAttentionPrefs(input = {}) {
84
+ const prefs = input && typeof input === "object" && !Array.isArray(input) ? input : {};
85
+ const activeAttentionIds = [...new Set(
86
+ (Array.isArray(prefs.activeAttentionIds) ? prefs.activeAttentionIds : [])
87
+ .map((value) => String(value || ""))
88
+ .filter(Boolean),
89
+ )];
90
+ const active = new Set(activeAttentionIds);
91
+ const presentedRelayIds = [...new Set(
92
+ (Array.isArray(prefs.presentedRelayIds) ? prefs.presentedRelayIds : [])
93
+ .map((value) => String(value || ""))
94
+ .filter(Boolean),
95
+ )].filter((id) => !active.has(id));
96
+
97
+ return {
98
+ prefs: { ...prefs, presentedRelayIds, activeAttentionIds: [] },
99
+ interruptedAttentionIds: activeAttentionIds,
100
+ };
101
+ }
102
+
103
+ module.exports = {
104
+ overlayWanted,
105
+ createHostRunningTracker,
106
+ shouldIgnoreDismiss,
107
+ boundedPresentedRelayIds,
108
+ recoverInterruptedAttentionPrefs,
109
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "scripts": {
11
11
  "mcp": "node src/mcp.js",
12
12
  "daemon": "node src/task-daemon.js",
13
+ "postinstall": "node src/postinstall.js",
13
14
  "test": "node --test test/*.test.mjs"
14
15
  },
15
16
  "dependencies": {