relay-companion 0.1.34 → 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 +87 -4
- package/overlay/main.cjs +182 -52
- package/overlay/preload.cjs +1 -0
- 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 +7 -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
|
@@ -452,6 +452,14 @@
|
|
|
452
452
|
.cv-input::placeholder { color:var(--muted-3); }
|
|
453
453
|
.cv-input:focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
|
|
454
454
|
.cv-form-actions { display:flex; align-items:center; justify-content:flex-end; gap:14px; margin-top:1px; }
|
|
455
|
+
.cv-delete {
|
|
456
|
+
appearance:none; border:0; background:transparent; color:#b4332a; font-family:var(--sans);
|
|
457
|
+
font-size:12.5px; font-weight:600; cursor:pointer; transition:opacity .15s var(--settle), color .15s var(--settle);
|
|
458
|
+
margin-right:auto; padding:0;
|
|
459
|
+
}
|
|
460
|
+
.cv-delete:hover { opacity:.72; }
|
|
461
|
+
.cv-delete.armed { color:#8f261f; }
|
|
462
|
+
.cv-delete:disabled { opacity:.4; cursor:default; }
|
|
455
463
|
.cv-cancel { appearance:none; border:0; background:transparent; color:var(--muted); font-family:var(--sans); font-size:12.5px; font-weight:500; cursor:pointer; transition:color .15s var(--settle); }
|
|
456
464
|
.cv-cancel:hover { color:var(--ink-2); }
|
|
457
465
|
.cv-save {
|
|
@@ -601,6 +609,7 @@
|
|
|
601
609
|
<div class="cv-hint" id="cvHint"></div>
|
|
602
610
|
<div class="cv-error" id="cvError"></div>
|
|
603
611
|
<div class="cv-form-actions">
|
|
612
|
+
<button class="cv-delete gone" type="button" id="cvDelete">Delete</button>
|
|
604
613
|
<button class="cv-cancel" type="button" id="cvCancel">Cancel</button>
|
|
605
614
|
<button class="cv-save" type="submit" id="cvSave">Save</button>
|
|
606
615
|
</div>
|
|
@@ -643,6 +652,7 @@
|
|
|
643
652
|
const cvErrorEl = document.getElementById("cvError");
|
|
644
653
|
const cvSaveEl = document.getElementById("cvSave");
|
|
645
654
|
const cvCancelEl = document.getElementById("cvCancel");
|
|
655
|
+
const cvDeleteEl = document.getElementById("cvDelete");
|
|
646
656
|
|
|
647
657
|
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
648
658
|
const EXPANDED = { w: 344, h: 524 };
|
|
@@ -748,6 +758,15 @@
|
|
|
748
758
|
}
|
|
749
759
|
// ✕ — the card leaves gracefully, then the main process hides the window and keeps
|
|
750
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
|
+
}
|
|
751
770
|
function dismissOverlay() {
|
|
752
771
|
if (cardEl.classList.contains("bye")) return;
|
|
753
772
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
@@ -755,7 +774,8 @@
|
|
|
755
774
|
const wasGhost = ghost;
|
|
756
775
|
cardEl.classList.add("bye");
|
|
757
776
|
setInteractive(false);
|
|
758
|
-
setTimeout(() => {
|
|
777
|
+
byeTimer = setTimeout(() => {
|
|
778
|
+
byeTimer = null;
|
|
759
779
|
if (wasGhost) window.relay.notifDone(); // already dismissed; just re-hide the window
|
|
760
780
|
else window.relay.dismiss();
|
|
761
781
|
resetOffstage();
|
|
@@ -766,6 +786,7 @@
|
|
|
766
786
|
// when done it fades away instead of folding back into anything.
|
|
767
787
|
function ghostArrival(row) {
|
|
768
788
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
789
|
+
cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
|
|
769
790
|
clearPeek();
|
|
770
791
|
collapsed = false;
|
|
771
792
|
cardEl.classList.remove("collapsed", "offstage", "bye");
|
|
@@ -790,6 +811,7 @@
|
|
|
790
811
|
// Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
|
|
791
812
|
function trayOpen() {
|
|
792
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
|
|
793
815
|
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
|
|
794
816
|
ghost = false;
|
|
795
817
|
clearPeek();
|
|
@@ -833,8 +855,14 @@
|
|
|
833
855
|
scrollEl.scrollTop = 0;
|
|
834
856
|
springTo(PEEK.w, peekHeight());
|
|
835
857
|
playTink();
|
|
836
|
-
peekTimer = setTimeout(()
|
|
837
|
-
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;
|
|
838
866
|
cardEl.classList.remove("peek");
|
|
839
867
|
collapsed = true; cardEl.classList.add("collapsed");
|
|
840
868
|
springTo(PILL.w, PILL.h);
|
|
@@ -1848,6 +1876,8 @@
|
|
|
1848
1876
|
const COMMON_DOMAINS = ["gmail.com","outlook.com","hotmail.com","yahoo.com","icloud.com","me.com","proton.me","protonmail.com","live.com","aol.com"];
|
|
1849
1877
|
let cvEmailsList = []; // committed, lowercased, deduped addresses (the chips)
|
|
1850
1878
|
let cvSuggIdx = 0; // highlighted suggestion row
|
|
1879
|
+
let cvDeleteArmed = false;
|
|
1880
|
+
let cvDeleteTimer = null;
|
|
1851
1881
|
function isValidEmail(s) { return EMAIL_RE.test(String(s).trim()); }
|
|
1852
1882
|
function cvSuggestions() {
|
|
1853
1883
|
const draft = cvEmailEl.value;
|
|
@@ -1907,6 +1937,21 @@
|
|
|
1907
1937
|
cvEmailsList = cvEmailsList.filter((x) => x !== e);
|
|
1908
1938
|
renderEmailChips();
|
|
1909
1939
|
}
|
|
1940
|
+
function editingContactId() {
|
|
1941
|
+
return cvEditing && (cvEditing.id || cvEditing.contactId) ? String(cvEditing.id || cvEditing.contactId) : "";
|
|
1942
|
+
}
|
|
1943
|
+
function resetDeleteButton() {
|
|
1944
|
+
cvDeleteArmed = false;
|
|
1945
|
+
if (cvDeleteTimer) { clearTimeout(cvDeleteTimer); cvDeleteTimer = null; }
|
|
1946
|
+
cvDeleteEl.textContent = "Delete";
|
|
1947
|
+
cvDeleteEl.classList.remove("armed");
|
|
1948
|
+
cvDeleteEl.disabled = false;
|
|
1949
|
+
}
|
|
1950
|
+
function syncDeleteButton() {
|
|
1951
|
+
resetDeleteButton();
|
|
1952
|
+
const canDelete = Boolean(editingContactId());
|
|
1953
|
+
cvDeleteEl.classList.toggle("gone", !canDelete);
|
|
1954
|
+
}
|
|
1910
1955
|
cvEmailsEl.addEventListener("mousedown", (e) => {
|
|
1911
1956
|
if (e.target === cvEmailsEl) { e.preventDefault(); cvEmailEl.focus(); }
|
|
1912
1957
|
});
|
|
@@ -1944,6 +1989,7 @@
|
|
|
1944
1989
|
cvErrorEl.textContent = "";
|
|
1945
1990
|
cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
|
|
1946
1991
|
renderEmailChips();
|
|
1992
|
+
syncDeleteButton();
|
|
1947
1993
|
cvFormEl.classList.remove("hidden");
|
|
1948
1994
|
if (window.relay.setFocusable) window.relay.setFocusable(true);
|
|
1949
1995
|
setInteractive(true);
|
|
@@ -1957,10 +2003,37 @@
|
|
|
1957
2003
|
cvErrorEl.textContent = "";
|
|
1958
2004
|
cvHintEl.textContent = "";
|
|
1959
2005
|
cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
|
|
2006
|
+
cvDeleteEl.classList.add("gone");
|
|
2007
|
+
resetDeleteButton();
|
|
1960
2008
|
if (window.relay.setFocusable) window.relay.setFocusable(false);
|
|
1961
2009
|
}
|
|
1962
2010
|
cvAddEl.addEventListener("click", () => openContactForm(null));
|
|
1963
2011
|
cvCancelEl.addEventListener("click", closeContactForm);
|
|
2012
|
+
cvDeleteEl.addEventListener("click", async () => {
|
|
2013
|
+
const contactId = editingContactId();
|
|
2014
|
+
if (!contactId || cvDeleteEl.disabled) return;
|
|
2015
|
+
if (!cvDeleteArmed) {
|
|
2016
|
+
cvDeleteArmed = true;
|
|
2017
|
+
cvDeleteEl.textContent = "Delete?";
|
|
2018
|
+
cvDeleteEl.classList.add("armed");
|
|
2019
|
+
cvDeleteTimer = setTimeout(resetDeleteButton, 3500);
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
cvDeleteEl.disabled = true;
|
|
2023
|
+
cvSaveEl.disabled = true;
|
|
2024
|
+
cvErrorEl.textContent = "";
|
|
2025
|
+
try {
|
|
2026
|
+
const next = await window.relay.contactDelete({ contactId });
|
|
2027
|
+
if (Array.isArray(next)) contactsList = next;
|
|
2028
|
+
closeContactForm();
|
|
2029
|
+
renderContacts();
|
|
2030
|
+
} catch (err) {
|
|
2031
|
+
cvErrorEl.textContent = (err && err.message) ? err.message : "Could not delete.";
|
|
2032
|
+
cvDeleteEl.disabled = false;
|
|
2033
|
+
cvSaveEl.disabled = false;
|
|
2034
|
+
resetDeleteButton();
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
1964
2037
|
cvFormEl.addEventListener("submit", async (e) => {
|
|
1965
2038
|
e.preventDefault();
|
|
1966
2039
|
const name = [cvFirstEl.value, cvLastEl.value].map((s) => s.trim()).filter(Boolean).join(" ");
|
|
@@ -1973,7 +2046,8 @@
|
|
|
1973
2046
|
cvErrorEl.textContent = "";
|
|
1974
2047
|
try {
|
|
1975
2048
|
// Match the website's ContactWrite shape: emails[] is the identity, email = primary.
|
|
1976
|
-
const
|
|
2049
|
+
const contactId = editingContactId();
|
|
2050
|
+
const next = await window.relay.contactSave({ contactId, name, emails, email: emails[0] });
|
|
1977
2051
|
if (Array.isArray(next)) contactsList = next;
|
|
1978
2052
|
closeContactForm();
|
|
1979
2053
|
renderContacts();
|
|
@@ -2070,6 +2144,15 @@
|
|
|
2070
2144
|
onPayload(p);
|
|
2071
2145
|
console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
|
|
2072
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);
|
|
2073
2156
|
</script>
|
|
2074
2157
|
</body>
|
|
2075
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,32 +309,48 @@ 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 {
|
|
312
|
-
id: c.id || "",
|
|
328
|
+
id: c.id || c.contactId || "",
|
|
313
329
|
name: c.name || emails[0] || "",
|
|
314
330
|
email: emails[0] || c.email || "",
|
|
315
331
|
emails,
|
|
332
|
+
onRelay: Boolean(c.onRelay),
|
|
316
333
|
updatedAt: c.updatedAt || null,
|
|
317
334
|
};
|
|
318
335
|
})
|
|
319
336
|
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
320
337
|
} catch (error) {
|
|
338
|
+
// Keep the last good cache; a transient network failure must not blank the UI.
|
|
321
339
|
console.error("[overlay] listContacts failed:", error && error.message);
|
|
322
|
-
return [];
|
|
323
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;
|
|
324
350
|
}
|
|
325
351
|
|
|
326
352
|
async function saveContact(input) {
|
|
353
|
+
const contactId = String((input && input.contactId) || "").trim();
|
|
327
354
|
const name = String((input && input.name) || "").trim();
|
|
328
355
|
const emails = Array.isArray(input && input.emails)
|
|
329
356
|
? input.emails.map((e) => String(e || "").trim()).filter(Boolean)
|
|
@@ -333,32 +360,56 @@ async function saveContact(input) {
|
|
|
333
360
|
if (!name) throw new Error("A name is required");
|
|
334
361
|
for (const e of emails) if (!e.includes("@")) throw new Error("That email looks off.");
|
|
335
362
|
const client = await relayClient();
|
|
336
|
-
await client.
|
|
363
|
+
if (contactId) await client.updateContact(contactId, { name, emails, email: emails[0] || "" });
|
|
364
|
+
else await client.upsertContact({ name, emails, email: emails[0] || "" });
|
|
365
|
+
return readContacts();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function deleteContactFromBook(input) {
|
|
369
|
+
const contactId = String((input && input.contactId) || input || "").trim();
|
|
370
|
+
if (!contactId) throw new Error("Missing contact id.");
|
|
371
|
+
const client = await relayClient();
|
|
372
|
+
await client.deleteContact(contactId);
|
|
337
373
|
return readContacts();
|
|
338
374
|
}
|
|
339
375
|
|
|
340
376
|
// ---- payload assembly + push to renderer ---------------------------------
|
|
341
377
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(() => {});
|
|
345
387
|
return {
|
|
346
388
|
account: account(),
|
|
347
389
|
ui: {
|
|
348
390
|
canDismiss: trayAvailable,
|
|
349
391
|
reopenSurface: reopenSurfaceName(),
|
|
350
392
|
},
|
|
351
|
-
relays,
|
|
393
|
+
relays: readRelays(),
|
|
352
394
|
sent: sentCache,
|
|
353
395
|
tasks: [],
|
|
354
|
-
contacts,
|
|
396
|
+
contacts: contactsCache,
|
|
355
397
|
};
|
|
356
398
|
}
|
|
357
399
|
|
|
358
400
|
let knownIds = null; // null until first load, so we never "notify" for rows already present
|
|
359
|
-
|
|
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) {
|
|
360
411
|
if (!win || win.isDestroyed()) return;
|
|
361
|
-
const payload =
|
|
412
|
+
const payload = buildPayload();
|
|
362
413
|
const rows = payload.relays;
|
|
363
414
|
const notifiableRows = visibleRelayRows(rows);
|
|
364
415
|
if (knownIds === null) {
|
|
@@ -382,7 +433,19 @@ async function pushInbox(force) {
|
|
|
382
433
|
}
|
|
383
434
|
}
|
|
384
435
|
const sig = JSON.stringify({
|
|
385
|
-
|
|
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
|
+
]),
|
|
386
449
|
sent: payload.sent.map((r) => [
|
|
387
450
|
r.relayId,
|
|
388
451
|
r.state,
|
|
@@ -394,31 +457,37 @@ async function pushInbox(force) {
|
|
|
394
457
|
contacts: payload.contacts.map((c) => [c.id, c.name, c.email]),
|
|
395
458
|
account: [payload.account.paired, payload.account.email],
|
|
396
459
|
});
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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;
|
|
400
463
|
lastSig = sig;
|
|
401
464
|
if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
|
|
402
465
|
}
|
|
403
466
|
|
|
404
467
|
// Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
|
|
405
468
|
function pushInboxQuiet() {
|
|
406
|
-
pushInbox(false)
|
|
469
|
+
pushInbox(false);
|
|
407
470
|
}
|
|
408
471
|
|
|
409
472
|
// ---- mark-read (write state.json directly) -------------------------------
|
|
410
473
|
|
|
411
474
|
function ackPacket(packetId) {
|
|
412
475
|
if (!packetId) return;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const row =
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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;
|
|
422
491
|
if (row.relayNotificationKind === "plain_relay") {
|
|
423
492
|
relayClient()
|
|
424
493
|
.then((client) => client.markRead(packetId, { idempotencyKey: idempotencyKey("read") }))
|
|
@@ -520,18 +589,20 @@ async function answerQuestion(taskId, messageId, text, packetId) {
|
|
|
520
589
|
}
|
|
521
590
|
|
|
522
591
|
function markQuestionAnswered(packetId, answerMarkdown) {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
+
});
|
|
535
606
|
}
|
|
536
607
|
|
|
537
608
|
function taskVersion(taskId) {
|
|
@@ -701,7 +772,7 @@ function claudeLastFocusedAt(metaPath) {
|
|
|
701
772
|
return 0;
|
|
702
773
|
}
|
|
703
774
|
}
|
|
704
|
-
function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
|
|
775
|
+
function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = false } = {}) {
|
|
705
776
|
const fire = () =>
|
|
706
777
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
707
778
|
if (process.platform !== "darwin") return fire();
|
|
@@ -712,13 +783,20 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label) {
|
|
|
712
783
|
const logOffset = claudeLogSize();
|
|
713
784
|
fire();
|
|
714
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.
|
|
715
793
|
const confirmed = () => {
|
|
716
794
|
if (metaPath) {
|
|
717
795
|
const focusedAt = claudeLastFocusedAt(metaPath);
|
|
718
|
-
//
|
|
719
|
-
// focus write racing our baseline snapshot) — either way Claude has it.
|
|
796
|
+
// A genuine Claude focus advances the timestamp past our pre-fire baseline.
|
|
720
797
|
if (focusedAt > focusBaseline) return true;
|
|
721
|
-
|
|
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;
|
|
722
800
|
}
|
|
723
801
|
return claudeLogSawResume(sessionId, logOffset);
|
|
724
802
|
};
|
|
@@ -792,16 +870,18 @@ function openPacket(packetId) {
|
|
|
792
870
|
child.on("exit", (code) => {
|
|
793
871
|
let url = null;
|
|
794
872
|
let skipExternalOpen = false;
|
|
873
|
+
let freshlyForged = false;
|
|
795
874
|
try {
|
|
796
875
|
const parsed = JSON.parse(out.trim());
|
|
797
876
|
url = parsed.url || null;
|
|
798
877
|
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
878
|
+
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
799
879
|
} catch {}
|
|
800
880
|
if (url || skipExternalOpen) {
|
|
801
881
|
if (url && !skipExternalOpen) {
|
|
802
882
|
const webFallback = row && row.taskId ? taskWebUrl(row.taskId) : row && row.actionUrl ? absoluteUrl(row.actionUrl) : webBase();
|
|
803
883
|
if (claudeSessionIdFromUrl(url)) {
|
|
804
|
-
openClaudeDeepLinkVerified(url, webFallback, packetId);
|
|
884
|
+
openClaudeDeepLinkVerified(url, webFallback, packetId, { freshlyForged });
|
|
805
885
|
} else {
|
|
806
886
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
807
887
|
}
|
|
@@ -864,15 +944,17 @@ function openTaskDetail(taskId) {
|
|
|
864
944
|
child.on("exit", (code) => {
|
|
865
945
|
let url = null;
|
|
866
946
|
let skipExternalOpen = false;
|
|
947
|
+
let freshlyForged = false;
|
|
867
948
|
try {
|
|
868
949
|
const parsed = JSON.parse(out.trim());
|
|
869
950
|
url = parsed.url || null;
|
|
870
951
|
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
952
|
+
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
871
953
|
} catch {}
|
|
872
954
|
if (url || skipExternalOpen) {
|
|
873
955
|
if (url && !skipExternalOpen) {
|
|
874
956
|
if (claudeSessionIdFromUrl(url)) {
|
|
875
|
-
openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId);
|
|
957
|
+
openClaudeDeepLinkVerified(url, taskWebUrl(taskId), taskId, { freshlyForged });
|
|
876
958
|
} else {
|
|
877
959
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
878
960
|
}
|
|
@@ -942,16 +1024,21 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
|
|
|
942
1024
|
}
|
|
943
1025
|
placeOverlayWindow({ reposition });
|
|
944
1026
|
const shown = showInactiveOnAllSpaces(win, { force });
|
|
945
|
-
|
|
946
|
-
|
|
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
|
+
}
|
|
947
1035
|
}
|
|
948
1036
|
|
|
949
1037
|
function maybeShow({ force = false, reposition = true } = {}) {
|
|
950
1038
|
if (!win || win.isDestroyed()) return;
|
|
951
1039
|
// Dismissed keeps the window hidden except while a ghost notification is up.
|
|
952
1040
|
// trayForcedVisible honors an explicit status-area click even when no host runs.
|
|
953
|
-
const
|
|
954
|
-
const wanted = (hostRunning || trayForcedVisible) && (!dismissedEffective || ghostActive);
|
|
1041
|
+
const wanted = overlayWanted({ hostRunning, trayForcedVisible, trayAvailable, dismissed, ghostActive });
|
|
955
1042
|
if (wanted) {
|
|
956
1043
|
showOverlayWindow({ force, reposition });
|
|
957
1044
|
} else if (win.isVisible()) {
|
|
@@ -977,6 +1064,10 @@ function readHostProcesses(cb) {
|
|
|
977
1064
|
execFile("/bin/ps", ["-Ao", "comm"], cb);
|
|
978
1065
|
}
|
|
979
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 });
|
|
980
1071
|
function updateHostRunningFromProcesses(text) {
|
|
981
1072
|
const s = String(text || "");
|
|
982
1073
|
if (process.platform === "darwin") {
|
|
@@ -992,7 +1083,7 @@ function updateHostRunningFromProcesses(text) {
|
|
|
992
1083
|
claudeRunning = /(^|\n)Claude(\n|$)/.test(s) || /(^|\n)claude(\n|$)/.test(s);
|
|
993
1084
|
codexRunning = /(^|\n)Codex(\n|$)/.test(s) || /(^|\n)codex(\n|$)/.test(s);
|
|
994
1085
|
}
|
|
995
|
-
hostRunning = claudeRunning
|
|
1086
|
+
hostRunning = hostTracker.update(claudeRunning, codexRunning);
|
|
996
1087
|
}
|
|
997
1088
|
|
|
998
1089
|
function pollHosts() {
|
|
@@ -1046,7 +1137,21 @@ function createWindow() {
|
|
|
1046
1137
|
console.error("[renderer]", d);
|
|
1047
1138
|
});
|
|
1048
1139
|
win.webContents.on("preload-error", (_e, p, err) => console.error("[preload-error]", p, err && err.message));
|
|
1049
|
-
|
|
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
|
+
});
|
|
1050
1155
|
win.webContents.on("did-finish-load", () => resetWindowZoom(win));
|
|
1051
1156
|
win.webContents.on("zoom-changed", (event) => {
|
|
1052
1157
|
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
@@ -1060,7 +1165,8 @@ function createWindow() {
|
|
|
1060
1165
|
}
|
|
1061
1166
|
});
|
|
1062
1167
|
win.once("ready-to-show", () => {
|
|
1063
|
-
//
|
|
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).
|
|
1064
1170
|
pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
|
|
1065
1171
|
pollHosts();
|
|
1066
1172
|
});
|
|
@@ -1079,6 +1185,11 @@ function createWindow() {
|
|
|
1079
1185
|
.then(() => pushInbox(false))
|
|
1080
1186
|
.catch(() => {});
|
|
1081
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
|
|
1082
1193
|
setInterval(pollHosts, 1500);
|
|
1083
1194
|
}
|
|
1084
1195
|
|
|
@@ -1124,7 +1235,9 @@ function createTrayIcon() {
|
|
|
1124
1235
|
return icon && !icon.isEmpty() ? icon : templateIcon && !templateIcon.isEmpty() ? templateIcon : null;
|
|
1125
1236
|
}
|
|
1126
1237
|
|
|
1238
|
+
let lastTrayShowAt = 0;
|
|
1127
1239
|
function showFromTray() {
|
|
1240
|
+
lastTrayShowAt = Date.now();
|
|
1128
1241
|
setDismissed(false);
|
|
1129
1242
|
ghostActive = false;
|
|
1130
1243
|
trayForcedVisible = true; // an explicit click always gets the card
|
|
@@ -1188,6 +1301,7 @@ ipcMain.handle("relay:decline", (_e, taskId, approvalId) => declineShare(taskId,
|
|
|
1188
1301
|
ipcMain.handle("relay:answer", (_e, taskId, messageId, text, packetId) => answerQuestion(taskId, messageId, text, packetId));
|
|
1189
1302
|
ipcMain.handle("relay:contacts", () => readContacts());
|
|
1190
1303
|
ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
|
|
1304
|
+
ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
|
|
1191
1305
|
|
|
1192
1306
|
// The overlay is normally focusable:false so it never steals keyboard focus from
|
|
1193
1307
|
// Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
|
|
@@ -1217,6 +1331,10 @@ ipcMain.on("relay:setPos", (_e, x, y) => {
|
|
|
1217
1331
|
// ✕ on the card: hide the overlay entirely until the status-area Relay mark is clicked.
|
|
1218
1332
|
// The renderer plays its exit animation first, then sends this.
|
|
1219
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
|
|
1220
1338
|
if (!trayAvailable) {
|
|
1221
1339
|
setDismissed(false);
|
|
1222
1340
|
ghostActive = false;
|
|
@@ -1272,6 +1390,18 @@ if (!gotSingleInstanceLock) {
|
|
|
1272
1390
|
createWindow();
|
|
1273
1391
|
createTray();
|
|
1274
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
|
+
}
|
|
1275
1405
|
});
|
|
1276
1406
|
|
|
1277
1407
|
app.on("window-all-closed", () => {
|
package/overlay/preload.cjs
CHANGED
|
@@ -32,6 +32,7 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
32
32
|
// contacts
|
|
33
33
|
contacts: () => ipcRenderer.invoke("relay:contacts"),
|
|
34
34
|
contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
|
|
35
|
+
contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
|
|
35
36
|
|
|
36
37
|
// window plumbing
|
|
37
38
|
setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),
|