relay-companion 0.1.22 → 0.1.24

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
@@ -96,6 +96,12 @@ function printActivationStatus(activation) {
96
96
  }
97
97
  }
98
98
 
99
+ function statusAreaReopenText() {
100
+ if (process.platform === "darwin") return "the Relay icon in the menu bar";
101
+ if (process.platform === "win32") return "the Relay icon in the system tray near the clock";
102
+ return "the Relay icon in the system tray";
103
+ }
104
+
99
105
  /** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
100
106
  async function applyInstall({ requireLiveTools = false, requiredHosts = requiredLiveHosts() } = {}) {
101
107
  const { installed, missing, daemon, pill, activations = [] } = await runSetupInstall();
@@ -108,10 +114,18 @@ async function applyInstall({ requireLiveTools = false, requiredHosts = required
108
114
  if (daemon.ok) console.log("Relay is running in the background and will start automatically when you log in.");
109
115
  else if (daemon.reason === "autostart_unsupported_platform") {
110
116
  console.log("Run `relay daemon` to receive relays (background autostart is macOS-only for now).");
117
+ } else {
118
+ const detail = daemon.reason ? ` (${daemon.reason})` : "";
119
+ console.log(`Could not install Relay background autostart${detail}. Run \`relay daemon\` to receive relays.`);
111
120
  }
112
- if (pill?.ok) console.log("The Relay pill is open and will start automatically when you log in.");
121
+ if (pill?.ok) console.log(`The Relay pill is open and will start automatically when you log in. Reopen it from ${statusAreaReopenText()} after hiding it.`);
113
122
  else if (pill?.reason === "pill_runtime_missing") {
114
123
  console.log("Run `relay pill` to open the Relay pill.");
124
+ } else if (pill?.reason === "autostart_unsupported_platform") {
125
+ console.log(`Run \`relay pill\` to open the Relay pill. Reopen it from ${statusAreaReopenText()} after hiding it.`);
126
+ } else if (pill) {
127
+ const detail = pill.reason ? ` (${pill.reason})` : "";
128
+ console.log(`Could not install Relay pill autostart${detail}. Run \`relay pill\` to open it.`);
115
129
  }
116
130
  const liveRequirement = liveToolRequirement({ activations, requiredHosts });
117
131
  if (requireLiveTools && installed.length && !liveRequirement.ok) {
@@ -280,7 +294,7 @@ function cmdPill() {
280
294
  env: { ...process.env },
281
295
  });
282
296
  child.unref();
283
- console.log("Relay pill launched. It shows when Claude Code or Codex is running.");
297
+ console.log(`Relay pill launched. It shows when Claude Code or Codex is running; reopen it from ${statusAreaReopenText()} after hiding it.`);
284
298
  }
285
299
 
286
300
  async function main() {
@@ -103,8 +103,8 @@
103
103
  color:var(--accent); line-height:1; transition:color .3s var(--settle);
104
104
  }
105
105
  .count.zero { color:var(--muted-3); }
106
- /* ✕ — fully hide the overlay; the Relay mark in the menu bar brings it back. Present in
107
- every card state (expanded, pill, notification) so the pill is always escapable. */
106
+ /* ✕ — fully hide the overlay; the Relay mark in the OS status area brings it back.
107
+ Present in every card state (expanded, pill, notification) so the pill is always escapable. */
108
108
  .close-x {
109
109
  flex:0 0 auto; appearance:none; border:0; background:transparent; cursor:pointer;
110
110
  width:20px; height:20px; border-radius:6px; padding:0; margin-left:6px; margin-right:-6px;
@@ -114,6 +114,8 @@
114
114
  }
115
115
  .close-x:hover { color:var(--ink-2); background:rgba(31,26,23,.06); }
116
116
  .close-x:active { color:var(--ink); }
117
+ .close-x.unavailable { opacity:.38; cursor:default; }
118
+ .close-x.unavailable:hover { color:var(--muted-3); background:transparent; }
117
119
  @keyframes countRoll { from { transform:translateY(-6px); opacity:.3; } to { transform:none; opacity:1; } }
118
120
 
119
121
  /* tab control — individual rounded-rect buttons (the loved control) */
@@ -528,7 +530,7 @@
528
530
  <span class="spacer"></span>
529
531
  <span class="minimize-hint">Minimize</span>
530
532
  <span class="count zero" id="count">0</span>
531
- <button class="close-x" id="closeX" type="button" aria-label="Hide Relay" title="Hide Relay — reopen from the menu bar">
533
+ <button class="close-x" id="closeX" type="button" aria-label="Hide Relay" title="Hide Relay - reopen from the Relay icon">
532
534
  <svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M1 1l8 8M9 1L1 9"/></svg>
533
535
  </button>
534
536
  </div>
@@ -737,7 +739,7 @@
737
739
  snapSize(EXPANDED.w, EXPANDED.h);
738
740
  }
739
741
  // ✕ — the card leaves gracefully, then the main process hides the window and keeps
740
- // it hidden (persisted) until the menu-bar Relay mark is clicked.
742
+ // it hidden (persisted) until the status-area Relay mark is clicked.
741
743
  function dismissOverlay() {
742
744
  if (cardEl.classList.contains("bye")) return;
743
745
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
@@ -778,7 +780,7 @@
778
780
  dismissOverlay(); // rides the ghostOut exit, then main re-hides the window
779
781
  }, 7000);
780
782
  }
781
- // Menu-bar Relay mark clicked: the card comes back FULLY OPEN (never the pill).
783
+ // Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
782
784
  function trayOpen() {
783
785
  const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
784
786
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
@@ -854,6 +856,7 @@
854
856
  closeXEl.addEventListener("mousedown", (e) => { e.stopPropagation(); });
855
857
  closeXEl.addEventListener("click", (e) => {
856
858
  e.stopPropagation();
859
+ if (closeXEl.disabled) return;
857
860
  dismissOverlay();
858
861
  });
859
862
 
@@ -1099,7 +1102,7 @@
1099
1102
 
1100
1103
  // ---------- state ----------
1101
1104
  let activeView = "relays";
1102
- let payload = { account: { paired: false }, relays: [], tasks: [], contacts: [] };
1105
+ let payload = { account: { paired: false }, ui: { canDismiss: true, reopenSurface: "the Relay icon" }, relays: [], tasks: [], contacts: [] };
1103
1106
  let prevRelayIds = new Set();
1104
1107
  let contactsList = [];
1105
1108
  let cvEditing = null;
@@ -1928,6 +1931,7 @@
1928
1931
  }
1929
1932
 
1930
1933
  function renderAll() {
1934
+ syncDismissControl();
1931
1935
  // Relays badge/count reflect only the items that show in the Relays tab
1932
1936
  // (the clean split keeps task_request / share_approval out of this count).
1933
1937
  const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
@@ -1940,10 +1944,23 @@
1940
1944
  // contacts renders from its own load path
1941
1945
  }
1942
1946
 
1947
+ function syncDismissControl() {
1948
+ const ui = (payload && payload.ui) || {};
1949
+ const canDismiss = ui.canDismiss !== false;
1950
+ const surface = ui.reopenSurface || "the Relay icon";
1951
+ closeXEl.disabled = !canDismiss;
1952
+ closeXEl.classList.toggle("unavailable", !canDismiss);
1953
+ closeXEl.title = canDismiss
1954
+ ? `Hide Relay - reopen from ${surface}`
1955
+ : "Hide Relay unavailable because the Relay status icon did not load.";
1956
+ closeXEl.setAttribute("aria-label", canDismiss ? "Hide Relay" : "Relay status icon unavailable");
1957
+ }
1958
+
1943
1959
  function onPayload(next) {
1944
1960
  if (!next || typeof next !== "object") return;
1945
1961
  payload = {
1946
1962
  account: next.account || payload.account,
1963
+ ui: next.ui || payload.ui,
1947
1964
  relays: Array.isArray(next.relays) ? next.relays : [],
1948
1965
  tasks: Array.isArray(next.tasks) ? next.tasks : [],
1949
1966
  contacts: Array.isArray(next.contacts) ? next.contacts : (payload.contacts || []),
package/overlay/main.cjs CHANGED
@@ -7,9 +7,10 @@
7
7
  // click-through, so the overlay never blocks the app behind it.
8
8
  //
9
9
  // Visibility: the card carries a ✕ that hides the overlay entirely (persisted). The Relay
10
- // mark in the macOS menu bar (Tray) brings it back fully open. While dismissed, fresh
11
- // relays still show the designed banner as a "ghost" notification — the window appears
12
- // just for the banner, which fades away instead of folding back into a pill.
10
+ // mark in the OS status area (macOS menu bar, Windows system tray) brings it back fully
11
+ // open. While dismissed, fresh relays still show the designed banner as a "ghost"
12
+ // notification — the window appears just for the banner, which fades away instead of
13
+ // folding back into a pill.
13
14
  //
14
15
  // Data:
15
16
  // - Relay attention rows come from ${RELAY_HOME:-~/.relay-companion}/state.json under
@@ -19,7 +20,7 @@
19
20
  // - Mutations (accept/reject/approve/decline/answer) call RelayClient with device-token
20
21
  // auth. ack/mark-read writes state.json directly (atomic temp+rename).
21
22
 
22
- const { app, BrowserWindow, Tray, ipcMain, nativeImage, shell, screen } = require("electron");
23
+ const { app, BrowserWindow, Menu, Tray, ipcMain, nativeImage, shell, screen } = require("electron");
23
24
  const fs = require("node:fs");
24
25
  const os = require("node:os");
25
26
  const path = require("node:path");
@@ -43,6 +44,7 @@ const DEFAULT_WEB_BASE = "https://sendrelays.com";
43
44
 
44
45
  let win = null;
45
46
  let tray = null;
47
+ let trayAvailable = false;
46
48
  let hostRunning = false; // Claude or Codex desktop app is running (either)
47
49
  let claudeRunning = false; // Claude Desktop process is running (any macOS Space)
48
50
  let codexRunning = false; // Codex process is running (any macOS Space)
@@ -51,7 +53,7 @@ let lastHostSeenAt = 0;
51
53
  let lastSig = "";
52
54
 
53
55
  // Dismissed mode: the user clicked the ✕ on the card, so the overlay stays hidden
54
- // until they click the Relay menu-bar icon. Persisted across relaunches. While
56
+ // until they click the Relay status-area icon. Persisted across relaunches. While
55
57
  // dismissed, a fresh relay still shows the designed notification (a "ghost" peek):
56
58
  // the window appears just for the banner, then hides again when it finishes.
57
59
  const OVERLAY_PREFS_PATH = path.join(RELAY_HOME, "overlay-prefs.json");
@@ -71,6 +73,12 @@ function setDismissed(value) {
71
73
  }
72
74
  }
73
75
 
76
+ function reopenSurfaceName() {
77
+ if (process.platform === "darwin") return "the Relay icon in the menu bar";
78
+ if (process.platform === "win32") return "the Relay icon in the system tray near the clock";
79
+ return "the Relay icon in the system tray";
80
+ }
81
+
74
82
  // The ESM companion modules (config/client) are loaded lazily via dynamic import,
75
83
  // because this overlay runs as CommonJS under Electron while the package is ESM.
76
84
  let relayModules = null;
@@ -306,6 +314,10 @@ async function buildPayload() {
306
314
  const [relays, contacts] = [readRelays(), await readContacts()];
307
315
  return {
308
316
  account: account(),
317
+ ui: {
318
+ canDismiss: trayAvailable,
319
+ reopenSurface: reopenSurfaceName(),
320
+ },
309
321
  relays,
310
322
  tasks: tasksCache,
311
323
  contacts,
@@ -326,7 +338,7 @@ async function pushInbox(force) {
326
338
  // newest arrival drives the peek. While dismissed, the same designed banner still
327
339
  // shows — as a "ghost": the window appears for the notification only, and the
328
340
  // renderer fades it out (never folding back into a pill) when it's done.
329
- if (dismissed) {
341
+ if (trayAvailable && dismissed) {
330
342
  if (hostRunning) {
331
343
  ghostActive = true;
332
344
  showOverlayWindow();
@@ -499,6 +511,7 @@ function taskVersion(taskId) {
499
511
 
500
512
  // Best-effort frontmost-app bundle id (no permission prompt; uses lsappinfo).
501
513
  function frontmostBundleId(cb) {
514
+ if (process.platform !== "darwin") return cb(null);
502
515
  execFile("/usr/bin/lsappinfo", ["front"], (e1, asn) => {
503
516
  const a = String(asn || "").trim();
504
517
  if (e1 || !a) return cb(null);
@@ -518,10 +531,20 @@ function rememberForegroundHost(host) {
518
531
  function appInstalled(appName) {
519
532
  const name = String(appName || "").replace(/[^A-Za-z0-9 ._-]/g, "");
520
533
  if (!name) return false;
521
- const candidates = [
522
- path.join("/Applications", `${name}.app`),
523
- path.join(os.homedir(), "Applications", `${name}.app`),
524
- ];
534
+ let candidates = [];
535
+ if (process.platform === "darwin") {
536
+ candidates = [
537
+ path.join("/Applications", `${name}.app`),
538
+ path.join(os.homedir(), "Applications", `${name}.app`),
539
+ ];
540
+ } else if (process.platform === "win32") {
541
+ const exe = `${name}.exe`;
542
+ candidates = [
543
+ process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Programs", name, exe),
544
+ process.env.PROGRAMFILES && path.join(process.env.PROGRAMFILES, name, exe),
545
+ process.env["PROGRAMFILES(X86)"] && path.join(process.env["PROGRAMFILES(X86)"], name, exe),
546
+ ].filter(Boolean);
547
+ }
525
548
  return candidates.some((candidate) => fs.existsSync(candidate));
526
549
  }
527
550
 
@@ -843,8 +866,9 @@ function showOverlayWindow() {
843
866
  function maybeShow() {
844
867
  if (!win || win.isDestroyed()) return;
845
868
  // Dismissed keeps the window hidden except while a ghost notification is up.
846
- // trayForcedVisible honors an explicit menu-bar click even when no host runs.
847
- const wanted = (hostRunning || trayForcedVisible) && (!dismissed || ghostActive);
869
+ // trayForcedVisible honors an explicit status-area click even when no host runs.
870
+ const dismissedEffective = trayAvailable && dismissed;
871
+ const wanted = (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
848
872
  if (wanted) {
849
873
  showOverlayWindow();
850
874
  } else if (win.isVisible()) {
@@ -852,19 +876,38 @@ function maybeShow() {
852
876
  }
853
877
  }
854
878
 
855
- function pollHosts() {
856
- // Show whenever EITHER host app is running; track the foregrounded one for click routing.
857
- execFile("/bin/ps", ["-Ao", "comm"], (err, stdout) => {
858
- const s = String(stdout || "");
879
+ function readHostProcesses(cb) {
880
+ if (process.platform === "win32") {
881
+ execFile("tasklist", ["/FO", "CSV", "/NH"], cb);
882
+ return;
883
+ }
884
+ execFile("/bin/ps", ["-Ao", "comm"], cb);
885
+ }
886
+
887
+ function updateHostRunningFromProcesses(text) {
888
+ const s = String(text || "");
889
+ if (process.platform === "darwin") {
859
890
  // `ps` lists processes on every macOS Space, so this sees a host even when it is
860
891
  // not the frontmost app — the signal click routing uses to beat frontmost's
861
- // Space-blindness. On a `ps` error, leave the last-known running state untouched
862
- // rather than flapping both hosts to "not running".
863
- if (!err) {
864
- claudeRunning = /\/Claude\.app\/Contents\/MacOS\/Claude(\n|$)/.test(s);
865
- codexRunning = /\/Codex\.app\/Contents\/MacOS\/Codex(\n|$)/.test(s);
866
- }
867
- hostRunning = claudeRunning || codexRunning;
892
+ // Space-blindness.
893
+ claudeRunning = /\/Claude\.app\/Contents\/MacOS\/Claude(\n|$)/.test(s);
894
+ codexRunning = /\/Codex\.app\/Contents\/MacOS\/Codex(\n|$)/.test(s);
895
+ } else if (process.platform === "win32") {
896
+ claudeRunning = /"Claude\.exe"/i.test(s);
897
+ codexRunning = /"Codex\.exe"/i.test(s);
898
+ } else {
899
+ claudeRunning = /(^|\n)Claude(\n|$)/.test(s) || /(^|\n)claude(\n|$)/.test(s);
900
+ codexRunning = /(^|\n)Codex(\n|$)/.test(s) || /(^|\n)codex(\n|$)/.test(s);
901
+ }
902
+ hostRunning = claudeRunning || codexRunning;
903
+ }
904
+
905
+ function pollHosts() {
906
+ // Show whenever EITHER host app is running; track the foregrounded one for click routing.
907
+ readHostProcesses((err, stdout) => {
908
+ // On a process-list error, leave the last-known running state untouched rather than
909
+ // flapping both hosts to "not running".
910
+ if (!err) updateHostRunningFromProcesses(stdout);
868
911
  if (hostRunning) trayForcedVisible = false; // normal host-based visibility takes back over
869
912
  maybeShow();
870
913
  });
@@ -934,27 +977,59 @@ function createWindow() {
934
977
  setInterval(pollHosts, 1500);
935
978
  }
936
979
 
937
- // ---- menu bar (tray) -------------------------------------------------------
980
+ // ---- OS status area (tray/menu bar) ----------------------------------------
981
+
982
+ // The Relay mark lives in the OS status area: macOS menu bar, Windows system tray.
983
+ // Clicking it brings the card back FULLY OPEN (never the folded pill) — the
984
+ // counterpart to the card's ✕, which hides the overlay entirely.
985
+ function createNonMacTrayIcon() {
986
+ const svg = `
987
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
988
+ <rect x="1" y="6" width="4" height="4" rx="0.6" fill="#D9704E"/>
989
+ <rect x="6" y="6" width="4" height="4" rx="0.6" fill="#F8FAFC"/>
990
+ <rect x="11" y="6" width="4" height="4" rx="0.6" fill="#3B82F6"/>
991
+ </svg>
992
+ `.trim();
993
+ const icon = nativeImage.createFromDataURL(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
994
+ return icon && !icon.isEmpty() ? icon.resize({ width: 16, height: 16 }) : null;
995
+ }
996
+
997
+ function createTrayIcon() {
998
+ const templateIcon = nativeImage.createFromPath(path.join(__dirname, "relayTrayTemplate.png"));
999
+ if (process.platform === "darwin") {
1000
+ if (templateIcon && !templateIcon.isEmpty() && templateIcon.setTemplateImage) templateIcon.setTemplateImage(true);
1001
+ return templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
1002
+ }
1003
+ const icon = createNonMacTrayIcon();
1004
+ return icon && !icon.isEmpty() ? icon : templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
1005
+ }
1006
+
1007
+ function showFromTray() {
1008
+ setDismissed(false);
1009
+ ghostActive = false;
1010
+ if (!hostRunning) trayForcedVisible = true; // an explicit click always gets the card
1011
+ maybeShow();
1012
+ if (win && !win.isDestroyed()) win.webContents.send("openFull");
1013
+ }
938
1014
 
939
- // The Relay mark lives in the macOS menu bar. Clicking it brings the card back
940
- // FULLY OPEN (never the folded pill) — the counterpart to the card's ✕, which
941
- // hides the overlay entirely. The *Template naming makes macOS tint the mark
942
- // for light/dark menu bars automatically.
943
1015
  function createTray() {
944
- const icon = nativeImage.createFromPath(path.join(__dirname, "relayTrayTemplate.png"));
945
- if (icon.isEmpty()) {
946
- console.error("[overlay] tray icon missing; menu bar reopen unavailable");
1016
+ const icon = createTrayIcon();
1017
+ if (!icon || icon.isEmpty()) {
1018
+ trayAvailable = false;
1019
+ setDismissed(false);
1020
+ console.error("[overlay] tray icon missing; status-area reopen unavailable");
947
1021
  return;
948
1022
  }
949
1023
  tray = new Tray(icon);
950
- tray.setToolTip("Relay");
951
- tray.on("click", () => {
952
- setDismissed(false);
953
- ghostActive = false;
954
- if (!hostRunning) trayForcedVisible = true; // an explicit click always gets the card
955
- maybeShow();
956
- if (win && !win.isDestroyed()) win.webContents.send("openFull");
957
- });
1024
+ trayAvailable = true;
1025
+ tray.setToolTip(`Relay - click to show the pill`);
1026
+ tray.setContextMenu(
1027
+ Menu.buildFromTemplate([
1028
+ { label: "Show Relay", click: showFromTray },
1029
+ ]),
1030
+ );
1031
+ tray.on("click", showFromTray);
1032
+ tray.on("double-click", showFromTray);
958
1033
  }
959
1034
 
960
1035
  // ---- ipc -----------------------------------------------------------------
@@ -1011,9 +1086,17 @@ ipcMain.on("relay:interactive", (_e, interactive) => {
1011
1086
  ipcMain.on("relay:setPos", (_e, x, y) => {
1012
1087
  if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) win.setPosition(Math.round(x), Math.round(y));
1013
1088
  });
1014
- // ✕ on the card: hide the overlay entirely until the menu-bar Relay mark is clicked.
1089
+ // ✕ on the card: hide the overlay entirely until the status-area Relay mark is clicked.
1015
1090
  // The renderer plays its exit animation first, then sends this.
1016
1091
  ipcMain.on("relay:dismiss", () => {
1092
+ if (!trayAvailable) {
1093
+ setDismissed(false);
1094
+ ghostActive = false;
1095
+ trayForcedVisible = true;
1096
+ maybeShow();
1097
+ if (win && !win.isDestroyed()) win.webContents.send("openFull");
1098
+ return;
1099
+ }
1017
1100
  setDismissed(true);
1018
1101
  ghostActive = false;
1019
1102
  trayForcedVisible = false;
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld("relay", {
5
5
  onInbox: (cb) => ipcRenderer.on("inbox", (_e, payload) => cb(payload)),
6
6
  onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay, opts) => cb(relay, opts || {})),
7
7
  onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
8
- onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // menu-bar icon clicked
8
+ onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // status-area icon clicked
9
9
  onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
10
10
 
11
11
  // pull the full payload on demand
@@ -38,7 +38,7 @@ contextBridge.exposeInMainWorld("relay", {
38
38
  setPos: (x, y) => ipcRenderer.send("relay:setPos", x, y),
39
39
  soundBytes: (name) => ipcRenderer.invoke("relay:soundBytes", name),
40
40
 
41
- // dismissal (the card's ✕ / the menu-bar mark / ghost notifications)
41
+ // dismissal (the card's ✕ / the status-area mark / ghost notifications)
42
42
  dismiss: () => ipcRenderer.send("relay:dismiss"),
43
43
  undismiss: () => ipcRenderer.send("relay:undismiss"),
44
44
  notifDone: () => ipcRenderer.send("relay:notifDone"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,10 @@ export async function notifyCodexDesktopThreads({
46
46
  threadIds = [],
47
47
  pinnedThreadIds = null,
48
48
  openThreadId = null,
49
- timeoutMs = Number(process.env.RELAY_CODEX_DESKTOP_TIMEOUT_MS || 4000),
49
+ primeOpen = true,
50
+ // The open path navigates hotkey -> (prime wait) -> /local inside the renderer,
51
+ // so the inspector eval must outlast RELAY_CODEX_OPEN_PRIME_MS; default higher.
52
+ timeoutMs = Number(process.env.RELAY_CODEX_DESKTOP_TIMEOUT_MS || 8000),
50
53
  } = {}) {
51
54
  if (process.env.RELAY_CODEX_DESKTOP_REFRESH === "0") return { attempted: false, reason: "disabled" };
52
55
  if (process.platform !== "darwin") return { attempted: false, reason: "not-darwin" };
@@ -59,7 +62,7 @@ export async function notifyCodexDesktopThreads({
59
62
  const pids = await findCodexMainPids();
60
63
  if (!pids.length) return { attempted: true, ok: false, reason: "codex-not-running", results: [] };
61
64
 
62
- const expression = buildCodexDesktopRefreshExpression({ threadIds: ids, pinnedThreadIds: pinnedIds, openThreadId: openId });
65
+ const expression = buildCodexDesktopRefreshExpression({ threadIds: ids, pinnedThreadIds: pinnedIds, openThreadId: openId, primeOpen });
63
66
  const results = [];
64
67
  for (const pid of pids) {
65
68
  try {
@@ -88,9 +91,15 @@ export async function relayRefreshCodexRenderer(payload) {
88
91
  if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
89
92
 
90
93
  const sent = [];
91
- async function send(message) {
94
+ // Bound every bridge send. Some host messages (notably maybe-resume-conversation)
95
+ // can hang indefinitely; the original serial awaits then block the navigation
96
+ // that follows, leaving the thread unopened. Cap each send so the open proceeds.
97
+ async function send(message, timeoutMs = 2000) {
92
98
  try {
93
- await bridge.sendMessageFromView(message);
99
+ await Promise.race([
100
+ bridge.sendMessageFromView(message),
101
+ new Promise((_, reject) => setTimeout(() => reject(new Error("send-timeout")), timeoutMs)),
102
+ ]);
94
103
  sent.push({ type: message.type, ok: true });
95
104
  } catch (error) {
96
105
  sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
@@ -127,33 +136,52 @@ export async function relayRefreshCodexRenderer(payload) {
127
136
  await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
128
137
  }
129
138
  if (payload.openThreadId) {
130
- try {
131
- const encoded = encodeURIComponent(String(payload.openThreadId));
132
- // Codex renders a thread through two separate router surfaces: the main
133
- // window (sidebar + recents rail) at /local/<id>, and the borderless hotkey
134
- // "quick chat" overlay at /hotkey-window/thread/<id>. The app's own
135
- // navigation chooses between them by whether the current window is the
136
- // hotkey one (its QH() keys off location.pathname). Mirror that here so the
137
- // thread opens in whichever surface the user is actually in — otherwise
138
- // every open is forced into the overlay even when the main window is up.
139
- const path = location.pathname.startsWith("/hotkey-window")
140
- ? `/hotkey-window/thread/${encoded}`
141
- : `/local/${encoded}`;
142
- window.postMessage({ type: "navigate-to-route", path }, location.origin);
143
- sent.push({ type: "navigate-to-route", ok: true, threadId: payload.openThreadId, path });
144
- } catch (error) {
145
- sent.push({ type: "navigate-to-route", ok: false, error: String(error?.message || error) });
139
+ const encoded = encodeURIComponent(String(payload.openThreadId));
140
+ const navTo = (path) => {
141
+ try {
142
+ window.postMessage({ type: "navigate-to-route", path }, location.origin);
143
+ sent.push({ type: "navigate-to-route", ok: true, path });
144
+ } catch (error) {
145
+ sent.push({ type: "navigate-to-route", ok: false, path, error: String(error?.message || error) });
146
+ }
147
+ };
148
+ // Codex renders a thread through two router surfaces: the main window
149
+ // (sidebar + recents rail) at /local/<id>, and the borderless hotkey
150
+ // "quick chat" view at /hotkey-window/thread/<id>. Two facts drive the logic:
151
+ // 1. The app uses an in-memory router, so location.pathname is ALWAYS
152
+ // /index.html — it never reveals the current route or window kind.
153
+ // 2. The /local/<id> route CANNOT cold-load an externally-created relay
154
+ // thread: it renders the empty "New chat" state and never recovers.
155
+ // Only /hotkey-window/thread/<id> loads such a thread from disk into the
156
+ // renderer store. Once loaded, /local/<id> renders it fine.
157
+ // So to land the user in the main window WITH content, prime the load via the
158
+ // hotkey route, then switch to /local/<id> once it is warm. The hotkey view
159
+ // shows the thread during the prime, so the transition is content -> content,
160
+ // never a blank flash. The second materializer pass runs with primeOpen=false
161
+ // and just re-asserts /local (the thread is warm by then).
162
+ const inHotkeyWindow = /hotkey-window/.test(location.href) || /hotkey-window/.test(location.search || "");
163
+ const primeMs = Number.isFinite(payload.primeMs) ? payload.primeMs : 1200;
164
+ if (inHotkeyWindow) {
165
+ navTo(`/hotkey-window/thread/${encoded}`);
166
+ } else if (payload.primeOpen === false) {
167
+ navTo(`/local/${encoded}`);
168
+ } else {
169
+ navTo(`/hotkey-window/thread/${encoded}`);
170
+ await new Promise((resolve) => setTimeout(resolve, primeMs));
171
+ navTo(`/local/${encoded}`);
146
172
  }
147
173
  }
148
174
 
149
175
  return { ok: sent.some((item) => item.ok), href: location.href, openThreadId: payload.openThreadId || null, sent };
150
176
  }
151
177
 
152
- export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null } = {}) {
178
+ export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null, primeOpen = true } = {}) {
153
179
  const rendererCode = `(${relayRefreshCodexRenderer.toString()})(${JSON.stringify({
154
180
  threadIds: uniqueStrings(threadIds),
155
181
  pinnedThreadIds,
156
182
  openThreadId: String(openThreadId || "").trim() || null,
183
+ primeOpen: primeOpen !== false,
184
+ primeMs: Number(process.env.RELAY_CODEX_OPEN_PRIME_MS) || 1200,
157
185
  })})`;
158
186
  // Only bring a window forward when we're actually opening a thread.
159
187
  const wantsFocus = Boolean(String(openThreadId || "").trim());
package/src/install.js CHANGED
@@ -15,6 +15,9 @@ import { activateCodexMcp, verifyClaudeMcpRegistration } from "./setup-activatio
15
15
 
16
16
  const DAEMON_LAUNCH_LABEL = "work.relay.companion";
17
17
  const PILL_LAUNCH_LABEL = "work.relay.companion.pill";
18
+ const WINDOWS_DAEMON_TASK_NAME = "Relay Companion Daemon";
19
+ const WINDOWS_PILL_TASK_NAME = "Relay Companion Pill";
20
+ const DEFAULT_NPM_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
18
21
 
19
22
  export const PACKAGE_NAME = "relay-companion";
20
23
 
@@ -45,7 +48,9 @@ export function resolveStableBin() {
45
48
  const here = relayBinPath();
46
49
  const ephemeral = /[\\/](_npx|\.npm[\\/]_npx|npm-cache[\\/]_npx)[\\/]/.test(here);
47
50
  if (!ephemeral) return here;
48
- const install = run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`]);
51
+ const install = run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
52
+ timeoutMs: npmInstallTimeoutMs(),
53
+ });
49
54
  if (!install.ok) return here; // fall back; caller still gets a working (if cache-bound) path
50
55
  const rootRes = spawnSync("npm", ["root", "-g"], { encoding: "utf8", timeout: 20_000 });
51
56
  const root = (rootRes.stdout || "").trim();
@@ -53,8 +58,13 @@ export function resolveStableBin() {
53
58
  return fs.existsSync(globalBin) ? globalBin : here;
54
59
  }
55
60
 
56
- function run(cmd, args) {
57
- const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 20_000 });
61
+ function npmInstallTimeoutMs(env = process.env) {
62
+ const parsed = Number(env.RELAY_NPM_INSTALL_TIMEOUT_MS);
63
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_NPM_INSTALL_TIMEOUT_MS;
64
+ }
65
+
66
+ function run(cmd, args, { timeoutMs = 20_000 } = {}) {
67
+ const res = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
58
68
  return {
59
69
  ok: !res.error && res.status === 0,
60
70
  status: res.status,
@@ -63,6 +73,163 @@ function run(cmd, args) {
63
73
  };
64
74
  }
65
75
 
76
+ export function claudeCodeConfigPath() {
77
+ return process.env.CLAUDE_CODE_CONFIG || path.join(os.homedir(), ".claude.json");
78
+ }
79
+
80
+ export function codexConfigPath() {
81
+ if (process.env.CODEX_CONFIG) return process.env.CODEX_CONFIG;
82
+ const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
83
+ return path.join(home, "config.toml");
84
+ }
85
+
86
+ function readJsonObject(file) {
87
+ const text = fs.readFileSync(file, "utf8");
88
+ const parsed = JSON.parse(text);
89
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
90
+ }
91
+
92
+ function writeJsonAtomic(file, value) {
93
+ fs.mkdirSync(path.dirname(file), { recursive: true });
94
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
95
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
96
+ fs.renameSync(tmp, file);
97
+ }
98
+
99
+ function writeTextAtomic(file, value) {
100
+ fs.mkdirSync(path.dirname(file), { recursive: true });
101
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
102
+ fs.writeFileSync(tmp, value);
103
+ fs.renameSync(tmp, file);
104
+ }
105
+
106
+ export function writeClaudeCodeMcpConfig(bin = relayBinPath(), node = process.execPath, configPath = claudeCodeConfigPath()) {
107
+ if (!fs.existsSync(configPath)) return { ok: false, reason: "claude_config_missing", configPath };
108
+ try {
109
+ const cfg = readJsonObject(configPath);
110
+ const existingServers =
111
+ cfg.mcpServers && typeof cfg.mcpServers === "object" && !Array.isArray(cfg.mcpServers)
112
+ ? cfg.mcpServers
113
+ : {};
114
+ cfg.mcpServers = {
115
+ ...existingServers,
116
+ relay: {
117
+ type: "stdio",
118
+ command: node,
119
+ args: [bin, "mcp"],
120
+ env: {},
121
+ },
122
+ };
123
+ writeJsonAtomic(configPath, cfg);
124
+ return { ok: true, method: "config", configPath };
125
+ } catch (error) {
126
+ return {
127
+ ok: false,
128
+ reason: "claude_config_write_failed",
129
+ configPath,
130
+ detail: error && error.message ? error.message : String(error),
131
+ };
132
+ }
133
+ }
134
+
135
+ export function removeClaudeCodeMcpConfig(configPath = claudeCodeConfigPath()) {
136
+ if (!fs.existsSync(configPath)) return { ok: true, configPath };
137
+ try {
138
+ const cfg = readJsonObject(configPath);
139
+ if (cfg.mcpServers && typeof cfg.mcpServers === "object" && !Array.isArray(cfg.mcpServers)) {
140
+ delete cfg.mcpServers.relay;
141
+ writeJsonAtomic(configPath, cfg);
142
+ }
143
+ return { ok: true, configPath };
144
+ } catch (error) {
145
+ return {
146
+ ok: false,
147
+ reason: "claude_config_write_failed",
148
+ configPath,
149
+ detail: error && error.message ? error.message : String(error),
150
+ };
151
+ }
152
+ }
153
+
154
+ function tomlQuote(value) {
155
+ return JSON.stringify(String(value));
156
+ }
157
+
158
+ export function codexRelayMcpTomlSection(bin = relayBinPath(), node = process.execPath) {
159
+ return [
160
+ "[mcp_servers.relay]",
161
+ `command = ${tomlQuote(node)}`,
162
+ `args = [${[bin, "mcp"].map(tomlQuote).join(", ")}]`,
163
+ "startup_timeout_sec = 30",
164
+ "tool_timeout_sec = 300",
165
+ "",
166
+ ].join("\n");
167
+ }
168
+
169
+ export function replaceTomlTable(text, tableName, replacement) {
170
+ const header = `[${tableName}]`;
171
+ const lines = String(text || "").split(/\r?\n/);
172
+ const start = lines.findIndex((line) => line.trim() === header);
173
+ const cleanReplacement = String(replacement || "").trimEnd();
174
+ if (start === -1) {
175
+ const prefix = String(text || "").trimEnd();
176
+ return `${prefix}${prefix ? "\n\n" : ""}${cleanReplacement}\n`;
177
+ }
178
+
179
+ let end = start + 1;
180
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
181
+ const before = lines.slice(0, start).join("\n").trimEnd();
182
+ const after = lines.slice(end).join("\n").trimStart();
183
+ return `${before ? `${before}\n\n` : ""}${cleanReplacement}${after ? `\n\n${after}` : ""}\n`;
184
+ }
185
+
186
+ export function removeTomlTable(text, tableName) {
187
+ const header = `[${tableName}]`;
188
+ const lines = String(text || "").split(/\r?\n/);
189
+ const start = lines.findIndex((line) => line.trim() === header);
190
+ if (start === -1) return String(text || "");
191
+
192
+ let end = start + 1;
193
+ while (end < lines.length && !/^\s*\[[^\]]+\]\s*$/.test(lines[end])) end += 1;
194
+ const before = lines.slice(0, start).join("\n").trimEnd();
195
+ const after = lines.slice(end).join("\n").trimStart();
196
+ return `${before}${before && after ? "\n\n" : ""}${after}${before || after ? "\n" : ""}`;
197
+ }
198
+
199
+ export function writeCodexMcpConfig(bin = relayBinPath(), node = process.execPath, configPath = codexConfigPath()) {
200
+ const configDir = path.dirname(configPath);
201
+ if (!fs.existsSync(configPath) && !fs.existsSync(configDir)) {
202
+ return { ok: false, reason: "codex_config_missing", configPath };
203
+ }
204
+ try {
205
+ const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
206
+ writeTextAtomic(configPath, replaceTomlTable(existing, "mcp_servers.relay", codexRelayMcpTomlSection(bin, node)));
207
+ return { ok: true, method: "config", configPath };
208
+ } catch (error) {
209
+ return {
210
+ ok: false,
211
+ reason: "codex_config_write_failed",
212
+ configPath,
213
+ detail: error && error.message ? error.message : String(error),
214
+ };
215
+ }
216
+ }
217
+
218
+ export function removeCodexMcpConfig(configPath = codexConfigPath()) {
219
+ if (!fs.existsSync(configPath)) return { ok: true, configPath };
220
+ try {
221
+ writeTextAtomic(configPath, removeTomlTable(fs.readFileSync(configPath, "utf8"), "mcp_servers.relay"));
222
+ return { ok: true, configPath };
223
+ } catch (error) {
224
+ return {
225
+ ok: false,
226
+ reason: "codex_config_write_failed",
227
+ configPath,
228
+ detail: error && error.message ? error.message : String(error),
229
+ };
230
+ }
231
+ }
232
+
66
233
  function electronPathForPackageRoot(packageRoot) {
67
234
  try {
68
235
  const require = createRequire(path.join(packageRoot, "package.json"));
@@ -80,22 +247,54 @@ export function commandExists(cmd) {
80
247
  }
81
248
 
82
249
  /** Register the Relay MCP into Claude Code at user scope (loads in every session). Idempotent. */
83
- export function installClaudeCode(bin = relayBinPath(), node = process.execPath) {
84
- run("claude", ["mcp", "remove", "-s", "user", "relay"]); // ignore if absent
85
- const res = run("claude", ["mcp", "add", "-s", "user", "relay", "--", node, bin, "mcp"]);
86
- return res.ok || /already exists/i.test(res.out);
250
+ export function installClaudeCode(bin = relayBinPath(), node = process.execPath, { command = "claude" } = {}) {
251
+ let cliResult = null;
252
+ if (commandExists(command)) {
253
+ run(command, ["mcp", "remove", "-s", "user", "relay"]); // ignore if absent
254
+ cliResult = run(command, ["mcp", "add", "-s", "user", "relay", "--", node, bin, "mcp"]);
255
+ if (cliResult.ok || /already exists/i.test(cliResult.out)) {
256
+ return { ok: true, method: "cli" };
257
+ }
258
+ }
259
+
260
+ const configResult = writeClaudeCodeMcpConfig(bin, node);
261
+ if (configResult.ok) return configResult;
262
+ if (cliResult) return { ok: false, reason: "registration_failed", detail: cliResult.out, configPath: configResult.configPath };
263
+ return { ok: false, reason: "claude_code_not_found", configPath: configResult.configPath };
87
264
  }
88
265
 
89
266
  /** Register the Relay MCP into Codex (loads in every session). Idempotent. */
90
- export function installCodex(bin = relayBinPath(), node = process.execPath) {
91
- run("codex", ["mcp", "remove", "relay"]); // ignore if absent
92
- const res = run("codex", ["mcp", "add", "relay", "--", node, bin, "mcp"]);
93
- return res.ok || /already exists/i.test(res.out);
267
+ export function installCodex(bin = relayBinPath(), node = process.execPath, { command = "codex" } = {}) {
268
+ let cliResult = null;
269
+ if (commandExists(command)) {
270
+ run(command, ["mcp", "remove", "relay"]); // ignore if absent
271
+ cliResult = run(command, ["mcp", "add", "relay", "--", node, bin, "mcp"]);
272
+ if (cliResult.ok || /already exists/i.test(cliResult.out)) {
273
+ return { ok: true, method: "cli" };
274
+ }
275
+ }
276
+
277
+ const configResult = writeCodexMcpConfig(bin, node);
278
+ if (configResult.ok) return configResult;
279
+ if (cliResult) return { ok: false, reason: "registration_failed", detail: cliResult.out, configPath: configResult.configPath };
280
+ return { ok: false, reason: "codex_not_found", configPath: configResult.configPath };
94
281
  }
95
282
 
96
283
  /** Install + load a launchd agent that keeps `relay daemon` running (macOS). Idempotent. */
97
- export function installDaemonAutostart(bin = relayBinPath(), node = process.execPath) {
98
- if (process.platform !== "darwin") return { ok: false, reason: "autostart_unsupported_platform" };
284
+ export function installDaemonAutostart(bin = relayBinPath(), node = process.execPath, { platform = process.platform, runCommand = run } = {}) {
285
+ if (platform === "win32") {
286
+ const logPath = path.join(os.homedir(), ".relay", "daemon.log");
287
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
288
+ const res = installWindowsLogonTask({
289
+ taskName: WINDOWS_DAEMON_TASK_NAME,
290
+ executable: node,
291
+ args: [bin, "daemon"],
292
+ logPath,
293
+ runCommand,
294
+ });
295
+ return { ...res, logPath };
296
+ }
297
+ if (platform !== "darwin") return { ok: false, reason: "autostart_unsupported_platform" };
99
298
  const home = os.homedir();
100
299
  const plistPath = path.join(home, "Library", "LaunchAgents", `${DAEMON_LAUNCH_LABEL}.plist`);
101
300
  const logPath = path.join(home, ".relay", "daemon.log");
@@ -125,20 +324,32 @@ export function installDaemonAutostart(bin = relayBinPath(), node = process.exec
125
324
  </plist>
126
325
  `;
127
326
  fs.writeFileSync(plistPath, plist);
128
- run("launchctl", ["unload", plistPath]); // ignore if not loaded
129
- const res = run("launchctl", ["load", plistPath]);
327
+ runCommand("launchctl", ["unload", plistPath]); // ignore if not loaded
328
+ const res = runCommand("launchctl", ["load", plistPath]);
130
329
  return { ok: res.ok, plistPath, logPath };
131
330
  }
132
331
 
133
332
  /** Install + load a launchd agent that starts the visible Relay pill (macOS). */
134
- export function installPillAutostart(bin = relayBinPath()) {
135
- if (process.platform !== "darwin") return { ok: false, reason: "autostart_unsupported_platform" };
333
+ export function installPillAutostart(bin = relayBinPath(), { platform = process.platform, runCommand = run } = {}) {
334
+ if (platform !== "darwin" && platform !== "win32") return { ok: false, reason: "autostart_unsupported_platform" };
136
335
  const packageRoot = packageRootForBin(bin);
137
336
  const electronPath = electronPathForPackageRoot(packageRoot);
138
337
  const overlayMain = path.join(packageRoot, "overlay", "main.cjs");
139
338
  if (!electronPath || !fs.existsSync(overlayMain)) {
140
339
  return { ok: false, reason: "pill_runtime_missing", electronPath, overlayMain };
141
340
  }
341
+ if (platform === "win32") {
342
+ const logPath = path.join(os.homedir(), ".relay", "pill.log");
343
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
344
+ const res = installWindowsLogonTask({
345
+ taskName: WINDOWS_PILL_TASK_NAME,
346
+ executable: electronPath,
347
+ args: [overlayMain],
348
+ logPath,
349
+ runCommand,
350
+ });
351
+ return { ...res, electronPath, overlayMain, logPath };
352
+ }
142
353
 
143
354
  const home = os.homedir();
144
355
  const plistPath = path.join(home, "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
@@ -167,11 +378,37 @@ export function installPillAutostart(bin = relayBinPath()) {
167
378
  </plist>
168
379
  `;
169
380
  fs.writeFileSync(plistPath, plist);
170
- run("launchctl", ["unload", plistPath]); // ignore if not loaded
171
- const res = run("launchctl", ["load", plistPath]);
381
+ runCommand("launchctl", ["unload", plistPath]); // ignore if not loaded
382
+ const res = runCommand("launchctl", ["load", plistPath]);
172
383
  return { ok: res.ok, plistPath, logPath };
173
384
  }
174
385
 
386
+ function quoteWindowsCmdArg(value) {
387
+ return `"${String(value).replaceAll('"', '\\"')}"`;
388
+ }
389
+
390
+ export function windowsTaskAction(executable, args = [], logPath = null, comspec = process.env.ComSpec || "cmd.exe") {
391
+ const command = [quoteWindowsCmdArg(executable), ...args.map(quoteWindowsCmdArg)].join(" ");
392
+ const redirect = logPath ? ` >> ${quoteWindowsCmdArg(logPath)} 2>&1` : "";
393
+ return `${quoteWindowsCmdArg(comspec)} /d /s /c "${command}${redirect}"`;
394
+ }
395
+
396
+ function installWindowsLogonTask({ taskName, executable, args = [], logPath = null, runCommand = run }) {
397
+ const action = windowsTaskAction(executable, args, logPath);
398
+ const create = runCommand("schtasks", ["/Create", "/TN", taskName, "/SC", "ONLOGON", "/TR", action, "/F"]);
399
+ if (!create.ok) {
400
+ return { ok: false, reason: "scheduled_task_create_failed", taskName, action, detail: create.out };
401
+ }
402
+ const start = runCommand("schtasks", ["/Run", "/TN", taskName]);
403
+ return {
404
+ ok: true,
405
+ taskName,
406
+ action,
407
+ started: start.ok,
408
+ startDetail: start.ok ? "" : start.out,
409
+ };
410
+ }
411
+
175
412
  /**
176
413
  * Detect Claude Code + Codex, register the Relay MCP into each present, and
177
414
  * start the receive daemon. Returns a summary for the CLI to print.
@@ -181,18 +418,24 @@ export async function runSetupInstall() {
181
418
  const installed = [];
182
419
  const missing = [];
183
420
  const activations = [];
184
- if (commandExists("claude")) {
185
- if (installClaudeCode(bin)) {
186
- installed.push("Claude Code");
187
- activations.push(verifyClaudeMcpRegistration());
188
- } else missing.push("Claude Code (registration failed)");
189
- } else missing.push("Claude Code");
190
- if (commandExists("codex")) {
191
- if (installCodex(bin)) {
192
- installed.push("Codex");
193
- activations.push(await activateCodexMcp());
194
- } else missing.push("Codex (registration failed)");
195
- } else missing.push("Codex");
421
+ const claude = installClaudeCode(bin);
422
+ if (claude.ok) {
423
+ installed.push("Claude Code");
424
+ activations.push(verifyClaudeMcpRegistration({ configPath: claude.configPath }));
425
+ } else if (claude.reason === "claude_code_not_found") {
426
+ missing.push("Claude Code");
427
+ } else {
428
+ missing.push("Claude Code (registration failed)");
429
+ }
430
+ const codex = installCodex(bin);
431
+ if (codex.ok) {
432
+ installed.push("Codex");
433
+ activations.push(await activateCodexMcp());
434
+ } else if (codex.reason === "codex_not_found") {
435
+ missing.push("Codex");
436
+ } else {
437
+ missing.push("Codex (registration failed)");
438
+ }
196
439
  const daemon = installDaemonAutostart(bin);
197
440
  const pill = installPillAutostart(bin);
198
441
  return { installed, missing, daemon, pill, activations };
@@ -202,6 +445,8 @@ export async function runSetupInstall() {
202
445
  export function runUninstall() {
203
446
  run("claude", ["mcp", "remove", "-s", "user", "relay"]);
204
447
  run("codex", ["mcp", "remove", "relay"]);
448
+ removeClaudeCodeMcpConfig();
449
+ removeCodexMcpConfig();
205
450
  if (process.platform === "darwin") {
206
451
  const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${DAEMON_LAUNCH_LABEL}.plist`);
207
452
  run("launchctl", ["unload", plistPath]);
@@ -217,5 +462,10 @@ export function runUninstall() {
217
462
  } catch {
218
463
  /* already gone */
219
464
  }
465
+ } else if (process.platform === "win32") {
466
+ run("schtasks", ["/End", "/TN", WINDOWS_DAEMON_TASK_NAME]);
467
+ run("schtasks", ["/Delete", "/TN", WINDOWS_DAEMON_TASK_NAME, "/F"]);
468
+ run("schtasks", ["/End", "/TN", WINDOWS_PILL_TASK_NAME]);
469
+ run("schtasks", ["/Delete", "/TN", WINDOWS_PILL_TASK_NAME, "/F"]);
220
470
  }
221
471
  }
@@ -257,13 +257,19 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
257
257
  // works. The refresh therefore discards the stale cache entry and navigates
258
258
  // through the bridge, which forces the view to reload the now-complete file.
259
259
  // The codex:// deep link is only the fallback when the bridge is unreachable.
260
+ // First pass PRIMES the open: it navigates to /hotkey-window/thread/<id>
261
+ // (the only route that cold-loads an externally-created thread from disk),
262
+ // waits, then switches to /local/<id> so the thread shows in the main window
263
+ // with the sidebar. The /local route alone renders an empty "New chat" pane
264
+ // for a fresh relay thread and never recovers.
260
265
  const desktopOpenResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId });
261
266
  // The desktop occasionally re-caches the conversation between our discard
262
267
  // and the view's load, so a single refresh still races on some first opens.
263
- // A second discard + navigate pass after a short settle has rendered
264
- // deterministically in every observed failure case.
268
+ // The second pass runs with primeOpen=false — the thread is warm from the
269
+ // first pass, so a plain /local navigate re-asserts it deterministically
270
+ // without a second hotkey->local visual transition.
265
271
  await sleep(Number(process.env.RELAY_CODEX_OPEN_SECOND_PASS_MS || 1200));
266
- const secondPassResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId });
272
+ const secondPassResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId, primeOpen: false });
267
273
  openedInHost = Boolean(desktopOpenResult?.ok || secondPassResult?.ok);
268
274
  skipExternalOpen = openedInHost;
269
275
  url = `codex://threads/${encodeURIComponent(threadId)}`;
@@ -393,7 +399,7 @@ function ensureRelayCodexIndexMarker({ threadId, sessionPath, packetId }) {
393
399
  }
394
400
  }
395
401
 
396
- async function refreshCodexDesktopForThreads(threadIds, { force = false, openThreadId = null } = {}) {
402
+ async function refreshCodexDesktopForThreads(threadIds, { force = false, openThreadId = null, primeOpen = true } = {}) {
397
403
  const uniqueThreadIds = Array.from(new Set(threadIds.filter(Boolean)));
398
404
  if (!uniqueThreadIds.length && !force) return null;
399
405
  try {
@@ -401,6 +407,7 @@ async function refreshCodexDesktopForThreads(threadIds, { force = false, openThr
401
407
  threadIds: uniqueThreadIds,
402
408
  pinnedThreadIds: readPinnedThreadIds(),
403
409
  openThreadId,
410
+ primeOpen,
404
411
  });
405
412
  if (process.env.RELAY_DEBUG && result.attempted && !result.ok) {
406
413
  console.error(`Relay Codex desktop refresh did not reach a running window: ${JSON.stringify(result)}`);
@@ -1,4 +1,7 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import readline from "node:readline";
3
6
 
4
7
  const DEFAULT_TIMEOUT_MS = 12_000;
@@ -144,7 +147,7 @@ class JsonlRpcClient {
144
147
 
145
148
  function classifyCodexActivationError(error) {
146
149
  const text = `${error?.message || ""}\n${error?.stderr || ""}\n${error?.data ? JSON.stringify(error.data) : ""}`;
147
- if (/app-server-control|control socket|No such file|not running|missing/i.test(text)) {
150
+ if (/app-server-control|control socket|No such file|not running|missing|ENOENT|not found/i.test(text)) {
148
151
  return "codex_control_socket_unavailable";
149
152
  }
150
153
  if (/experimentalApi/i.test(text)) return "codex_experimental_api_rejected";
@@ -229,9 +232,32 @@ export async function activateCodexMcp({
229
232
  }
230
233
  }
231
234
 
232
- export function verifyClaudeMcpRegistration({ runCommand, command = "claude" } = {}) {
235
+ function defaultClaudeConfigPath() {
236
+ return process.env.CLAUDE_CODE_CONFIG || path.join(os.homedir(), ".claude.json");
237
+ }
238
+
239
+ function claudeConfigHasRelay(configPath = defaultClaudeConfigPath()) {
240
+ try {
241
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
242
+ const relay = cfg?.mcpServers?.relay;
243
+ return Boolean(relay?.command && Array.isArray(relay?.args) && relay.args.includes("mcp"));
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ export function verifyClaudeMcpRegistration({ runCommand, command = "claude", configPath = defaultClaudeConfigPath() } = {}) {
233
250
  const run = runCommand || ((cmd, args) => spawnSyncCompat(cmd, args));
234
251
  const res = run(command, ["mcp", "get", "relay"]);
252
+ if (!res.ok && claudeConfigHasRelay(configPath)) {
253
+ return {
254
+ host: "Claude Code",
255
+ ok: true,
256
+ currentSessionReady: false,
257
+ reason: "registered_in_claude_config",
258
+ detail: configPath,
259
+ };
260
+ }
235
261
  return {
236
262
  host: "Claude Code",
237
263
  ok: Boolean(res.ok),