relay-companion 0.1.80 → 0.1.82
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 +49 -18
- package/overlay/main.cjs +94 -4
- package/overlay/preload.cjs +6 -0
- package/package.json +1 -1
package/overlay/inbox.html
CHANGED
|
@@ -1702,7 +1702,7 @@
|
|
|
1702
1702
|
${previewHtml}
|
|
1703
1703
|
${actions}
|
|
1704
1704
|
${openCard}
|
|
1705
|
-
<div class="row-err" data-err="${esc(r.id)}"
|
|
1705
|
+
<div class="row-err${rowNotes.get(r.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(r.id)}">${esc(rowNotes.get(r.id)?.text || "")}</div>
|
|
1706
1706
|
</div>`;
|
|
1707
1707
|
}
|
|
1708
1708
|
|
|
@@ -1756,9 +1756,23 @@
|
|
|
1756
1756
|
if (el) el.textContent = message || "";
|
|
1757
1757
|
}
|
|
1758
1758
|
function cssId(id) { return String(id).replace(/"/g, '\\"'); }
|
|
1759
|
+
// Injection hand-off notes are STATE, not one-shot DOM writes: the relay list is
|
|
1760
|
+
// rebuilt via innerHTML on every payload push, which used to wipe the note within
|
|
1761
|
+
// seconds — precisely while the user was staring at a click that looked dead.
|
|
1762
|
+
// relayRowHtml renders the current note into every rebuild; timers only ever
|
|
1763
|
+
// remove entries from this map. id -> { text, cls }
|
|
1764
|
+
const rowNotes = new Map();
|
|
1765
|
+
function setRowNote(id, text, cls) {
|
|
1766
|
+
rowNotes.set(id, { text, cls });
|
|
1767
|
+
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
|
|
1768
|
+
el.textContent = text;
|
|
1769
|
+
el.classList.toggle("ok", cls === "ok");
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1759
1772
|
// The .row-err slot carries both inline errors and the green injection note, and one
|
|
1760
1773
|
// relay can be rendered into more than one list, so wipe every copy of the row's slot.
|
|
1761
1774
|
function clearRowNote(id) {
|
|
1775
|
+
rowNotes.delete(id);
|
|
1762
1776
|
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
|
|
1763
1777
|
el.textContent = "";
|
|
1764
1778
|
el.classList.remove("ok");
|
|
@@ -3067,31 +3081,48 @@
|
|
|
3067
3081
|
// local flag so the next mousemove re-runs the interactivity handshake cleanly.
|
|
3068
3082
|
if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
|
|
3069
3083
|
if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
|
|
3070
|
-
// Visible confirmation for "Open in current chat"
|
|
3071
|
-
//
|
|
3072
|
-
//
|
|
3084
|
+
// Visible confirmation for "Open in current chat". Two honest states:
|
|
3085
|
+
// waiting — staged for an idle chat; ONLY that chat's next turn can ingest
|
|
3086
|
+
// it, so the note stays up (surviving re-renders) until delivery
|
|
3087
|
+
// and tells the user how to pull it in themselves.
|
|
3088
|
+
// delivered — main watched the consume-once injection file disappear; the
|
|
3089
|
+
// instruction is IN the chat. Green check, then fade.
|
|
3090
|
+
// A Codex bridge/automation hand-off keeps the classic brief confirmation.
|
|
3073
3091
|
const injectedNoteTimers = new Map();
|
|
3092
|
+
function fadeRowNoteLater(id, ms) {
|
|
3093
|
+
if (injectedNoteTimers.has(id)) clearTimeout(injectedNoteTimers.get(id));
|
|
3094
|
+
injectedNoteTimers.set(id, setTimeout(() => {
|
|
3095
|
+
injectedNoteTimers.delete(id);
|
|
3096
|
+
clearRowNote(id);
|
|
3097
|
+
}, ms));
|
|
3098
|
+
}
|
|
3074
3099
|
if (window.relay.onInjected) window.relay.onInjected((id, info) => {
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
injectedNoteTimers.set(id, setTimeout(() => {
|
|
3081
|
-
injectedNoteTimers.delete(id);
|
|
3082
|
-
if (el.isConnected) { el.textContent = ""; el.classList.remove("ok"); }
|
|
3083
|
-
}, 12000));
|
|
3100
|
+
if (info.awaitingTurn) {
|
|
3101
|
+
// Persistent until delivery / the auto-fresh hop — never a silent limbo.
|
|
3102
|
+
if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
|
|
3103
|
+
setRowNote(id, "Handed to your current Claude chat…", "ok");
|
|
3104
|
+
return;
|
|
3084
3105
|
}
|
|
3106
|
+
const hostName = info.host === "codex" ? "Codex" : "Claude";
|
|
3107
|
+
setRowNote(id, `✓ Sent to your current ${hostName} chat — it will pick this up at its next checkpoint.`, "ok");
|
|
3108
|
+
fadeRowNoteLater(id, 12000);
|
|
3109
|
+
});
|
|
3110
|
+
if (window.relay.onInjectionDelivered) window.relay.onInjectionDelivered((id) => {
|
|
3111
|
+
setRowNote(id, "✓ Delivered — your current Claude chat is acting on it now.", "ok");
|
|
3112
|
+
fadeRowNoteLater(id, 8000);
|
|
3113
|
+
});
|
|
3114
|
+
if (window.relay.onInjectionAutoFresh) window.relay.onInjectionAutoFresh((id) => {
|
|
3115
|
+
setRowNote(id, "Your current chat was idle, so this is opening in a new chat instead.", "ok");
|
|
3116
|
+
fadeRowNoteLater(id, 10000);
|
|
3085
3117
|
});
|
|
3086
3118
|
// An open that failed leaves the relay UNREAD on purpose (it never reached the user),
|
|
3087
3119
|
// so the row has to say what happened — otherwise the spinner just stops and the click
|
|
3088
|
-
// looks like it worked.
|
|
3120
|
+
// looks like it worked. Persisted via rowNotes so a payload-push rebuild can't wipe
|
|
3121
|
+
// the explanation while the user is reading it. Cleared by the next open of the row.
|
|
3089
3122
|
if (window.relay.onOpenError) window.relay.onOpenError((id, message) => {
|
|
3090
3123
|
if (!message) return;
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
el.innerHTML = esc(message);
|
|
3094
|
-
}
|
|
3124
|
+
if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
|
|
3125
|
+
setRowNote(id, message, "err");
|
|
3095
3126
|
});
|
|
3096
3127
|
window.relay.refresh().then((p) => {
|
|
3097
3128
|
onPayload(p);
|
package/overlay/main.cjs
CHANGED
|
@@ -1890,6 +1890,81 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
|
|
|
1890
1890
|
// How many messages (inbound rows + sent copies, deduped) share a thread.
|
|
1891
1891
|
// Gives the injected instruction its "N messages so far" and tells the model
|
|
1892
1892
|
// when fetching the whole thread is worth it.
|
|
1893
|
+
// The staged injection file is consume-once: the claude-hook renames it away at
|
|
1894
|
+
// the moment of delivery. Watching for its disappearance is therefore a REAL
|
|
1895
|
+
// delivery signal, and the row note can tell the truth in realtime — "waiting
|
|
1896
|
+
// for that chat's turn" versus "delivered". Field lesson (2026-08-05): an
|
|
1897
|
+
// injection for an idle session sat invisible for minutes and the click read as
|
|
1898
|
+
// completely dead; a waiting state that never lies beats a success toast.
|
|
1899
|
+
//
|
|
1900
|
+
// The GRACE WINDOW closes the remaining gap. Nothing outside a session can make
|
|
1901
|
+
// it take a turn (verified against the platform docs: deep links only pre-fill
|
|
1902
|
+
// NEW sessions; no external submit API exists), so an idle chat cannot be woken
|
|
1903
|
+
// — but the user must still get the relay QUICKLY. A busy chat ingests at a
|
|
1904
|
+
// tool boundary within seconds; if the grace window passes unconsumed, the
|
|
1905
|
+
// target is idle: reclaim the injection (same rename-first consume-once
|
|
1906
|
+
// discipline as the hook, so a racing delivery always wins) and open the relay
|
|
1907
|
+
// in a fresh chat instead. Every click produces a visible result in seconds.
|
|
1908
|
+
const injectionWatchers = new Map(); // packetId -> interval
|
|
1909
|
+
const AUTO_FRESH_GRACE_MS = (() => {
|
|
1910
|
+
const raw = Number(process.env.RELAY_CURRENT_CHAT_GRACE_MS);
|
|
1911
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 6000; // 0 disables auto-fresh
|
|
1912
|
+
})();
|
|
1913
|
+
function reclaimInjection(stagedPath) {
|
|
1914
|
+
const claimed = `${stagedPath}.${process.pid}.${Date.now()}.autofresh`;
|
|
1915
|
+
try {
|
|
1916
|
+
fs.renameSync(stagedPath, claimed); // loses the race iff the hook consumed it
|
|
1917
|
+
} catch {
|
|
1918
|
+
return false;
|
|
1919
|
+
}
|
|
1920
|
+
try {
|
|
1921
|
+
fs.rmSync(claimed, { force: true });
|
|
1922
|
+
} catch {}
|
|
1923
|
+
return true;
|
|
1924
|
+
}
|
|
1925
|
+
function watchInjectionDelivery(packetId, stagedPath, { timeoutMs = 10 * 60 * 1000, intervalMs = 500, autoFreshMs = AUTO_FRESH_GRACE_MS, onAutoFresh = null } = {}) {
|
|
1926
|
+
if (!stagedPath) return;
|
|
1927
|
+
const existing = injectionWatchers.get(packetId);
|
|
1928
|
+
if (existing) clearInterval(existing); // newest click owns the watch
|
|
1929
|
+
const startedAt = Date.now();
|
|
1930
|
+
const timer = setInterval(() => {
|
|
1931
|
+
let gone = false;
|
|
1932
|
+
try {
|
|
1933
|
+
gone = !fs.existsSync(stagedPath);
|
|
1934
|
+
} catch {
|
|
1935
|
+
gone = false;
|
|
1936
|
+
}
|
|
1937
|
+
if (gone) {
|
|
1938
|
+
clearInterval(timer);
|
|
1939
|
+
injectionWatchers.delete(packetId);
|
|
1940
|
+
console.error(`[overlay] openInCurrent ${packetId}: delivered (injection consumed after ${Math.round((Date.now() - startedAt) / 1000)}s)`);
|
|
1941
|
+
if (win && !win.isDestroyed()) win.webContents.send("injectionDelivered", packetId);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
if (onAutoFresh && autoFreshMs > 0 && Date.now() - startedAt >= autoFreshMs) {
|
|
1945
|
+
clearInterval(timer);
|
|
1946
|
+
injectionWatchers.delete(packetId);
|
|
1947
|
+
if (!reclaimInjection(stagedPath)) {
|
|
1948
|
+
// The hook won the rename race: this IS a delivery.
|
|
1949
|
+
console.error(`[overlay] openInCurrent ${packetId}: delivered (won by the hook at the grace boundary)`);
|
|
1950
|
+
if (win && !win.isDestroyed()) win.webContents.send("injectionDelivered", packetId);
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
console.error(`[overlay] openInCurrent ${packetId}: current chat idle for ${autoFreshMs}ms; reclaimed the injection and opening fresh`);
|
|
1954
|
+
onAutoFresh();
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
// A restaged/overwritten click or a very long idle: stop polling quietly.
|
|
1958
|
+
// The waiting note remains accurate — the injection is still pending.
|
|
1959
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
1960
|
+
clearInterval(timer);
|
|
1961
|
+
injectionWatchers.delete(packetId);
|
|
1962
|
+
}
|
|
1963
|
+
}, intervalMs);
|
|
1964
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1965
|
+
injectionWatchers.set(packetId, timer);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1893
1968
|
function threadInfoFor(row) {
|
|
1894
1969
|
const threadId = (row && row.threadId) || null;
|
|
1895
1970
|
if (!threadId) return { threadId: null, threadCount: 0 };
|
|
@@ -1913,8 +1988,10 @@ async function openPacketInCurrent(packetId) {
|
|
|
1913
1988
|
};
|
|
1914
1989
|
// Confirm the staging in the UI: without this, a successful injection is
|
|
1915
1990
|
// indistinguishable from a dead click until the agent's next checkpoint.
|
|
1916
|
-
|
|
1917
|
-
|
|
1991
|
+
// extra.awaitingTurn tells the renderer this is a WAITING state (idle target
|
|
1992
|
+
// session), not a completed hand-off.
|
|
1993
|
+
const confirmInjected = (host, extra = {}) => {
|
|
1994
|
+
if (win && !win.isDestroyed()) win.webContents.send("injected", packetId, { host, ...extra });
|
|
1918
1995
|
};
|
|
1919
1996
|
const fallbackFresh = (note) => {
|
|
1920
1997
|
// The click said "current chat" but we're opening a NEW one — never do that
|
|
@@ -1983,8 +2060,9 @@ async function openPacketInCurrent(packetId) {
|
|
|
1983
2060
|
`(${target.source}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago)`,
|
|
1984
2061
|
);
|
|
1985
2062
|
const stageNow = () => {
|
|
2063
|
+
let staged = null;
|
|
1986
2064
|
try {
|
|
1987
|
-
claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
|
|
2065
|
+
staged = claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
|
|
1988
2066
|
relayId: packetId,
|
|
1989
2067
|
senderName: row.senderName || "",
|
|
1990
2068
|
title: row.title || row.displayTitle || "",
|
|
@@ -1997,8 +2075,20 @@ async function openPacketInCurrent(packetId) {
|
|
|
1997
2075
|
// Raise Claude Desktop so the user watches the instruction land; a
|
|
1998
2076
|
// terminal-only session stays where it is (its window is not ours to raise).
|
|
1999
2077
|
if (target.source === "desktop") activateHost("claude", bundle);
|
|
2000
|
-
|
|
2078
|
+
// awaitingTurn: an idle session consumes only on its NEXT hook event, so the
|
|
2079
|
+
// renderer must show an honest "waiting for that chat's turn" state, not a
|
|
2080
|
+
// success toast. The watcher below flips it to Delivered in realtime — or,
|
|
2081
|
+
// past the grace window, hops to a fresh chat so the click is never slow.
|
|
2082
|
+
confirmInjected("claude", { awaitingTurn: true });
|
|
2001
2083
|
finish();
|
|
2084
|
+
watchInjectionDelivery(packetId, staged.path, {
|
|
2085
|
+
onAutoFresh: () => {
|
|
2086
|
+
if (win && !win.isDestroyed()) win.webContents.send("injectionAutoFresh", packetId);
|
|
2087
|
+
openPacket(packetId, { fresh: true }).catch((error) =>
|
|
2088
|
+
console.error("[overlay] auto-fresh after idle grace failed:", error && error.message),
|
|
2089
|
+
);
|
|
2090
|
+
},
|
|
2091
|
+
});
|
|
2002
2092
|
};
|
|
2003
2093
|
// NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
|
|
2004
2094
|
// ever reads the staged file. Where setup never wrote those hooks (Windows installs
|
package/overlay/preload.cjs
CHANGED
|
@@ -11,6 +11,12 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
11
11
|
// "Open in current chat" staged an injection: the UI confirms it so the
|
|
12
12
|
// click has visible feedback (delivery is checkpoint-based, not instant).
|
|
13
13
|
onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
|
|
14
|
+
// The staged injection was actually CONSUMED by the target session — flip the
|
|
15
|
+
// waiting note to a delivered one (main watches the consume-once file).
|
|
16
|
+
onInjectionDelivered: (cb) => ipcRenderer.on("injectionDelivered", (_e, id) => cb(id)),
|
|
17
|
+
// The current chat stayed idle through the grace window; the injection was
|
|
18
|
+
// reclaimed and the relay is opening in a fresh chat instead.
|
|
19
|
+
onInjectionAutoFresh: (cb) => ipcRenderer.on("injectionAutoFresh", (_e, id) => cb(id)),
|
|
14
20
|
// The open failed (CLI error / helper wouldn't spawn). The row stays unread, so
|
|
15
21
|
// say why inline instead of letting the spinner just stop.
|
|
16
22
|
onOpenError: (cb) => ipcRenderer.on("openError", (_e, id, message) => cb(id, message || "")),
|