relay-companion 0.1.35 → 0.1.37
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 +6 -1
- package/overlay/host-select.cjs +18 -14
- package/overlay/inbox.html +30 -3
- package/overlay/main.cjs +190 -50
- package/overlay/space-presence.cjs +14 -4
- package/overlay/visibility.cjs +54 -0
- package/package.json +1 -1
- package/src/attachments.js +55 -1
- package/src/auto-update.js +74 -4
- package/src/client.js +3 -0
- package/src/codex-app-server.js +10 -1
- package/src/codex-session-writer.js +6 -1
- package/src/codex-state.js +37 -5
- package/src/config.js +24 -3
- package/src/install.js +77 -16
- package/src/materializer.js +46 -12
- package/src/notifications.js +95 -61
- package/src/relay-briefing.js +35 -5
- package/src/runtime.js +104 -23
- package/src/state-lock.cjs +73 -0
- package/src/task-daemon.js +92 -12
package/bin/relay.js
CHANGED
|
@@ -104,8 +104,13 @@ function statusAreaReopenText() {
|
|
|
104
104
|
|
|
105
105
|
/** Register the Relay tools into Claude Code + Codex and start the receive daemon. */
|
|
106
106
|
async function applyInstall({ requireLiveTools = false, requiredHosts = requiredLiveHosts() } = {}) {
|
|
107
|
-
const { installed, missing, daemon, pill, activations = [] } = await runSetupInstall();
|
|
107
|
+
const { installed, missing, daemon, pill, activations = [], binStable = true } = await runSetupInstall();
|
|
108
108
|
if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
|
|
109
|
+
if (!binStable) {
|
|
110
|
+
console.log(
|
|
111
|
+
"Heads up: Relay is running from a temporary npx cache and couldn't global-install. It may stop working when that cache is cleaned — run `npm install -g relay-companion` to make it permanent.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
109
114
|
for (const m of missing) {
|
|
110
115
|
if (!/registration failed/.test(m)) console.log(`${m} was not found here, so it was skipped.`);
|
|
111
116
|
else console.log(`Could not register ${m}.`);
|
package/overlay/host-select.cjs
CHANGED
|
@@ -22,28 +22,31 @@ function isRelayBundle(bundle) {
|
|
|
22
22
|
return clean.includes("relay") || clean.includes("electron");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// Cold-start choice when NEITHER agent is running: pick by what's
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
25
|
+
// Cold-start choice when NEITHER agent is foregrounded/running: pick by what's
|
|
26
|
+
// installed. When BOTH are available and neither is foregrounded, prefer Claude
|
|
27
|
+
// (per the product rule: "if I have claude and codex and neither is foregrounded,
|
|
28
|
+
// foreground claude"). Windows is Claude-only for now: the Codex materializer still
|
|
29
|
+
// depends on macOS Codex Desktop paths/app-server behavior, while Claude's claude://
|
|
30
|
+
// resume handler is registered by Claude Desktop on Windows.
|
|
30
31
|
function defaultClickHost({ codexInstalled = false, claudeInstalled = false, platform = process.platform } = {}) {
|
|
31
32
|
if (platform === "win32") return "claude";
|
|
32
|
-
if (codexInstalled) return "codex";
|
|
33
33
|
if (claudeInstalled) return "claude";
|
|
34
|
-
return "codex";
|
|
34
|
+
if (codexInstalled) return "codex";
|
|
35
|
+
return "claude";
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
// Decide which host a pill click should open into. Precedence, strongest signal first:
|
|
38
|
-
// 1. The frontmost app IS a host — the user is literally looking at it.
|
|
39
|
+
// 1. The frontmost app IS a host — the user is literally looking at it, so open there.
|
|
40
|
+
// (Codex frontmost -> Codex; Claude frontmost -> Claude.)
|
|
39
41
|
// 2. The click momentarily activated the accessory overlay (or no frontmost app could
|
|
40
|
-
// be read) AND a host was foregrounded moments ago — use that
|
|
42
|
+
// be read) AND a host was foregrounded moments ago — use that just-foregrounded host.
|
|
41
43
|
// 3. Exactly one host PROCESS is running — route to it. This is the off-Space fix:
|
|
42
44
|
// `ps` sees a host on any Space, so a running-but-not-frontmost Claude wins over a
|
|
43
|
-
// closed Codex instead of cold-starting Codex.
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// 5. Nothing
|
|
45
|
+
// closed Codex instead of cold-starting Codex. (Only Codex up -> Codex; only Claude
|
|
46
|
+
// up -> Claude.)
|
|
47
|
+
// 4. Both hosts are running but NEITHER is foregrounded — prefer Claude (product rule).
|
|
48
|
+
// 5. Nothing running — installed-preference default (true cold start; prefers Claude
|
|
49
|
+
// when both are installed).
|
|
47
50
|
function chooseClickHost({
|
|
48
51
|
frontHost = null,
|
|
49
52
|
bundle = null,
|
|
@@ -67,7 +70,8 @@ function chooseClickHost({
|
|
|
67
70
|
|
|
68
71
|
if (claudeRunning && !effectiveCodexRunning) return "claude";
|
|
69
72
|
if (effectiveCodexRunning && !claudeRunning) return "codex";
|
|
70
|
-
|
|
73
|
+
// Both running, neither foregrounded (and none foregrounded moments ago): prefer Claude.
|
|
74
|
+
if (claudeRunning && effectiveCodexRunning) return "claude";
|
|
71
75
|
|
|
72
76
|
return defaultClickHost({ codexInstalled: effectiveCodexInstalled, claudeInstalled, platform });
|
|
73
77
|
}
|
package/overlay/inbox.html
CHANGED
|
@@ -758,6 +758,15 @@
|
|
|
758
758
|
}
|
|
759
759
|
// ✕ — the card leaves gracefully, then the main process hides the window and keeps
|
|
760
760
|
// it hidden (persisted) until the status-area Relay mark is clicked.
|
|
761
|
+
// The exit is cancellable: if the user reopens (tray click / new ghost banner) while
|
|
762
|
+
// the bye animation is in flight, the pending timeout must NOT fire — it would
|
|
763
|
+
// dismiss the pill they just reopened and park the card offstage under a visible
|
|
764
|
+
// window (a click that "does nothing").
|
|
765
|
+
let byeTimer = null;
|
|
766
|
+
function cancelBye() {
|
|
767
|
+
if (byeTimer) { clearTimeout(byeTimer); byeTimer = null; }
|
|
768
|
+
cardEl.classList.remove("bye");
|
|
769
|
+
}
|
|
761
770
|
function dismissOverlay() {
|
|
762
771
|
if (cardEl.classList.contains("bye")) return;
|
|
763
772
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
@@ -765,7 +774,8 @@
|
|
|
765
774
|
const wasGhost = ghost;
|
|
766
775
|
cardEl.classList.add("bye");
|
|
767
776
|
setInteractive(false);
|
|
768
|
-
setTimeout(() => {
|
|
777
|
+
byeTimer = setTimeout(() => {
|
|
778
|
+
byeTimer = null;
|
|
769
779
|
if (wasGhost) window.relay.notifDone(); // already dismissed; just re-hide the window
|
|
770
780
|
else window.relay.dismiss();
|
|
771
781
|
resetOffstage();
|
|
@@ -776,6 +786,7 @@
|
|
|
776
786
|
// when done it fades away instead of folding back into anything.
|
|
777
787
|
function ghostArrival(row) {
|
|
778
788
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
789
|
+
cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
|
|
779
790
|
clearPeek();
|
|
780
791
|
collapsed = false;
|
|
781
792
|
cardEl.classList.remove("collapsed", "offstage", "bye");
|
|
@@ -800,6 +811,7 @@
|
|
|
800
811
|
// Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
|
|
801
812
|
function trayOpen() {
|
|
802
813
|
const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
|
|
814
|
+
cancelBye(); // a reopen mid-exit must survive: the pending dismiss would hide us again
|
|
803
815
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
804
816
|
ghost = false;
|
|
805
817
|
clearPeek();
|
|
@@ -843,8 +855,14 @@
|
|
|
843
855
|
scrollEl.scrollTop = 0;
|
|
844
856
|
springTo(PEEK.w, peekHeight());
|
|
845
857
|
playTink();
|
|
846
|
-
peekTimer = setTimeout(()
|
|
847
|
-
peekTimer = null;
|
|
858
|
+
peekTimer = setTimeout(function tick() {
|
|
859
|
+
peekTimer = null;
|
|
860
|
+
// Never fold the banner away while the pointer is on it or a reply is being
|
|
861
|
+
// typed — that rug-pulls the user mid-interaction and can trap keyboard focus
|
|
862
|
+
// in a now-invisible textarea. Check again shortly instead (macOS banners
|
|
863
|
+
// hold while hovered too). Mirrors the ghost-notification guard.
|
|
864
|
+
if (interactive || quickReplyIsActive()) { peekTimer = setTimeout(tick, 4000); return; }
|
|
865
|
+
peeking = false;
|
|
848
866
|
cardEl.classList.remove("peek");
|
|
849
867
|
collapsed = true; cardEl.classList.add("collapsed");
|
|
850
868
|
springTo(PILL.w, PILL.h);
|
|
@@ -2126,6 +2144,15 @@
|
|
|
2126
2144
|
onPayload(p);
|
|
2127
2145
|
console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
|
|
2128
2146
|
}).catch((e) => console.log("[overlay] refresh failed", e && e.message ? e.message : e));
|
|
2147
|
+
|
|
2148
|
+
// Keep the relative "3m / 2h" times honest now that identical payloads are no longer
|
|
2149
|
+
// re-pushed. Skip while the pointer is on the card (a rebuild under the pointer wipes
|
|
2150
|
+
// hover state) and while dragging; the next tick catches up.
|
|
2151
|
+
setInterval(() => {
|
|
2152
|
+
if (dragging || interactive) return;
|
|
2153
|
+
if (activeView === "relays") renderRelays();
|
|
2154
|
+
else if (activeView === "sent") renderSent();
|
|
2155
|
+
}, 60000);
|
|
2129
2156
|
</script>
|
|
2130
2157
|
</body>
|
|
2131
2158
|
</html>
|
package/overlay/main.cjs
CHANGED
|
@@ -30,6 +30,8 @@ const { execFile, execFileSync, spawn, pathToFileURL } = (() => {
|
|
|
30
30
|
return { execFile: cp.execFile, execFileSync: cp.execFileSync, spawn: cp.spawn, pathToFileURL: url.pathToFileURL };
|
|
31
31
|
})();
|
|
32
32
|
const { hostFromBundle, chooseClickHost } = require("./host-select.cjs");
|
|
33
|
+
const { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss } = require("./visibility.cjs");
|
|
34
|
+
const { withJsonLock } = require("../src/state-lock.cjs");
|
|
33
35
|
const {
|
|
34
36
|
reinforceSpacePresence,
|
|
35
37
|
resetWindowZoom,
|
|
@@ -113,6 +115,15 @@ async function relayClient() {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
app.setName("Relay");
|
|
118
|
+
// Test seam: an isolated userData dir gives the e2e harness its own single-instance
|
|
119
|
+
// lock, so a sandboxed overlay can run beside the real pill without fighting it.
|
|
120
|
+
if (process.env.RELAY_OVERLAY_USER_DATA) {
|
|
121
|
+
try {
|
|
122
|
+
app.setPath("userData", process.env.RELAY_OVERLAY_USER_DATA);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error("[overlay] userData override failed:", error && error.message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
116
127
|
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
|
117
128
|
|
|
118
129
|
// ---- config / account ----------------------------------------------------
|
|
@@ -298,14 +309,19 @@ function ensureTasksLoaded() {
|
|
|
298
309
|
return tasksLoadedOnce;
|
|
299
310
|
}
|
|
300
311
|
|
|
301
|
-
// ---- contacts (
|
|
312
|
+
// ---- contacts (cached; refreshed on a slow interval + on demand) ----------
|
|
313
|
+
// Contacts must NEVER be fetched per state push: a slow or failing network would
|
|
314
|
+
// stall every inbox paint and blank the list (observed live: "listContacts failed:
|
|
315
|
+
// fetch failed" every 2.5s while offline). Keep the last good list; refresh lazily.
|
|
302
316
|
|
|
303
|
-
|
|
317
|
+
let contactsCache = [];
|
|
318
|
+
let contactsLoadedOnce = null;
|
|
319
|
+
async function refreshContacts() {
|
|
304
320
|
try {
|
|
305
321
|
const client = await relayClient();
|
|
306
322
|
const res = await client.listContacts();
|
|
307
323
|
const contacts = Array.isArray(res && res.contacts) ? res.contacts : [];
|
|
308
|
-
|
|
324
|
+
contactsCache = contacts
|
|
309
325
|
.map((c) => {
|
|
310
326
|
const emails = Array.isArray(c.emails) ? c.emails.filter(Boolean) : c.email ? [c.email] : [];
|
|
311
327
|
return {
|
|
@@ -319,9 +335,18 @@ async function readContacts() {
|
|
|
319
335
|
})
|
|
320
336
|
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
321
337
|
} catch (error) {
|
|
338
|
+
// Keep the last good cache; a transient network failure must not blank the UI.
|
|
322
339
|
console.error("[overlay] listContacts failed:", error && error.message);
|
|
323
|
-
return [];
|
|
324
340
|
}
|
|
341
|
+
return contactsCache;
|
|
342
|
+
}
|
|
343
|
+
function ensureContactsLoaded() {
|
|
344
|
+
if (!contactsLoadedOnce) contactsLoadedOnce = refreshContacts().catch(() => contactsCache);
|
|
345
|
+
return contactsLoadedOnce;
|
|
346
|
+
}
|
|
347
|
+
async function readContacts() {
|
|
348
|
+
await refreshContacts();
|
|
349
|
+
return contactsCache;
|
|
325
350
|
}
|
|
326
351
|
|
|
327
352
|
async function saveContact(input) {
|
|
@@ -350,26 +375,41 @@ async function deleteContactFromBook(input) {
|
|
|
350
375
|
|
|
351
376
|
// ---- payload assembly + push to renderer ---------------------------------
|
|
352
377
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
378
|
+
// buildPayload is synchronous over CACHES so the first paint never waits on the
|
|
379
|
+
// network. Relays come straight from local state.json (instant, works offline); sent
|
|
380
|
+
// + contacts are served from cache and their first background load triggers its own
|
|
381
|
+
// pushInbox when it lands. This is what makes the pill appear immediately even on a
|
|
382
|
+
// black-holed network (the client fetch timeout is 15s — far too long to block paint).
|
|
383
|
+
function buildPayload() {
|
|
384
|
+
// Kick the first loads without awaiting; each calls pushInbox(false) on completion.
|
|
385
|
+
if (!sentLoadedOnce) ensureSentLoaded().then(() => pushInbox(false)).catch(() => {});
|
|
386
|
+
if (!contactsLoadedOnce) ensureContactsLoaded().then(() => pushInbox(false)).catch(() => {});
|
|
356
387
|
return {
|
|
357
388
|
account: account(),
|
|
358
389
|
ui: {
|
|
359
390
|
canDismiss: trayAvailable,
|
|
360
391
|
reopenSurface: reopenSurfaceName(),
|
|
361
392
|
},
|
|
362
|
-
relays,
|
|
393
|
+
relays: readRelays(),
|
|
363
394
|
sent: sentCache,
|
|
364
395
|
tasks: [],
|
|
365
|
-
contacts,
|
|
396
|
+
contacts: contactsCache,
|
|
366
397
|
};
|
|
367
398
|
}
|
|
368
399
|
|
|
369
400
|
let knownIds = null; // null until first load, so we never "notify" for rows already present
|
|
370
|
-
|
|
401
|
+
// Pushes are serialized: overlapping timers (fs.watch + safety poll + sent refresh)
|
|
402
|
+
// must not interleave sends, or the renderer can paint an older payload last.
|
|
403
|
+
let pushChain = Promise.resolve();
|
|
404
|
+
function pushInbox(force) {
|
|
405
|
+
pushChain = pushChain.then(() => pushInboxNow(force)).catch((error) => {
|
|
406
|
+
console.error("[overlay] pushInbox failed:", error && error.message);
|
|
407
|
+
});
|
|
408
|
+
return pushChain;
|
|
409
|
+
}
|
|
410
|
+
async function pushInboxNow(force) {
|
|
371
411
|
if (!win || win.isDestroyed()) return;
|
|
372
|
-
const payload =
|
|
412
|
+
const payload = buildPayload();
|
|
373
413
|
const rows = payload.relays;
|
|
374
414
|
const notifiableRows = visibleRelayRows(rows);
|
|
375
415
|
if (knownIds === null) {
|
|
@@ -393,7 +433,19 @@ async function pushInbox(force) {
|
|
|
393
433
|
}
|
|
394
434
|
}
|
|
395
435
|
const sig = JSON.stringify({
|
|
396
|
-
|
|
436
|
+
// Include the answered-state fields: a question answered on the web / in a host
|
|
437
|
+
// session flips quickReply/humanAnswered without changing unread/title, and
|
|
438
|
+
// omitting them left a live reply box in the pill for an already-answered question.
|
|
439
|
+
relays: rows.map((r) => [
|
|
440
|
+
r.id,
|
|
441
|
+
r.relayNotificationKind,
|
|
442
|
+
r.unread,
|
|
443
|
+
r.title,
|
|
444
|
+
r.senderName,
|
|
445
|
+
r.urgency,
|
|
446
|
+
r.quickReply,
|
|
447
|
+
r.humanAnswered,
|
|
448
|
+
]),
|
|
397
449
|
sent: payload.sent.map((r) => [
|
|
398
450
|
r.relayId,
|
|
399
451
|
r.state,
|
|
@@ -405,31 +457,37 @@ async function pushInbox(force) {
|
|
|
405
457
|
contacts: payload.contacts.map((c) => [c.id, c.name, c.email]),
|
|
406
458
|
account: [payload.account.paired, payload.account.email],
|
|
407
459
|
});
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
460
|
+
// Unchanged data: skip the send entirely. Re-sending identical payloads made the
|
|
461
|
+
// renderer rebuild its DOM every few seconds (hover flicker, restarted transitions).
|
|
462
|
+
if (!force && sig === lastSig) return;
|
|
411
463
|
lastSig = sig;
|
|
412
464
|
if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
|
|
413
465
|
}
|
|
414
466
|
|
|
415
467
|
// Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
|
|
416
468
|
function pushInboxQuiet() {
|
|
417
|
-
pushInbox(false)
|
|
469
|
+
pushInbox(false);
|
|
418
470
|
}
|
|
419
471
|
|
|
420
472
|
// ---- mark-read (write state.json directly) -------------------------------
|
|
421
473
|
|
|
422
474
|
function ackPacket(packetId) {
|
|
423
475
|
if (!packetId) return;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const row =
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
476
|
+
// Locked read-modify-write: the daemon stages rows into the same file, and an
|
|
477
|
+
// unsynchronized ack racing a stage would silently drop the new relay.
|
|
478
|
+
const row = withJsonLock(STATE_PATH, () => {
|
|
479
|
+
const store = readStore();
|
|
480
|
+
if (!store.packets || !store.packets[packetId]) return null;
|
|
481
|
+
const existing = store.packets[packetId];
|
|
482
|
+
store.packets[packetId] = {
|
|
483
|
+
...existing,
|
|
484
|
+
state: "read",
|
|
485
|
+
updatedAt: new Date().toISOString(),
|
|
486
|
+
};
|
|
487
|
+
writeStateAtomic(store);
|
|
488
|
+
return existing;
|
|
489
|
+
});
|
|
490
|
+
if (!row) return;
|
|
433
491
|
if (row.relayNotificationKind === "plain_relay") {
|
|
434
492
|
relayClient()
|
|
435
493
|
.then((client) => client.markRead(packetId, { idempotencyKey: idempotencyKey("read") }))
|
|
@@ -531,18 +589,20 @@ async function answerQuestion(taskId, messageId, text, packetId) {
|
|
|
531
589
|
}
|
|
532
590
|
|
|
533
591
|
function markQuestionAnswered(packetId, answerMarkdown) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
592
|
+
withJsonLock(STATE_PATH, () => {
|
|
593
|
+
const store = readStore();
|
|
594
|
+
const row = store.packets && store.packets[packetId];
|
|
595
|
+
if (!row) return;
|
|
596
|
+
store.packets[packetId] = {
|
|
597
|
+
...row,
|
|
598
|
+
state: "read",
|
|
599
|
+
quickReply: false,
|
|
600
|
+
humanAnswered: true,
|
|
601
|
+
humanAnswerMarkdown: answerMarkdown,
|
|
602
|
+
updatedAt: new Date().toISOString(),
|
|
603
|
+
};
|
|
604
|
+
writeStateAtomic(store);
|
|
605
|
+
});
|
|
546
606
|
}
|
|
547
607
|
|
|
548
608
|
function taskVersion(taskId) {
|
|
@@ -571,6 +631,23 @@ function rememberForegroundHost(host) {
|
|
|
571
631
|
lastHostSeenAt = Date.now();
|
|
572
632
|
}
|
|
573
633
|
|
|
634
|
+
// Bring the host app to the FOREGROUND so the just-opened relay is actually visible.
|
|
635
|
+
// shell.openExternal(claude://…) / the Codex bridge route the content to the host but
|
|
636
|
+
// do NOT reliably raise it: the overlay is a non-activating accessory window, and a
|
|
637
|
+
// host sitting on another macOS Space stays put (observed live: "it opened it in claude
|
|
638
|
+
// (good) but didnt foreground claude"). `open -b <bundle>` activates the app — switching
|
|
639
|
+
// to its Space and launching it if it wasn't running. `-g` is intentionally NOT passed,
|
|
640
|
+
// because raising the app is exactly the point.
|
|
641
|
+
const HOST_BUNDLE_ID = { claude: "com.anthropic.claudefordesktop", codex: "com.openai.codex" };
|
|
642
|
+
function activateHost(host) {
|
|
643
|
+
if (process.platform !== "darwin") return; // Windows foregrounding is handled by its own open path
|
|
644
|
+
const bundle = HOST_BUNDLE_ID[host];
|
|
645
|
+
if (!bundle) return;
|
|
646
|
+
execFile("/usr/bin/open", ["-b", bundle], (error) => {
|
|
647
|
+
if (error) console.error("[overlay] activateHost failed:", host, error && error.message);
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
574
651
|
function appInstalled(appName) {
|
|
575
652
|
const name = String(appName || "").replace(/[^A-Za-z0-9 ._-]/g, "");
|
|
576
653
|
if (!name) return false;
|
|
@@ -712,7 +789,7 @@ function claudeLastFocusedAt(metaPath) {
|
|
|
712
789
|
return 0;
|
|
713
790
|
}
|
|
714
791
|
}
|
|
715
|
-
function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
|
|
792
|
+
function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = false } = {}) {
|
|
716
793
|
const fire = () =>
|
|
717
794
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
718
795
|
if (process.platform !== "darwin") return fire();
|
|
@@ -723,13 +800,20 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
|
|
|
723
800
|
const logOffset = claudeLogSize();
|
|
724
801
|
fire();
|
|
725
802
|
if (!metaPath && logOffset < 0) return; // nothing to verify against — legacy behavior
|
|
803
|
+
// On a FIRST open the companion CLI just forged the session and stamped
|
|
804
|
+
// lastFocusedAt=now — so the "focused moments ago" recency check would be vacuously
|
|
805
|
+
// true and confirm a resume that a wedged Claude actually swallowed (no nudge, no web
|
|
806
|
+
// fallback). Suppress recency ONLY on a fresh forge (signalled by the CLI). On a
|
|
807
|
+
// resume of an already-imported session the CLI does NOT stamp the timestamp, so a
|
|
808
|
+
// recent lastFocusedAt is a GENUINE Claude focus — keep trusting it, or a real resume
|
|
809
|
+
// the user was just looking at would falsely fall back to the web view.
|
|
726
810
|
const confirmed = () => {
|
|
727
811
|
if (metaPath) {
|
|
728
812
|
const focusedAt = claudeLastFocusedAt(metaPath);
|
|
729
|
-
//
|
|
730
|
-
// focus write racing our baseline snapshot) — either way Claude has it.
|
|
813
|
+
// A genuine Claude focus advances the timestamp past our pre-fire baseline.
|
|
731
814
|
if (focusedAt > focusBaseline) return true;
|
|
732
|
-
|
|
815
|
+
// Recency: trust it unless the CLI's own bootstrap write made it vacuously fresh.
|
|
816
|
+
if (!freshlyForged && focusedAt && Date.now() - focusedAt < 15000) return true;
|
|
733
817
|
}
|
|
734
818
|
return claudeLogSawResume(sessionId, logOffset);
|
|
735
819
|
};
|
|
@@ -803,16 +887,22 @@ function openPacket(packetId) {
|
|
|
803
887
|
child.on("exit", (code) => {
|
|
804
888
|
let url = null;
|
|
805
889
|
let skipExternalOpen = false;
|
|
890
|
+
let freshlyForged = false;
|
|
806
891
|
try {
|
|
807
892
|
const parsed = JSON.parse(out.trim());
|
|
808
893
|
url = parsed.url || null;
|
|
809
894
|
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
895
|
+
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
810
896
|
} catch {}
|
|
811
897
|
if (url || skipExternalOpen) {
|
|
898
|
+
// Raise the host so the relay is visible, whichever open path ran (Claude deep
|
|
899
|
+
// link, Codex bridge, or a plain url). This is the fix for "opened in the right
|
|
900
|
+
// host but didn't foreground it".
|
|
901
|
+
activateHost(host);
|
|
812
902
|
if (url && !skipExternalOpen) {
|
|
813
903
|
const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
|
|
814
904
|
if (claudeSessionIdFromUrl(url)) {
|
|
815
|
-
openClaudeDeepLinkVerified(url, webFallback, packetId);
|
|
905
|
+
openClaudeDeepLinkVerified(url, webFallback, packetId, { freshlyForged });
|
|
816
906
|
} else {
|
|
817
907
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
818
908
|
}
|
|
@@ -875,15 +965,18 @@ function openTaskDetail(taskId) {
|
|
|
875
965
|
child.on("exit", (code) => {
|
|
876
966
|
let url = null;
|
|
877
967
|
let skipExternalOpen = false;
|
|
968
|
+
let freshlyForged = false;
|
|
878
969
|
try {
|
|
879
970
|
const parsed = JSON.parse(out.trim());
|
|
880
971
|
url = parsed.url || null;
|
|
881
972
|
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
973
|
+
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
882
974
|
} catch {}
|
|
883
975
|
if (url || skipExternalOpen) {
|
|
976
|
+
activateHost(host); // raise the host so the opened task is visible
|
|
884
977
|
if (url && !skipExternalOpen) {
|
|
885
978
|
if (claudeSessionIdFromUrl(url)) {
|
|
886
|
-
openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId);
|
|
979
|
+
openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
|
|
887
980
|
} else {
|
|
888
981
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
889
982
|
}
|
|
@@ -953,16 +1046,21 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
|
|
|
953
1046
|
}
|
|
954
1047
|
placeOverlayWindow({ reposition });
|
|
955
1048
|
const shown = showInactiveOnAllSpaces(win, { force });
|
|
956
|
-
|
|
957
|
-
|
|
1049
|
+
if (shown) {
|
|
1050
|
+
// hidden -> shown only: re-assert click-through and reset the renderer's
|
|
1051
|
+
// interactivity handshake. Touching setIgnoreMouseEvents on an already-visible
|
|
1052
|
+
// window would silently flip it click-through while the renderer still thinks
|
|
1053
|
+
// the pointer handshake is live — a dead card until the pointer re-enters.
|
|
1054
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
1055
|
+
win.webContents.send("shown");
|
|
1056
|
+
}
|
|
958
1057
|
}
|
|
959
1058
|
|
|
960
1059
|
function maybeShow({ force = false, reposition = true } = {}) {
|
|
961
1060
|
if (!win || win.isDestroyed()) return;
|
|
962
1061
|
// Dismissed keeps the window hidden except while a ghost notification is up.
|
|
963
1062
|
// trayForcedVisible honors an explicit status-area click even when no host runs.
|
|
964
|
-
const
|
|
965
|
-
const wanted = (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
|
|
1063
|
+
const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive });
|
|
966
1064
|
if (wanted) {
|
|
967
1065
|
showOverlayWindow({ force, reposition });
|
|
968
1066
|
} else if (win.isVisible()) {
|
|
@@ -988,6 +1086,10 @@ function readHostProcesses(cb) {
|
|
|
988
1086
|
execFile("/bin/ps", ["-Ao", "comm"], cb);
|
|
989
1087
|
}
|
|
990
1088
|
|
|
1089
|
+
// Debounced OFF transition (~3 misses = ~4.5s at the 1.5s poll): a host relaunching
|
|
1090
|
+
// during its own auto-update, or one truncated process-list read, must not blink the
|
|
1091
|
+
// pill off and back on.
|
|
1092
|
+
const hostTracker = createHostRunningTracker({ missThreshold: 3 });
|
|
991
1093
|
function updateHostRunningFromProcesses(text) {
|
|
992
1094
|
const s = String(text || "");
|
|
993
1095
|
if (process.platform === "darwin") {
|
|
@@ -1003,7 +1105,7 @@ function updateHostRunningFromProcesses(text) {
|
|
|
1003
1105
|
claudeRunning = /(^|\n)Claude(\n|$)/.test(s) || /(^|\n)claude(\n|$)/.test(s);
|
|
1004
1106
|
codexRunning = /(^|\n)Codex(\n|$)/.test(s) || /(^|\n)codex(\n|$)/.test(s);
|
|
1005
1107
|
}
|
|
1006
|
-
hostRunning = claudeRunning
|
|
1108
|
+
hostRunning = hostTracker.update(claudeRunning, codexRunning);
|
|
1007
1109
|
}
|
|
1008
1110
|
|
|
1009
1111
|
function pollHosts() {
|
|
@@ -1057,7 +1159,21 @@ function createWindow() {
|
|
|
1057
1159
|
console.error("[renderer]", d);
|
|
1058
1160
|
});
|
|
1059
1161
|
win.webContents.on("preload-error", (_e, p, err) => console.error("[preload-error]", p, err && err.message));
|
|
1060
|
-
|
|
1162
|
+
// A dead renderer leaves an invisible, unrecoverable window (the tray click would
|
|
1163
|
+
// "do nothing"). Reload it — with a short backoff so a hard crash can't hot-loop.
|
|
1164
|
+
let renderGoneAt = 0;
|
|
1165
|
+
win.webContents.on("render-process-gone", (_e, det) => {
|
|
1166
|
+
console.error("[render-gone]", JSON.stringify(det));
|
|
1167
|
+
if (!win || win.isDestroyed()) return;
|
|
1168
|
+
const delay = Date.now() - renderGoneAt < 30000 ? 5000 : 250;
|
|
1169
|
+
renderGoneAt = Date.now();
|
|
1170
|
+
setTimeout(() => {
|
|
1171
|
+
if (!win || win.isDestroyed()) return;
|
|
1172
|
+
lastSig = ""; // force a full repaint after reload
|
|
1173
|
+
knownIds = null;
|
|
1174
|
+
win.webContents.reload();
|
|
1175
|
+
}, delay);
|
|
1176
|
+
});
|
|
1061
1177
|
win.webContents.on("did-finish-load", () => resetWindowZoom(win));
|
|
1062
1178
|
win.webContents.on("zoom-changed", (event) => {
|
|
1063
1179
|
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
@@ -1071,7 +1187,8 @@ function createWindow() {
|
|
|
1071
1187
|
}
|
|
1072
1188
|
});
|
|
1073
1189
|
win.once("ready-to-show", () => {
|
|
1074
|
-
//
|
|
1190
|
+
// First paint is instant: relays come from local state.json; the sent + contacts
|
|
1191
|
+
// background loads kicked off here each re-push when they land (buildPayload).
|
|
1075
1192
|
pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
|
|
1076
1193
|
pollHosts();
|
|
1077
1194
|
});
|
|
@@ -1090,6 +1207,11 @@ function createWindow() {
|
|
|
1090
1207
|
.then(() => pushInbox(false))
|
|
1091
1208
|
.catch(() => {});
|
|
1092
1209
|
}, 5000); // live sent-relay refresh
|
|
1210
|
+
setInterval(() => {
|
|
1211
|
+
refreshContacts()
|
|
1212
|
+
.then(() => pushInbox(false))
|
|
1213
|
+
.catch(() => {});
|
|
1214
|
+
}, 60000); // slow contact refresh; the contacts view also refreshes on open
|
|
1093
1215
|
setInterval(pollHosts, 1500);
|
|
1094
1216
|
}
|
|
1095
1217
|
|
|
@@ -1135,7 +1257,9 @@ function createTrayIcon() {
|
|
|
1135
1257
|
return icon && !icon.isEmpty() ? icon : templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
|
|
1136
1258
|
}
|
|
1137
1259
|
|
|
1260
|
+
let lastTrayShowAt = 0;
|
|
1138
1261
|
function showFromTray() {
|
|
1262
|
+
lastTrayShowAt = Date.now();
|
|
1139
1263
|
setDismissed(false);
|
|
1140
1264
|
ghostActive = false;
|
|
1141
1265
|
trayForcedVisible = true; // an explicit click always gets the card
|
|
@@ -1229,6 +1353,10 @@ ipcMain.on("relay:setPos", (_e, x, y) => {
|
|
|
1229
1353
|
// ✕ on the card: hide the overlay entirely until the status-area Relay mark is clicked.
|
|
1230
1354
|
// The renderer plays its exit animation first, then sends this.
|
|
1231
1355
|
ipcMain.on("relay:dismiss", () => {
|
|
1356
|
+
// Ignore only a dismiss whose IPC was already in flight when a tray "Show Relay"
|
|
1357
|
+
// just landed (narrow window). A genuine ✕ click is always honored.
|
|
1358
|
+
if (shouldIgnoreDismiss({ now: Date.now(), lastTrayShowAt })) return;
|
|
1359
|
+
lastTrayShowAt = 0; // honored: clear the guard so it can't linger
|
|
1232
1360
|
if (!trayAvailable) {
|
|
1233
1361
|
setDismissed(false);
|
|
1234
1362
|
ghostActive = false;
|
|
@@ -1284,6 +1412,18 @@ if (!gotSingleInstanceLock) {
|
|
|
1284
1412
|
createWindow();
|
|
1285
1413
|
createTray();
|
|
1286
1414
|
installActiveSpaceWatcher();
|
|
1415
|
+
// Test seam: the e2e harness (test/e2e-overlay.mjs) drives the real state machine
|
|
1416
|
+
// over the main-process inspector. Never set outside the harness.
|
|
1417
|
+
if (process.env.RELAY_OVERLAY_TEST === "1") {
|
|
1418
|
+
global.__relayTest = {
|
|
1419
|
+
showFromTray,
|
|
1420
|
+
maybeShow,
|
|
1421
|
+
refreshOverlayForActiveSpace,
|
|
1422
|
+
pollHosts,
|
|
1423
|
+
getWin: () => win,
|
|
1424
|
+
state: () => ({ dismissed, ghostActive, trayForcedVisible, hostRunning, visible: win && !win.isDestroyed() ? win.isVisible() : null }),
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1287
1427
|
});
|
|
1288
1428
|
|
|
1289
1429
|
app.on("window-all-closed", () => {
|
|
@@ -21,11 +21,18 @@ function resetWindowZoom(win) {
|
|
|
21
21
|
function reinforceSpacePresence(win, { moveTop = false } = {}) {
|
|
22
22
|
if (!isUsableWindow(win)) return false;
|
|
23
23
|
resetWindowZoom(win);
|
|
24
|
+
// Only touch window-server state that actually drifted. Re-asserting
|
|
25
|
+
// setVisibleOnAllWorkspaces / setAlwaysOnTop unconditionally (every 1.5s poll +
|
|
26
|
+
// every Space change) reorders the window each time and reads as flicker.
|
|
24
27
|
try {
|
|
25
|
-
win.
|
|
28
|
+
if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
|
|
29
|
+
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
30
|
+
}
|
|
26
31
|
} catch {}
|
|
27
32
|
try {
|
|
28
|
-
win.
|
|
33
|
+
if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
|
|
34
|
+
win.setAlwaysOnTop(true, "floating");
|
|
35
|
+
}
|
|
29
36
|
} catch {}
|
|
30
37
|
if (moveTop && typeof win.moveTop === "function") {
|
|
31
38
|
try {
|
|
@@ -43,11 +50,14 @@ function showInactiveOnAllSpaces(win, { force = false } = {}) {
|
|
|
43
50
|
return false;
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
// NEVER hide() an already-visible window here: the hide/show cycle was a visible
|
|
54
|
+
// blink on every forced re-show (twice per Space change). showInactive() on a
|
|
55
|
+
// visible window is enough to re-attach it to the active Space once the
|
|
56
|
+
// all-workspaces collection behavior has been re-asserted above.
|
|
46
57
|
reinforceSpacePresence(win);
|
|
47
|
-
if (visible && force && typeof win.hide === "function") win.hide();
|
|
48
58
|
if (typeof win.showInactive === "function") win.showInactive();
|
|
49
59
|
reinforceSpacePresence(win, { moveTop: true });
|
|
50
|
-
return
|
|
60
|
+
return !visible;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
function subscribeActiveSpaceChanges(systemPreferences, callback, { log = () => {} } = {}) {
|