relay-companion 0.1.40 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/relay.js +55 -6
- package/overlay/host-select.cjs +40 -3
- package/overlay/main.cjs +132 -20
- package/overlay/relayAppIcon.svg +27 -0
- package/overlay/visibility.cjs +39 -5
- package/package.json +2 -1
- package/src/auto-update.js +235 -102
- package/src/codex-desktop.js +102 -12
- package/src/desktop-migration.js +260 -0
- package/src/install.js +293 -10
- package/src/pill-control.js +49 -0
- package/src/postinstall.js +307 -0
- package/src/task-daemon.js +45 -21
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
|
|
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
|
|
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
|
-
|
|
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",
|
package/overlay/host-select.cjs
CHANGED
|
@@ -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 ===
|
|
16
|
-
if (bundle
|
|
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
|
-
|
|
116
|
+
// Terminal Claude Code is useful diagnostic state, but it is not a desktop surface
|
|
117
|
+
// Relay can foreground: Relay's Claude materializer opens a claude:// resume link in
|
|
118
|
+
// Claude Desktop. Keep this separate from runningHostsFromProcessList so a terminal-
|
|
119
|
+
// only process never diverts a click away from a usable Codex/ChatGPT desktop window.
|
|
120
|
+
function terminalClaudeCodeRunningFromProcessList(text, platform = process.platform) {
|
|
121
|
+
const lines = String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
122
|
+
if (platform === "darwin") {
|
|
123
|
+
return lines.some(
|
|
124
|
+
(line) =>
|
|
125
|
+
!/\.app\/Contents\//i.test(line) &&
|
|
126
|
+
/(?:^|\/)claude(?:-code)?(?:\.exe)?$/i.test(line),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (platform === "win32") {
|
|
130
|
+
return lines.some((line) => /(?:^|[\\/\"])(?:claude-code|claude)(?:\.exe)?(?:\"|$)/i.test(line));
|
|
131
|
+
}
|
|
132
|
+
return lines.some((line) => /(?:^|\/)claude(?:-code)?$/i.test(line));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
hostFromBundle,
|
|
137
|
+
activationBundleCandidates,
|
|
138
|
+
isRelayBundle,
|
|
139
|
+
defaultClickHost,
|
|
140
|
+
chooseClickHost,
|
|
141
|
+
runningHostsFromProcessList,
|
|
142
|
+
terminalClaudeCodeRunningFromProcessList,
|
|
143
|
+
};
|
package/overlay/main.cjs
CHANGED
|
@@ -29,8 +29,20 @@ const { execFile, execFileSync, spawn, pathToFileURL } = (() => {
|
|
|
29
29
|
const url = require("node:url");
|
|
30
30
|
return { execFile: cp.execFile, execFileSync: cp.execFileSync, spawn: cp.spawn, pathToFileURL: url.pathToFileURL };
|
|
31
31
|
})();
|
|
32
|
-
const {
|
|
33
|
-
|
|
32
|
+
const {
|
|
33
|
+
hostFromBundle,
|
|
34
|
+
activationBundleCandidates,
|
|
35
|
+
chooseClickHost,
|
|
36
|
+
runningHostsFromProcessList,
|
|
37
|
+
terminalClaudeCodeRunningFromProcessList,
|
|
38
|
+
} = require("./host-select.cjs");
|
|
39
|
+
const {
|
|
40
|
+
overlayWanted,
|
|
41
|
+
createHostRunningTracker,
|
|
42
|
+
shouldIgnoreDismiss,
|
|
43
|
+
boundedPresentedRelayIds,
|
|
44
|
+
recoverInterruptedAttentionPrefs,
|
|
45
|
+
} = require("./visibility.cjs");
|
|
34
46
|
const { withJsonLock } = require("../src/state-lock.cjs");
|
|
35
47
|
const {
|
|
36
48
|
reinforceSpacePresence,
|
|
@@ -41,6 +53,7 @@ const {
|
|
|
41
53
|
|
|
42
54
|
const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
|
|
43
55
|
const STATE_PATH = path.join(RELAY_HOME, "state.json");
|
|
56
|
+
const PILL_STATUS_PATH = path.join(RELAY_HOME, "pill-status.json");
|
|
44
57
|
// The companion CLI entrypoint (same path the overlay resolves its ESM modules
|
|
45
58
|
// from). Spawned with ELECTRON_RUN_AS_NODE so Electron runs it as plain Node.
|
|
46
59
|
const RELAY_CLI = path.resolve(__dirname, "..", "bin", "relay.js");
|
|
@@ -56,6 +69,7 @@ let trayAvailable = false;
|
|
|
56
69
|
let hostRunning = false; // Claude or Codex desktop app is running (either)
|
|
57
70
|
let claudeRunning = false; // Claude Desktop process is running (any macOS Space)
|
|
58
71
|
let codexRunning = false; // Codex process is running (any macOS Space)
|
|
72
|
+
let terminalClaudeCodeRunning = false; // diagnostic only; not a foregroundable desktop host
|
|
59
73
|
let lastHost = null; // most-recently foregrounded host: "claude" | "codex"
|
|
60
74
|
let lastHostSeenAt = 0;
|
|
61
75
|
let lastSig = "";
|
|
@@ -69,29 +83,71 @@ let overlayPrefs = {};
|
|
|
69
83
|
try {
|
|
70
84
|
overlayPrefs = JSON.parse(fs.readFileSync(OVERLAY_PREFS_PATH, "utf8")) || {};
|
|
71
85
|
} catch {}
|
|
86
|
+
const recoveredAttentionPrefs = recoverInterruptedAttentionPrefs(overlayPrefs);
|
|
87
|
+
overlayPrefs = recoveredAttentionPrefs.prefs;
|
|
72
88
|
let dismissed = overlayPrefs.dismissed === true;
|
|
73
89
|
let attentionLatched = overlayPrefs.attentionLatched === true;
|
|
74
90
|
const presentedRelayIds = new Set(
|
|
75
91
|
Array.isArray(overlayPrefs.presentedRelayIds) ? overlayPrefs.presentedRelayIds.filter(Boolean).map(String) : [],
|
|
76
92
|
);
|
|
93
|
+
let activeAttentionIds = new Set();
|
|
77
94
|
let ghostActive = false; // a ghost notification is on screen while dismissed
|
|
78
95
|
let trayForcedVisible = false; // tray click while no host is running still shows the card
|
|
96
|
+
let pillReady = false;
|
|
97
|
+
let pendingReopenNonce = "";
|
|
98
|
+
let lastReopenNonce = "";
|
|
99
|
+
let lastPillStatusSig = "";
|
|
79
100
|
const PRESENTED_RELAY_CAP = 500;
|
|
80
101
|
function writeOverlayPrefs() {
|
|
81
102
|
try {
|
|
82
103
|
fs.mkdirSync(RELAY_HOME, { recursive: true });
|
|
83
104
|
fs.writeFileSync(
|
|
84
105
|
OVERLAY_PREFS_PATH,
|
|
85
|
-
`${JSON.stringify({ dismissed, attentionLatched, presentedRelayIds: [...presentedRelayIds] }, null, 2)}\n`,
|
|
106
|
+
`${JSON.stringify({ dismissed, attentionLatched, presentedRelayIds: [...presentedRelayIds], activeAttentionIds: [...activeAttentionIds] }, null, 2)}\n`,
|
|
86
107
|
);
|
|
87
108
|
} catch (error) {
|
|
88
109
|
console.error("[overlay] prefs write failed:", error && error.message);
|
|
89
110
|
}
|
|
90
111
|
}
|
|
112
|
+
// Commit startup recovery before Electron is ready. A second crash before the
|
|
113
|
+
// recovered stack is rendered must still leave those ids eligible for replay.
|
|
114
|
+
if (recoveredAttentionPrefs.interruptedAttentionIds.length) writeOverlayPrefs();
|
|
91
115
|
function setDismissed(value) {
|
|
92
116
|
dismissed = Boolean(value);
|
|
93
117
|
writeOverlayPrefs();
|
|
94
118
|
}
|
|
119
|
+
|
|
120
|
+
function writePillStatus(reopenNonce = "") {
|
|
121
|
+
if (reopenNonce) lastReopenNonce = String(reopenNonce);
|
|
122
|
+
const stableStatus = {
|
|
123
|
+
pid: process.pid,
|
|
124
|
+
ready: pillReady,
|
|
125
|
+
visible: Boolean(win && !win.isDestroyed() && win.isVisible()),
|
|
126
|
+
dismissed: Boolean(dismissed),
|
|
127
|
+
reopenNonce: lastReopenNonce,
|
|
128
|
+
terminalClaudeCodeRunning,
|
|
129
|
+
};
|
|
130
|
+
const sig = JSON.stringify(stableStatus);
|
|
131
|
+
if (sig === lastPillStatusSig) return stableStatus;
|
|
132
|
+
const status = {
|
|
133
|
+
...stableStatus,
|
|
134
|
+
updatedAt: new Date().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
let tmp = "";
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(RELAY_HOME, { recursive: true });
|
|
139
|
+
tmp = `${PILL_STATUS_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
140
|
+
fs.writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
|
|
141
|
+
fs.renameSync(tmp, PILL_STATUS_PATH);
|
|
142
|
+
lastPillStatusSig = sig;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try {
|
|
145
|
+
if (tmp) fs.rmSync(tmp, { force: true });
|
|
146
|
+
} catch {}
|
|
147
|
+
console.error("[overlay] status write failed:", error && error.message);
|
|
148
|
+
}
|
|
149
|
+
return status;
|
|
150
|
+
}
|
|
95
151
|
function markRelaysPresented(ids, retainedUnreadIds = []) {
|
|
96
152
|
const current = [...presentedRelayIds];
|
|
97
153
|
const bounded = boundedPresentedRelayIds(
|
|
@@ -155,6 +211,15 @@ async function relayClient() {
|
|
|
155
211
|
}
|
|
156
212
|
|
|
157
213
|
app.setName("Relay");
|
|
214
|
+
function reopenNonceFromArgs(argv) {
|
|
215
|
+
const args = Array.isArray(argv) ? argv.map(String) : [];
|
|
216
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
217
|
+
if (args[i] === "--relay-reopen") return args[i + 1] || "";
|
|
218
|
+
if (args[i].startsWith("--relay-reopen=")) return args[i].slice("--relay-reopen=".length);
|
|
219
|
+
}
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
pendingReopenNonce = reopenNonceFromArgs(process.argv);
|
|
158
223
|
// Test seam: an isolated userData dir gives the e2e harness its own single-instance
|
|
159
224
|
// lock, so a sandboxed overlay can run beside the real pill without fighting it.
|
|
160
225
|
if (process.env.RELAY_OVERLAY_USER_DATA) {
|
|
@@ -164,7 +229,7 @@ if (process.env.RELAY_OVERLAY_USER_DATA) {
|
|
|
164
229
|
console.error("[overlay] userData override failed:", error && error.message);
|
|
165
230
|
}
|
|
166
231
|
}
|
|
167
|
-
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
|
232
|
+
const gotSingleInstanceLock = app.requestSingleInstanceLock({ relayReopenNonce: pendingReopenNonce });
|
|
168
233
|
|
|
169
234
|
// ---- config / account ----------------------------------------------------
|
|
170
235
|
|
|
@@ -446,7 +511,6 @@ let systemSuspended = false;
|
|
|
446
511
|
let screenLocked = false;
|
|
447
512
|
let loginSessionActive = true;
|
|
448
513
|
let deferredAttention = false;
|
|
449
|
-
let activeAttentionIds = new Set();
|
|
450
514
|
let testAwayOverride = null;
|
|
451
515
|
|
|
452
516
|
function userIsAway() {
|
|
@@ -499,9 +563,12 @@ function presentUnopenedStack(payload, unpresented) {
|
|
|
499
563
|
trayForcedVisible = false;
|
|
500
564
|
deferredAttention = false;
|
|
501
565
|
maybeShow({ force: true });
|
|
502
|
-
win.webContents.send("newRelay", stack, { ghost: false, stacked: true });
|
|
503
566
|
activeAttentionIds = new Set(stack.map((row) => row.id));
|
|
504
567
|
markRelaysPresented(unpresented.map((row) => row.id), stack.map((row) => row.id));
|
|
568
|
+
// Persist the in-flight marker before renderer delivery. If the process dies at
|
|
569
|
+
// any point during the animation, startup recovery can replay the whole stack.
|
|
570
|
+
writeOverlayPrefs();
|
|
571
|
+
win.webContents.send("newRelay", stack, { ghost: false, stacked: true });
|
|
505
572
|
syncTray();
|
|
506
573
|
return true;
|
|
507
574
|
}
|
|
@@ -733,14 +800,20 @@ function rememberForegroundHost(host) {
|
|
|
733
800
|
// (good) but didnt foreground claude"). `open -b <bundle>` activates the app — switching
|
|
734
801
|
// to its Space and launching it if it wasn't running. `-g` is intentionally NOT passed,
|
|
735
802
|
// because raising the app is exactly the point.
|
|
736
|
-
|
|
737
|
-
function activateHost(host) {
|
|
803
|
+
function activateHost(host, observedBundle = null) {
|
|
738
804
|
if (process.platform !== "darwin") return; // Windows foregrounding is handled by its own open path
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (
|
|
743
|
-
|
|
805
|
+
const bundles = activationBundleCandidates(host, observedBundle);
|
|
806
|
+
const tryBundle = (index, lastError = null) => {
|
|
807
|
+
const bundle = bundles[index];
|
|
808
|
+
if (!bundle) {
|
|
809
|
+
if (lastError) console.error("[overlay] activateHost failed:", host, lastError && lastError.message);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
execFile("/usr/bin/open", ["-b", bundle], (error) => {
|
|
813
|
+
if (error) tryBundle(index + 1, error);
|
|
814
|
+
});
|
|
815
|
+
};
|
|
816
|
+
tryBundle(0);
|
|
744
817
|
}
|
|
745
818
|
|
|
746
819
|
function appInstalled(appName) {
|
|
@@ -1024,7 +1097,7 @@ async function openPacket(packetId, { sent = false } = {}) {
|
|
|
1024
1097
|
// Raise the host so the relay is visible, whichever open path ran (Claude deep
|
|
1025
1098
|
// link, Codex bridge, or a plain url). This is the fix for "opened in the right
|
|
1026
1099
|
// host but didn't foreground it".
|
|
1027
|
-
activateHost(host);
|
|
1100
|
+
activateHost(host, bundle);
|
|
1028
1101
|
if (url && !skipExternalOpen) {
|
|
1029
1102
|
const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
|
|
1030
1103
|
if (claudeSessionIdFromUrl(url)) {
|
|
@@ -1099,7 +1172,7 @@ function openTaskDetail(taskId) {
|
|
|
1099
1172
|
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
1100
1173
|
} catch {}
|
|
1101
1174
|
if (url || skipExternalOpen) {
|
|
1102
|
-
activateHost(host); // raise the host so the opened task is visible
|
|
1175
|
+
activateHost(host, bundle); // raise the host so the opened task is visible
|
|
1103
1176
|
if (url && !skipExternalOpen) {
|
|
1104
1177
|
if (claudeSessionIdFromUrl(url)) {
|
|
1105
1178
|
openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
|
|
@@ -1193,6 +1266,7 @@ function maybeShow({ force = false, reposition = true } = {}) {
|
|
|
1193
1266
|
win.hide();
|
|
1194
1267
|
}
|
|
1195
1268
|
syncTray(); // keep the tray tooltip in step with the pill's actual visibility
|
|
1269
|
+
writePillStatus();
|
|
1196
1270
|
}
|
|
1197
1271
|
|
|
1198
1272
|
function refreshOverlayForActiveSpace({ force = false } = {}) {
|
|
@@ -1222,6 +1296,7 @@ function updateHostRunningFromProcesses(text) {
|
|
|
1222
1296
|
// not the frontmost app — the signal click routing uses to beat frontmost's
|
|
1223
1297
|
// Space-blindness.
|
|
1224
1298
|
const running = runningHostsFromProcessList(text, process.platform);
|
|
1299
|
+
terminalClaudeCodeRunning = terminalClaudeCodeRunningFromProcessList(text, process.platform);
|
|
1225
1300
|
claudeRunning = running.claudeRunning;
|
|
1226
1301
|
codexRunning = running.codexRunning;
|
|
1227
1302
|
hostRunning = hostTracker.update(claudeRunning, codexRunning);
|
|
@@ -1309,7 +1384,13 @@ function createWindow() {
|
|
|
1309
1384
|
// First paint is instant: relays come from local state.json; the sent + contacts
|
|
1310
1385
|
// background loads kicked off here each re-push when they land (buildPayload).
|
|
1311
1386
|
pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
|
|
1387
|
+
pillReady = true;
|
|
1388
|
+
// Relay is independently useful and searchable even when no host app happens to
|
|
1389
|
+
// be open. A normal login launch still respects the persisted dismissed preference.
|
|
1390
|
+
maybeShow({ force: true });
|
|
1312
1391
|
pollHosts();
|
|
1392
|
+
if (pendingReopenNonce) requestExternalReopen(pendingReopenNonce);
|
|
1393
|
+
else writePillStatus();
|
|
1313
1394
|
});
|
|
1314
1395
|
|
|
1315
1396
|
try {
|
|
@@ -1421,6 +1502,20 @@ function showFromTray() {
|
|
|
1421
1502
|
syncTray();
|
|
1422
1503
|
}
|
|
1423
1504
|
|
|
1505
|
+
// Spotlight's Relay.app and `relay pill` both launch another copy of the overlay.
|
|
1506
|
+
// Electron forwards that launch to this single-instance owner. Treat it exactly like
|
|
1507
|
+
// an explicit "Show Relay" action: revoke any old dismissal, open the full card, and
|
|
1508
|
+
// acknowledge the caller only after the OS window reports visible.
|
|
1509
|
+
function requestExternalReopen(reopenNonce = "") {
|
|
1510
|
+
if (reopenNonce) pendingReopenNonce = String(reopenNonce);
|
|
1511
|
+
if (!pillReady || !win || win.isDestroyed()) return false;
|
|
1512
|
+
const nonce = pendingReopenNonce;
|
|
1513
|
+
pendingReopenNonce = "";
|
|
1514
|
+
showFromTray();
|
|
1515
|
+
writePillStatus(nonce);
|
|
1516
|
+
return pillIsOnScreen();
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1424
1519
|
// Whether the pill card is actually on screen right now.
|
|
1425
1520
|
function pillIsOnScreen() {
|
|
1426
1521
|
return Boolean(win && !win.isDestroyed() && win.isVisible() && !dismissed);
|
|
@@ -1438,6 +1533,7 @@ function hideFromTray() {
|
|
|
1438
1533
|
trayForcedVisible = false;
|
|
1439
1534
|
if (win && !win.isDestroyed() && win.isVisible()) win.hide();
|
|
1440
1535
|
syncTray();
|
|
1536
|
+
writePillStatus();
|
|
1441
1537
|
}
|
|
1442
1538
|
|
|
1443
1539
|
let lastTrayToggleAt = 0;
|
|
@@ -1566,7 +1662,9 @@ ipcMain.on("relay:dismiss", () => {
|
|
|
1566
1662
|
if (shouldIgnoreDismiss({ now: Date.now(), lastTrayShowAt })) return;
|
|
1567
1663
|
lastTrayShowAt = 0; // honored: clear the guard so it can't linger
|
|
1568
1664
|
if (!trayAvailable) {
|
|
1569
|
-
|
|
1665
|
+
dismissed = false;
|
|
1666
|
+
activeAttentionIds = new Set();
|
|
1667
|
+
writeOverlayPrefs();
|
|
1570
1668
|
ghostActive = false;
|
|
1571
1669
|
trayForcedVisible = true;
|
|
1572
1670
|
maybeShow();
|
|
@@ -1580,6 +1678,7 @@ ipcMain.on("relay:dismiss", () => {
|
|
|
1580
1678
|
ghostActive = false;
|
|
1581
1679
|
trayForcedVisible = false;
|
|
1582
1680
|
if (win && !win.isDestroyed() && win.isVisible()) win.hide();
|
|
1681
|
+
writePillStatus();
|
|
1583
1682
|
});
|
|
1584
1683
|
// The user engaged with a ghost notification (tapped the lockup to expand it):
|
|
1585
1684
|
// the card is fully open again, so leave dismissed mode.
|
|
@@ -1593,6 +1692,7 @@ ipcMain.on("relay:undismiss", () => {
|
|
|
1593
1692
|
ipcMain.on("relay:notifDone", () => {
|
|
1594
1693
|
ghostActive = false;
|
|
1595
1694
|
if (dismissed && win && !win.isDestroyed() && win.isVisible()) win.hide();
|
|
1695
|
+
writePillStatus();
|
|
1596
1696
|
});
|
|
1597
1697
|
// The notification stack completed its visible interval and folded into the pill.
|
|
1598
1698
|
// Main tracks this boundary so a later lock does not replay a stack the user already
|
|
@@ -1605,7 +1705,10 @@ ipcMain.on("relay:attentionDone", (_event, ids) => {
|
|
|
1605
1705
|
// it pending for their next input. This covers walking away just before an arrival,
|
|
1606
1706
|
// not only explicit sleep/lock events.
|
|
1607
1707
|
if (userIsAway()) requeueActiveAttention();
|
|
1608
|
-
else
|
|
1708
|
+
else {
|
|
1709
|
+
activeAttentionIds = new Set();
|
|
1710
|
+
writeOverlayPrefs();
|
|
1711
|
+
}
|
|
1609
1712
|
});
|
|
1610
1713
|
ipcMain.handle("relay:soundBytes", (_e, name) => {
|
|
1611
1714
|
const clean = String(name).replace(/[^A-Za-z]/g, "");
|
|
@@ -1625,12 +1728,18 @@ ipcMain.handle("relay:soundBytes", (_e, name) => {
|
|
|
1625
1728
|
if (!gotSingleInstanceLock) {
|
|
1626
1729
|
app.quit();
|
|
1627
1730
|
} else {
|
|
1628
|
-
app.on("second-instance", () => {
|
|
1629
|
-
|
|
1731
|
+
app.on("second-instance", (_event, argv, _workingDirectory, additionalData = {}) => {
|
|
1732
|
+
const nonce =
|
|
1733
|
+
(additionalData && typeof additionalData.relayReopenNonce === "string" && additionalData.relayReopenNonce) ||
|
|
1734
|
+
reopenNonceFromArgs(argv);
|
|
1630
1735
|
pollHosts();
|
|
1631
|
-
|
|
1736
|
+
requestExternalReopen(nonce);
|
|
1632
1737
|
});
|
|
1633
1738
|
|
|
1739
|
+
// LaunchServices may reactivate the existing process instead of executing the
|
|
1740
|
+
// launcher again. Activation is still an explicit user request to restore Relay.
|
|
1741
|
+
app.on("activate", () => requestExternalReopen());
|
|
1742
|
+
|
|
1634
1743
|
app.whenReady().then(() => {
|
|
1635
1744
|
if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
|
|
1636
1745
|
createWindow();
|
|
@@ -1642,6 +1751,8 @@ if (!gotSingleInstanceLock) {
|
|
|
1642
1751
|
if (process.env.RELAY_OVERLAY_TEST === "1") {
|
|
1643
1752
|
global.__relayTest = {
|
|
1644
1753
|
showFromTray,
|
|
1754
|
+
requestExternalReopen,
|
|
1755
|
+
writePillStatus,
|
|
1645
1756
|
toggleFromTray,
|
|
1646
1757
|
pillIsOnScreen,
|
|
1647
1758
|
maybeShow,
|
|
@@ -1663,6 +1774,7 @@ if (!gotSingleInstanceLock) {
|
|
|
1663
1774
|
ghostActive,
|
|
1664
1775
|
trayForcedVisible,
|
|
1665
1776
|
hostRunning,
|
|
1777
|
+
terminalClaudeCodeRunning,
|
|
1666
1778
|
deferredAttention,
|
|
1667
1779
|
presentedRelayIds: [...presentedRelayIds],
|
|
1668
1780
|
activeAttentionIds: [...activeAttentionIds],
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<svg width="1024" height="1024" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="relayGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#C1C1FA"/>
|
|
5
|
+
<stop offset="16%" stop-color="#C0B1F9"/>
|
|
6
|
+
<stop offset="38%" stop-color="#7790F7"/>
|
|
7
|
+
<stop offset="58%" stop-color="#5F74F6"/>
|
|
8
|
+
<stop offset="82%" stop-color="#342CF5"/>
|
|
9
|
+
<stop offset="100%" stop-color="#3229F5"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<radialGradient id="relayHighlight" cx="28%" cy="4%" r="50%">
|
|
12
|
+
<stop offset="0%" stop-color="#C1C1FA"/>
|
|
13
|
+
<stop offset="36%" stop-color="#C0B1F9" stop-opacity="0.82"/>
|
|
14
|
+
<stop offset="100%" stop-color="#C0B1F9" stop-opacity="0"/>
|
|
15
|
+
</radialGradient>
|
|
16
|
+
<radialGradient id="relayGlow" cx="72%" cy="36%" r="58%">
|
|
17
|
+
<stop offset="0%" stop-color="#7590F7" stop-opacity="0.82"/>
|
|
18
|
+
<stop offset="100%" stop-color="#7590F7" stop-opacity="0"/>
|
|
19
|
+
</radialGradient>
|
|
20
|
+
</defs>
|
|
21
|
+
<rect width="32" height="32" rx="7" fill="#f7f4ef"/>
|
|
22
|
+
<rect x="5.5" y="13.5" width="5" height="5" rx="0.5" fill="#D9704E"/>
|
|
23
|
+
<rect x="13.5" y="13.5" width="5" height="5" rx="0.5" fill="#111111"/>
|
|
24
|
+
<rect x="21.5" y="13.5" width="5" height="5" rx="0.5" fill="url(#relayGradient)"/>
|
|
25
|
+
<rect x="21.5" y="13.5" width="5" height="5" rx="0.5" fill="url(#relayHighlight)"/>
|
|
26
|
+
<rect x="21.5" y="13.5" width="5" height="5" rx="0.5" fill="url(#relayGlow)"/>
|
|
27
|
+
</svg>
|
package/overlay/visibility.cjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Pure overlay-visibility logic, extracted from main.cjs so the state machine that
|
|
2
2
|
// decides when the pill window is on screen is unit-testable without Electron.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Relay is an independently installed desktop surface, not a child of Claude/Codex.
|
|
5
|
+
// Keep it available whenever its background process runs; only an explicit dismissal
|
|
6
|
+
// hides it. Host-running remains an input for click routing and backwards-compatible
|
|
7
|
+
// callers, but closing an agent terminal/app must never make Relay disappear.
|
|
7
8
|
|
|
8
9
|
function overlayWanted({
|
|
10
|
+
persistent = true,
|
|
9
11
|
hostRunning = false,
|
|
10
12
|
trayForcedVisible = false,
|
|
11
13
|
trayAvailable = false,
|
|
@@ -14,7 +16,7 @@ function overlayWanted({
|
|
|
14
16
|
attentionLatched = false,
|
|
15
17
|
} = {}) {
|
|
16
18
|
const dismissedEffective = trayAvailable && dismissed;
|
|
17
|
-
return (hostRunning || trayForcedVisible || attentionLatched) && (!dismissedEffective || ghostActive);
|
|
19
|
+
return (persistent || hostRunning || trayForcedVisible || attentionLatched) && (!dismissedEffective || ghostActive);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
// Debounced "a host is running" tracker. The OFF transition requires `missThreshold`
|
|
@@ -72,4 +74,36 @@ function boundedPresentedRelayIds(currentIds, newlyPresentedIds, retainedUnreadI
|
|
|
72
74
|
return [...ids];
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
// An attention stack is recorded as presented before its seven-second animation
|
|
78
|
+
// starts. If the process dies during that interval, the renderer never gets to send
|
|
79
|
+
// `attentionDone`, so those ids must be put back into the unpresented pool on the next
|
|
80
|
+
// launch. Clear the in-flight marker at the same time: if the new process also dies
|
|
81
|
+
// before it can present the recovered stack, the ids are already absent from history
|
|
82
|
+
// and remain eligible on the following launch.
|
|
83
|
+
function recoverInterruptedAttentionPrefs(input = {}) {
|
|
84
|
+
const prefs = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
85
|
+
const activeAttentionIds = [...new Set(
|
|
86
|
+
(Array.isArray(prefs.activeAttentionIds) ? prefs.activeAttentionIds : [])
|
|
87
|
+
.map((value) => String(value || ""))
|
|
88
|
+
.filter(Boolean),
|
|
89
|
+
)];
|
|
90
|
+
const active = new Set(activeAttentionIds);
|
|
91
|
+
const presentedRelayIds = [...new Set(
|
|
92
|
+
(Array.isArray(prefs.presentedRelayIds) ? prefs.presentedRelayIds : [])
|
|
93
|
+
.map((value) => String(value || ""))
|
|
94
|
+
.filter(Boolean),
|
|
95
|
+
)].filter((id) => !active.has(id));
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
prefs: { ...prefs, presentedRelayIds, activeAttentionIds: [] },
|
|
99
|
+
interruptedAttentionIds: activeAttentionIds,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
overlayWanted,
|
|
105
|
+
createHostRunningTracker,
|
|
106
|
+
shouldIgnoreDismiss,
|
|
107
|
+
boundedPresentedRelayIds,
|
|
108
|
+
recoverInterruptedAttentionPrefs,
|
|
109
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relay-companion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.41",
|
|
4
4
|
"description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"mcp": "node src/mcp.js",
|
|
12
12
|
"daemon": "node src/task-daemon.js",
|
|
13
|
+
"postinstall": "node src/postinstall.js",
|
|
13
14
|
"test": "node --test test/*.test.mjs"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|