relay-companion 0.1.93 → 0.1.95
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/overlay/inbox.html +134 -16
- package/overlay/main.cjs +8 -2
- package/package.json +1 -1
- package/src/auto-update.js +8 -0
- package/src/claude-inject.cjs +100 -5
- package/src/mcp.js +8 -2
- package/src/notifications.js +4 -0
package/overlay/inbox.html
CHANGED
|
@@ -679,14 +679,32 @@
|
|
|
679
679
|
font-family:var(--serif); font-size:14.5px; font-weight:500; color:var(--ink);
|
|
680
680
|
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; flex:1 1 auto;
|
|
681
681
|
}
|
|
682
|
+
/* Conversation messages read like a group chat: each person's own messages
|
|
683
|
+
sit on one side, and every party's name keeps a stable color so a busy
|
|
684
|
+
thread is scannable by who-said-what (the WhatsApp convention). */
|
|
685
|
+
.th-detail-title { padding:2px 16px 10px; font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--ink); font-weight:600; overflow-wrap:anywhere; }
|
|
686
|
+
.th-detail-title:empty { display:none; }
|
|
682
687
|
.th-msg { position:relative; display:block; width:100%; text-align:left; appearance:none; border:0; cursor:pointer; background:transparent; padding:11px 16px; border-bottom:1px solid var(--hair); font-family:var(--sans); }
|
|
683
688
|
.th-msg:hover { background:rgba(31,26,23,.035); }
|
|
689
|
+
.th-msg.expanded { background:rgba(31,26,23,.045); }
|
|
690
|
+
.th-msg .open-actions { margin-top:9px; text-align:left; }
|
|
684
691
|
.th-msg-top { display:flex; align-items:baseline; gap:7px; min-width:0; }
|
|
685
692
|
.th-dir { flex:0 0 auto; font-family:var(--mono); font-size:11px; line-height:1; align-self:center; color:var(--muted-2); }
|
|
686
693
|
.th-dir.out { color:var(--accent); }
|
|
687
694
|
.th-msg-title { font-family:var(--serif); font-size:14px; line-height:1.3; color:var(--muted); margin-top:4px;
|
|
688
695
|
overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow-wrap:anywhere; }
|
|
689
696
|
.th-msg.unread .th-msg-title { color:var(--ink); font-weight:500; }
|
|
697
|
+
/* Your own messages hang to the right, like your side of a group chat. */
|
|
698
|
+
.th-msg.mine { text-align:right; background:rgba(31,26,23,.022); }
|
|
699
|
+
.th-msg.mine .th-msg-top { flex-direction:row-reverse; }
|
|
700
|
+
.th-msg.mine .th-party { text-align:right; }
|
|
701
|
+
.th-msg.mine .th-msg-title { text-align:right; }
|
|
702
|
+
/* Per-party name colors. Assigned by a stable hash of the party name, so a
|
|
703
|
+
person keeps the same color across every conversation and every restart. */
|
|
704
|
+
.th-party.p0 { color:#1f6f5c; } .th-party.p1 { color:#8a4b2a; } .th-party.p2 { color:#4a4aa8; }
|
|
705
|
+
.th-party.p3 { color:#8a2f5e; } .th-party.p4 { color:#2f6f8a; } .th-party.p5 { color:#6b5a1f; }
|
|
706
|
+
.th-party.p6 { color:#7a3030; } .th-party.p7 { color:#3f6b2a; }
|
|
707
|
+
.th-party.me { color:var(--accent); }
|
|
690
708
|
|
|
691
709
|
/* Sent-row read receipt chip: "read" sits in a light-green enclosure, "unread" stays quiet. */
|
|
692
710
|
.sent-chip {
|
|
@@ -828,6 +846,7 @@
|
|
|
828
846
|
</button>
|
|
829
847
|
<span class="th-detail-name" id="thDetailName"></span>
|
|
830
848
|
</div>
|
|
849
|
+
<div class="th-detail-title" id="thDetailTitle"></div>
|
|
831
850
|
<div id="thHistory"></div>
|
|
832
851
|
</div>
|
|
833
852
|
</section>
|
|
@@ -944,6 +963,7 @@
|
|
|
944
963
|
const thDetailEl = document.getElementById("thDetail");
|
|
945
964
|
const thBackEl = document.getElementById("thBack");
|
|
946
965
|
const thDetailNameEl = document.getElementById("thDetailName");
|
|
966
|
+
const thDetailTitleEl = document.getElementById("thDetailTitle");
|
|
947
967
|
const thHistoryEl = document.getElementById("thHistory");
|
|
948
968
|
const settingsViewEl = document.getElementById("settingsView");
|
|
949
969
|
const signInBannerEl = document.getElementById("signInBanner");
|
|
@@ -1702,7 +1722,7 @@
|
|
|
1702
1722
|
${t.msgs.length > 1 ? `<span class="th-count">${t.msgs.length}</span>` : ""}
|
|
1703
1723
|
<span class="th-time">${esc(timeAgo(t.latest.at))}</span>
|
|
1704
1724
|
</span>
|
|
1705
|
-
<span class="th-title">${esc(t.latest.title)}</span>
|
|
1725
|
+
<span class="th-title">${esc(t.title || t.latest.title)}</span>
|
|
1706
1726
|
${tMenu}
|
|
1707
1727
|
${newestInbound ? `<div class="row-err${rowNotes.get(newestInbound.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(newestInbound.id)}" data-stop="1">${esc(rowNotes.get(newestInbound.id)?.text || "")}</div>` : ""}
|
|
1708
1728
|
</div>`;
|
|
@@ -2984,6 +3004,7 @@
|
|
|
2984
3004
|
// relays plus the live Sent list. Local data only — the pill shows the
|
|
2985
3005
|
// user's own exchanges; the API's /v1/threads serves web and agents.
|
|
2986
3006
|
let threadDetailId = null; // threadId while a thread's history is open
|
|
3007
|
+
let expandedThreadMsgId = null; // message inside the thread whose actions are open
|
|
2987
3008
|
let threadDetailScrolledFor = null; // thread already auto-scrolled to newest (reset on leave)
|
|
2988
3009
|
let threadDetailPartyHint = ""; // who it's with, for the header while messages load
|
|
2989
3010
|
function threadMessages() {
|
|
@@ -2993,7 +3014,7 @@
|
|
|
2993
3014
|
msgs.push({
|
|
2994
3015
|
id: r.id, threadId: r.threadId || r.id, direction: "in",
|
|
2995
3016
|
title: relaySubject(r), party: relaySender(r), at: r.createdAt || r.updatedAt,
|
|
2996
|
-
unread: Boolean(r.unread),
|
|
3017
|
+
unread: Boolean(r.unread), threadTitle: r.threadTitle || "",
|
|
2997
3018
|
});
|
|
2998
3019
|
}
|
|
2999
3020
|
// A self-send exists on BOTH sides with the same relay id; keep only the
|
|
@@ -3005,7 +3026,7 @@
|
|
|
3005
3026
|
msgs.push({
|
|
3006
3027
|
id, threadId: s.threadId || id, direction: "out",
|
|
3007
3028
|
title: sentSubject(s), party: sentRecipient(s), at: s.createdAt,
|
|
3008
|
-
unread: false,
|
|
3029
|
+
unread: false, threadTitle: s.threadTitle || "",
|
|
3009
3030
|
});
|
|
3010
3031
|
}
|
|
3011
3032
|
return msgs;
|
|
@@ -3027,6 +3048,10 @@
|
|
|
3027
3048
|
// inbound sender, else the latest outbound recipient.
|
|
3028
3049
|
const lastIn = [...t.msgs].reverse().find((m) => m.direction === "in");
|
|
3029
3050
|
t.party = (lastIn && lastIn.party) || latest.party || "Conversation";
|
|
3051
|
+
// The conversation's NAME: an explicit thread title (set by whoever named
|
|
3052
|
+
// the exchange, carried on every item) wins; otherwise the oldest
|
|
3053
|
+
// message's subject, which is the relay that started it.
|
|
3054
|
+
t.title = (t.msgs.find((m) => m.threadTitle) || {}).threadTitle || t.msgs[0].title || "";
|
|
3030
3055
|
}
|
|
3031
3056
|
threads.sort((a, b) => new Date(b.latest.at || 0) - new Date(a.latest.at || 0));
|
|
3032
3057
|
return threads;
|
|
@@ -3046,7 +3071,7 @@
|
|
|
3046
3071
|
<span class="th-count">${t.msgs.length}</span>
|
|
3047
3072
|
<span class="th-time">${esc(timeAgo(t.latest.at))}</span>
|
|
3048
3073
|
</span>
|
|
3049
|
-
<span class="th-title">${esc(t.latest.title)}</span>
|
|
3074
|
+
<span class="th-title">${esc(t.title || t.latest.title)}</span>
|
|
3050
3075
|
</button>`).join("");
|
|
3051
3076
|
for (const el of threadsListEl.querySelectorAll(".th-item")) {
|
|
3052
3077
|
el.addEventListener("click", () => {
|
|
@@ -3055,6 +3080,22 @@
|
|
|
3055
3080
|
});
|
|
3056
3081
|
}
|
|
3057
3082
|
}
|
|
3083
|
+
// Stable per-party color: same person, same color, every conversation and
|
|
3084
|
+
// every restart (a hash, never an index into render order).
|
|
3085
|
+
function partyColorClass(name, mine) {
|
|
3086
|
+
if (mine) return "me";
|
|
3087
|
+
const key = String(name || "").trim().toLowerCase();
|
|
3088
|
+
let h = 0;
|
|
3089
|
+
for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0;
|
|
3090
|
+
return `p${h % 8}`;
|
|
3091
|
+
}
|
|
3092
|
+
// What the user's own messages are labelled with in a conversation. Read from
|
|
3093
|
+
// the inbox payload's account (always present, unlike the Settings view's
|
|
3094
|
+
// lazily-loaded info), falling back to the chat convention "You".
|
|
3095
|
+
function ownDisplayName() {
|
|
3096
|
+
const acct = (payload && payload.account) || {};
|
|
3097
|
+
return String(acct.name || acct.email || "You").trim() || "You";
|
|
3098
|
+
}
|
|
3058
3099
|
function renderThreadDetail() {
|
|
3059
3100
|
const thread = buildThreads().find((t) => t.threadId === threadDetailId);
|
|
3060
3101
|
if (!thread) {
|
|
@@ -3068,6 +3109,11 @@
|
|
|
3068
3109
|
return;
|
|
3069
3110
|
}
|
|
3070
3111
|
thDetailNameEl.textContent = thread.party;
|
|
3112
|
+
// The conversation's name sits under the party, the way a chat header shows
|
|
3113
|
+
// the subject: an explicit thread title when one was set, else the relay
|
|
3114
|
+
// that started the exchange. Hidden when it would only repeat the party.
|
|
3115
|
+
const headerTitle = String(thread.title || "").trim();
|
|
3116
|
+
thDetailTitleEl.textContent = headerTitle && headerTitle !== thread.party ? headerTitle : "";
|
|
3071
3117
|
// Thread-level opens act on the newest inbound message (falling back to the
|
|
3072
3118
|
// newest message overall): "current" injects with the threadId so the agent
|
|
3073
3119
|
// fetches the whole exchange; "new chat" seeds the full transcript.
|
|
@@ -3088,25 +3134,88 @@
|
|
|
3088
3134
|
// note): messages read oldest -> newest downward, the open actions sit at
|
|
3089
3135
|
// the BOTTOM next to the newest message, and entering the thread lands you
|
|
3090
3136
|
// at the newest message, not the stalest.
|
|
3091
|
-
|
|
3092
|
-
|
|
3137
|
+
const me = ownDisplayName();
|
|
3138
|
+
const nextHistory = thread.msgs.map((m) => {
|
|
3139
|
+
// Outbound = this user's own message: labelled with their name (not the
|
|
3140
|
+
// recipient's) and hung on the right, like your side of a group chat.
|
|
3141
|
+
// Labelling it with the recipient read as "Shane sent this" on a message
|
|
3142
|
+
// David sent, contradicting the transcript's own "You" (field report).
|
|
3143
|
+
const mine = m.direction === "out";
|
|
3144
|
+
const who = mine ? me : m.party;
|
|
3145
|
+
// Clicking a message expands ITS own actions rather than firing an open:
|
|
3146
|
+
// a single click used to jump straight to a new chat with no choice.
|
|
3147
|
+
const expanded = m.id === expandedThreadMsgId;
|
|
3148
|
+
const menu = expanded
|
|
3149
|
+
? openActionsHtml(m.id, { sent: mine, previewable: true })
|
|
3150
|
+
: "";
|
|
3151
|
+
return `
|
|
3152
|
+
<div class="th-msg${m.unread ? " unread" : ""}${mine ? " mine" : ""}${expanded ? " expanded" : ""}" role="button" tabindex="0" data-msg="${esc(m.id)}" data-dir="${m.direction}" data-opening-id="${esc(m.id)}">
|
|
3093
3153
|
<span class="th-msg-top">
|
|
3094
|
-
<span class="th-
|
|
3095
|
-
<span class="th-party">${esc(m.party)}</span>
|
|
3154
|
+
<span class="th-party ${partyColorClass(who, mine)}">${esc(who)}</span>
|
|
3096
3155
|
<span class="th-time">${esc(timeAgo(m.at))}</span>
|
|
3097
3156
|
</span>
|
|
3098
3157
|
<span class="th-msg-title">${esc(m.title)}</span>
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3158
|
+
${menu}
|
|
3159
|
+
<div class="row-err${rowNotes.get(m.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(m.id)}" data-stop="1">${esc(rowNotes.get(m.id)?.text || "")}</div>
|
|
3160
|
+
</div>`;
|
|
3161
|
+
}).join("") + threadActions + threadNote;
|
|
3162
|
+
// Repaint guard. This view is rebuilt on EVERY payload push (and entering it
|
|
3163
|
+
// triggers a sent refresh), which tore down and recreated the open-actions
|
|
3164
|
+
// card ~a second after opening — the flash David reported on "Read the
|
|
3165
|
+
// Conversation". Identical markup now means no DOM write and no rewiring.
|
|
3166
|
+
if (thHistoryEl.innerHTML !== nextHistory) {
|
|
3167
|
+
thHistoryEl.innerHTML = nextHistory;
|
|
3168
|
+
// Land at the newest message when ENTERING the thread; rebuilds of the same
|
|
3169
|
+
// open thread (payload pushes) keep the user's scroll position untouched.
|
|
3170
|
+
if (threadDetailScrolledFor !== thread.threadId) {
|
|
3171
|
+
threadDetailScrolledFor = thread.threadId;
|
|
3172
|
+
requestAnimationFrame(() => { scrollEl.scrollTop = scrollEl.scrollHeight; });
|
|
3173
|
+
}
|
|
3174
|
+
} else if (threadDetailScrolledFor !== thread.threadId) {
|
|
3103
3175
|
threadDetailScrolledFor = thread.threadId;
|
|
3104
3176
|
requestAnimationFrame(() => { scrollEl.scrollTop = scrollEl.scrollHeight; });
|
|
3177
|
+
return; // markup unchanged: existing handlers are still bound
|
|
3178
|
+
} else {
|
|
3179
|
+
return; // nothing changed at all
|
|
3180
|
+
}
|
|
3181
|
+
for (const z of thHistoryEl.querySelectorAll('[data-stop="1"]')) {
|
|
3182
|
+
z.addEventListener("click", (e) => e.stopPropagation());
|
|
3105
3183
|
}
|
|
3106
3184
|
for (const el of thHistoryEl.querySelectorAll(".th-msg")) {
|
|
3185
|
+
const id = el.getAttribute("data-msg");
|
|
3186
|
+
if (openingIds.has(id)) el.classList.add("opening");
|
|
3107
3187
|
el.addEventListener("click", () => {
|
|
3108
|
-
|
|
3109
|
-
|
|
3188
|
+
// Expand this message's own actions — never open straight into a new
|
|
3189
|
+
// chat. Same contract as a relay row in the Relays list.
|
|
3190
|
+
expandedThreadMsgId = expandedThreadMsgId === id ? null : id;
|
|
3191
|
+
renderThreadDetail();
|
|
3192
|
+
});
|
|
3193
|
+
}
|
|
3194
|
+
// Per-message actions. A message this user SENT routes through the sent
|
|
3195
|
+
// paths (its copy is forged before injection); a received one through the
|
|
3196
|
+
// relay paths. Both leave the menu closed after acting.
|
|
3197
|
+
for (const btn of thHistoryEl.querySelectorAll("[data-preview], [data-sent-preview]")) {
|
|
3198
|
+
const id = btn.getAttribute("data-preview") || btn.getAttribute("data-sent-preview");
|
|
3199
|
+
btn.addEventListener("click", () => {
|
|
3200
|
+
expandedThreadMsgId = null;
|
|
3201
|
+
window.relay.preview(id);
|
|
3202
|
+
renderThreadDetail();
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
for (const btn of thHistoryEl.querySelectorAll("[data-open-current], [data-sent-open-current]")) {
|
|
3206
|
+
const sentSide = btn.hasAttribute("data-sent-open-current");
|
|
3207
|
+
const id = btn.getAttribute("data-open-current") || btn.getAttribute("data-sent-open-current");
|
|
3208
|
+
btn.addEventListener("click", () => {
|
|
3209
|
+
expandedThreadMsgId = null;
|
|
3210
|
+
openRelayFromUI(id, sentSide ? "sent" : "relay", "current");
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
for (const btn of thHistoryEl.querySelectorAll("[data-open-fresh], [data-sent-open-fresh]")) {
|
|
3214
|
+
const sentSide = btn.hasAttribute("data-sent-open-fresh");
|
|
3215
|
+
const id = btn.getAttribute("data-open-fresh") || btn.getAttribute("data-sent-open-fresh");
|
|
3216
|
+
btn.addEventListener("click", () => {
|
|
3217
|
+
expandedThreadMsgId = null;
|
|
3218
|
+
openRelayFromUI(id, sentSide ? "sent" : "relay", "fresh");
|
|
3110
3219
|
});
|
|
3111
3220
|
}
|
|
3112
3221
|
const curBtn = thHistoryEl.querySelector("[data-thread-open-current]");
|
|
@@ -3119,12 +3228,14 @@
|
|
|
3119
3228
|
thBackEl.addEventListener("click", () => {
|
|
3120
3229
|
threadDetailId = null;
|
|
3121
3230
|
threadDetailScrolledFor = null;
|
|
3231
|
+
expandedThreadMsgId = null;
|
|
3122
3232
|
activeView = "relays";
|
|
3123
3233
|
syncTabs();
|
|
3124
3234
|
applyView();
|
|
3125
3235
|
renderAll();
|
|
3126
3236
|
});
|
|
3127
3237
|
function openThreadDetail(threadId, partyHint = "") {
|
|
3238
|
+
expandedThreadMsgId = null; // a fresh entry never opens with a menu up
|
|
3128
3239
|
threadDetailId = threadId;
|
|
3129
3240
|
threadDetailPartyHint = partyHint;
|
|
3130
3241
|
activeView = "threads";
|
|
@@ -3486,6 +3597,7 @@
|
|
|
3486
3597
|
// instruction is IN the chat. Green check, then fade.
|
|
3487
3598
|
// A Codex bridge/automation hand-off keeps the classic brief confirmation.
|
|
3488
3599
|
const injectedNoteTimers = new Map();
|
|
3600
|
+
const deliveredChatNames = new Map(); // packetId -> chat the injection was staged for
|
|
3489
3601
|
function fadeRowNoteLater(id, ms) {
|
|
3490
3602
|
if (injectedNoteTimers.has(id)) clearTimeout(injectedNoteTimers.get(id));
|
|
3491
3603
|
injectedNoteTimers.set(id, setTimeout(() => {
|
|
@@ -3503,7 +3615,12 @@
|
|
|
3503
3615
|
if (info.awaitingTurn) {
|
|
3504
3616
|
// Persistent until delivery / the auto-fresh hop — never a silent limbo.
|
|
3505
3617
|
if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
|
|
3506
|
-
|
|
3618
|
+
// Name the destination chat when we know it: "your \u201cgranular-relay\u201d chat"
|
|
3619
|
+
// tells the user WHERE to look; "your current chat" leaves them hunting
|
|
3620
|
+
// (field report: the injection landed in a chat they never found).
|
|
3621
|
+
if (info.chat) deliveredChatNames.set(id, info.chat);
|
|
3622
|
+
const where = info.chat ? `your \u201c${info.chat}\u201d chat` : "your current Claude chat";
|
|
3623
|
+
setRowNote(id, `Handed to ${where} — lands when it next takes a turn. If it\u2019s idle: type anything there, or use Open in New Chat for instant.`, "ok");
|
|
3507
3624
|
return;
|
|
3508
3625
|
}
|
|
3509
3626
|
const hostName = info.host === "codex" ? "Codex" : "Claude";
|
|
@@ -3511,7 +3628,8 @@
|
|
|
3511
3628
|
fadeRowNoteLater(id, 12000);
|
|
3512
3629
|
});
|
|
3513
3630
|
if (window.relay.onInjectionDelivered) window.relay.onInjectionDelivered((id) => {
|
|
3514
|
-
|
|
3631
|
+
const chat = deliveredChatNames.get(id);
|
|
3632
|
+
setRowNote(id, `✓ Delivered — ${chat ? `your \u201c${chat}\u201d chat` : "your current Claude chat"} is acting on it now.`, "ok");
|
|
3515
3633
|
fadeRowNoteLater(id, 8000);
|
|
3516
3634
|
});
|
|
3517
3635
|
if (window.relay.onInjectionAutoFresh) window.relay.onInjectionAutoFresh((id) => {
|
package/overlay/main.cjs
CHANGED
|
@@ -697,6 +697,7 @@ function readRelays() {
|
|
|
697
697
|
updatedAt: p.updatedAt || "",
|
|
698
698
|
inReplyToRelayId: p.inReplyToRelayId || null,
|
|
699
699
|
threadId: p.threadId || null,
|
|
700
|
+
threadTitle: p.threadTitle || "",
|
|
700
701
|
actionUrl: p.actionUrl || "",
|
|
701
702
|
action: p.action || {},
|
|
702
703
|
quickReply: Boolean(p.quickReply),
|
|
@@ -1231,6 +1232,7 @@ async function pushInboxNow(force) {
|
|
|
1231
1232
|
// Thread identity: a re-parented or newly threaded relay must repaint so
|
|
1232
1233
|
// per-thread grouping and unread counts stay correct.
|
|
1233
1234
|
r.threadId,
|
|
1235
|
+
r.threadTitle,
|
|
1234
1236
|
r.inReplyToRelayId,
|
|
1235
1237
|
]),
|
|
1236
1238
|
sent: payload.sent.map((r) => [
|
|
@@ -2164,7 +2166,8 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
|
|
|
2164
2166
|
if (!target) return fallbackFresh("No live Claude chat found");
|
|
2165
2167
|
console.error(
|
|
2166
2168
|
`[overlay] openInCurrent ${packetId}: staging for claude session ${String(target.sessionId).slice(0, 8)}… ` +
|
|
2167
|
-
`(${target.source}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago
|
|
2169
|
+
`(${target.source}${target.label ? `, "${target.label}"` : ""}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago` +
|
|
2170
|
+
`${target.skippedStubs ? `, skipped ${target.skippedStubs} never-used chat(s)` : ""})`,
|
|
2168
2171
|
);
|
|
2169
2172
|
const stageNow = () => {
|
|
2170
2173
|
let staged = null;
|
|
@@ -2186,7 +2189,10 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
|
|
|
2186
2189
|
// renderer must show an honest "waiting for that chat's turn" state, not a
|
|
2187
2190
|
// success toast. The watcher below flips it to Delivered in realtime — or,
|
|
2188
2191
|
// past the grace window, hops to a fresh chat so the click is never slow.
|
|
2189
|
-
|
|
2192
|
+
// Name the chat the relay actually went to: "your granular-relay chat"
|
|
2193
|
+
// beats "your current chat" when several are open (field report — the
|
|
2194
|
+
// injection landed somewhere invisible and nothing said where).
|
|
2195
|
+
confirmInjected("claude", { awaitingTurn: true, chat: target.label || "" });
|
|
2190
2196
|
finish();
|
|
2191
2197
|
watchInjectionDelivery(packetId, staged.path, {
|
|
2192
2198
|
onAutoFresh: () => {
|
package/package.json
CHANGED
package/src/auto-update.js
CHANGED
|
@@ -560,6 +560,14 @@ export function spawnDetachedUpdate({
|
|
|
560
560
|
`start_job "$DAEMON_PLIST" ${DAEMON_LABEL} || true`,
|
|
561
561
|
`ensure_job "$PILL_PLIST" ${PILL_LABEL} || { log_line "pill restart verification FAILED"; RESTART_FAILED=1; }`,
|
|
562
562
|
`ensure_job "$DAEMON_PLIST" ${DAEMON_LABEL} || { log_line "daemon restart verification FAILED"; RESTART_FAILED=1; }`,
|
|
563
|
+
// A "Boot-out failed: 3: No such process" means launchd had already lost the
|
|
564
|
+
// job while its PROCESS kept running — bootstrap then registers the job
|
|
565
|
+
// without replacing that stale pill, which keeps drawing with the old tree's
|
|
566
|
+
// window state (field report: wedged Space attachment after the 01:58
|
|
567
|
+
// restart, cured by hand with exactly this kickstart). Forcing the pill once
|
|
568
|
+
// more guarantees the process on screen came from the new tree. Pill only:
|
|
569
|
+
// kickstarting the daemon here would kill the job that owns this script.
|
|
570
|
+
`if [ "$RESTART_FAILED" != 1 ]; then "$LAUNCHCTL" kickstart -k "gui/${uid}/${PILL_LABEL}" >> ${LOG} 2>&1 || log_line "pill kickstart after bootstrap failed (continuing)"; fi`,
|
|
563
571
|
`if [ "$RESTART_FAILED" = 1 ]; then`,
|
|
564
572
|
` log_line "restart verification FAILED; restoring previous tree"`,
|
|
565
573
|
` restore_tree`,
|
package/src/claude-inject.cjs
CHANGED
|
@@ -255,6 +255,53 @@ function hookResponseFor(eventName, instruction) {
|
|
|
255
255
|
|
|
256
256
|
// ---- current-session resolution (pill side) --------------------------------
|
|
257
257
|
|
|
258
|
+
// A short, human-recognizable name for a chat with no title: its working
|
|
259
|
+
// directory's last segment ("granular-relay"), which is how people refer to
|
|
260
|
+
// their chats anyway.
|
|
261
|
+
function cwdLabel(cwd) {
|
|
262
|
+
const clean = String(cwd || "").replace(/[/\\]+$/, "");
|
|
263
|
+
if (!clean) return "";
|
|
264
|
+
const seg = clean.split(/[/\\]/).filter(Boolean).pop() || "";
|
|
265
|
+
return seg === "." || seg === ".." ? "" : seg;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Did a HUMAN ever speak in this session? Reads only the head of the transcript
|
|
269
|
+
// (the first real user message is always near the top), so this stays cheap on
|
|
270
|
+
// multi-megabyte logs. A user line that carries toolUseResult is a tool result
|
|
271
|
+
// being fed back, and isMeta lines are system-injected — neither is a person
|
|
272
|
+
// typing. Returns null when the transcript can't be read: unknown must never
|
|
273
|
+
// masquerade as "nobody spoke here" and disqualify a real chat.
|
|
274
|
+
const TRANSCRIPT_HEAD_BYTES = 512 * 1024;
|
|
275
|
+
function transcriptHasHumanTurn(transcriptPath) {
|
|
276
|
+
const file = String(transcriptPath || "").trim();
|
|
277
|
+
if (!file) return null;
|
|
278
|
+
let fd = null;
|
|
279
|
+
try {
|
|
280
|
+
fd = fs.openSync(file, "r");
|
|
281
|
+
const size = fs.fstatSync(fd).size;
|
|
282
|
+
const length = Math.min(size, TRANSCRIPT_HEAD_BYTES);
|
|
283
|
+
const buf = Buffer.alloc(length);
|
|
284
|
+
fs.readSync(fd, buf, 0, length, 0);
|
|
285
|
+
const text = buf.toString("utf8");
|
|
286
|
+
for (const line of text.split("\n")) {
|
|
287
|
+
if (!line.includes('"type":"user"') && !line.includes('"type": "user"')) continue;
|
|
288
|
+
if (line.includes('"toolUseResult"') || line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
// Only trust a "no" when the whole file was inspected; a longer transcript
|
|
292
|
+
// whose head is all tool traffic stays unknown.
|
|
293
|
+
return size <= TRANSCRIPT_HEAD_BYTES ? false : null;
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
} finally {
|
|
297
|
+
if (fd !== null) {
|
|
298
|
+
try {
|
|
299
|
+
fs.closeSync(fd);
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
258
305
|
function listDesktopSessionCandidates(desktopSessionsDir, { nowMs, maxAgeMs }) {
|
|
259
306
|
const candidates = [];
|
|
260
307
|
let orgs = [];
|
|
@@ -292,7 +339,21 @@ function listDesktopSessionCandidates(desktopSessionsDir, { nowMs, maxAgeMs }) {
|
|
|
292
339
|
if (!(lastActiveAt > 0) || nowMs - lastActiveAt > maxAgeMs) continue;
|
|
293
340
|
const sessionId = String(meta.cliSessionId || name.replace(/^local_/, "").replace(/\.json$/, "")).trim();
|
|
294
341
|
if (!sessionId) continue;
|
|
295
|
-
|
|
342
|
+
// completedTurns is Claude Desktop's own count of finished turns: 0 means
|
|
343
|
+
// nobody has ever spoken in this chat. Field report (Sven, 2026-08-06): a
|
|
344
|
+
// never-used stub chat won targeting purely on file/hook activity and the
|
|
345
|
+
// injection sat there forever. `undefined` (older Desktop builds) stays
|
|
346
|
+
// unknown rather than false — never exclude on missing data.
|
|
347
|
+
const turns = Number(meta.completedTurns);
|
|
348
|
+
candidates.push({
|
|
349
|
+
sessionId,
|
|
350
|
+
lastActiveAt,
|
|
351
|
+
source: "desktop",
|
|
352
|
+
hasHumanTurn: Number.isFinite(turns) ? turns > 0 : null,
|
|
353
|
+
// What to call this chat when telling the user where the relay went.
|
|
354
|
+
label: String(meta.title || "").trim() || cwdLabel(meta.cwd || meta.originCwd),
|
|
355
|
+
cwd: String(meta.cwd || meta.originCwd || ""),
|
|
356
|
+
});
|
|
296
357
|
}
|
|
297
358
|
}
|
|
298
359
|
}
|
|
@@ -319,7 +380,16 @@ function listRendezvousCandidates(homeDir, { nowMs, maxAgeMs }) {
|
|
|
319
380
|
if (!(lastActiveAt > 0) || nowMs - lastActiveAt > maxAgeMs) continue;
|
|
320
381
|
const sessionId = String((row && row.sessionId) || name.replace(/\.json$/, "")).trim();
|
|
321
382
|
if (!sessionId) continue;
|
|
322
|
-
candidates.push({
|
|
383
|
+
candidates.push({
|
|
384
|
+
sessionId,
|
|
385
|
+
lastActiveAt,
|
|
386
|
+
source: "terminal",
|
|
387
|
+
// A terminal session has no turn counter, so ask its transcript whether a
|
|
388
|
+
// human ever spoke there (null when we cannot tell).
|
|
389
|
+
hasHumanTurn: transcriptHasHumanTurn(row && row.transcriptPath),
|
|
390
|
+
label: cwdLabel(row && row.cwd),
|
|
391
|
+
cwd: String((row && row.cwd) || ""),
|
|
392
|
+
});
|
|
323
393
|
}
|
|
324
394
|
return candidates;
|
|
325
395
|
}
|
|
@@ -341,7 +411,7 @@ function findCurrentClaudeSession({
|
|
|
341
411
|
maxAgeMs = SESSION_MAX_AGE_MS,
|
|
342
412
|
focusPriorityMs = DESKTOP_FOCUS_PRIORITY_MS,
|
|
343
413
|
} = {}) {
|
|
344
|
-
const merged = new Map(); // sessionId -> { sessionId, lastActiveAt, desktop, focusedAt }
|
|
414
|
+
const merged = new Map(); // sessionId -> { sessionId, lastActiveAt, desktop, focusedAt, ... }
|
|
345
415
|
const fold = (candidate) => {
|
|
346
416
|
const existing = merged.get(candidate.sessionId);
|
|
347
417
|
if (!existing) {
|
|
@@ -353,20 +423,37 @@ function findCurrentClaudeSession({
|
|
|
353
423
|
// activity. Track focus separately so priority never leaks to busy
|
|
354
424
|
// background sessions.
|
|
355
425
|
focusedAt: candidate.source === "desktop" ? candidate.lastActiveAt : 0,
|
|
426
|
+
hasHumanTurn: candidate.hasHumanTurn ?? null,
|
|
427
|
+
label: candidate.label || "",
|
|
428
|
+
cwd: candidate.cwd || "",
|
|
356
429
|
});
|
|
357
430
|
return;
|
|
358
431
|
}
|
|
359
432
|
existing.lastActiveAt = Math.max(existing.lastActiveAt, candidate.lastActiveAt);
|
|
360
433
|
existing.desktop = existing.desktop || candidate.source === "desktop";
|
|
361
434
|
if (candidate.source === "desktop") existing.focusedAt = Math.max(existing.focusedAt, candidate.lastActiveAt);
|
|
435
|
+
// Any surface that can prove a human spoke here settles it; otherwise a
|
|
436
|
+
// known false only survives while nothing else has evidence.
|
|
437
|
+
if (candidate.hasHumanTurn === true) existing.hasHumanTurn = true;
|
|
438
|
+
else if (candidate.hasHumanTurn === false && existing.hasHumanTurn === null) existing.hasHumanTurn = false;
|
|
439
|
+
// Desktop metadata carries the real chat title; prefer it over a cwd label.
|
|
440
|
+
if (candidate.label && (!existing.label || candidate.source === "desktop")) existing.label = candidate.label;
|
|
441
|
+
if (candidate.cwd && !existing.cwd) existing.cwd = candidate.cwd;
|
|
362
442
|
};
|
|
363
443
|
if (desktopSessionsDir) {
|
|
364
444
|
for (const candidate of listDesktopSessionCandidates(desktopSessionsDir, { nowMs, maxAgeMs })) fold(candidate);
|
|
365
445
|
}
|
|
366
446
|
for (const candidate of listRendezvousCandidates(homeDir, { nowMs, maxAgeMs })) fold(candidate);
|
|
447
|
+
// Chats nobody has ever spoken in are a LAST resort, not a rival: they win on
|
|
448
|
+
// raw activity (a stub can log file/hook events forever) while being exactly
|
|
449
|
+
// the place a relay must not be delivered. A never-used chat is still allowed
|
|
450
|
+
// when it is all that exists — the user may have just opened a fresh one.
|
|
451
|
+
const all = [...merged.values()];
|
|
452
|
+
const spoken = all.filter((e) => e.hasHumanTurn !== false);
|
|
453
|
+
const pool = spoken.length ? spoken : all;
|
|
367
454
|
let bestFocused = null;
|
|
368
455
|
let best = null;
|
|
369
|
-
for (const entry of
|
|
456
|
+
for (const entry of pool) {
|
|
370
457
|
if (entry.focusedAt > 0 && nowMs - entry.focusedAt <= focusPriorityMs) {
|
|
371
458
|
if (!bestFocused || entry.focusedAt > bestFocused.focusedAt) bestFocused = entry;
|
|
372
459
|
}
|
|
@@ -376,7 +463,15 @@ function findCurrentClaudeSession({
|
|
|
376
463
|
}
|
|
377
464
|
const chosen = bestFocused || best;
|
|
378
465
|
return chosen
|
|
379
|
-
? {
|
|
466
|
+
? {
|
|
467
|
+
sessionId: chosen.sessionId,
|
|
468
|
+
lastActiveAt: chosen.lastActiveAt,
|
|
469
|
+
source: chosen.desktop ? "desktop" : "terminal",
|
|
470
|
+
// What to call this chat when confirming where the relay went.
|
|
471
|
+
label: chosen.label || "",
|
|
472
|
+
cwd: chosen.cwd || "",
|
|
473
|
+
skippedStubs: all.length - pool.length,
|
|
474
|
+
}
|
|
380
475
|
: null;
|
|
381
476
|
}
|
|
382
477
|
|
package/src/mcp.js
CHANGED
|
@@ -14,7 +14,7 @@ export const TOOLS = [
|
|
|
14
14
|
{
|
|
15
15
|
name: "relay_send",
|
|
16
16
|
description:
|
|
17
|
-
"Send an ordinary Relay message or handoff. Use this for the normal product flow when the human says things like 'send Sven a relay asking how he is doing' or 'relay this context to Jordan'. THREADING: Relay groups messages into conversation threads built from reply chains, so before sending, check the recent exchange with this recipient — call relay_inbox_list for what they sent this human (each item carries threadId and inReplyToRelayId), and use the sent context you already have from earlier sends. If the new message continues an ongoing exchange — topically, or within a recent back-and-forth window — pass inReplyToRelayId set to the LATEST relay id of that exchange (whichever side sent it) so the conversation stays one thread. If it starts a new topic, omit inReplyToRelayId and send it standalone. Granular digital employees appear as managed, workspace-labelled contacts; use relay_contacts_search and pass the exact matching contactId so a user with several workspaces reaches the intended employee. That message is authored by this human and wakes their employee; it is NOT a Relay task or deterministic workflow. Never use task-only tools to contact a Granular employee. For other recipients, an on-Relay message appears in their companion and Relays list; an off-Relay recipient receives an email open link.",
|
|
17
|
+
"Send an ordinary Relay message or handoff. Use this for the normal product flow when the human says things like 'send Sven a relay asking how he is doing' or 'relay this context to Jordan'. THREADING: Relay groups messages into conversation threads built from reply chains, so before sending, check the recent exchange with this recipient — call relay_inbox_list for what they sent this human (each item carries threadId and inReplyToRelayId), and use the sent context you already have from earlier sends. If the new message continues an ongoing exchange — topically, or within a recent back-and-forth window — pass inReplyToRelayId set to the LATEST relay id of that exchange (whichever side sent it) so the conversation stays one thread. If it starts a new topic, omit inReplyToRelayId and send it standalone. A thread has a NAME the human scans in their Relay list: whenever you reply into a thread that is not already well named, pass threadTitle to name the conversation (see that field). Granular digital employees appear as managed, workspace-labelled contacts; use relay_contacts_search and pass the exact matching contactId so a user with several workspaces reaches the intended employee. That message is authored by this human and wakes their employee; it is NOT a Relay task or deterministic workflow. Never use task-only tools to contact a Granular employee. For other recipients, an on-Relay message appears in their companion and Relays list; an off-Relay recipient receives an email open link.",
|
|
18
18
|
inputSchema: {
|
|
19
19
|
type: "object",
|
|
20
20
|
properties: {
|
|
@@ -79,6 +79,11 @@ export const TOOLS = [
|
|
|
79
79
|
description:
|
|
80
80
|
"The id of the relay this message replies to. Threads are built from reply chains: when continuing an ongoing exchange, reply to the NEWEST relay in that exchange (from relay_inbox_list or an earlier send), never an older one, so the whole conversation shares one thread. Omit it to start a new standalone thread. When answering a question relay, pass that relay's id here — this is what resumes the asking agent's run.",
|
|
81
81
|
},
|
|
82
|
+
threadTitle: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description:
|
|
85
|
+
"Name the CONVERSATION (not this message). A thread comes into existence the moment a relay replies to another, and its name is what both people scan in their Relay list ever after — so whenever you send a reply (inReplyToRelayId set) into a thread that is not already well named, title it here. Same discipline as `title`, one level up: in as few words as it honestly takes (~5-12 words, hard stop 140 characters), say what this exchange IS and what it is for, so someone reading the list weeks later knows whether to open it. Name the ongoing subject, not the latest turn: 'Postmark migration for sendrelays.com — DNS + verification' beats 'Re: DNS records' or 'Postmark'. Never a bare noun ('Update', 'Question'), never a timestamp, never the other person's name alone. Setting it again renames the thread — use that when a conversation genuinely changes subject, and leave it alone when the existing name still fits.",
|
|
86
|
+
},
|
|
82
87
|
type: {
|
|
83
88
|
type: "string",
|
|
84
89
|
enum: ["question", "completion"],
|
|
@@ -181,7 +186,7 @@ export const TOOLS = [
|
|
|
181
186
|
{
|
|
182
187
|
name: "relay_thread_fetch",
|
|
183
188
|
description:
|
|
184
|
-
"Fetch ONE full Relay conversation thread, oldest first: every relay in the exchange (both directions) with sender, recipients, timestamps, title, body, and read state. Use this whenever a relay you are opening or replying to is part of a thread (its threadId differs from its own id, or an Open-in-chat instruction names a threadId) so the WHOLE exchange is in context before you act — never reconstruct a conversation by fetching relays one at a time. Pass the threadId (the root relay's id, carried on every relay_inbox_list item).",
|
|
189
|
+
"Fetch ONE full Relay conversation thread, oldest first: its threadTitle (the conversation's name — check it before replying; if it is missing or no longer describes the exchange, pass a better one as relay_send's threadTitle) and every relay in the exchange (both directions) with sender, recipients, timestamps, title, body, and read state. Use this whenever a relay you are opening or replying to is part of a thread (its threadId differs from its own id, or an Open-in-chat instruction names a threadId) so the WHOLE exchange is in context before you act — never reconstruct a conversation by fetching relays one at a time. Pass the threadId (the root relay's id, carried on every relay_inbox_list item).",
|
|
185
190
|
inputSchema: {
|
|
186
191
|
type: "object",
|
|
187
192
|
properties: {
|
|
@@ -602,6 +607,7 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
|
|
|
602
607
|
targetSurfaces: args.targetSurfaces || [],
|
|
603
608
|
attachments: await prepareOrdinaryRelayAttachments(args),
|
|
604
609
|
...(args.inReplyToRelayId ? { inReplyToRelayId: args.inReplyToRelayId } : {}),
|
|
610
|
+
...(args.threadTitle ? { threadTitle: args.threadTitle } : {}),
|
|
605
611
|
...(args.type ? { type: args.type } : {}),
|
|
606
612
|
idempotencyKey: args.idempotencyKey,
|
|
607
613
|
})),
|
package/src/notifications.js
CHANGED
|
@@ -650,6 +650,9 @@ export function stagePlainRelayItem(
|
|
|
650
650
|
// exchange and keep its unread counting stable per thread.
|
|
651
651
|
inReplyToRelayId: item.inReplyToRelayId || packet?.inReplyToRelayId || null,
|
|
652
652
|
threadId: item.threadId || packet?.threadId || item.relayId,
|
|
653
|
+
// The conversation's name, carried on every item by the API so the pill can
|
|
654
|
+
// label a thread without fetching it.
|
|
655
|
+
threadTitle: item.threadTitle || packet?.threadTitle || null,
|
|
653
656
|
// A restoration is a fresh inbox delivery even when the underlying relay was
|
|
654
657
|
// read before deletion. Use that timestamp so an older account read-all
|
|
655
658
|
// cutoff cannot immediately collapse the restored row back to read.
|
|
@@ -746,6 +749,7 @@ export function stageSentRelayItem(
|
|
|
746
749
|
// inbound side. An item without one must not clobber a previously staged
|
|
747
750
|
// id (this row is re-staged on every open).
|
|
748
751
|
threadId: item.threadId || existing.threadId || null,
|
|
752
|
+
threadTitle: item.threadTitle || existing.threadTitle || null,
|
|
749
753
|
inReplyToRelayId: item.inReplyToRelayId || existing.inReplyToRelayId || null,
|
|
750
754
|
};
|
|
751
755
|
const contentPath = writeNotificationPacketContent({ id: materializationId }, content, statePath);
|