relay-companion 0.1.35 → 0.1.36
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/inbox.html +30 -3
- package/overlay/main.cjs +168 -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/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) {
|
|
@@ -712,7 +772,7 @@ function claudeLastFocusedAt(metaPath) {
|
|
|
712
772
|
return 0;
|
|
713
773
|
}
|
|
714
774
|
}
|
|
715
|
-
function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
|
|
775
|
+
function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = false } = {}) {
|
|
716
776
|
const fire = () =>
|
|
717
777
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
718
778
|
if (process.platform !== "darwin") return fire();
|
|
@@ -723,13 +783,20 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
|
|
|
723
783
|
const logOffset = claudeLogSize();
|
|
724
784
|
fire();
|
|
725
785
|
if (!metaPath && logOffset < 0) return; // nothing to verify against — legacy behavior
|
|
786
|
+
// On a FIRST open the companion CLI just forged the session and stamped
|
|
787
|
+
// lastFocusedAt=now — so the "focused moments ago" recency check would be vacuously
|
|
788
|
+
// true and confirm a resume that a wedged Claude actually swallowed (no nudge, no web
|
|
789
|
+
// fallback). Suppress recency ONLY on a fresh forge (signalled by the CLI). On a
|
|
790
|
+
// resume of an already-imported session the CLI does NOT stamp the timestamp, so a
|
|
791
|
+
// recent lastFocusedAt is a GENUINE Claude focus — keep trusting it, or a real resume
|
|
792
|
+
// the user was just looking at would falsely fall back to the web view.
|
|
726
793
|
const confirmed = () => {
|
|
727
794
|
if (metaPath) {
|
|
728
795
|
const focusedAt = claudeLastFocusedAt(metaPath);
|
|
729
|
-
//
|
|
730
|
-
// focus write racing our baseline snapshot) — either way Claude has it.
|
|
796
|
+
// A genuine Claude focus advances the timestamp past our pre-fire baseline.
|
|
731
797
|
if (focusedAt > focusBaseline) return true;
|
|
732
|
-
|
|
798
|
+
// Recency: trust it unless the CLI's own bootstrap write made it vacuously fresh.
|
|
799
|
+
if (!freshlyForged && focusedAt && Date.now() - focusedAt < 15000) return true;
|
|
733
800
|
}
|
|
734
801
|
return claudeLogSawResume(sessionId, logOffset);
|
|
735
802
|
};
|
|
@@ -803,16 +870,18 @@ function openPacket(packetId) {
|
|
|
803
870
|
child.on("exit", (code) => {
|
|
804
871
|
let url = null;
|
|
805
872
|
let skipExternalOpen = false;
|
|
873
|
+
let freshlyForged = false;
|
|
806
874
|
try {
|
|
807
875
|
const parsed = JSON.parse(out.trim());
|
|
808
876
|
url = parsed.url || null;
|
|
809
877
|
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
878
|
+
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
810
879
|
} catch {}
|
|
811
880
|
if (url || skipExternalOpen) {
|
|
812
881
|
if (url && !skipExternalOpen) {
|
|
813
882
|
const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
|
|
814
883
|
if (claudeSessionIdFromUrl(url)) {
|
|
815
|
-
openClaudeDeepLinkVerified(url, webFallback, packetId);
|
|
884
|
+
openClaudeDeepLinkVerified(url, webFallback, packetId, { freshlyForged });
|
|
816
885
|
} else {
|
|
817
886
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
818
887
|
}
|
|
@@ -875,15 +944,17 @@ function openTaskDetail(taskId) {
|
|
|
875
944
|
child.on("exit", (code) => {
|
|
876
945
|
let url = null;
|
|
877
946
|
let skipExternalOpen = false;
|
|
947
|
+
let freshlyForged = false;
|
|
878
948
|
try {
|
|
879
949
|
const parsed = JSON.parse(out.trim());
|
|
880
950
|
url = parsed.url || null;
|
|
881
951
|
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
952
|
+
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
882
953
|
} catch {}
|
|
883
954
|
if (url || skipExternalOpen) {
|
|
884
955
|
if (url && !skipExternalOpen) {
|
|
885
956
|
if (claudeSessionIdFromUrl(url)) {
|
|
886
|
-
openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId);
|
|
957
|
+
openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
|
|
887
958
|
} else {
|
|
888
959
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
889
960
|
}
|
|
@@ -953,16 +1024,21 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
|
|
|
953
1024
|
}
|
|
954
1025
|
placeOverlayWindow({ reposition });
|
|
955
1026
|
const shown = showInactiveOnAllSpaces(win, { force });
|
|
956
|
-
|
|
957
|
-
|
|
1027
|
+
if (shown) {
|
|
1028
|
+
// hidden -> shown only: re-assert click-through and reset the renderer's
|
|
1029
|
+
// interactivity handshake. Touching setIgnoreMouseEvents on an already-visible
|
|
1030
|
+
// window would silently flip it click-through while the renderer still thinks
|
|
1031
|
+
// the pointer handshake is live — a dead card until the pointer re-enters.
|
|
1032
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
1033
|
+
win.webContents.send("shown");
|
|
1034
|
+
}
|
|
958
1035
|
}
|
|
959
1036
|
|
|
960
1037
|
function maybeShow({ force = false, reposition = true } = {}) {
|
|
961
1038
|
if (!win || win.isDestroyed()) return;
|
|
962
1039
|
// Dismissed keeps the window hidden except while a ghost notification is up.
|
|
963
1040
|
// trayForcedVisible honors an explicit status-area click even when no host runs.
|
|
964
|
-
const
|
|
965
|
-
const wanted = (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
|
|
1041
|
+
const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive });
|
|
966
1042
|
if (wanted) {
|
|
967
1043
|
showOverlayWindow({ force, reposition });
|
|
968
1044
|
} else if (win.isVisible()) {
|
|
@@ -988,6 +1064,10 @@ function readHostProcesses(cb) {
|
|
|
988
1064
|
execFile("/bin/ps", ["-Ao", "comm"], cb);
|
|
989
1065
|
}
|
|
990
1066
|
|
|
1067
|
+
// Debounced OFF transition (~3 misses = ~4.5s at the 1.5s poll): a host relaunching
|
|
1068
|
+
// during its own auto-update, or one truncated process-list read, must not blink the
|
|
1069
|
+
// pill off and back on.
|
|
1070
|
+
const hostTracker = createHostRunningTracker({ missThreshold: 3 });
|
|
991
1071
|
function updateHostRunningFromProcesses(text) {
|
|
992
1072
|
const s = String(text || "");
|
|
993
1073
|
if (process.platform === "darwin") {
|
|
@@ -1003,7 +1083,7 @@ function updateHostRunningFromProcesses(text) {
|
|
|
1003
1083
|
claudeRunning = /(^|\n)Claude(\n|$)/.test(s) || /(^|\n)claude(\n|$)/.test(s);
|
|
1004
1084
|
codexRunning = /(^|\n)Codex(\n|$)/.test(s) || /(^|\n)codex(\n|$)/.test(s);
|
|
1005
1085
|
}
|
|
1006
|
-
hostRunning = claudeRunning
|
|
1086
|
+
hostRunning = hostTracker.update(claudeRunning, codexRunning);
|
|
1007
1087
|
}
|
|
1008
1088
|
|
|
1009
1089
|
function pollHosts() {
|
|
@@ -1057,7 +1137,21 @@ function createWindow() {
|
|
|
1057
1137
|
console.error("[renderer]", d);
|
|
1058
1138
|
});
|
|
1059
1139
|
win.webContents.on("preload-error", (_e, p, err) => console.error("[preload-error]", p, err && err.message));
|
|
1060
|
-
|
|
1140
|
+
// A dead renderer leaves an invisible, unrecoverable window (the tray click would
|
|
1141
|
+
// "do nothing"). Reload it — with a short backoff so a hard crash can't hot-loop.
|
|
1142
|
+
let renderGoneAt = 0;
|
|
1143
|
+
win.webContents.on("render-process-gone", (_e, det) => {
|
|
1144
|
+
console.error("[render-gone]", JSON.stringify(det));
|
|
1145
|
+
if (!win || win.isDestroyed()) return;
|
|
1146
|
+
const delay = Date.now() - renderGoneAt < 30000 ? 5000 : 250;
|
|
1147
|
+
renderGoneAt = Date.now();
|
|
1148
|
+
setTimeout(() => {
|
|
1149
|
+
if (!win || win.isDestroyed()) return;
|
|
1150
|
+
lastSig = ""; // force a full repaint after reload
|
|
1151
|
+
knownIds = null;
|
|
1152
|
+
win.webContents.reload();
|
|
1153
|
+
}, delay);
|
|
1154
|
+
});
|
|
1061
1155
|
win.webContents.on("did-finish-load", () => resetWindowZoom(win));
|
|
1062
1156
|
win.webContents.on("zoom-changed", (event) => {
|
|
1063
1157
|
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
@@ -1071,7 +1165,8 @@ function createWindow() {
|
|
|
1071
1165
|
}
|
|
1072
1166
|
});
|
|
1073
1167
|
win.once("ready-to-show", () => {
|
|
1074
|
-
//
|
|
1168
|
+
// First paint is instant: relays come from local state.json; the sent + contacts
|
|
1169
|
+
// background loads kicked off here each re-push when they land (buildPayload).
|
|
1075
1170
|
pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
|
|
1076
1171
|
pollHosts();
|
|
1077
1172
|
});
|
|
@@ -1090,6 +1185,11 @@ function createWindow() {
|
|
|
1090
1185
|
.then(() => pushInbox(false))
|
|
1091
1186
|
.catch(() => {});
|
|
1092
1187
|
}, 5000); // live sent-relay refresh
|
|
1188
|
+
setInterval(() => {
|
|
1189
|
+
refreshContacts()
|
|
1190
|
+
.then(() => pushInbox(false))
|
|
1191
|
+
.catch(() => {});
|
|
1192
|
+
}, 60000); // slow contact refresh; the contacts view also refreshes on open
|
|
1093
1193
|
setInterval(pollHosts, 1500);
|
|
1094
1194
|
}
|
|
1095
1195
|
|
|
@@ -1135,7 +1235,9 @@ function createTrayIcon() {
|
|
|
1135
1235
|
return icon && !icon.isEmpty() ? icon : templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
|
|
1136
1236
|
}
|
|
1137
1237
|
|
|
1238
|
+
let lastTrayShowAt = 0;
|
|
1138
1239
|
function showFromTray() {
|
|
1240
|
+
lastTrayShowAt = Date.now();
|
|
1139
1241
|
setDismissed(false);
|
|
1140
1242
|
ghostActive = false;
|
|
1141
1243
|
trayForcedVisible = true; // an explicit click always gets the card
|
|
@@ -1229,6 +1331,10 @@ ipcMain.on("relay:setPos", (_e, x, y) => {
|
|
|
1229
1331
|
// ✕ on the card: hide the overlay entirely until the status-area Relay mark is clicked.
|
|
1230
1332
|
// The renderer plays its exit animation first, then sends this.
|
|
1231
1333
|
ipcMain.on("relay:dismiss", () => {
|
|
1334
|
+
// Ignore only a dismiss whose IPC was already in flight when a tray "Show Relay"
|
|
1335
|
+
// just landed (narrow window). A genuine ✕ click is always honored.
|
|
1336
|
+
if (shouldIgnoreDismiss({ now: Date.now(), lastTrayShowAt })) return;
|
|
1337
|
+
lastTrayShowAt = 0; // honored: clear the guard so it can't linger
|
|
1232
1338
|
if (!trayAvailable) {
|
|
1233
1339
|
setDismissed(false);
|
|
1234
1340
|
ghostActive = false;
|
|
@@ -1284,6 +1390,18 @@ if (!gotSingleInstanceLock) {
|
|
|
1284
1390
|
createWindow();
|
|
1285
1391
|
createTray();
|
|
1286
1392
|
installActiveSpaceWatcher();
|
|
1393
|
+
// Test seam: the e2e harness (test/e2e-overlay.mjs) drives the real state machine
|
|
1394
|
+
// over the main-process inspector. Never set outside the harness.
|
|
1395
|
+
if (process.env.RELAY_OVERLAY_TEST === "1") {
|
|
1396
|
+
global.__relayTest = {
|
|
1397
|
+
showFromTray,
|
|
1398
|
+
maybeShow,
|
|
1399
|
+
refreshOverlayForActiveSpace,
|
|
1400
|
+
pollHosts,
|
|
1401
|
+
getWin: () => win,
|
|
1402
|
+
state: () => ({ dismissed, ghostActive, trayForcedVisible, hostRunning, visible: win && !win.isDestroyed() ? win.isVisible() : null }),
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1287
1405
|
});
|
|
1288
1406
|
|
|
1289
1407
|
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 = () => {} } = {}) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Pure overlay-visibility logic, extracted from main.cjs so the state machine that
|
|
2
|
+
// decides when the pill window is on screen is unit-testable without Electron.
|
|
3
|
+
//
|
|
4
|
+
// The window is wanted when a host app is running (or the user explicitly clicked the
|
|
5
|
+
// status-area icon), unless the user dismissed the pill — with a ghost notification
|
|
6
|
+
// temporarily overriding a dismissal.
|
|
7
|
+
|
|
8
|
+
function overlayWanted({
|
|
9
|
+
hostRunning = false,
|
|
10
|
+
trayForcedVisible = false,
|
|
11
|
+
trayAvailable = false,
|
|
12
|
+
dismissed = false,
|
|
13
|
+
ghostActive = false,
|
|
14
|
+
} = {}) {
|
|
15
|
+
const dismissedEffective = trayAvailable && dismissed;
|
|
16
|
+
return (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Debounced "a host is running" tracker. The OFF transition requires `missThreshold`
|
|
20
|
+
// consecutive polls with neither host present: a host relaunching during its own
|
|
21
|
+
// auto-update (or one truncated process-list read) must not blink the pill off.
|
|
22
|
+
function createHostRunningTracker({ missThreshold = 3 } = {}) {
|
|
23
|
+
let running = false;
|
|
24
|
+
let missStreak = 0;
|
|
25
|
+
return {
|
|
26
|
+
update(claudeRunning, codexRunning) {
|
|
27
|
+
if (claudeRunning || codexRunning) {
|
|
28
|
+
missStreak = 0;
|
|
29
|
+
running = true;
|
|
30
|
+
} else {
|
|
31
|
+
missStreak += 1;
|
|
32
|
+
if (missStreak >= missThreshold) running = false;
|
|
33
|
+
}
|
|
34
|
+
return running;
|
|
35
|
+
},
|
|
36
|
+
get running() {
|
|
37
|
+
return running;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A dismiss whose IPC was ALREADY in flight when a tray "Show Relay" landed would hide
|
|
43
|
+
// the pill the user just reopened. The renderer's cancelBye() prevents the exit timer
|
|
44
|
+
// from firing after a reopen, so this guard only needs to cover the narrow window where
|
|
45
|
+
// the dismiss IPC had already been SENT (~IPC latency). Keep it short — a wide window
|
|
46
|
+
// would swallow a GENUINE ✕ click made shortly after Show Relay. lastTrayShowAt must be
|
|
47
|
+
// reset once a dismiss is honored so the guard never lingers across interactions.
|
|
48
|
+
function shouldIgnoreDismiss({ now, lastTrayShowAt, windowMs = 250 } = {}) {
|
|
49
|
+
if (!lastTrayShowAt) return false;
|
|
50
|
+
const delta = Number(now) - Number(lastTrayShowAt);
|
|
51
|
+
return delta >= 0 && delta < windowMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { overlayWanted, createHostRunningTracker, shouldIgnoreDismiss };
|