relay-companion 0.1.39 → 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
+ };
@@ -100,7 +100,7 @@
100
100
  }
101
101
  .count {
102
102
  font-family:var(--mono); font-size:13px; font-weight:500; font-variant-numeric:tabular-nums;
103
- color:var(--accent); line-height:1; transition:color .3s var(--settle);
103
+ color:var(--accent); line-height:1; white-space:nowrap; transition:color .3s var(--settle);
104
104
  }
105
105
  .count.zero { color:var(--muted-3); }
106
106
  /* ✕ — fully hide the overlay; the Relay mark in the OS status area brings it back.
@@ -651,7 +651,7 @@
651
651
 
652
652
  const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
653
653
  const EXPANDED = { w: 344, h: 524 };
654
- const PILL = { w: 178, h: 44 }; // wide enough for mark + word + count + ✕
654
+ const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N unopened" + ✕
655
655
  const PEEK = { w: 344, h: 118 };
656
656
 
657
657
  // ---------- zero-latency sound (Web Audio, pre-decoded) ----------
@@ -720,16 +720,19 @@
720
720
  // ---------- collapse / expand / peek ----------
721
721
  let collapsed = false;
722
722
  let peeking = false, peekTimer = null;
723
+ let notificationDurationMs = 7000;
724
+ let activeNotificationIds = [];
723
725
  let ghost = false, ghostTimer = null; // notification-only mode while the pill is dismissed
724
726
  function clearPeek() {
725
727
  if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
726
728
  peeking = false;
727
- cardEl.classList.remove("peek");
729
+ cardEl.classList.remove("peek", "notifying");
728
730
  }
729
731
  function setCollapsed(v) {
730
732
  clearPeek();
731
733
  if (v === collapsed) return;
732
734
  collapsed = v;
735
+ refreshCountLabel();
733
736
  cardEl.classList.toggle("collapsed", collapsed);
734
737
  springTo(collapsed ? PILL.w : EXPANDED.w, collapsed ? PILL.h : EXPANDED.h);
735
738
  playTink();
@@ -747,6 +750,7 @@
747
750
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
748
751
  clearPeek();
749
752
  collapsed = false;
753
+ refreshCountLabel();
750
754
  cardEl.classList.add("offstage");
751
755
  cardEl.classList.remove("collapsed", "ghost", "bye");
752
756
  snapSize(EXPANDED.w, EXPANDED.h);
@@ -767,6 +771,10 @@
767
771
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
768
772
  if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
769
773
  const wasGhost = ghost;
774
+ if (!wasGhost && activeNotificationIds.length && window.relay.attentionDone) {
775
+ window.relay.attentionDone(activeNotificationIds);
776
+ activeNotificationIds = [];
777
+ }
770
778
  cardEl.classList.add("bye");
771
779
  setInteractive(false);
772
780
  byeTimer = setTimeout(() => {
@@ -784,6 +792,7 @@
784
792
  cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
785
793
  clearPeek();
786
794
  collapsed = false;
795
+ refreshCountLabel();
787
796
  cardEl.classList.remove("collapsed", "offstage", "bye");
788
797
  ghost = true; peeking = true;
789
798
  cardEl.classList.add("peek", "ghost");
@@ -801,11 +810,13 @@
801
810
  ghostTimer = null;
802
811
  if (interactive || quickReplyIsActive()) { ghostTimer = setTimeout(tick, 4000); return; }
803
812
  dismissOverlay(); // rides the ghostOut exit, then main re-hides the window
804
- }, 7000);
813
+ }, notificationDurationMs);
805
814
  }
806
815
  // Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
807
816
  function trayOpen() {
808
817
  const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
818
+ const completedNotificationIds = activeNotificationIds;
819
+ activeNotificationIds = [];
809
820
  cancelBye(); // a reopen mid-exit must survive: the pending dismiss would hide us again
810
821
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
811
822
  ghost = false;
@@ -825,30 +836,38 @@
825
836
  springTo(EXPANDED.w, EXPANDED.h);
826
837
  }
827
838
  renderAll();
839
+ if (completedNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(completedNotificationIds);
828
840
  playTink();
829
841
  }
830
842
  // The banner must show its WHOLE first row — a clipped preview reads as broken.
831
843
  // Measure after render; QR question rows are tall, so allow room before capping.
832
844
  function peekHeight() {
833
- const first = scrollEl.querySelector(".row, .task");
834
- const rowH = first ? first.offsetHeight : 74;
835
- return Math.min(44 + rowH + 6, 340);
836
- }
837
- // A new relay arrived. If folded, peek open like an iOS banner (~7s) then fold back.
838
- // The peek routes to Relays. Task/share rows are hidden until that product surface is ready.
839
- function notifyArrival(row) {
840
- if (!collapsed && !peeking) return;
845
+ const stack = [...relaysListEl.querySelectorAll(".row")];
846
+ const rowsHeight = stack.reduce((sum, row) => sum + row.offsetHeight, 0);
847
+ return Math.min(44 + rowsHeight + 6, EXPANDED.h);
848
+ }
849
+ // New relays always enter the notification stack, including from a fully expanded
850
+ // card. The main process has already pushed the new payload, so render/sizing uses
851
+ // the latest unopened rows rather than the previous inbox.
852
+ function notifyArrival(rows) {
841
853
  if (peekTimer) clearTimeout(peekTimer);
854
+ cancelBye();
855
+ if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
856
+ ghost = false;
842
857
  peeking = true;
843
858
  collapsed = false;
844
- cardEl.classList.remove("collapsed");
845
- cardEl.classList.add("peek");
859
+ refreshCountLabel();
860
+ activeNotificationIds = (Array.isArray(rows) ? rows : [rows]).filter(Boolean).map((row) => row.id).filter(Boolean);
861
+ cardEl.classList.remove("collapsed", "peek", "offstage", "ghost", "bye");
862
+ // Keep the exact production expanded UI (tabs, typography, separators). This
863
+ // class is state-only for tests; it intentionally has no visual CSS of its own.
864
+ cardEl.classList.add("notifying");
846
865
  activeView = "relays";
847
866
  syncTabs();
848
867
  applyView();
849
868
  renderAll();
850
869
  scrollEl.scrollTop = 0;
851
- springTo(PEEK.w, peekHeight());
870
+ springTo(EXPANDED.w, EXPANDED.h);
852
871
  playTink();
853
872
  peekTimer = setTimeout(function tick() {
854
873
  peekTimer = null;
@@ -858,10 +877,14 @@
858
877
  // hold while hovered too). Mirrors the ghost-notification guard.
859
878
  if (interactive || quickReplyIsActive()) { peekTimer = setTimeout(tick, 4000); return; }
860
879
  peeking = false;
861
- cardEl.classList.remove("peek");
880
+ cardEl.classList.remove("peek", "notifying");
862
881
  collapsed = true; cardEl.classList.add("collapsed");
882
+ refreshCountLabel();
863
883
  springTo(PILL.w, PILL.h);
864
- }, 7000);
884
+ renderAll(); // restore the normal full list behind the now-folded pill
885
+ if (activeNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(activeNotificationIds);
886
+ activeNotificationIds = [];
887
+ }, notificationDurationMs);
865
888
  }
866
889
  function openFull() {
867
890
  if (ghost) {
@@ -872,10 +895,15 @@
872
895
  if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
873
896
  window.relay.undismiss();
874
897
  }
898
+ const completedNotificationIds = activeNotificationIds;
899
+ activeNotificationIds = [];
875
900
  clearPeek();
876
901
  collapsed = false;
902
+ refreshCountLabel();
877
903
  cardEl.classList.remove("collapsed");
878
904
  springTo(EXPANDED.w, EXPANDED.h);
905
+ renderAll();
906
+ if (completedNotificationIds.length && window.relay.attentionDone) window.relay.attentionDone(completedNotificationIds);
879
907
  playTink();
880
908
  }
881
909
 
@@ -1159,13 +1187,19 @@
1159
1187
  else window.relay.open(id);
1160
1188
  }
1161
1189
 
1190
+ let unreadCount = 0;
1191
+ function refreshCountLabel() {
1192
+ const label = collapsed ? `${unreadCount} unopened` : String(unreadCount);
1193
+ if (countEl.textContent !== label) {
1194
+ countEl.textContent = label;
1195
+ if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
1196
+ }
1197
+ }
1162
1198
  function setCount(n) {
1199
+ unreadCount = n;
1163
1200
  cardEl.classList.toggle("has-unread", n > 0);
1164
1201
  countEl.classList.toggle("zero", n === 0);
1165
- if (countEl.textContent !== String(n)) {
1166
- countEl.textContent = String(n);
1167
- if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
1168
- }
1202
+ refreshCountLabel();
1169
1203
  }
1170
1204
 
1171
1205
  function setBadge(el, n) {
@@ -1199,7 +1233,8 @@
1199
1233
  // focused textarea and lose what the user is typing. Defer and re-render on blur/submit.
1200
1234
  if (quickReplyIsActive()) { pendingRelaysRender = true; return; }
1201
1235
  pendingRelaysRender = false;
1202
- const rows = (payload.relays || []).filter(isRelayListKind);
1236
+ const allRows = (payload.relays || []).filter(isRelayListKind);
1237
+ const rows = peeking ? allRows.filter((row) => row.unread) : allRows;
1203
1238
  relaysEmptyEl.classList.toggle("gone", rows.length > 0);
1204
1239
  const seen = prevRelayIds;
1205
1240
  relaysListEl.innerHTML = rows.map((r) => {
@@ -2115,6 +2150,10 @@
2115
2150
  tasks: Array.isArray(next.tasks) ? next.tasks : [],
2116
2151
  contacts: Array.isArray(next.contacts) ? next.contacts : (payload.contacts || []),
2117
2152
  };
2153
+ const configuredDuration = Number(next.ui && next.ui.notificationDurationMs);
2154
+ notificationDurationMs = Number.isFinite(configuredDuration) && configuredDuration >= 250
2155
+ ? configuredDuration
2156
+ : 7000;
2118
2157
  renderAll();
2119
2158
  }
2120
2159