relay-companion 0.1.151 → 0.1.153
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 +63 -7
- package/overlay/main.cjs +81 -0
- package/overlay/preload.cjs +3 -0
- package/overlay/preview.html +9 -3
- package/package.json +1 -1
- package/src/links.js +83 -0
- package/src/materializer.js +76 -2
- package/src/mcp.js +8 -3
- package/src/notifications.js +18 -0
- package/src/task-daemon.js +54 -0
package/overlay/inbox.html
CHANGED
|
@@ -249,6 +249,16 @@
|
|
|
249
249
|
.scroll::-webkit-scrollbar { width:10px; }
|
|
250
250
|
.scroll::-webkit-scrollbar-thumb { background:var(--edge); border-radius:var(--r-pill); border:3px solid var(--bg); background-clip:padding-box; }
|
|
251
251
|
.scroll::-webkit-scrollbar-thumb:hover { background:var(--edge); background-clip:padding-box; }
|
|
252
|
+
/* The list must NEVER scroll sideways. overflow-y:auto computes overflow-x
|
|
253
|
+
to auto, so any row whose min-content crosses the width (the custom
|
|
254
|
+
scrollbar already eats 10px of it) sprouted a horizontal scrollbar — and
|
|
255
|
+
with both bars present, the UNSTYLED scrollbar corner painted as a white
|
|
256
|
+
square over the card's bottom-right (field report, 2026-08-12). Rows
|
|
257
|
+
already ellipsize/wrap internally; sideways overflow is always a bug, so
|
|
258
|
+
clip it categorically and keep the corner transparent as belt-and-braces. */
|
|
259
|
+
.scroll { overflow-x:hidden; }
|
|
260
|
+
.scroll::-webkit-scrollbar:horizontal { height:0; }
|
|
261
|
+
.scroll::-webkit-scrollbar-corner { background:transparent; }
|
|
252
262
|
.view.hidden { display:none; }
|
|
253
263
|
|
|
254
264
|
/* ---- group headers (Tasks) ---- */
|
|
@@ -546,6 +556,10 @@
|
|
|
546
556
|
|
|
547
557
|
/* attachment chips (markup emitted by attachmentChips helper) */
|
|
548
558
|
.td-att { display:flex; align-items:center; gap:7px; margin-top:8px; padding:6px 9px; border:1px solid var(--hair); border-radius:var(--r-2); min-width:0; background:color-mix(in srgb, var(--bg) 60%, transparent); }
|
|
559
|
+
/* clickable variant: same chip, rendered as a real button that opens the file */
|
|
560
|
+
button.td-att { width:100%; font:inherit; color:inherit; text-align:left; cursor:pointer; }
|
|
561
|
+
button.td-att:hover { border-color:var(--muted-2); background:color-mix(in srgb, var(--bg) 85%, transparent); }
|
|
562
|
+
button.td-att:disabled { opacity:.55; cursor:default; }
|
|
549
563
|
.td-att svg { flex:none; opacity:.75; }
|
|
550
564
|
.td-att-name { font-size:12px; font-weight:600; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
|
551
565
|
.td-att-meta { margin-left:auto; flex:none; font-family:var(--mono); font-size:10px; color:var(--muted-2); }
|
|
@@ -1709,18 +1723,56 @@
|
|
|
1709
1723
|
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
|
1710
1724
|
return n > 0 ? `${n} B` : "";
|
|
1711
1725
|
}
|
|
1712
|
-
|
|
1726
|
+
// Handles both attachment shapes: task-message files ({filename, sizeBytes})
|
|
1727
|
+
// and relay attachments ({name, bytes}). With a relayId the chip becomes a
|
|
1728
|
+
// button that opens the file via main (local copy, or download-then-open);
|
|
1729
|
+
// without one it stays the plain readout.
|
|
1730
|
+
function attachmentChips(attachments, { relayId = "" } = {}) {
|
|
1713
1731
|
const list = Array.isArray(attachments) ? attachments : [];
|
|
1714
1732
|
return list.map((a) => {
|
|
1715
|
-
const
|
|
1733
|
+
const name = a.filename || a.name || "file";
|
|
1734
|
+
const size = fmtBytes(a.sizeBytes != null ? a.sizeBytes : a.bytes);
|
|
1735
|
+
const inner = `
|
|
1736
|
+
${fileIconSvg(fileFamilyOf(name, a.contentType))}
|
|
1737
|
+
<span class="td-att-name">${esc(name)}</span>
|
|
1738
|
+
${size ? `<span class="td-att-meta">${esc(size)}</span>` : ""}`;
|
|
1739
|
+
if (relayId && a.id) {
|
|
1740
|
+
return `
|
|
1741
|
+
<button class="td-att td-att-open" type="button" data-att-relay="${esc(relayId)}" data-att-id="${esc(a.id)}" data-stop="1" title="Open ${esc(name)}" aria-label="Open attachment ${esc(name)}">${inner}</button>`;
|
|
1742
|
+
}
|
|
1716
1743
|
return `
|
|
1717
|
-
<div class="td-att"
|
|
1718
|
-
${fileIconSvg(fileFamilyOf(a.filename, a.contentType))}
|
|
1719
|
-
<span class="td-att-name">${esc(a.filename || "file")}</span>
|
|
1720
|
-
${size ? `<span class="td-att-meta">${esc(size)}</span>` : ""}
|
|
1721
|
-
</div>`;
|
|
1744
|
+
<div class="td-att">${inner}</div>`;
|
|
1722
1745
|
}).join("");
|
|
1723
1746
|
}
|
|
1747
|
+
|
|
1748
|
+
// One delegated wiring pass per rebuilt container: chip click -> main opens
|
|
1749
|
+
// the file. Never toggles the row (data-stop) and reports failures into the
|
|
1750
|
+
// same .row-err slot every other row action uses.
|
|
1751
|
+
function wireAttachmentChips(root) {
|
|
1752
|
+
for (const chip of root.querySelectorAll("[data-att-relay]")) {
|
|
1753
|
+
chip.addEventListener("click", async (event) => {
|
|
1754
|
+
event.stopPropagation();
|
|
1755
|
+
if (chip.disabled) return;
|
|
1756
|
+
chip.disabled = true;
|
|
1757
|
+
const relayId = chip.getAttribute("data-att-relay");
|
|
1758
|
+
showRowError(relayId, "");
|
|
1759
|
+
try {
|
|
1760
|
+
const res = await window.relay.openAttachment(relayId, chip.getAttribute("data-att-id"));
|
|
1761
|
+
if (!res || res.ok === false) showRowError(relayId, (res && res.error) || "Could not open this attachment.");
|
|
1762
|
+
} catch {
|
|
1763
|
+
showRowError(relayId, "Could not open this attachment.");
|
|
1764
|
+
} finally {
|
|
1765
|
+
chip.disabled = false;
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Attachments for one relay row, straight from the current payload.
|
|
1772
|
+
function relayAttachmentsForId(id) {
|
|
1773
|
+
const row = (payload.relays || []).find((r) => r.id === id);
|
|
1774
|
+
return row && Array.isArray(row.attachments) ? row.attachments : [];
|
|
1775
|
+
}
|
|
1724
1776
|
// Tasks/share approvals are hidden from the pill until that product surface is ready.
|
|
1725
1777
|
const RELAY_HIDDEN_KINDS = new Set(["task_request", "share_approval"]);
|
|
1726
1778
|
function isRelayListKind(row) { return !RELAY_HIDDEN_KINDS.has(row && row.relayNotificationKind); }
|
|
@@ -2116,6 +2168,7 @@
|
|
|
2116
2168
|
</div>
|
|
2117
2169
|
<div class="rk-subject${kind === "human_question" ? " q" : ""}">${esc(subject)}</div>
|
|
2118
2170
|
${previewHtml}
|
|
2171
|
+
${expanded ? attachmentChips(r.attachments, { relayId: r.id }) : ""}
|
|
2119
2172
|
${actions}
|
|
2120
2173
|
${openCard}
|
|
2121
2174
|
<div class="row-err${rowNotes.get(r.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(r.id)}">${esc(rowNotes.get(r.id)?.text || "")}</div>
|
|
@@ -2233,6 +2286,7 @@
|
|
|
2233
2286
|
for (const z of relaysListEl.querySelectorAll('[data-stop="1"]')) {
|
|
2234
2287
|
z.addEventListener("click", (e) => e.stopPropagation());
|
|
2235
2288
|
}
|
|
2289
|
+
wireAttachmentChips(relaysListEl);
|
|
2236
2290
|
for (const ta of relaysListEl.querySelectorAll(".qr-input")) {
|
|
2237
2291
|
// Keep the window focusable the whole time the reply field is focused so no keystroke is
|
|
2238
2292
|
// ever dropped; only release focusability on blur.
|
|
@@ -3524,6 +3578,7 @@
|
|
|
3524
3578
|
<span class="th-time">${esc(timeAgo(m.at))}</span>
|
|
3525
3579
|
</span>
|
|
3526
3580
|
<span class="th-msg-title">${esc(m.title)}</span>
|
|
3581
|
+
${expanded ? attachmentChips(relayAttachmentsForId(m.id), { relayId: m.id }) : ""}
|
|
3527
3582
|
${menu}
|
|
3528
3583
|
<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>
|
|
3529
3584
|
</div>`;
|
|
@@ -3551,6 +3606,7 @@
|
|
|
3551
3606
|
for (const z of thHistoryEl.querySelectorAll('[data-stop="1"]')) {
|
|
3552
3607
|
z.addEventListener("click", (e) => e.stopPropagation());
|
|
3553
3608
|
}
|
|
3609
|
+
wireAttachmentChips(thHistoryEl);
|
|
3554
3610
|
for (const el of thHistoryEl.querySelectorAll(".th-msg")) {
|
|
3555
3611
|
const id = el.getAttribute("data-msg");
|
|
3556
3612
|
if (openingIds.has(id)) el.classList.add("opening");
|
package/overlay/main.cjs
CHANGED
|
@@ -789,6 +789,20 @@ function readRelays() {
|
|
|
789
789
|
expectedVersion: p.expectedVersion || null,
|
|
790
790
|
humanAnswered: Boolean(p.humanAnswered),
|
|
791
791
|
humanAnswerMarkdown: p.humanAnswerMarkdown || null,
|
|
792
|
+
// Metadata only, never content: no localPath, no signed URL (the rule at
|
|
793
|
+
// previewPayloadForPacket applies here too). The chip click goes back
|
|
794
|
+
// through main, which resolves the local copy itself.
|
|
795
|
+
attachments: Array.isArray(p.attachments)
|
|
796
|
+
? p.attachments
|
|
797
|
+
.filter((a) => a && typeof a === "object")
|
|
798
|
+
.map((a) => ({
|
|
799
|
+
id: a.id || "",
|
|
800
|
+
name: a.name || a.filename || "file",
|
|
801
|
+
bytes: Number(a.bytes || a.sizeBytes) || 0,
|
|
802
|
+
contentType: a.contentType || "",
|
|
803
|
+
hasLocalCopy: Boolean(a.localPath),
|
|
804
|
+
}))
|
|
805
|
+
: [],
|
|
792
806
|
}))
|
|
793
807
|
.sort((a, b) => {
|
|
794
808
|
if (a.unread !== b.unread) return a.unread ? -1 : 1; // unread always on top
|
|
@@ -2612,6 +2626,72 @@ function openUrlTarget(url) {
|
|
|
2612
2626
|
shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
2613
2627
|
}
|
|
2614
2628
|
|
|
2629
|
+
/**
|
|
2630
|
+
* Open one relay attachment from the pill chip. The renderer only ever holds
|
|
2631
|
+
* attachment metadata (id/name/bytes), so the click comes back here and main
|
|
2632
|
+
* resolves the actual target: the ingest-prefetched local copy when it exists
|
|
2633
|
+
* (containment-checked against the attachments store), else a download NOW —
|
|
2634
|
+
* with a URL refresh, since any staged signed URL is long expired — then the
|
|
2635
|
+
* same local open. `shell.openPath` is the file:// path: self-contained HTML
|
|
2636
|
+
* opens perfectly from disk, where the web origin can never render it.
|
|
2637
|
+
*/
|
|
2638
|
+
async function openRelayAttachment(relayId, attachmentId) {
|
|
2639
|
+
const id = String(relayId || "").trim();
|
|
2640
|
+
const attId = String(attachmentId || "").trim();
|
|
2641
|
+
if (!id || !attId) return { ok: false, error: "missing id" };
|
|
2642
|
+
const store = readStore();
|
|
2643
|
+
const row = (store.packets || {})[id];
|
|
2644
|
+
if (!row) return { ok: false, error: "relay not found" };
|
|
2645
|
+
const attachments = Array.isArray(row.attachments) ? row.attachments : [];
|
|
2646
|
+
const attachment = attachments.find((a) => a && a.id === attId);
|
|
2647
|
+
if (!attachment) return { ok: false, error: "attachment not found" };
|
|
2648
|
+
|
|
2649
|
+
const attachmentsRoot = path.join(RELAY_HOME, "attachments");
|
|
2650
|
+
const containedLocalPath = (candidate) => {
|
|
2651
|
+
const clean = String(candidate || "").trim();
|
|
2652
|
+
if (!clean) return "";
|
|
2653
|
+
const resolved = path.resolve(clean);
|
|
2654
|
+
// Never openPath something outside the store: `localPath` lives in a state
|
|
2655
|
+
// file other processes write, so treat it as data, not as authority.
|
|
2656
|
+
if (!resolved.startsWith(attachmentsRoot + path.sep)) return "";
|
|
2657
|
+
return fs.existsSync(resolved) ? resolved : "";
|
|
2658
|
+
};
|
|
2659
|
+
|
|
2660
|
+
let target = containedLocalPath(attachment.localPath);
|
|
2661
|
+
if (!target) {
|
|
2662
|
+
try {
|
|
2663
|
+
const { materializeAttachmentFiles } = await import(
|
|
2664
|
+
pathToFileURL(path.join(__dirname, "..", "src", "materializer.js")).href
|
|
2665
|
+
);
|
|
2666
|
+
const materialized = await materializeAttachmentFiles(
|
|
2667
|
+
{ id, attachments, attachmentUrls: row.attachmentUrls || {} },
|
|
2668
|
+
{ log: (m) => console.error(`[overlay] ${m}`) },
|
|
2669
|
+
);
|
|
2670
|
+
const fresh = (materialized.attachments || []).find((a) => a && a.id === attId);
|
|
2671
|
+
target = containedLocalPath(fresh && fresh.localPath);
|
|
2672
|
+
if (target) {
|
|
2673
|
+
withJsonLock(STATE_PATH, () => {
|
|
2674
|
+
const state = readStore();
|
|
2675
|
+
if (state.packets && state.packets[id]) {
|
|
2676
|
+
state.packets[id].attachments = materialized.attachments;
|
|
2677
|
+
writeStateAtomic(state);
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
} catch (error) {
|
|
2682
|
+
console.error("[overlay] attachment download failed:", error && error.message);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
if (!target) return { ok: false, error: "attachment unavailable — download failed" };
|
|
2686
|
+
if (process.env.RELAY_OVERLAY_TEST_NO_HOST_OPEN === "1") {
|
|
2687
|
+
console.error("[overlay] test seam: suppressed attachment open:", target);
|
|
2688
|
+
return { ok: true, path: target, suppressed: true };
|
|
2689
|
+
}
|
|
2690
|
+
const openError = await shell.openPath(target);
|
|
2691
|
+
if (openError) return { ok: false, error: openError };
|
|
2692
|
+
return { ok: true, path: target };
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2615
2695
|
// ---- window placement / visibility ---------------------------------------
|
|
2616
2696
|
|
|
2617
2697
|
function anchorTopRight() {
|
|
@@ -3925,6 +4005,7 @@ ipcMain.handle("relay:preview:steer", (event, input) => {
|
|
|
3925
4005
|
});
|
|
3926
4006
|
ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
|
|
3927
4007
|
ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
|
|
4008
|
+
ipcMain.handle("relay:openAttachment", (_e, relayId, attachmentId) => openRelayAttachment(relayId, attachmentId));
|
|
3928
4009
|
ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
|
|
3929
4010
|
ipcMain.handle("relay:delete", (_e, id) => deletePacket(id));
|
|
3930
4011
|
ipcMain.handle("relay:markAllRead", () => markAllVisibleRelaysRead());
|
package/overlay/preload.cjs
CHANGED
|
@@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
40
40
|
deleteRelay: (id) => ipcRenderer.invoke("relay:delete", id),
|
|
41
41
|
markAllRead: () => ipcRenderer.invoke("relay:markAllRead"),
|
|
42
42
|
openUrl: (url) => ipcRenderer.send("relay:openUrl", url),
|
|
43
|
+
// Attachment chip click: main resolves the local copy (or downloads it) and
|
|
44
|
+
// opens it; the renderer never sees paths or URLs. Returns { ok, error? }.
|
|
45
|
+
openAttachment: (relayId, attachmentId) => ipcRenderer.invoke("relay:openAttachment", relayId, attachmentId),
|
|
43
46
|
refreshSent: () => ipcRenderer.invoke("relay:refreshSent"),
|
|
44
47
|
|
|
45
48
|
// task mutations (return { ok, error?, conflict? })
|
package/overlay/preview.html
CHANGED
|
@@ -640,8 +640,14 @@
|
|
|
640
640
|
<p class="reply-note" id="replyNote">Press Enter to send, Shift + Enter for a new line.</p>
|
|
641
641
|
<!-- The task composer's caption: the runtime as words, tap to change.
|
|
642
642
|
Shown only on an unstarted task face; replyNote keeps carrying
|
|
643
|
-
errors and hints for every face.
|
|
644
|
-
|
|
643
|
+
errors and hints for every face.
|
|
644
|
+
|
|
645
|
+
A DIV, never a <p>: this caption contains the runtime popover, and
|
|
646
|
+
an HTML parser auto-closes a <p> at the first block child — which
|
|
647
|
+
ejected the popover and every node after it OUT of the element
|
|
648
|
+
carrying `gone`, so the trailing sentence rendered in every preview
|
|
649
|
+
and the popover lost its positioning anchor. -->
|
|
650
|
+
<div class="reply-note runtime-note gone" id="taskRuntimeNote">
|
|
645
651
|
<span>Runs on</span>
|
|
646
652
|
<span class="rt-anchor">
|
|
647
653
|
<button class="runtime-btn" id="runtimeBtn" type="button" aria-haspopup="menu" aria-expanded="false">
|
|
@@ -665,7 +671,7 @@
|
|
|
665
671
|
</div>
|
|
666
672
|
</span>
|
|
667
673
|
<span>— on your machine. The sender sees when it starts and what you send back.</span>
|
|
668
|
-
</
|
|
674
|
+
</div>
|
|
669
675
|
</footer>
|
|
670
676
|
</section>
|
|
671
677
|
<script src="./preview-renderer.js"></script>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relay-companion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.153",
|
|
4
4
|
"description": "Companion CLI for Relay (sendrelays.com): pairs this machine with your Relay account so coding agents like Claude Code and Codex can send and receive Relay messages.",
|
|
5
5
|
"homepage": "https://sendrelays.com",
|
|
6
6
|
"repository": {
|
package/src/links.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Send-time scan for links that will not survive the trip to the recipient.
|
|
2
|
+
//
|
|
3
|
+
// Two families, one symptom (the recipient clicks and gets a login wall, a 404,
|
|
4
|
+
// or an expired signature — and tells the sender Relay is broken):
|
|
5
|
+
//
|
|
6
|
+
// 1. Provider-scoped links: claude.ai artifacts/chats, ChatGPT canvases,
|
|
7
|
+
// private Notion pages. Scoped to the SENDER's account; the recipient's
|
|
8
|
+
// browser has no session that can see them.
|
|
9
|
+
// 2. Relay's own signed /storage URLs: valid for anyone, but only for ~15
|
|
10
|
+
// minutes — long dead by the time a person reads the message.
|
|
11
|
+
//
|
|
12
|
+
// Advisory by design, sibling to attachments.js's refuse-and-warn model but
|
|
13
|
+
// softer: the send is never blocked (agents attach public links in legitimate
|
|
14
|
+
// shapes we cannot enumerate), the warning rides back on the send result's
|
|
15
|
+
// agentInstruction so the agent can repair the message it just sent — attach
|
|
16
|
+
// the file, or paste the content — while the moment is still live.
|
|
17
|
+
|
|
18
|
+
const FRAGILE_LINK_RULES = [
|
|
19
|
+
{
|
|
20
|
+
kind: "claude",
|
|
21
|
+
// claude.ai/public/* artifacts are the one shareable shape; everything
|
|
22
|
+
// else under claude.ai is account-scoped.
|
|
23
|
+
test: (url) => /^https?:\/\/(?:www\.)?claude\.ai\//i.test(url) && !/claude\.ai\/public\//i.test(url),
|
|
24
|
+
describe: (url) => `${url} is a Claude link scoped to YOUR account — the recipient will hit a login wall or a 404.`,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
kind: "chatgpt",
|
|
28
|
+
// chatgpt.com/share/* links are published; canvases and chats are not.
|
|
29
|
+
test: (url) =>
|
|
30
|
+
/^https?:\/\/(?:www\.)?(?:chatgpt\.com|chat\.openai\.com)\//i.test(url) &&
|
|
31
|
+
!/(?:chatgpt\.com|chat\.openai\.com)\/share\//i.test(url),
|
|
32
|
+
describe: (url) => `${url} is a ChatGPT link scoped to YOUR account — the recipient will hit a login wall.`,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
kind: "notion",
|
|
36
|
+
// notion.site is Notion's publish-to-web domain and is genuinely public;
|
|
37
|
+
// notion.so pages are workspace-scoped unless shared, which we can't see.
|
|
38
|
+
test: (url) => /^https?:\/\/(?:www\.)?notion\.so\//i.test(url),
|
|
39
|
+
describe: (url) => `${url} is a Notion workspace link — it only opens for the recipient if that page is explicitly shared with them.`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
kind: "signed-storage",
|
|
43
|
+
test: (url) => /\/storage\/[^\s]*[?&](?:sig|expires)=/i.test(url),
|
|
44
|
+
describe: (url) => `${url} is a signed Relay storage URL that expires in minutes — it will be dead when the recipient reads this.`,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi;
|
|
49
|
+
|
|
50
|
+
/** Every fragile link in a markdown body, with why it is fragile. */
|
|
51
|
+
export function scanFragileLinks(bodyMarkdown) {
|
|
52
|
+
const body = String(bodyMarkdown || "");
|
|
53
|
+
const findings = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const match of body.matchAll(URL_RE)) {
|
|
56
|
+
const url = match[0].replace(/[.,;:!?]+$/, "");
|
|
57
|
+
if (seen.has(url)) continue;
|
|
58
|
+
seen.add(url);
|
|
59
|
+
for (const rule of FRAGILE_LINK_RULES) {
|
|
60
|
+
if (rule.test(url)) {
|
|
61
|
+
findings.push({ kind: rule.kind, url, message: rule.describe(url) });
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The agentInstruction paragraph for a body's fragile links, or "" when clean.
|
|
71
|
+
* Attaching the file is the remedy we point at: attachments download locally
|
|
72
|
+
* on the recipient's machine and carry durable links everywhere else.
|
|
73
|
+
*/
|
|
74
|
+
export function fragileLinkWarning(bodyMarkdown) {
|
|
75
|
+
const findings = scanFragileLinks(bodyMarkdown);
|
|
76
|
+
if (!findings.length) return "";
|
|
77
|
+
const details = findings.map((f) => f.message).join(" ");
|
|
78
|
+
return (
|
|
79
|
+
`WARNING — this message contains ${findings.length === 1 ? "a link" : "links"} the recipient likely cannot open: ${details} ` +
|
|
80
|
+
"The message was still sent. Repair it now: send a follow-up that attaches the actual file (relay_send's attachments) " +
|
|
81
|
+
"or pastes the relevant content, instead of the link."
|
|
82
|
+
);
|
|
83
|
+
}
|
package/src/materializer.js
CHANGED
|
@@ -350,6 +350,13 @@ async function materializeRowInHost({
|
|
|
350
350
|
openable = Boolean(cwd),
|
|
351
351
|
} = resolveCwd({ row, rowState, requestedCwd, allowUnanchoredFallback });
|
|
352
352
|
log(`relay open cwd: ${cwd || "<unresolved>"} (${cwdReason}${repoName ? `: ${repoName}` : ""}${workspaceKey ? ` ${workspaceKey}` : ""})`);
|
|
353
|
+
// Attachments are delivery, not routing: land the local copies BEFORE the
|
|
354
|
+
// routing gate. A relay that cannot open in a host (workspace-unmapped) must
|
|
355
|
+
// still put its files on disk — otherwise the pill chip and the seed link
|
|
356
|
+
// have nothing to point at, and the only remaining route is a signed URL
|
|
357
|
+
// that expires in minutes. This ran after the gate once, which silently
|
|
358
|
+
// stripped attachments from every unopenable relay.
|
|
359
|
+
const rowWithAttachments = await materializeAttachmentFiles(row, { log });
|
|
353
360
|
if (!openable || !cwd) {
|
|
354
361
|
rememberRow(id, {
|
|
355
362
|
relayThreadId: row?.threadId || id,
|
|
@@ -359,6 +366,9 @@ async function materializeRowInHost({
|
|
|
359
366
|
openWorkspaceKey: workspaceKey,
|
|
360
367
|
materializationError: cwdReason,
|
|
361
368
|
materializedAt: new Date().toISOString(),
|
|
369
|
+
// Keep the downloaded local copies (no signature keys: signatures are
|
|
370
|
+
// open-time state, and this row did not open).
|
|
371
|
+
...(hasAttachments(rowWithAttachments) ? { attachments: rowWithAttachments.attachments } : {}),
|
|
362
372
|
});
|
|
363
373
|
return {
|
|
364
374
|
id,
|
|
@@ -381,7 +391,6 @@ async function materializeRowInHost({
|
|
|
381
391
|
// Neither host can retarget a live session's model either — a different
|
|
382
392
|
// requested model re-forges exactly like a cwd change does.
|
|
383
393
|
const modelChanged = Boolean(model) && Boolean(rowState.openModel) && String(rowState.openModel) !== String(model);
|
|
384
|
-
const rowWithAttachments = await materializeAttachmentFiles(row, { log });
|
|
385
394
|
const materializationSignature = rowMaterializationSignature(rowWithAttachments);
|
|
386
395
|
const materializationSignatureKey = attachmentSignatureKeyForHost(cleanHost);
|
|
387
396
|
// forceFresh ("Open in new chat"): deliberately ignore the remembered native
|
|
@@ -527,17 +536,19 @@ async function materializeRowInHost({
|
|
|
527
536
|
return { id, host: cleanHost, url, openedInHost, skipExternalOpen, claudeFreshlyForged, cwd, cwdReason, workspaceKey };
|
|
528
537
|
}
|
|
529
538
|
|
|
530
|
-
async function materializeAttachmentFiles(row, { log = () => {} } = {}) {
|
|
539
|
+
export async function materializeAttachmentFiles(row, { log = () => {}, refreshUrls = defaultRefreshAttachmentUrls } = {}) {
|
|
531
540
|
const attachments = Array.isArray(row?.attachments) ? row.attachments : [];
|
|
532
541
|
if (!attachments.length) return row;
|
|
533
542
|
const attachmentUrls = row?.attachmentUrls && typeof row.attachmentUrls === "object" ? row.attachmentUrls : {};
|
|
534
543
|
const dir = path.join(storeDir(), "attachments", safeFileStem(row?.id || row?.relayId || "relay"));
|
|
535
544
|
const materialized = [];
|
|
545
|
+
let misses = 0;
|
|
536
546
|
for (const [index, attachment] of attachments.entries()) {
|
|
537
547
|
if (!attachment || typeof attachment !== "object") continue;
|
|
538
548
|
const name = String(attachment.name || attachment.filename || `attachment-${index + 1}`).trim() || `attachment-${index + 1}`;
|
|
539
549
|
const url = String(attachmentUrls[attachment.id] || attachment.openUrl || "").trim();
|
|
540
550
|
const localPath = await ensureAttachmentLocalCopy({ attachment, url, dir, name, index, log });
|
|
551
|
+
if (!localPath && attachment.id) misses += 1;
|
|
541
552
|
materialized.push({
|
|
542
553
|
...attachment,
|
|
543
554
|
name,
|
|
@@ -546,9 +557,41 @@ async function materializeAttachmentFiles(row, { log = () => {} } = {}) {
|
|
|
546
557
|
...(localPath ? { localPath } : {}),
|
|
547
558
|
});
|
|
548
559
|
}
|
|
560
|
+
// The signed URLs in `attachmentUrls` go stale ~15 minutes after the packet
|
|
561
|
+
// was fetched, so a lazy open days later downloads nothing. When anything is
|
|
562
|
+
// still missing, re-fetch the packet ONCE for fresh URLs and retry just the
|
|
563
|
+
// misses; failures still degrade to the durable openUrl in the seed.
|
|
564
|
+
if (misses && typeof refreshUrls === "function") {
|
|
565
|
+
let fresh = null;
|
|
566
|
+
try {
|
|
567
|
+
fresh = await refreshUrls(row);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
log(`relay attachment URL refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
570
|
+
}
|
|
571
|
+
if (fresh && typeof fresh === "object") {
|
|
572
|
+
for (const [index, attachment] of materialized.entries()) {
|
|
573
|
+
if (!attachment || attachment.localPath || !attachment.id) continue;
|
|
574
|
+
const url = String(fresh[attachment.id] || "").trim();
|
|
575
|
+
if (!url) continue;
|
|
576
|
+
const localPath = await ensureAttachmentLocalCopy({ attachment, url, dir, name: attachment.name, index, log });
|
|
577
|
+
if (localPath) materialized[index] = { ...attachment, localPath };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
549
581
|
return { ...row, attachments: materialized };
|
|
550
582
|
}
|
|
551
583
|
|
|
584
|
+
/** Fresh signed attachment URLs for one staged row, via a single packet re-fetch. */
|
|
585
|
+
async function defaultRefreshAttachmentUrls(row) {
|
|
586
|
+
const id = String(row?.id || row?.relayId || "").trim();
|
|
587
|
+
if (!id) return null;
|
|
588
|
+
const { RelayClient } = await import("./client.js");
|
|
589
|
+
const client = new RelayClient();
|
|
590
|
+
const response = await client.fetchRelayPackets([id]);
|
|
591
|
+
const entry = response?.packets?.[id];
|
|
592
|
+
return entry && entry.attachmentUrls && typeof entry.attachmentUrls === "object" ? entry.attachmentUrls : null;
|
|
593
|
+
}
|
|
594
|
+
|
|
552
595
|
async function ensureAttachmentLocalCopy({ attachment, url, dir, name, index, log }) {
|
|
553
596
|
const existing = String(attachment.localPath || "").trim();
|
|
554
597
|
if (existing && fs.existsSync(existing) && sizeMatches(existing, attachment.bytes)) return existing;
|
|
@@ -583,6 +626,37 @@ async function ensureAttachmentLocalCopy({ attachment, url, dir, name, index, lo
|
|
|
583
626
|
// lying Content-Length). 200MB is generous for real attachments.
|
|
584
627
|
const ATTACHMENT_DOWNLOAD_CAP = 200 * 1024 * 1024;
|
|
585
628
|
|
|
629
|
+
/** How long a downloaded attachment dir may sit untouched before the sweep removes it. */
|
|
630
|
+
const ATTACHMENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Age out old local attachment copies. Attachments now download at ingest for
|
|
634
|
+
* every arriving relay (not only on open), so without retention the store grows
|
|
635
|
+
* with the inbox forever. Directory mtime is the signal: a re-download or a new
|
|
636
|
+
* file in the dir refreshes it. Runs at daemon start; failures never propagate.
|
|
637
|
+
*/
|
|
638
|
+
export function sweepStaleAttachmentFiles({ maxAgeMs = ATTACHMENT_RETENTION_MS, nowMs = Date.now(), log = () => {} } = {}) {
|
|
639
|
+
const root = path.join(storeDir(), "attachments");
|
|
640
|
+
const removed = [];
|
|
641
|
+
let entries = [];
|
|
642
|
+
try {
|
|
643
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
644
|
+
} catch {
|
|
645
|
+
return removed;
|
|
646
|
+
}
|
|
647
|
+
for (const entry of entries) {
|
|
648
|
+
if (!entry.isDirectory()) continue;
|
|
649
|
+
const dir = path.join(root, entry.name);
|
|
650
|
+
try {
|
|
651
|
+
if (nowMs - fs.statSync(dir).mtimeMs <= maxAgeMs) continue;
|
|
652
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
653
|
+
removed.push(entry.name);
|
|
654
|
+
} catch {}
|
|
655
|
+
}
|
|
656
|
+
if (removed.length) log(`swept ${removed.length} stale attachment dir(s)`);
|
|
657
|
+
return removed;
|
|
658
|
+
}
|
|
659
|
+
|
|
586
660
|
function sizeMatches(filePath, expectedBytes) {
|
|
587
661
|
if (!Number.isFinite(Number(expectedBytes))) return true;
|
|
588
662
|
try {
|
package/src/mcp.js
CHANGED
|
@@ -3,6 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { prepareOrdinaryRelayAttachments, prepareTaskMessageAttachments, prepareTaskResult } from "./attachments.js";
|
|
5
5
|
import { workspacePassportFromDeclaration } from "./repo-identity.js";
|
|
6
|
+
import { fragileLinkWarning } from "./links.js";
|
|
6
7
|
import { createRequire } from "node:module";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { RelayClient } from "./client.js";
|
|
@@ -866,14 +867,17 @@ function replyTitleFromBody(body) {
|
|
|
866
867
|
return title.length > 120 ? `${title.slice(0, 119).trimEnd()}…` : title;
|
|
867
868
|
}
|
|
868
869
|
|
|
869
|
-
function relaySendResultForAgent(result) {
|
|
870
|
+
function relaySendResultForAgent(result, { linkWarning = "" } = {}) {
|
|
870
871
|
// Servers that predate threading hints simply omit the field, so an older API
|
|
871
872
|
// keeps working and only loses the nudge.
|
|
872
873
|
const hint = result?.threadingHint;
|
|
873
874
|
const contactNeedsWork = Boolean(result?.contact?.autoCreated);
|
|
874
|
-
if (!hint && !contactNeedsWork) return result;
|
|
875
|
+
if (!hint && !contactNeedsWork && !linkWarning) return result;
|
|
875
876
|
|
|
876
877
|
const instructions = [];
|
|
878
|
+
// Broken-for-the-recipient links come first: the repair (attach the file)
|
|
879
|
+
// has the shortest useful window.
|
|
880
|
+
if (linkWarning) instructions.push(linkWarning);
|
|
877
881
|
if (hint?.message) instructions.push(hint.message);
|
|
878
882
|
if (contactNeedsWork) {
|
|
879
883
|
instructions.push(
|
|
@@ -975,7 +979,7 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
|
|
|
975
979
|
...(args.threadTitle ? { threadTitle: args.threadTitle } : {}),
|
|
976
980
|
...(args.type ? { type: args.type } : {}),
|
|
977
981
|
idempotencyKey: args.idempotencyKey,
|
|
978
|
-
})),
|
|
982
|
+
}), { linkWarning: fragileLinkWarning(args.bodyMarkdown) }),
|
|
979
983
|
);
|
|
980
984
|
case "relay_contacts_search":
|
|
981
985
|
return text(await client.searchContacts(args.query));
|
|
@@ -1101,6 +1105,7 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
|
|
|
1101
1105
|
...(args.threadTitle ? { threadTitle: args.threadTitle } : {}),
|
|
1102
1106
|
idempotencyKey: args.idempotencyKey,
|
|
1103
1107
|
}),
|
|
1108
|
+
{ linkWarning: fragileLinkWarning(bodyMarkdown) },
|
|
1104
1109
|
),
|
|
1105
1110
|
);
|
|
1106
1111
|
}
|
package/src/notifications.js
CHANGED
|
@@ -600,6 +600,24 @@ export function reconcileAnsweredQuestions(relays = [], { statePath = companionS
|
|
|
600
600
|
});
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Patch a staged row's attachments in place (the ingest prefetch writes the
|
|
605
|
+
* acquired localPath values back after download). Deliberately writes ONLY the
|
|
606
|
+
* attachments array — never the materialization-signature keys, which are
|
|
607
|
+
* open-time state owned by the materializer.
|
|
608
|
+
*/
|
|
609
|
+
export function updateStagedRelayAttachments(relayId, attachments, { statePath = companionStatePath() } = {}) {
|
|
610
|
+
if (!relayId || !Array.isArray(attachments)) return false;
|
|
611
|
+
return withJsonLock(statePath, () => {
|
|
612
|
+
const state = readCompanionState(statePath);
|
|
613
|
+
const row = state.packets && state.packets[relayId];
|
|
614
|
+
if (!row) return false;
|
|
615
|
+
row.attachments = attachments;
|
|
616
|
+
writeCompanionState(statePath, state);
|
|
617
|
+
return true;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
603
621
|
export function stagePlainRelayItem(
|
|
604
622
|
{ item, packet, attachmentUrls = {} },
|
|
605
623
|
{ statePath = companionStatePath(), forceUnread = false } = {},
|
package/src/task-daemon.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
removeSuppressedCompanionItems,
|
|
19
19
|
reconcileAnsweredQuestions,
|
|
20
20
|
stagePlainRelayItem as defaultStagePlainRelayItem,
|
|
21
|
+
updateStagedRelayAttachments,
|
|
21
22
|
} from "./notifications.js";
|
|
22
23
|
import { createAutoUpdater } from "./auto-update.js";
|
|
23
24
|
import { startLogRotation } from "./log-rotate.js";
|
|
@@ -200,6 +201,31 @@ async function fetchPacketsForItems({ client, items, log }) {
|
|
|
200
201
|
return byId;
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Download a freshly staged relay's attachments NOW, while the packet's signed
|
|
206
|
+
* URLs are seconds old, and patch the acquired localPaths onto the staged row.
|
|
207
|
+
* Ingest is the one moment the URLs are guaranteed live — a lazy download on a
|
|
208
|
+
* click days later starts from an expired signature. Failures only log: the
|
|
209
|
+
* open path re-runs the same idempotent download (with a URL refresh) anyway.
|
|
210
|
+
*/
|
|
211
|
+
async function prefetchStagedRelayAttachments({ item, packet, attachmentUrls, log }) {
|
|
212
|
+
const attachments = Array.isArray(packet?.attachments) ? packet.attachments : [];
|
|
213
|
+
if (!attachments.length) return;
|
|
214
|
+
try {
|
|
215
|
+
const { materializeAttachmentFiles } = await import("./materializer.js");
|
|
216
|
+
const materialized = await materializeAttachmentFiles(
|
|
217
|
+
{ id: item.relayId, attachments, attachmentUrls: attachmentUrls || {} },
|
|
218
|
+
{ log },
|
|
219
|
+
);
|
|
220
|
+
const withLocal = (materialized.attachments || []).filter((a) => a && a.localPath).length;
|
|
221
|
+
if (withLocal && updateStagedRelayAttachments(item.relayId, materialized.attachments)) {
|
|
222
|
+
log(`prefetched ${withLocal}/${attachments.length} attachment(s) for ${item.relayId}`);
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
log(`attachment prefetch failed for ${item.relayId}: ${err && err.message ? err.message : err}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
203
229
|
async function pollPlainInbox({ client, ledger, stagePlainRelay, log, persistLedger = () => {} }) {
|
|
204
230
|
if (typeof client.inbox !== "function" || typeof client.fetchRelay !== "function") return [];
|
|
205
231
|
try {
|
|
@@ -242,6 +268,18 @@ async function pollPlainInbox({ client, ledger, stagePlainRelay, log, persistLed
|
|
|
242
268
|
log(`ordinary Relay staging failed for ${item.relayId}: ${err.message}`);
|
|
243
269
|
}
|
|
244
270
|
}
|
|
271
|
+
// Prefetch attachments only after every stage + ledger commit has landed,
|
|
272
|
+
// so a slow download can never delay the relays queued behind it.
|
|
273
|
+
for (const item of staged) {
|
|
274
|
+
const relay = packets.get(item.relayId);
|
|
275
|
+
if (!relay || !relay.packet) continue;
|
|
276
|
+
await prefetchStagedRelayAttachments({
|
|
277
|
+
item,
|
|
278
|
+
packet: relay.packet,
|
|
279
|
+
attachmentUrls: relay.attachmentUrls || {},
|
|
280
|
+
log,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
245
283
|
staged.inboxOk = true;
|
|
246
284
|
return staged;
|
|
247
285
|
} catch (err) {
|
|
@@ -522,6 +560,14 @@ export async function runOrdinaryRelayDaemon({ intervalMs = 4000 } = {}) {
|
|
|
522
560
|
// Keep the always-on logs bounded. The daemon is the single long-lived owner on
|
|
523
561
|
// the machine, so it is the natural place to cap files that nothing else prunes.
|
|
524
562
|
startLogRotation({ log });
|
|
563
|
+
// Age out downloaded attachment copies; ingest prefetch would otherwise grow
|
|
564
|
+
// ~/.relay-companion/attachments with the inbox forever.
|
|
565
|
+
try {
|
|
566
|
+
const { sweepStaleAttachmentFiles } = await import("./materializer.js");
|
|
567
|
+
sweepStaleAttachmentFiles({ log });
|
|
568
|
+
} catch (err) {
|
|
569
|
+
log(`attachment sweep failed: ${err && err.message ? err.message : err}`);
|
|
570
|
+
}
|
|
525
571
|
// Re-register Windows autostart tasks that have gone missing. Without them the
|
|
526
572
|
// machine has no logon start at all, and (before the updater stopped depending on
|
|
527
573
|
// them) every self-update rolled itself back. Cheap query, no-ops off win32.
|
|
@@ -619,6 +665,14 @@ export async function runTaskDaemon({ intervalMs = 4000 } = {}) {
|
|
|
619
665
|
// Keep the always-on logs bounded. The daemon is the single long-lived owner on
|
|
620
666
|
// the machine, so it is the natural place to cap files that nothing else prunes.
|
|
621
667
|
startLogRotation({ log });
|
|
668
|
+
// Age out downloaded attachment copies; ingest prefetch would otherwise grow
|
|
669
|
+
// ~/.relay-companion/attachments with the inbox forever.
|
|
670
|
+
try {
|
|
671
|
+
const { sweepStaleAttachmentFiles } = await import("./materializer.js");
|
|
672
|
+
sweepStaleAttachmentFiles({ log });
|
|
673
|
+
} catch (err) {
|
|
674
|
+
log(`attachment sweep failed: ${err && err.message ? err.message : err}`);
|
|
675
|
+
}
|
|
622
676
|
// Re-register Windows autostart tasks that have gone missing. Without them the
|
|
623
677
|
// machine has no logon start at all, and (before the updater stopped depending on
|
|
624
678
|
// them) every self-update rolled itself back. Cheap query, no-ops off win32.
|