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.
@@ -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
- function attachmentChips(attachments) {
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 size = fmtBytes(a.sizeBytes);
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());
@@ -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? })
@@ -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
- <p class="reply-note runtime-note gone" id="taskRuntimeNote">
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
- </p>
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.151",
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
+ }
@@ -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
  }
@@ -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 } = {},
@@ -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.