relay-companion 0.1.70 → 0.1.72
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 +5 -2
- package/overlay/inbox.html +288 -0
- package/overlay/main.cjs +343 -47
- package/overlay/preload.cjs +8 -0
- package/overlay/space-presence.cjs +32 -14
- package/package.json +1 -1
- package/src/account.js +65 -0
- package/src/config.js +14 -6
- package/src/install.js +23 -3
package/bin/relay.js
CHANGED
|
@@ -28,6 +28,7 @@ import { pillStatusPath, waitForPillReady } from "../src/pill-control.js";
|
|
|
28
28
|
import { liveToolRequirement, requiredLiveHosts, shouldRequireLiveTools } from "../src/setup-activation.js";
|
|
29
29
|
import { openRelay, openTask } from "../src/materializer.js";
|
|
30
30
|
import { runClaudeHook } from "../src/claude-hook.js";
|
|
31
|
+
import { normalizePairingCode, persistPairedAccount } from "../src/account.js";
|
|
31
32
|
import { resetCompanionStateForAccount } from "../src/notifications.js";
|
|
32
33
|
import { finishSetupOpenRelay, normalizeSetupHost, setupOpenRelayToken, setupOpenStatus } from "../src/setup-open.js";
|
|
33
34
|
|
|
@@ -95,8 +96,10 @@ async function cmdPair(flags, { promptForDefaults = true } = {}) {
|
|
|
95
96
|
}
|
|
96
97
|
writeConfig({ apiUrl: url, webUrl: appUrl });
|
|
97
98
|
const client = new RelayClient({ url });
|
|
98
|
-
const res = await client.registerDevice({ pairingCode: code, name, platform: process.platform });
|
|
99
|
-
|
|
99
|
+
const res = await client.registerDevice({ pairingCode: normalizePairingCode(code), name, platform: process.platform });
|
|
100
|
+
// The same persistence the pill's Settings tab uses (src/account.js), so the
|
|
101
|
+
// stored credential shape can never drift between the two pairing surfaces.
|
|
102
|
+
persistPairedAccount({ apiUrl: url, webUrl: appUrl, deviceName: name, registration: res });
|
|
100
103
|
resetCompanionStateForAccount({ user: res.user, deviceId: res.deviceId });
|
|
101
104
|
console.log(`Paired as ${res.user.name} <${res.user.email}>. This device is now connected to Relay.`);
|
|
102
105
|
}
|
package/overlay/inbox.html
CHANGED
|
@@ -647,6 +647,62 @@
|
|
|
647
647
|
}
|
|
648
648
|
.sent-chip.read { background:rgba(46,160,67,.14); color:#1a7f37; border:1px solid rgba(46,160,67,.28); }
|
|
649
649
|
.sent-chip.unread { background:transparent; color:var(--muted-2); border:1px solid rgba(31,26,23,.18); }
|
|
650
|
+
|
|
651
|
+
/* ---- Settings tab ---- */
|
|
652
|
+
/* The account block is flat content on the sheet, like rows: serif identity,
|
|
653
|
+
sans metadata, a tinted avatar disc. Depth only ever comes from tint and
|
|
654
|
+
the reused .open-actions light-built panel — never from borders. */
|
|
655
|
+
.sv-head { display:flex; align-items:center; justify-content:space-between; padding:10px 16px 8px; }
|
|
656
|
+
.sv-title { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); }
|
|
657
|
+
.sv-account { display:flex; align-items:center; gap:12px; padding:4px 16px 0; }
|
|
658
|
+
.sv-avatar {
|
|
659
|
+
flex:0 0 auto; width:38px; height:38px; border-radius:50%;
|
|
660
|
+
background:var(--accent-soft); color:var(--accent);
|
|
661
|
+
font-size:13px; font-weight:600; letter-spacing:.02em;
|
|
662
|
+
display:flex; align-items:center; justify-content:center;
|
|
663
|
+
}
|
|
664
|
+
.sv-id { min-width:0; display:flex; flex-direction:column; }
|
|
665
|
+
.sv-name { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
666
|
+
.sv-email { font-size:11.5px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
667
|
+
.sv-device { font-size:11px; color:var(--muted-2); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
668
|
+
.sv-copy { padding:2px 16px 0; font-size:12px; line-height:1.5; color:var(--muted); }
|
|
669
|
+
.sv-actions { margin:14px 12px 0; }
|
|
670
|
+
/* armed sign-out: the same flat menu row, flipped to the danger ink */
|
|
671
|
+
.oa-item.sv-signout-armed { color:#b4332a; }
|
|
672
|
+
.oa-item.sv-signout-armed:hover { background:rgba(180,51,42,.07); }
|
|
673
|
+
.oa-item.sv-signout-armed:active { background:rgba(180,51,42,.12); }
|
|
674
|
+
/* inline pairing-code entry: mono, tracked, uppercase — the code is the content */
|
|
675
|
+
.sv-pair { display:flex; gap:8px; margin:12px 16px 0; align-items:center; }
|
|
676
|
+
.sv-code {
|
|
677
|
+
flex:1 1 auto; min-width:0; appearance:none;
|
|
678
|
+
border:1px solid rgba(31,26,23,.14); border-radius:9px; padding:8px 11px;
|
|
679
|
+
font-family:var(--mono); font-size:13px; letter-spacing:.14em; text-transform:uppercase;
|
|
680
|
+
color:var(--ink); background:#fff; outline:none;
|
|
681
|
+
transition:border-color .15s var(--settle), box-shadow .15s var(--settle);
|
|
682
|
+
}
|
|
683
|
+
.sv-code::placeholder { font-family:var(--sans); letter-spacing:0; text-transform:none; color:var(--muted-3); }
|
|
684
|
+
.sv-code:focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
|
|
685
|
+
.sv-connect {
|
|
686
|
+
flex:0 0 auto; appearance:none; border:0; background:var(--accent); color:#fff;
|
|
687
|
+
font-family:var(--sans); font-size:12.5px; font-weight:600; padding:8px 15px; border-radius:9px;
|
|
688
|
+
cursor:pointer; transition:opacity .2s var(--settle);
|
|
689
|
+
}
|
|
690
|
+
.sv-connect:hover { opacity:.9; }
|
|
691
|
+
.sv-connect:disabled { opacity:.4; cursor:default; }
|
|
692
|
+
.sv-hint { padding:7px 16px 0; font-size:11px; line-height:1.45; color:var(--muted-2); }
|
|
693
|
+
.sv-err { margin-top:8px; padding:0 16px; }
|
|
694
|
+
.sv-note { padding:10px 16px 0; font-size:11.5px; color:#1a7f37; line-height:1.4; }
|
|
695
|
+
.sv-version { padding:20px 16px 12px; font-family:var(--mono); font-size:10.5px; color:var(--muted-3); }
|
|
696
|
+
/* signed-out call-to-action on the Relays empty state — quiet filled pill (th-count language) */
|
|
697
|
+
.sv-signin-banner {
|
|
698
|
+
appearance:none; border:0; cursor:pointer; margin-top:2px;
|
|
699
|
+
font-family:var(--sans); font-size:12px; font-weight:600; letter-spacing:.01em;
|
|
700
|
+
color:var(--accent); background:color-mix(in srgb, var(--accent) 9%, transparent);
|
|
701
|
+
box-shadow:inset 0 0 0 0.5px color-mix(in srgb, var(--accent) 14%, transparent);
|
|
702
|
+
border-radius:999px; padding:7px 15px;
|
|
703
|
+
transition:background-color .2s var(--settle);
|
|
704
|
+
}
|
|
705
|
+
.sv-signin-banner:hover { background:color-mix(in srgb, var(--accent) 15%, transparent); }
|
|
650
706
|
</style>
|
|
651
707
|
</head>
|
|
652
708
|
<body>
|
|
@@ -684,6 +740,7 @@
|
|
|
684
740
|
</button>
|
|
685
741
|
<button class="tab" type="button" data-view="sent">Sent</button>
|
|
686
742
|
<button class="tab" type="button" data-view="contacts">Contacts</button>
|
|
743
|
+
<button class="tab" type="button" data-view="settings">Settings</button>
|
|
687
744
|
<button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
|
|
688
745
|
</nav>
|
|
689
746
|
|
|
@@ -697,6 +754,7 @@
|
|
|
697
754
|
</svg>
|
|
698
755
|
<div class="t1">No relays yet</div>
|
|
699
756
|
<div class="t2">Questions, results, and notices will land here.</div>
|
|
757
|
+
<button class="sv-signin-banner gone" id="signInBanner" type="button">Sign in to Relay</button>
|
|
700
758
|
</div>
|
|
701
759
|
</section>
|
|
702
760
|
|
|
@@ -767,6 +825,9 @@
|
|
|
767
825
|
<div class="cv-list" id="cvList"></div>
|
|
768
826
|
<div id="contactsEmpty" class="cv-empty gone">No contacts yet.<br>Add someone, or your agent saves them as you relay.</div>
|
|
769
827
|
</section>
|
|
828
|
+
|
|
829
|
+
<!-- Settings view (account card + sign-out / switch-account; rendered by JS) -->
|
|
830
|
+
<section class="view hidden" id="settingsView"></section>
|
|
770
831
|
</div>
|
|
771
832
|
</div>
|
|
772
833
|
|
|
@@ -811,6 +872,8 @@
|
|
|
811
872
|
const thBackEl = document.getElementById("thBack");
|
|
812
873
|
const thDetailNameEl = document.getElementById("thDetailName");
|
|
813
874
|
const thHistoryEl = document.getElementById("thHistory");
|
|
875
|
+
const settingsViewEl = document.getElementById("settingsView");
|
|
876
|
+
const signInBannerEl = document.getElementById("signInBanner");
|
|
814
877
|
|
|
815
878
|
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
816
879
|
const EXPANDED = { w: 344, h: 524 };
|
|
@@ -1432,6 +1495,7 @@
|
|
|
1432
1495
|
}
|
|
1433
1496
|
function openRelayFromUI(id, source = "relay", mode = "open") {
|
|
1434
1497
|
if (!id || openingIds.has(id)) return;
|
|
1498
|
+
clearRowNote(id); // a previous open's failure text must not outlive the retry
|
|
1435
1499
|
openingIds.set(id, performance.now());
|
|
1436
1500
|
for (const el of openingTargets(id)) el.classList.add("opening");
|
|
1437
1501
|
setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
|
|
@@ -1634,6 +1698,14 @@
|
|
|
1634
1698
|
if (el) el.textContent = message || "";
|
|
1635
1699
|
}
|
|
1636
1700
|
function cssId(id) { return String(id).replace(/"/g, '\\"'); }
|
|
1701
|
+
// The .row-err slot carries both inline errors and the green injection note, and one
|
|
1702
|
+
// relay can be rendered into more than one list, so wipe every copy of the row's slot.
|
|
1703
|
+
function clearRowNote(id) {
|
|
1704
|
+
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
|
|
1705
|
+
el.textContent = "";
|
|
1706
|
+
el.classList.remove("ok");
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1637
1709
|
|
|
1638
1710
|
async function runRowAction(id, btn, fn) {
|
|
1639
1711
|
if (!btn || btn.disabled) return;
|
|
@@ -2573,6 +2645,201 @@
|
|
|
2573
2645
|
applyView();
|
|
2574
2646
|
}
|
|
2575
2647
|
|
|
2648
|
+
// ---------- Settings view ----------
|
|
2649
|
+
// The account card + sign-out / switch-account lifecycle. Renders from its own
|
|
2650
|
+
// relay:accountInfo load (like Contacts), never from inbox pushes, so a typed
|
|
2651
|
+
// pairing code is never rebuilt away mid-entry. Switch/sign-out succeed by
|
|
2652
|
+
// RESTARTING the pill into the new account, so the success note is brief.
|
|
2653
|
+
let settingsInfo = null; // last relay:accountInfo payload (null while loading)
|
|
2654
|
+
let settingsLoadSeq = 0;
|
|
2655
|
+
let svPairOpen = false; // inline code-entry row revealed
|
|
2656
|
+
let svDraft = ""; // pairing-code text preserved across re-renders
|
|
2657
|
+
let svBusy = false; // pair / sign-out in flight
|
|
2658
|
+
let svNote = ""; // success note while the pill restarts
|
|
2659
|
+
let svError = "";
|
|
2660
|
+
let svSignOutArmed = false;
|
|
2661
|
+
let svSignOutTimer = null;
|
|
2662
|
+
let svFocusCodeOnce = false;
|
|
2663
|
+
|
|
2664
|
+
function resetSignOutArm() {
|
|
2665
|
+
svSignOutArmed = false;
|
|
2666
|
+
if (svSignOutTimer) { clearTimeout(svSignOutTimer); svSignOutTimer = null; }
|
|
2667
|
+
}
|
|
2668
|
+
function svPairRowHtml() {
|
|
2669
|
+
return `
|
|
2670
|
+
<div class="sv-pair" data-stop="1">
|
|
2671
|
+
<input class="sv-code" id="svCode" type="text" inputmode="text" autocomplete="off" spellcheck="false"
|
|
2672
|
+
maxlength="16" placeholder="Pairing code" value="${esc(svDraft)}" ${svBusy ? "disabled" : ""} />
|
|
2673
|
+
<button class="sv-connect" type="button" id="svConnect" ${svBusy ? "disabled" : ""}>${svBusy ? "Connecting…" : "Connect"}</button>
|
|
2674
|
+
</div>
|
|
2675
|
+
<div class="sv-hint">Your browser opened the Relay setup page — paste the pairing code it shows.</div>`;
|
|
2676
|
+
}
|
|
2677
|
+
function renderSettings() {
|
|
2678
|
+
if (activeView !== "settings") return;
|
|
2679
|
+
const info = settingsInfo;
|
|
2680
|
+
if (!info) {
|
|
2681
|
+
settingsViewEl.innerHTML = '<div class="td-quiet">Loading account…</div>';
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
let html = "";
|
|
2685
|
+
if (info.paired) {
|
|
2686
|
+
const name = info.name || info.email || "Relay account";
|
|
2687
|
+
html += `
|
|
2688
|
+
<div class="sv-head"><span class="sv-title">Your account.</span></div>
|
|
2689
|
+
<div class="sv-account">
|
|
2690
|
+
<span class="sv-avatar">${esc(cvInitials(info.name, info.email))}</span>
|
|
2691
|
+
<span class="sv-id">
|
|
2692
|
+
<span class="sv-name">${esc(name)}</span>
|
|
2693
|
+
${info.email ? `<span class="sv-email">${esc(info.email)}</span>` : ""}
|
|
2694
|
+
<span class="sv-device">This device · ${esc(info.deviceName || "")}</span>
|
|
2695
|
+
</span>
|
|
2696
|
+
</div>
|
|
2697
|
+
<div class="open-actions sv-actions" data-stop="1" role="menu">
|
|
2698
|
+
<button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Switch Account…</button>
|
|
2699
|
+
<div class="oa-sep" role="separator"></div>
|
|
2700
|
+
<button class="oa-item${svSignOutArmed ? " sv-signout-armed" : ""}" type="button" role="menuitem" id="svSignOut" ${svBusy ? "disabled" : ""}>${svSignOutArmed ? "Sign Out — click again to confirm" : "Sign Out"}</button>
|
|
2701
|
+
</div>`;
|
|
2702
|
+
} else {
|
|
2703
|
+
html += `
|
|
2704
|
+
<div class="sv-head"><span class="sv-title">Sign in to Relay.</span></div>
|
|
2705
|
+
<div class="sv-copy">This device isn’t connected to an account yet. Sign in from your browser, then enter the pairing code here.</div>
|
|
2706
|
+
<div class="open-actions sv-actions" data-stop="1" role="menu">
|
|
2707
|
+
<button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Sign In to Relay</button>
|
|
2708
|
+
</div>`;
|
|
2709
|
+
}
|
|
2710
|
+
if (svPairOpen) html += svPairRowHtml();
|
|
2711
|
+
if (svNote) html += `<div class="sv-note">${esc(svNote)}</div>`;
|
|
2712
|
+
html += `<div class="row-err sv-err" id="svErr">${esc(svError)}</div>`;
|
|
2713
|
+
html += `<div class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</div>`;
|
|
2714
|
+
settingsViewEl.innerHTML = html;
|
|
2715
|
+
wireSettings();
|
|
2716
|
+
}
|
|
2717
|
+
function wireSettings() {
|
|
2718
|
+
for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
|
|
2719
|
+
z.addEventListener("click", (e) => e.stopPropagation());
|
|
2720
|
+
}
|
|
2721
|
+
const switchEl = document.getElementById("svSwitch");
|
|
2722
|
+
if (switchEl) switchEl.addEventListener("click", beginPairFlow);
|
|
2723
|
+
const signOutEl = document.getElementById("svSignOut");
|
|
2724
|
+
if (signOutEl) signOutEl.addEventListener("click", onSignOutClick);
|
|
2725
|
+
const connectEl = document.getElementById("svConnect");
|
|
2726
|
+
if (connectEl) connectEl.addEventListener("click", submitPairCode);
|
|
2727
|
+
const codeEl = document.getElementById("svCode");
|
|
2728
|
+
if (codeEl) {
|
|
2729
|
+
// Same focusability contract as the contact form / quick reply: the window
|
|
2730
|
+
// may take keyboard focus only while a text field needs it.
|
|
2731
|
+
codeEl.addEventListener("focus", () => { if (window.relay.setFocusable) window.relay.setFocusable(true); });
|
|
2732
|
+
codeEl.addEventListener("blur", () => { if (activeView !== "contacts" && window.relay.setFocusable) window.relay.setFocusable(false); });
|
|
2733
|
+
codeEl.addEventListener("mousedown", () => setInteractive(true));
|
|
2734
|
+
codeEl.addEventListener("input", () => {
|
|
2735
|
+
svDraft = codeEl.value;
|
|
2736
|
+
svError = "";
|
|
2737
|
+
const errEl = document.getElementById("svErr");
|
|
2738
|
+
if (errEl) errEl.textContent = ""; // clear inline, no rebuild mid-typing
|
|
2739
|
+
});
|
|
2740
|
+
codeEl.addEventListener("keydown", (ev) => {
|
|
2741
|
+
if (ev.key === "Enter") { ev.preventDefault(); submitPairCode(); }
|
|
2742
|
+
});
|
|
2743
|
+
if (svFocusCodeOnce) {
|
|
2744
|
+
svFocusCodeOnce = false;
|
|
2745
|
+
if (window.relay.setFocusable) window.relay.setFocusable(true);
|
|
2746
|
+
setTimeout(() => codeEl.focus(), 30);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
// Switch account / sign in: open the pairing page in the browser and reveal
|
|
2751
|
+
// the inline code-entry row in the same motion.
|
|
2752
|
+
function beginPairFlow() {
|
|
2753
|
+
if (svBusy) return;
|
|
2754
|
+
resetSignOutArm();
|
|
2755
|
+
svError = "";
|
|
2756
|
+
svNote = "";
|
|
2757
|
+
svPairOpen = true;
|
|
2758
|
+
svFocusCodeOnce = true;
|
|
2759
|
+
if (window.relay.openUrl) window.relay.openUrl((settingsInfo && settingsInfo.setupPath) || "/app/setup");
|
|
2760
|
+
renderSettings();
|
|
2761
|
+
}
|
|
2762
|
+
async function submitPairCode() {
|
|
2763
|
+
if (svBusy) return;
|
|
2764
|
+
const codeEl = document.getElementById("svCode");
|
|
2765
|
+
const code = (codeEl ? codeEl.value : svDraft).trim();
|
|
2766
|
+
if (!code) {
|
|
2767
|
+
svError = "Enter the pairing code from your browser.";
|
|
2768
|
+
renderSettings();
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
svBusy = true;
|
|
2772
|
+
svError = "";
|
|
2773
|
+
renderSettings();
|
|
2774
|
+
let res = null;
|
|
2775
|
+
try {
|
|
2776
|
+
res = await window.relay.pairWithCode({ code });
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
res = { ok: false, error: (err && err.message) || "Could not connect this device." };
|
|
2779
|
+
}
|
|
2780
|
+
svBusy = false;
|
|
2781
|
+
if (res && res.ok) {
|
|
2782
|
+
svDraft = "";
|
|
2783
|
+
svPairOpen = false;
|
|
2784
|
+
svNote = res.email ? `Connected as ${res.email} — restarting Relay…` : "Connected — restarting Relay…";
|
|
2785
|
+
} else {
|
|
2786
|
+
svError = (res && res.error) || "Could not connect this device.";
|
|
2787
|
+
}
|
|
2788
|
+
renderSettings();
|
|
2789
|
+
}
|
|
2790
|
+
// Sign out is destructive-ish (this device stops receiving relays), so the
|
|
2791
|
+
// button arms on the first click and only a second click within 4s commits.
|
|
2792
|
+
async function onSignOutClick() {
|
|
2793
|
+
if (svBusy) return;
|
|
2794
|
+
if (!svSignOutArmed) {
|
|
2795
|
+
svSignOutArmed = true;
|
|
2796
|
+
if (svSignOutTimer) clearTimeout(svSignOutTimer);
|
|
2797
|
+
svSignOutTimer = setTimeout(() => {
|
|
2798
|
+
svSignOutTimer = null;
|
|
2799
|
+
svSignOutArmed = false;
|
|
2800
|
+
renderSettings();
|
|
2801
|
+
}, 4000);
|
|
2802
|
+
renderSettings();
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
resetSignOutArm();
|
|
2806
|
+
svBusy = true;
|
|
2807
|
+
svError = "";
|
|
2808
|
+
svNote = "";
|
|
2809
|
+
renderSettings();
|
|
2810
|
+
let res = null;
|
|
2811
|
+
try {
|
|
2812
|
+
res = await window.relay.signOut();
|
|
2813
|
+
} catch (err) {
|
|
2814
|
+
res = { ok: false, error: (err && err.message) || "Could not sign out." };
|
|
2815
|
+
}
|
|
2816
|
+
svBusy = false;
|
|
2817
|
+
if (res && res.ok) svNote = "Signed out — restarting Relay…";
|
|
2818
|
+
else svError = (res && res.error) || "Could not sign out.";
|
|
2819
|
+
renderSettings();
|
|
2820
|
+
}
|
|
2821
|
+
async function loadSettings() {
|
|
2822
|
+
const seq = ++settingsLoadSeq;
|
|
2823
|
+
let info = null;
|
|
2824
|
+
try {
|
|
2825
|
+
info = window.relay.accountInfo ? await window.relay.accountInfo() : null;
|
|
2826
|
+
} catch {}
|
|
2827
|
+
if (seq !== settingsLoadSeq) return; // navigated / reloaded while fetching
|
|
2828
|
+
const prevSig = JSON.stringify(settingsInfo);
|
|
2829
|
+
settingsInfo = info && info.ok !== false ? info : { paired: false, name: "", email: "", deviceName: "", version: "" };
|
|
2830
|
+
// Unchanged info: leave the DOM alone so a focused code input (and its
|
|
2831
|
+
// caret) survives the refresh that happens on every visit to the tab.
|
|
2832
|
+
if (JSON.stringify(settingsInfo) !== prevSig) renderSettings();
|
|
2833
|
+
}
|
|
2834
|
+
if (signInBannerEl) signInBannerEl.addEventListener("click", (e) => {
|
|
2835
|
+
e.stopPropagation();
|
|
2836
|
+
activeView = "settings";
|
|
2837
|
+
syncTabs();
|
|
2838
|
+
applyView();
|
|
2839
|
+
renderAll();
|
|
2840
|
+
beginPairFlow(); // the banner IS the sign-in affordance: browser + code row
|
|
2841
|
+
});
|
|
2842
|
+
|
|
2576
2843
|
// ---------- views / tabs ----------
|
|
2577
2844
|
function syncTabs() {
|
|
2578
2845
|
// taskDetail belongs to Sent; the thread-detail view belongs to Relays —
|
|
@@ -2586,6 +2853,13 @@
|
|
|
2586
2853
|
sentViewEl.classList.toggle("hidden", activeView !== "sent");
|
|
2587
2854
|
taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
|
|
2588
2855
|
contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
|
|
2856
|
+
settingsViewEl.classList.toggle("hidden", activeView !== "settings");
|
|
2857
|
+
if (activeView === "settings") {
|
|
2858
|
+
renderSettings(); // paint immediately from the last known info…
|
|
2859
|
+
loadSettings(); // …then refresh it from config
|
|
2860
|
+
} else {
|
|
2861
|
+
resetSignOutArm(); // leaving the view disarms the confirm state
|
|
2862
|
+
}
|
|
2589
2863
|
if (activeView === "threads") {
|
|
2590
2864
|
// Entered only by clicking a conversation row (threadDetailId set there).
|
|
2591
2865
|
renderThreads();
|
|
@@ -2621,6 +2895,10 @@
|
|
|
2621
2895
|
|
|
2622
2896
|
function renderAll() {
|
|
2623
2897
|
syncDismissControl();
|
|
2898
|
+
// Signed out: the Relays empty state carries a slim sign-in affordance.
|
|
2899
|
+
if (signInBannerEl) {
|
|
2900
|
+
signInBannerEl.classList.toggle("gone", !payload.account || payload.account.paired !== false);
|
|
2901
|
+
}
|
|
2624
2902
|
// Relays badge/count reflect only the items that show in the Relays tab
|
|
2625
2903
|
// (the clean split keeps task_request / share_approval out of this count).
|
|
2626
2904
|
const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
|
|
@@ -2711,6 +2989,16 @@
|
|
|
2711
2989
|
}, 12000));
|
|
2712
2990
|
}
|
|
2713
2991
|
});
|
|
2992
|
+
// An open that failed leaves the relay UNREAD on purpose (it never reached the user),
|
|
2993
|
+
// so the row has to say what happened — otherwise the spinner just stops and the click
|
|
2994
|
+
// looks like it worked. Cleared by the next open of the same row.
|
|
2995
|
+
if (window.relay.onOpenError) window.relay.onOpenError((id, message) => {
|
|
2996
|
+
if (!message) return;
|
|
2997
|
+
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
|
|
2998
|
+
el.classList.remove("ok");
|
|
2999
|
+
el.innerHTML = esc(message);
|
|
3000
|
+
}
|
|
3001
|
+
});
|
|
2714
3002
|
window.relay.refresh().then((p) => {
|
|
2715
3003
|
onPayload(p);
|
|
2716
3004
|
console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
|
package/overlay/main.cjs
CHANGED
|
@@ -228,6 +228,24 @@ function loadRelayModules() {
|
|
|
228
228
|
return relayModulesPromise;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// Account lifecycle deps (ESM, lazy like the modules above): src/account.js is
|
|
232
|
+
// the shared pair/sign-out config persistence, src/notifications.js owns the
|
|
233
|
+
// packet-store reset that re-stages the new account's inbox cleanly.
|
|
234
|
+
let accountModulesPromise = null;
|
|
235
|
+
function loadAccountModules() {
|
|
236
|
+
if (!accountModulesPromise) {
|
|
237
|
+
const accountUrl = pathToFileURL(path.join(__dirname, "..", "src", "account.js")).href;
|
|
238
|
+
const notificationsUrl = pathToFileURL(path.join(__dirname, "..", "src", "notifications.js")).href;
|
|
239
|
+
accountModulesPromise = Promise.all([import(accountUrl), import(notificationsUrl)])
|
|
240
|
+
.then(([account, notifications]) => ({ account, notifications }))
|
|
241
|
+
.catch((error) => {
|
|
242
|
+
accountModulesPromise = null;
|
|
243
|
+
throw error;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return accountModulesPromise;
|
|
247
|
+
}
|
|
248
|
+
|
|
231
249
|
let sentStagerPromise = null;
|
|
232
250
|
function loadSentStager() {
|
|
233
251
|
if (!sentStagerPromise) {
|
|
@@ -332,6 +350,118 @@ function account() {
|
|
|
332
350
|
};
|
|
333
351
|
}
|
|
334
352
|
|
|
353
|
+
function pillVersion() {
|
|
354
|
+
try {
|
|
355
|
+
return String(require("../package.json").version || "");
|
|
356
|
+
} catch {
|
|
357
|
+
return "";
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The Settings account card: who this pill is signed in as, on which device,
|
|
362
|
+
// running which build. deviceName is remembered from pairing when available.
|
|
363
|
+
function accountInfo() {
|
|
364
|
+
const cfg = readConfigFile();
|
|
365
|
+
const user = cfg.user || {};
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
paired: Boolean(deviceToken()),
|
|
369
|
+
name: user.name || "",
|
|
370
|
+
email: user.email || "",
|
|
371
|
+
deviceName: String(cfg.deviceName || "").trim() || os.hostname(),
|
|
372
|
+
version: pillVersion(),
|
|
373
|
+
setupPath: "/app/setup",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// After an account change the daemon must restart into the new credentials, or
|
|
378
|
+
// it keeps polling (and staging) as the OLD account until its next launch.
|
|
379
|
+
function restartCompanionDaemon() {
|
|
380
|
+
try {
|
|
381
|
+
let cmd = null;
|
|
382
|
+
let args = null;
|
|
383
|
+
if (process.platform === "darwin") {
|
|
384
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
|
385
|
+
cmd = "/bin/launchctl";
|
|
386
|
+
args = ["kickstart", "-k", `gui/${uid}/work.relay.companion`];
|
|
387
|
+
} else if (process.platform === "win32") {
|
|
388
|
+
cmd = "cmd";
|
|
389
|
+
args = ["/c", 'schtasks /End /TN "Relay Companion Daemon" & schtasks /Run /TN "Relay Companion Daemon"'];
|
|
390
|
+
}
|
|
391
|
+
if (!cmd) return;
|
|
392
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
393
|
+
child.on("error", (error) => console.error("[overlay] daemon restart spawn failed:", error && error.message));
|
|
394
|
+
child.unref();
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error("[overlay] daemon restart failed:", error && error.message);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Relaunch the pill itself so every cache (sent, contacts, attention queue,
|
|
401
|
+
// renderer state) is rebuilt from the new account. launchd / the Scheduled Task
|
|
402
|
+
// respawns it; app.relaunch() covers a bare `relay pill` run. Delayed a beat so
|
|
403
|
+
// the invoking IPC reply reaches the renderer first.
|
|
404
|
+
function relaunchPillSoon() {
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
try {
|
|
407
|
+
app.relaunch();
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.error("[overlay] relaunch failed:", error && error.message);
|
|
410
|
+
}
|
|
411
|
+
app.exit(0);
|
|
412
|
+
}, 600);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Switch account: register this device against the pairing code, persist the
|
|
416
|
+
// fresh credentials with the SAME shape `relay pair` writes, wipe the local
|
|
417
|
+
// packet store for the new account, then restart the daemon and the pill.
|
|
418
|
+
async function pairWithCode(input) {
|
|
419
|
+
try {
|
|
420
|
+
const raw = input && typeof input === "object" ? input.code : input;
|
|
421
|
+
const [{ account: accountMod, notifications }, { RelayClient }] = await Promise.all([
|
|
422
|
+
loadAccountModules(),
|
|
423
|
+
loadRelayModules(),
|
|
424
|
+
]);
|
|
425
|
+
const code = accountMod.normalizePairingCode(raw);
|
|
426
|
+
if (!code) return { ok: false, error: "Enter the pairing code from your browser." };
|
|
427
|
+
const deviceName = accountMod.deviceNameForPairing(readConfigFile());
|
|
428
|
+
const client = new RelayClient();
|
|
429
|
+
const res = await client.registerDevice({ pairingCode: code, name: deviceName, platform: process.platform });
|
|
430
|
+
accountMod.persistPairedAccount({ deviceName, registration: res });
|
|
431
|
+
notifications.resetCompanionStateForAccount(
|
|
432
|
+
{ user: res.user, deviceId: res.deviceId, force: true },
|
|
433
|
+
{ statePath: STATE_PATH },
|
|
434
|
+
);
|
|
435
|
+
restartCompanionDaemon();
|
|
436
|
+
relaunchPillSoon();
|
|
437
|
+
return { ok: true, email: (res.user && res.user.email) || "" };
|
|
438
|
+
} catch (error) {
|
|
439
|
+
const message = error && error.message ? error.message : String(error);
|
|
440
|
+
console.error("[overlay] pair with code failed:", message);
|
|
441
|
+
return { ok: false, error: message };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Sign out: drop the credentials (keeping URLs + device name), wipe the local
|
|
446
|
+
// packet store, then restart the daemon and the pill into the signed-out state.
|
|
447
|
+
async function signOutAccount() {
|
|
448
|
+
try {
|
|
449
|
+
const { account: accountMod, notifications } = await loadAccountModules();
|
|
450
|
+
accountMod.persistSignedOutAccount();
|
|
451
|
+
notifications.resetCompanionStateForAccount(
|
|
452
|
+
{ user: null, deviceId: "", force: true },
|
|
453
|
+
{ statePath: STATE_PATH },
|
|
454
|
+
);
|
|
455
|
+
restartCompanionDaemon();
|
|
456
|
+
relaunchPillSoon();
|
|
457
|
+
return { ok: true };
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const message = error && error.message ? error.message : String(error);
|
|
460
|
+
console.error("[overlay] sign out failed:", message);
|
|
461
|
+
return { ok: false, error: message };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
335
465
|
// Turn an actionUrl (absolute https, or a relative path) into an absolute URL.
|
|
336
466
|
function absoluteUrl(pathOrUrl) {
|
|
337
467
|
const value = String(pathOrUrl || "").trim();
|
|
@@ -546,6 +676,7 @@ async function markAllVisibleRelaysRead() {
|
|
|
546
676
|
let sentCache = [];
|
|
547
677
|
let sentLoadedOnce = null;
|
|
548
678
|
async function refreshSent() {
|
|
679
|
+
if (!deviceToken()) return sentCache; // signed out: nothing to fetch, no 401 log storm
|
|
549
680
|
try {
|
|
550
681
|
const client = await relayClient();
|
|
551
682
|
const res = await client.sent();
|
|
@@ -569,6 +700,7 @@ async function refreshTasks() {
|
|
|
569
700
|
tasksCache = [];
|
|
570
701
|
return tasksCache;
|
|
571
702
|
}
|
|
703
|
+
if (!deviceToken()) return tasksCache; // signed out: skip the poll entirely
|
|
572
704
|
try {
|
|
573
705
|
const client = await relayClient();
|
|
574
706
|
const res = await client.listTasks();
|
|
@@ -594,6 +726,7 @@ function ensureTasksLoaded() {
|
|
|
594
726
|
let contactsCache = [];
|
|
595
727
|
let contactsLoadedOnce = null;
|
|
596
728
|
async function refreshContacts() {
|
|
729
|
+
if (!deviceToken()) return contactsCache; // signed out: skip the poll entirely
|
|
597
730
|
try {
|
|
598
731
|
const client = await relayClient();
|
|
599
732
|
const res = await client.listContacts();
|
|
@@ -1375,6 +1508,95 @@ function openClaudeDeepLinkVerified(url, fallbackUrl, label, { freshlyForged = f
|
|
|
1375
1508
|
}, 1000);
|
|
1376
1509
|
}
|
|
1377
1510
|
|
|
1511
|
+
// The CLI's `open` prints ONE pretty-printed JSON object on stdout and sends every
|
|
1512
|
+
// log line to stderr, so a plain parse should suffice — but a single stray stdout
|
|
1513
|
+
// line (a Node warning, a dep's console.log) used to turn a SUCCESSFUL open into a
|
|
1514
|
+
// browser fallback, so recover by slicing the outermost {...} instead of giving up.
|
|
1515
|
+
function parseOpenResult(out) {
|
|
1516
|
+
const text = String(out || "").trim();
|
|
1517
|
+
const read = (raw) => {
|
|
1518
|
+
const parsed = JSON.parse(raw);
|
|
1519
|
+
return {
|
|
1520
|
+
url: parsed.url || null,
|
|
1521
|
+
skipExternalOpen: Boolean(parsed.skipExternalOpen),
|
|
1522
|
+
freshlyForged: Boolean(parsed.claudeFreshlyForged),
|
|
1523
|
+
};
|
|
1524
|
+
};
|
|
1525
|
+
try {
|
|
1526
|
+
return read(text);
|
|
1527
|
+
} catch {}
|
|
1528
|
+
const start = text.indexOf("{");
|
|
1529
|
+
const end = text.lastIndexOf("}");
|
|
1530
|
+
if (start >= 0 && end > start) {
|
|
1531
|
+
try {
|
|
1532
|
+
return read(text.slice(start, end + 1));
|
|
1533
|
+
} catch {}
|
|
1534
|
+
}
|
|
1535
|
+
return { url: null, skipExternalOpen: false, freshlyForged: false };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Both stdout and stderr in the failure log: a Windows field report where stdout
|
|
1539
|
+
// was empty is the whole reason the "exit" -> "close" fix below exists.
|
|
1540
|
+
function tailFor(stream) {
|
|
1541
|
+
return String(stream || "").trim().split("\n").slice(-2).join(" | ");
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Whether the `relay claude-hook` runtime is registered in ~/.claude/settings.json.
|
|
1545
|
+
// "Open in current chat" stages a file that ONLY that runtime consumes, so without
|
|
1546
|
+
// it the click is a silent no-op. Loaded lazily like the modules above (install.js
|
|
1547
|
+
// is ESM; this overlay is CommonJS).
|
|
1548
|
+
let claudeHooksModulePromise = null;
|
|
1549
|
+
function loadClaudeHooksModule() {
|
|
1550
|
+
if (!claudeHooksModulePromise) {
|
|
1551
|
+
const installUrl = pathToFileURL(path.join(__dirname, "..", "src", "install.js")).href;
|
|
1552
|
+
claudeHooksModulePromise = import(installUrl).catch((error) => {
|
|
1553
|
+
claudeHooksModulePromise = null;
|
|
1554
|
+
throw error;
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
return claudeHooksModulePromise;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// A missing or unparseable settings file counts as NOT installed: staging into a
|
|
1561
|
+
// machine with no hook runtime is worse than a fresh open, so fail closed.
|
|
1562
|
+
function claudeHooksInstalled(install) {
|
|
1563
|
+
let settings;
|
|
1564
|
+
try {
|
|
1565
|
+
settings = JSON.parse(fs.readFileSync(install.claudeSettingsPath(), "utf8")) || {};
|
|
1566
|
+
} catch {
|
|
1567
|
+
return false;
|
|
1568
|
+
}
|
|
1569
|
+
const hooks = settings.hooks;
|
|
1570
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
|
|
1571
|
+
for (const entries of Object.values(hooks)) {
|
|
1572
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
1573
|
+
for (const hook of entry && Array.isArray(entry.hooks) ? entry.hooks : []) {
|
|
1574
|
+
if (install.isRelayClaudeHookCommand(hook && hook.command)) return true;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return false;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Register the claude-hook runtime for FUTURE Claude sessions (idempotent; it preserves
|
|
1582
|
+
// every user hook). installClaudeHooks defaults its node to process.execPath, which under
|
|
1583
|
+
// Electron is the OVERLAY binary — a hook command Claude could never run, and one that
|
|
1584
|
+
// would make the presence check above pass forever. So resolve a real node first and skip
|
|
1585
|
+
// the repair entirely when the machine has none.
|
|
1586
|
+
function repairClaudeHooks(install) {
|
|
1587
|
+
execFile(process.platform === "win32" ? "where" : "/usr/bin/which", ["node"], (error, out) => {
|
|
1588
|
+
const node = String(out || "").split("\n")[0].trim();
|
|
1589
|
+
if (error || !node) {
|
|
1590
|
+
console.error("[overlay] cannot register the claude-hook runtime: no node found on PATH");
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const result = install.installClaudeHooks(undefined, install.stableNodePath(node));
|
|
1594
|
+
if (!result || !result.ok) {
|
|
1595
|
+
console.error("[overlay] claude-hook registration failed:", (result && result.reason) || "unknown");
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1378
1600
|
// Click-to-open a Relay row. A relay materializes a REAL native agent session
|
|
1379
1601
|
// inside the already-running foregrounded host (Claude Desktop or Codex) via the
|
|
1380
1602
|
// companion CLI's `open` command, so it appears in that app's recents rail. The
|
|
@@ -1404,20 +1626,29 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1404
1626
|
}
|
|
1405
1627
|
const row = rowById(materializationId);
|
|
1406
1628
|
if (win && !win.isDestroyed()) win.webContents.send("opening", packetId);
|
|
1407
|
-
|
|
1629
|
+
// Ack marks the row read AND drops it from the attention queue, so a relay that
|
|
1630
|
+
// never reached the user must NOT be acked — otherwise a failed click silently
|
|
1631
|
+
// retires the notification. The spinner stop, in contrast, owes every path.
|
|
1632
|
+
const finishOpened = () => {
|
|
1408
1633
|
if (!sent) ackPacket(packetId);
|
|
1409
1634
|
if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
|
|
1410
1635
|
};
|
|
1636
|
+
const finishFailed = (message) => {
|
|
1637
|
+
if (win && !win.isDestroyed()) {
|
|
1638
|
+
if (message) win.webContents.send("openError", packetId, message);
|
|
1639
|
+
win.webContents.send("openDone", packetId);
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1411
1642
|
if (!TASK_FEATURES_ALLOWED && (row?.taskId || isRelayTaskWebTarget(row?.actionUrl))) {
|
|
1412
1643
|
console.error("[overlay] refusing to open a Relay Task in messages-only mode:", packetId);
|
|
1413
|
-
return
|
|
1644
|
+
return finishFailed();
|
|
1414
1645
|
}
|
|
1415
|
-
if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return
|
|
1646
|
+
if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") return finishOpened();
|
|
1416
1647
|
if (row && row.relayNotificationKind === "connector_reauth") {
|
|
1417
1648
|
shell
|
|
1418
1649
|
.openExternal(`${webBase()}/app/connectors`)
|
|
1419
1650
|
.catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
1420
|
-
return
|
|
1651
|
+
return finishOpened();
|
|
1421
1652
|
}
|
|
1422
1653
|
frontmostBundleId((bundle) => {
|
|
1423
1654
|
const host = resolveClickHost(bundle);
|
|
@@ -1446,16 +1677,11 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1446
1677
|
let err = "";
|
|
1447
1678
|
child.stdout.on("data", (data) => (out += data));
|
|
1448
1679
|
child.stderr.on("data", (data) => (err += data));
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
const parsed = JSON.parse(out.trim());
|
|
1455
|
-
url = parsed.url || null;
|
|
1456
|
-
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
1457
|
-
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
1458
|
-
} catch {}
|
|
1680
|
+
// "close", never "exit": on Windows "exit" routinely fires BEFORE the stdout pipe
|
|
1681
|
+
// drains, so `out` was empty, the parse failed, and a perfectly successful open was
|
|
1682
|
+
// routed to the web fallback — the user got a browser instead of Claude.
|
|
1683
|
+
child.on("close", (code) => {
|
|
1684
|
+
const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
|
|
1459
1685
|
if (url || skipExternalOpen) {
|
|
1460
1686
|
// Raise the host so the relay is visible, whichever open path ran (Claude deep
|
|
1461
1687
|
// link, Codex bridge, or a plain url). This is the fix for "opened in the right
|
|
@@ -1469,6 +1695,7 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1469
1695
|
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
1470
1696
|
}
|
|
1471
1697
|
}
|
|
1698
|
+
finishOpened();
|
|
1472
1699
|
} else {
|
|
1473
1700
|
console.error(
|
|
1474
1701
|
"[overlay] open failed for",
|
|
@@ -1477,22 +1704,25 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1477
1704
|
host,
|
|
1478
1705
|
"code",
|
|
1479
1706
|
code,
|
|
1707
|
+
"stdout:",
|
|
1708
|
+
tailFor(out),
|
|
1480
1709
|
"stderr:",
|
|
1481
|
-
err
|
|
1710
|
+
tailFor(err),
|
|
1482
1711
|
);
|
|
1483
|
-
// Last-resort web fallback so the click is never a dead end
|
|
1712
|
+
// Last-resort web fallback so the click is never a dead end — but the relay
|
|
1713
|
+
// did not open where it was meant to, so it stays unread and in the queue.
|
|
1484
1714
|
const target = row && row.taskId
|
|
1485
1715
|
? taskWebUrl(row.taskId)
|
|
1486
1716
|
: row && row.actionUrl
|
|
1487
1717
|
? absoluteUrl(row.actionUrl)
|
|
1488
1718
|
: webBase();
|
|
1489
1719
|
shell.openExternal(target).catch((e) => console.error("[overlay] web fallback failed:", e && e.message));
|
|
1720
|
+
finishFailed("Couldn't open in Claude — opened on the web instead.");
|
|
1490
1721
|
}
|
|
1491
|
-
finish();
|
|
1492
1722
|
});
|
|
1493
1723
|
child.on("error", (error) => {
|
|
1494
1724
|
console.error("[overlay] open spawn error:", error && error.message);
|
|
1495
|
-
|
|
1725
|
+
finishFailed("Couldn't open — Relay's open helper failed to start.");
|
|
1496
1726
|
});
|
|
1497
1727
|
});
|
|
1498
1728
|
}
|
|
@@ -1594,22 +1824,50 @@ async function openPacketInCurrent(packetId) {
|
|
|
1594
1824
|
}
|
|
1595
1825
|
}
|
|
1596
1826
|
if (!target) return fallbackFresh();
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1827
|
+
const stageNow = () => {
|
|
1828
|
+
try {
|
|
1829
|
+
claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
|
|
1830
|
+
relayId: packetId,
|
|
1831
|
+
senderName: row.senderName || "",
|
|
1832
|
+
title: row.title || row.displayTitle || "",
|
|
1833
|
+
...threadInfoFor(row),
|
|
1834
|
+
});
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
console.error("[overlay] claude injection staging failed:", error && error.message);
|
|
1837
|
+
return fallbackFresh();
|
|
1838
|
+
}
|
|
1839
|
+
// Raise Claude Desktop so the user watches the instruction land; a
|
|
1840
|
+
// terminal-only session stays where it is (its window is not ours to raise).
|
|
1841
|
+
if (target.source === "desktop") activateHost("claude", bundle);
|
|
1842
|
+
confirmInjected("claude");
|
|
1843
|
+
finish();
|
|
1844
|
+
};
|
|
1845
|
+
// NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
|
|
1846
|
+
// ever reads the staged file. Where setup never wrote those hooks (Windows installs
|
|
1847
|
+
// where the `claude` CLI isn't on PATH — see src/install.js runSetupInstall) staging
|
|
1848
|
+
// succeeds and the click is a silent, acked no-op. Check before staging. Two-arg
|
|
1849
|
+
// then, not .catch: a throw out of stageNow must not re-enter the failure handler
|
|
1850
|
+
// and finish the click twice.
|
|
1851
|
+
loadClaudeHooksModule().then(
|
|
1852
|
+
(install) => {
|
|
1853
|
+
if (claudeHooksInstalled(install)) return stageNow();
|
|
1854
|
+
// Repair for FUTURE sessions — but a Claude session that is already running
|
|
1855
|
+
// loaded its hooks at startup and will never consume a file staged now, so this
|
|
1856
|
+
// click has to take the fresh-open path, the only one that actually shows the
|
|
1857
|
+
// relay. No ack here: openPacket owns the read state for the fresh open.
|
|
1858
|
+
console.error(
|
|
1859
|
+
"[overlay] claude-hook runtime not registered in",
|
|
1860
|
+
install.claudeSettingsPath(),
|
|
1861
|
+
"— 'Open in current chat' would have been a silent no-op; installing it for future sessions and opening a fresh chat instead (restart Claude to use it)",
|
|
1862
|
+
);
|
|
1863
|
+
repairClaudeHooks(install);
|
|
1864
|
+
fallbackFresh();
|
|
1865
|
+
},
|
|
1866
|
+
(error) => {
|
|
1867
|
+
console.error("[overlay] claude-hook runtime check failed:", error && error.message);
|
|
1868
|
+
fallbackFresh();
|
|
1869
|
+
},
|
|
1870
|
+
);
|
|
1613
1871
|
});
|
|
1614
1872
|
}
|
|
1615
1873
|
|
|
@@ -1645,16 +1903,10 @@ function openTaskDetail(taskId) {
|
|
|
1645
1903
|
let err = "";
|
|
1646
1904
|
child.stdout.on("data", (data) => (out += data));
|
|
1647
1905
|
child.stderr.on("data", (data) => (err += data));
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
try {
|
|
1653
|
-
const parsed = JSON.parse(out.trim());
|
|
1654
|
-
url = parsed.url || null;
|
|
1655
|
-
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
1656
|
-
freshlyForged = Boolean(parsed.claudeFreshlyForged);
|
|
1657
|
-
} catch {}
|
|
1906
|
+
// "close", never "exit" — same Windows stdout-drain race as openPacket: an empty
|
|
1907
|
+
// parse used to send a successful task open to the browser.
|
|
1908
|
+
child.on("close", (code) => {
|
|
1909
|
+
const { url, skipExternalOpen, freshlyForged } = parseOpenResult(out);
|
|
1658
1910
|
if (url || skipExternalOpen) {
|
|
1659
1911
|
activateHost(host, bundle); // raise the host so the opened task is visible
|
|
1660
1912
|
if (url && !skipExternalOpen) {
|
|
@@ -1672,8 +1924,10 @@ function openTaskDetail(taskId) {
|
|
|
1672
1924
|
host,
|
|
1673
1925
|
"code",
|
|
1674
1926
|
code,
|
|
1927
|
+
"stdout:",
|
|
1928
|
+
tailFor(out),
|
|
1675
1929
|
"stderr:",
|
|
1676
|
-
err
|
|
1930
|
+
tailFor(err),
|
|
1677
1931
|
);
|
|
1678
1932
|
// Last-resort web fallback so the click is never a dead end.
|
|
1679
1933
|
shell
|
|
@@ -1696,6 +1950,12 @@ function openUrlTarget(url) {
|
|
|
1696
1950
|
return;
|
|
1697
1951
|
}
|
|
1698
1952
|
const target = absoluteUrl(url);
|
|
1953
|
+
// Sandboxed harness runs must never pop the user's real browser (the same
|
|
1954
|
+
// contract that keeps them from launching Claude/Codex).
|
|
1955
|
+
if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") {
|
|
1956
|
+
console.error("[overlay] test seam: suppressed external open:", target);
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1699
1959
|
shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
1700
1960
|
}
|
|
1701
1961
|
|
|
@@ -1704,7 +1964,21 @@ function openUrlTarget(url) {
|
|
|
1704
1964
|
function anchorTopRight() {
|
|
1705
1965
|
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) || screen.getPrimaryDisplay();
|
|
1706
1966
|
const wa = display.workArea;
|
|
1707
|
-
|
|
1967
|
+
const anchor = { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
|
|
1968
|
+
if (process.env.RELAY_OVERLAY_TEST === "1") {
|
|
1969
|
+
// Sandboxed harness runs must never materialize under the user's parked
|
|
1970
|
+
// cursor: a pointer resting on the card holds notifications open BY DESIGN
|
|
1971
|
+
// (macOS banners do the same), which would wedge every dwell-based scenario
|
|
1972
|
+
// when the mouse happens to sit in the top-right corner. Flip the sandbox
|
|
1973
|
+
// window to the opposite edge instead. Production placement is unchanged.
|
|
1974
|
+
try {
|
|
1975
|
+
const p = screen.getCursorScreenPoint();
|
|
1976
|
+
const overlaps =
|
|
1977
|
+
p.x >= anchor.x && p.x < anchor.x + anchor.width && p.y >= anchor.y && p.y < anchor.y + anchor.height;
|
|
1978
|
+
if (overlaps) anchor.x = wa.x + MARGIN;
|
|
1979
|
+
} catch {}
|
|
1980
|
+
}
|
|
1981
|
+
return anchor;
|
|
1708
1982
|
}
|
|
1709
1983
|
|
|
1710
1984
|
// Show the overlay window. Electron's forwarded-mousemove stream (the thing that
|
|
@@ -1834,6 +2108,12 @@ let hitTimer = null;
|
|
|
1834
2108
|
let hitIgnoring = true; // mirrors the last setIgnoreMouseEvents call
|
|
1835
2109
|
let hitHover = false;
|
|
1836
2110
|
let lastHostProbeAt = 0;
|
|
2111
|
+
// Harness seam: a sandboxed e2e run shares the screen with the user's REAL
|
|
2112
|
+
// mouse, and a pointer that happens to rest on the card rect legitimately
|
|
2113
|
+
// holds notifications open (the macOS-banner rule) — wedging every dwell-based
|
|
2114
|
+
// scenario. The harness drives only synthetic input, so in this mode the hit
|
|
2115
|
+
// test ignores the physical cursor entirely. Never set outside the harness.
|
|
2116
|
+
const HIT_TEST_POINTER_BLIND = process.env.RELAY_OVERLAY_TEST_IGNORE_POINTER === "1";
|
|
1837
2117
|
|
|
1838
2118
|
function applyIgnore(next) {
|
|
1839
2119
|
if (next === hitIgnoring) return;
|
|
@@ -1874,6 +2154,14 @@ function scheduleHit(ms) {
|
|
|
1874
2154
|
function hitTick() {
|
|
1875
2155
|
hitTimer = null;
|
|
1876
2156
|
if (!win || win.isDestroyed()) return;
|
|
2157
|
+
if (HIT_TEST_POINTER_BLIND) {
|
|
2158
|
+
// Interactive everywhere, hover never asserted: synthetic-input runs must
|
|
2159
|
+
// not have their dwell/fold behavior steered by the user's parked mouse.
|
|
2160
|
+
applyIgnore(false);
|
|
2161
|
+
applyHover(false);
|
|
2162
|
+
scheduleHit(POLL_HIDDEN_MS);
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
1877
2165
|
if (!win.isVisible()) {
|
|
1878
2166
|
// A hidden window must never hold clicks, and must not cost a cursor read.
|
|
1879
2167
|
applyIgnore(true);
|
|
@@ -1949,7 +2237,10 @@ function createWindow() {
|
|
|
1949
2237
|
},
|
|
1950
2238
|
});
|
|
1951
2239
|
|
|
1952
|
-
|
|
2240
|
+
// Windows strips WS_EX_TOPMOST from a scheduled-task-launched overlay that only asks
|
|
2241
|
+
// politely ("floating"); "screen-saver" is the level that survives. The periodic
|
|
2242
|
+
// re-assert that recovers from later demotions lives in space-presence.cjs.
|
|
2243
|
+
win.setAlwaysOnTop(true, process.platform === "win32" ? "screen-saver" : "floating");
|
|
1953
2244
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
1954
2245
|
// Empty canvas is click-through; startHitTest() below owns this from here on.
|
|
1955
2246
|
win.setIgnoreMouseEvents(true, { forward: true });
|
|
@@ -2291,6 +2582,11 @@ ipcMain.handle("relay:contacts", () => readContacts());
|
|
|
2291
2582
|
ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
|
|
2292
2583
|
ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
|
|
2293
2584
|
|
|
2585
|
+
// Settings tab: account card + the sign-out / switch-account lifecycle.
|
|
2586
|
+
ipcMain.handle("relay:accountInfo", () => accountInfo());
|
|
2587
|
+
ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
|
|
2588
|
+
ipcMain.handle("relay:signOut", () => signOutAccount());
|
|
2589
|
+
|
|
2294
2590
|
// The overlay is normally focusable:false so it never steals keyboard focus from
|
|
2295
2591
|
// Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
|
|
2296
2592
|
// asks us to grant it while a field is open and revoke it the moment the form closes.
|
package/overlay/preload.cjs
CHANGED
|
@@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
8
8
|
// "Open in current chat" staged an injection: the UI confirms it so the
|
|
9
9
|
// click has visible feedback (delivery is checkpoint-based, not instant).
|
|
10
10
|
onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
|
|
11
|
+
// The open failed (CLI error / helper wouldn't spawn). The row stays unread, so
|
|
12
|
+
// say why inline instead of letting the spinner just stop.
|
|
13
|
+
onOpenError: (cb) => ipcRenderer.on("openError", (_e, id, message) => cb(id, message || "")),
|
|
11
14
|
onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // status-area icon clicked
|
|
12
15
|
onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
|
|
13
16
|
|
|
@@ -43,6 +46,11 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
43
46
|
contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
|
|
44
47
|
contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
|
|
45
48
|
|
|
49
|
+
// settings / account (switch + sign-out relaunch the pill on success)
|
|
50
|
+
accountInfo: () => ipcRenderer.invoke("relay:accountInfo"),
|
|
51
|
+
pairWithCode: (input) => ipcRenderer.invoke("relay:pairWithCode", input),
|
|
52
|
+
signOut: () => ipcRenderer.invoke("relay:signOut"),
|
|
53
|
+
|
|
46
54
|
|
|
47
55
|
// window plumbing
|
|
48
56
|
setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),
|
|
@@ -18,22 +18,40 @@ function resetWindowZoom(win) {
|
|
|
18
18
|
return true;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function reinforceSpacePresence(win, { moveTop = false } = {}) {
|
|
21
|
+
function reinforceSpacePresence(win, { moveTop = false, platform = process.platform } = {}) {
|
|
22
22
|
if (!isUsableWindow(win)) return false;
|
|
23
23
|
resetWindowZoom(win);
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
const isWindows = platform === "win32";
|
|
25
|
+
// All-Spaces is a macOS Spaces concept. On Windows isVisibleOnAllWorkspaces()
|
|
26
|
+
// always reports false, so the drift check below re-called the setter on every
|
|
27
|
+
// 1.5s poll for nothing.
|
|
28
|
+
if (!isWindows) {
|
|
29
|
+
// Only touch window-server state that actually drifted. Re-asserting
|
|
30
|
+
// setVisibleOnAllWorkspaces / setAlwaysOnTop unconditionally (every 1.5s poll +
|
|
31
|
+
// every Space change) reorders the window each time and reads as flicker.
|
|
32
|
+
try {
|
|
33
|
+
if (typeof win.isVisibleOnAllWorkspaces !== "function" || !win.isVisibleOnAllWorkspaces()) {
|
|
34
|
+
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
if (isWindows) {
|
|
39
|
+
// Re-assert unconditionally: Electron's isAlwaysOnTop() is a cached flag from
|
|
40
|
+
// its own last setAlwaysOnTop call and cannot observe the OS stripping
|
|
41
|
+
// WS_EX_TOPMOST (scheduled-task-launched windows come up without it and sink
|
|
42
|
+
// behind maximized windows). Trusting the getter made the repair a permanent
|
|
43
|
+
// no-op. SetWindowPos-to-topmost on an already-topmost window is not a visible
|
|
44
|
+
// reorder here, so the macOS flicker rationale above does not apply.
|
|
45
|
+
try {
|
|
46
|
+
win.setAlwaysOnTop(true, "screen-saver");
|
|
47
|
+
} catch {}
|
|
48
|
+
} else {
|
|
49
|
+
try {
|
|
50
|
+
if (typeof win.isAlwaysOnTop !== "function" || !win.isAlwaysOnTop()) {
|
|
51
|
+
win.setAlwaysOnTop(true, "floating");
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
37
55
|
// moveTop is macOS orderWindow:NSWindowAbove — on a HIDDEN window that orders it
|
|
38
56
|
// back on-screen. That made the dismissed pill flash on every Space switch (shown
|
|
39
57
|
// here, then hidden again by the visibility sync). Raising only means anything
|
package/package.json
CHANGED
package/src/account.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { readConfig, writeConfigObject } from "./config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Account lifecycle for the companion: the ONE config-write shape shared by
|
|
6
|
+
* `relay pair` (bin/relay.js cmdPair) and the pill's Settings tab, so switching
|
|
7
|
+
* accounts from either surface persists identical credentials. The pure shapes
|
|
8
|
+
* are exported separately from the fs-touching persist helpers for unit tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pairing codes are 8 chars from an unambiguous uppercase alphabet
|
|
13
|
+
* (services/devices.ts). The server only trims + uppercases, so typed
|
|
14
|
+
* "abcd-efgh" / "ABCD EFGH" variants are folded here before the request.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizePairingCode(raw) {
|
|
17
|
+
return String(raw ?? "")
|
|
18
|
+
.replace(/[\s-]+/g, "")
|
|
19
|
+
.toUpperCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The device name a re-pair should register: the remembered one, else the hostname. */
|
|
23
|
+
export function deviceNameForPairing(config = readConfig()) {
|
|
24
|
+
const stored = String((config && config.deviceName) || "").trim();
|
|
25
|
+
return stored || os.hostname();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The post-registration config: everything the old config had, plus the fresh
|
|
30
|
+
* credentials. apiUrl/webUrl/deviceName are only written when explicitly given
|
|
31
|
+
* (the pill switches accounts without touching the URLs it was launched with).
|
|
32
|
+
*/
|
|
33
|
+
export function pairedAccountConfig(existing, { apiUrl, webUrl, deviceName, registration } = {}) {
|
|
34
|
+
const res = registration || {};
|
|
35
|
+
return {
|
|
36
|
+
...(existing || {}),
|
|
37
|
+
...(apiUrl ? { apiUrl } : {}),
|
|
38
|
+
...(webUrl ? { webUrl } : {}),
|
|
39
|
+
...(deviceName ? { deviceName } : {}),
|
|
40
|
+
deviceToken: res.deviceToken || "",
|
|
41
|
+
deviceId: res.deviceId || "",
|
|
42
|
+
user: res.user || null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sign-out clears the credential set — user, deviceToken, and the deviceId that
|
|
48
|
+
* belongs to that token — while preserving apiUrl/webUrl, the device name, the
|
|
49
|
+
* companion mode, and any other settings on the file.
|
|
50
|
+
*/
|
|
51
|
+
export function signedOutAccountConfig(existing) {
|
|
52
|
+
const next = { ...(existing || {}) };
|
|
53
|
+
delete next.user;
|
|
54
|
+
delete next.deviceToken;
|
|
55
|
+
delete next.deviceId;
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function persistPairedAccount({ apiUrl, webUrl, deviceName, registration } = {}) {
|
|
60
|
+
return writeConfigObject(pairedAccountConfig(readConfig(), { apiUrl, webUrl, deviceName, registration }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function persistSignedOutAccount() {
|
|
64
|
+
return writeConfigObject(signedOutAccountConfig(readConfig()));
|
|
65
|
+
}
|
package/src/config.js
CHANGED
|
@@ -23,7 +23,9 @@ export function configDir() {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function configPath() {
|
|
26
|
-
|
|
26
|
+
// RELAY_CONFIG (a full file path) matches the overlay's readConfigFile
|
|
27
|
+
// resolution, so a sandboxed pill and these helpers agree on ONE file.
|
|
28
|
+
return process.env.RELAY_CONFIG || path.join(configDir(), "config.json");
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function readConfig() {
|
|
@@ -51,19 +53,25 @@ export function readConfig() {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Replace config.json with exactly `next` (no merge). This is the primitive that
|
|
58
|
+
* lets sign-out REMOVE keys — writeConfig's patch merge can only add or overwrite.
|
|
59
|
+
*/
|
|
60
|
+
export function writeConfigObject(next) {
|
|
61
|
+
const file = configPath();
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
58
63
|
// Atomic write: a crash mid-write must not truncate config.json and wipe the
|
|
59
64
|
// device token (which would put the daemon into a launchd crash loop).
|
|
60
|
-
const file = configPath();
|
|
61
65
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
62
66
|
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
63
67
|
fs.renameSync(tmp, file);
|
|
64
68
|
return next;
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
export function writeConfig(patch) {
|
|
72
|
+
return writeConfigObject({ ...readConfig(), ...patch });
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
export function apiUrl() {
|
|
68
76
|
return (
|
|
69
77
|
process.env.RELAY_API_URL ||
|
package/src/install.js
CHANGED
|
@@ -501,6 +501,21 @@ export function claudeSettingsPath() {
|
|
|
501
501
|
);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/**
|
|
505
|
+
* True when Claude looks present on this machine, judged ONLY by the directory
|
|
506
|
+
* that holds claudeSettingsPath() (so CLAUDE_SETTINGS / CLAUDE_HOME overrides are
|
|
507
|
+
* respected for tests). Deliberately not a `claude` CLI probe: Windows installs
|
|
508
|
+
* routinely have Claude Code/Desktop with ~/.claude configured by hand and no
|
|
509
|
+
* `claude` on PATH, and those installs still need the hook runtime.
|
|
510
|
+
*/
|
|
511
|
+
export function claudeAppearsPresent({ settingsPath = claudeSettingsPath() } = {}) {
|
|
512
|
+
try {
|
|
513
|
+
return fs.existsSync(path.dirname(settingsPath));
|
|
514
|
+
} catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
504
519
|
function shellArg(value) {
|
|
505
520
|
const clean = String(value);
|
|
506
521
|
return /\s/.test(clean) ? `"${clean.replaceAll('"', '\\"')}"` : clean;
|
|
@@ -1009,14 +1024,19 @@ export async function runSetupInstall({ mode = DEFAULT_COMPANION_MODE } = {}) {
|
|
|
1009
1024
|
if (claude.ok) {
|
|
1010
1025
|
installed.push("Claude Code");
|
|
1011
1026
|
activations.push(verifyClaudeMcpRegistration({ configPath: claude.configPath }));
|
|
1012
|
-
// The Open-in-current-chat hook runtime rides along with the MCP
|
|
1013
|
-
// registration (both are what "Relay is installed into Claude" means).
|
|
1014
|
-
claudeHooks = installClaudeHooks(bin, node);
|
|
1015
1027
|
} else if (claude.reason === "claude_code_not_found") {
|
|
1016
1028
|
missing.push("Claude Code");
|
|
1017
1029
|
} else {
|
|
1018
1030
|
missing.push("Claude Code (registration failed)");
|
|
1019
1031
|
}
|
|
1032
|
+
// The Open-in-current-chat hook runtime rides along with the MCP registration
|
|
1033
|
+
// (both are what "Relay is installed into Claude" means) — but it must NOT
|
|
1034
|
+
// depend on the `claude` CLI being on PATH. On Windows installClaudeCode fails
|
|
1035
|
+
// with claude_code_not_found whenever the CLI is absent even though Claude
|
|
1036
|
+
// Code/Desktop is installed and the MCP was registered by hand; without this
|
|
1037
|
+
// fallback the hook is never written and the pill's "Open in current chat" is a
|
|
1038
|
+
// silent no-op on exactly those machines.
|
|
1039
|
+
if (claude.ok || claudeAppearsPresent()) claudeHooks = installClaudeHooks(bin, node);
|
|
1020
1040
|
const codex = installCodex(bin, node, { mode: normalizedMode });
|
|
1021
1041
|
if (codex.ok) {
|
|
1022
1042
|
installed.push("Codex");
|