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 +16 -2
- package/overlay/inbox.html +23 -6
- package/overlay/main.cjs +123 -40
- package/overlay/preload.cjs +2 -2
- package/package.json +1 -1
- package/src/codex-desktop.js +49 -21
- package/src/install.js +281 -31
- package/src/materializer.js +11 -4
- package/src/setup-activation.js +28 -2
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(
|
|
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(
|
|
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() {
|
package/overlay/inbox.html
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
11
|
-
// relays still show the designed banner as a "ghost"
|
|
12
|
-
// just for the banner, which fades away instead of
|
|
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
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
847
|
-
const
|
|
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
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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.
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
// ----
|
|
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 =
|
|
945
|
-
if (icon.isEmpty()) {
|
|
946
|
-
|
|
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
|
-
|
|
951
|
-
tray.
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
|
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;
|
package/overlay/preload.cjs
CHANGED
|
@@ -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()), //
|
|
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
|
|
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
package/src/codex-desktop.js
CHANGED
|
@@ -46,7 +46,10 @@ export async function notifyCodexDesktopThreads({
|
|
|
46
46
|
threadIds = [],
|
|
47
47
|
pinnedThreadIds = null,
|
|
48
48
|
openThreadId = null,
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
57
|
-
const
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 (
|
|
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
|
-
|
|
129
|
-
const res =
|
|
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 (
|
|
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
|
-
|
|
171
|
-
const res =
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
}
|
package/src/materializer.js
CHANGED
|
@@ -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
|
-
//
|
|
264
|
-
//
|
|
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)}`);
|
package/src/setup-activation.js
CHANGED
|
@@ -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
|
-
|
|
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),
|