lavish-axi 0.1.49 → 0.1.51

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/README.md CHANGED
@@ -203,6 +203,12 @@ pnpm link
203
203
  Agent-initiated ends keep reopening normally, same as before.
204
204
  `lavish-axi poll`'s `ended` response and the `feedback` response for the final batch before an end both carry `next_step` guidance telling the agent to stop polling and deliver remaining updates in chat instead of reopening.
205
205
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
206
+ - **Image attachments** - Attach reference images (PNG, JPEG, WebP) to an annotation by pasting, drag-dropping, or using the annotation card's **Attach image** picker; each shows a thumbnail chip with upload, remove, retry, and error states.
207
+ Images are stored under the state dir and the queued prompt carries a server-generated absolute `path` and content-hash `id` (plus mime and dimensions) - never the raw bytes - so `lavish-axi poll` hands the agent a local file path to open.
208
+ Limits are `LAVISH_AXI_MAX_ATTACHMENT_BYTES` (default 10 MiB per image), `LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT` (default 4), and `LAVISH_AXI_MAX_PROMPT_ATTACHMENT_BYTES` (default 25 MiB per annotation); if any image is missing or any annotation breaches a count or byte cap, the entire send batch is rejected, the queue is preserved, and the reason is surfaced in the composer rather than silently dropping images.
209
+ As a browser-side abuse guard, each chrome page allows 30 upload attempts per rolling minute, 4 uploads in flight at once, and 256 MiB of attempted image bytes over its lifetime; rejected uploads stay visible on their annotation card for retry or removal.
210
+ Attachments are cleaned up by `LAVISH_AXI_ATTACHMENT_TTL_MS` (default 7 days; `0`/`off` disables) but only once no pending prompt still references them; `LAVISH_AXI_MAX_ATTACHMENT_DISK_MB` (default 512 MiB; `0`/`off` disables) caps total attachment disk.
211
+ The cap is enforced when an image is uploaded, not just periodically: the upload first reclaims unreferenced files (oldest first, and never one added within the last hour), and if it still would not fit, that upload is refused with a storage-full error on its chip instead of discarding an image an annotation is about to send.
206
212
  - **Mermaid diagrams** - In the Lavish browser, every rendered Mermaid diagram in a `.mermaid` container becomes an embedded editable Excalidraw whiteboard.
207
213
  Click a diagram to unlock editing, and use its Fullscreen action to edit it over the whole viewport.
208
214
  Whiteboard scenes autosave locally.
@@ -217,7 +223,7 @@ pnpm link
217
223
  - **Diagnostic viewports** - `LAVISH_AXI_DIAGNOSTIC_VIEWPORTS` sets which viewport classes the layout-issue inbox tracks (`mobile`, `compact`, `desktop`; comma-separated, default all). Warnings whose class leaves the set are marked obsolete with an explicit reason instead of silently reading as fixed.
218
224
  - **Server port** - Set `LAVISH_AXI_PORT` to choose the server port; it defaults to `4387`.
219
225
  - **Network binding** - The server binds to loopback (`127.0.0.1`) by default. Set `LAVISH_AXI_HOST` to bind elsewhere; a wildcard (`0.0.0.0` or `::`) binds every interface. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to anything that can reach it, so only do so on a trusted network. Set `LAVISH_AXI_LINK_HOST` to control the hostname written into generated session links (defaults to the bind address, or loopback when bound to a wildcard).
220
- - **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: the loopback names (`127.0.0.1`, `::1`, `localhost`) plus the configured bind and link host. If you reach the server under another name - a wildcard bind accessed by LAN IP, a reverse-proxy hostname, or an extra interface - list those names in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated) to allow them. Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add your public hostname there and have the proxy send it. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely (only when the server sits behind your own authentication or proxy).
226
+ - **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: the loopback names (`127.0.0.1`, `::1`, `localhost`) plus the configured bind and link host. If you reach the server under another name - a wildcard bind accessed by LAN IP, a reverse-proxy hostname, or an extra interface - list those names in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated) to allow them. Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add your public hostname there and have the proxy send it together with `X-Forwarded-Proto`. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely (only when the server sits behind your own authentication or proxy).
221
227
  - **Browser opening** - Set `LAVISH_AXI_NO_OPEN=1`, equivalent to `--no-open`, to create or resume a session without launching a browser window.
222
228
 
223
229
  ## CLI Reference
@@ -11,6 +11,59 @@ const warningSelectionStorageKey = "lavish-axi:warning-selection:" + key;
11
11
  const internalQueueKeyField = "_lavishQueueKey";
12
12
  const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
13
13
  const MODE_TOGGLE_HOTKEY_KEY = String(sessionData.modeToggleHotkeyKey || "").toLowerCase();
14
+ const attachmentMaxBytes = Number(sessionData.attachmentMaxBytes) || 0;
15
+
16
+ // The chrome is the only path from the sandboxed (opaque-origin) artifact iframe to
17
+ // the loopback server, so it is the sole place a same-origin confused-deputy can be
18
+ // mediated: the frame's postMessage source check proves a message came from the
19
+ // artifact frame but NOT that a user gesture (paste/drop/pick) drove it, so hostile
20
+ // artifact script could otherwise drive unbounded uploads. Bound both the rate and
21
+ // the cumulative bytes per chrome session here; the server keeps a bounded disk
22
+ // quota as the durable backstop.
23
+ const UPLOAD_RATE_WINDOW_MS = 60_000;
24
+ const UPLOAD_RATE_MAX = 30;
25
+ const UPLOAD_SESSION_BYTE_QUOTA = 256 * 1024 * 1024; // 256 MiB per chrome session
26
+ // The rate and cumulative-byte guards bound uploads over time, but not how many run
27
+ // AT ONCE: each accepted message starts fetch() immediately, so a hostile artifact
28
+ // could post ~30 large bodies in one tick and hold hundreds of MiB of structured
29
+ // clones + server body buffers concurrently before the cumulative quota trips (D8).
30
+ // Cap the number in flight; the over-cap ones are refused with a retry hint (like the
31
+ // rate guard) and a freed slot admits the next.
32
+ const UPLOAD_MAX_IN_FLIGHT = 4;
33
+ const uploadTimestamps = [];
34
+ let uploadedBytesTotal = 0;
35
+ let uploadsInFlight = 0;
36
+
37
+ function formatByteLimit(bytes) {
38
+ if (bytes >= 1024 * 1024) return Math.round(bytes / (1024 * 1024)) + " MB";
39
+ if (bytes >= 1024) return Math.round(bytes / 1024) + " KB";
40
+ return bytes + " bytes";
41
+ }
42
+
43
+ // Turn the server's atomic-reject detail (C4) into one human line naming the cap
44
+ // that was hit, so the user knows what to fix. Nothing was delivered - the queue is
45
+ // preserved - so the wording is about correcting, not about a partial send.
46
+ function describeAttachmentRejection(rejected, caps) {
47
+ const reasons = new Set(rejected.map((ref) => ref && ref.reason));
48
+ const parts = [];
49
+ if (reasons.has("prompt-bytes-exceeded") && caps && caps.maxPromptBytes) {
50
+ parts.push("images exceed the " + formatByteLimit(caps.maxPromptBytes) + " per-annotation limit");
51
+ }
52
+ if (reasons.has("too-many") && caps && caps.maxPerPrompt) {
53
+ parts.push("more than " + caps.maxPerPrompt + " images on one annotation");
54
+ }
55
+ if (reasons.has("too-many-in-request")) {
56
+ parts.push("too many images queued at once");
57
+ }
58
+ if (reasons.has("malformed")) {
59
+ parts.push("an image attachment was malformed");
60
+ }
61
+ if (reasons.has("not-found")) {
62
+ parts.push("an image is no longer available");
63
+ }
64
+ const detail = parts.length ? parts.join("; ") : "some attachments could not be delivered";
65
+ return "Not sent — " + detail + ". Remove or fix the image, then send again.";
66
+ }
14
67
 
15
68
  function isModeToggleHotkeyEvent(event) {
16
69
  if (event.shiftKey || event.altKey) return false;
@@ -164,10 +217,49 @@ function saveJsonState(storageKey, value) {
164
217
  }
165
218
  }
166
219
 
220
+ // A queued prompt is authored by the untrusted artifact iframe, so its attachment
221
+ // refs are validated at the single boundary every prompt crosses before it is
222
+ // persisted or rendered. Anything that is not a well-formed `{id}` object is
223
+ // dropped: the queue is written to sessionStorage BEFORE it renders, so one bad
224
+ // entry would throw out of render(), stay on disk, and throw again on every
225
+ // reload - wedging the tab permanently instead of failing once. The card's own
226
+ // flow only ever produces `{id, name}`, so a malformed entry is fabricated and
227
+ // there is no user image to preserve. The server re-validates independently.
228
+ // Each surviving ref is PROJECTED onto a fresh primitives-only object rather than
229
+ // kept by reference: postMessage delivers a structured clone, which faithfully
230
+ // preserves BigInt values and cycles that `JSON.stringify` then refuses. Passing
231
+ // the artifact's own object through would carry that junk into sessionStorage and
232
+ // the POST body, where the throw makes the queue unsendable - the same wedge as a
233
+ // poisoned entry, just one step later.
234
+ function sanitizeAttachmentRefs(value) {
235
+ if (!Array.isArray(value)) return [];
236
+ const refs = [];
237
+ for (const ref of value) {
238
+ if (!ref || typeof ref !== "object" || Array.isArray(ref)) continue;
239
+ if (typeof ref.id !== "string" || !ref.id) continue;
240
+ const projected = { id: ref.id };
241
+ if (typeof ref.name === "string" && ref.name) projected.name = ref.name;
242
+ refs.push(projected);
243
+ }
244
+ return refs;
245
+ }
246
+
247
+ function sanitizeQueuedPrompt(prompt) {
248
+ if (!prompt || typeof prompt !== "object") return null;
249
+ if (!("attachments" in prompt)) return prompt;
250
+ const clean = { ...prompt };
251
+ const refs = sanitizeAttachmentRefs(clean.attachments);
252
+ if (refs.length) clean.attachments = refs;
253
+ else delete clean.attachments;
254
+ return clean;
255
+ }
256
+
167
257
  function loadQueuedPrompts() {
168
258
  try {
169
259
  const parsed = JSON.parse(sessionStorage.getItem(queueStorageKey) || "[]");
170
- return Array.isArray(parsed) ? parsed.filter((prompt) => prompt && typeof prompt === "object") : [];
260
+ // Also sanitize on restore: a tab poisoned before this guard existed still has
261
+ // the bad prompt on disk and would otherwise stay wedged after an upgrade.
262
+ return Array.isArray(parsed) ? parsed.map(sanitizeQueuedPrompt).filter(Boolean) : [];
171
263
  } catch {
172
264
  return [];
173
265
  }
@@ -190,8 +282,10 @@ function render() {
190
282
  .map(
191
283
  (prompt, index) =>
192
284
  '<div class="pill-wrap"><div class="pill"><span class="pill-preview">' +
193
- escapeHtml(prompt.prompt) +
194
- '</span><button class="pill-close" type="button" aria-label="Remove queued prompt" data-index="' +
285
+ escapeHtml(prompt.prompt || (attachmentCount(prompt) ? "Image annotation" : "")) +
286
+ "</span>" +
287
+ pillAttachmentsHtml(prompt) +
288
+ '<button class="pill-close" type="button" aria-label="Remove queued prompt" data-index="' +
195
289
  index +
196
290
  '"><svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div><div class="pill-tooltip">' +
197
291
  (prompt.selector
@@ -219,12 +313,65 @@ function updateSendState() {
219
313
  if (warningsQueueButton) updateWarningSelectionState();
220
314
  }
221
315
 
222
- function showSendHint() {
316
+ function attachmentCount(prompt) {
317
+ return Array.isArray(prompt.attachments) ? prompt.attachments.length : 0;
318
+ }
319
+
320
+ // How many thumbnails the compact pill shows before the rest collapse into a badge.
321
+ const PILL_THUMBNAIL_LIMIT = 4;
322
+
323
+ // Thumbnails for a queued prompt's images, served straight from the same-origin
324
+ // attachment endpoint (the ids are already server-vetted at upload time). The pill
325
+ // has room for only a few, but the per-prompt cap is configurable
326
+ // (LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT), so a prompt can legitimately carry more
327
+ // than fit: the remainder collapses into a +N badge rather than being dropped from
328
+ // the preview, which would make the queue look like it lost the extra images (W-A).
329
+ function pillAttachmentsHtml(prompt) {
330
+ const count = attachmentCount(prompt);
331
+ if (!count) return "";
332
+ const hidden = count - PILL_THUMBNAIL_LIMIT;
333
+ return (
334
+ '<span class="pill-attachments">' +
335
+ prompt.attachments
336
+ .slice(0, PILL_THUMBNAIL_LIMIT)
337
+ .map((attachment) => {
338
+ const alt = escapeHtml(attachment.name || "image");
339
+ return (
340
+ '<img class="pill-attachment" src="/api/' +
341
+ encodeURIComponent(key) +
342
+ "/attachments/" +
343
+ encodeURIComponent(attachment.id) +
344
+ '" alt="' +
345
+ alt +
346
+ '" title="' +
347
+ alt +
348
+ '">'
349
+ );
350
+ })
351
+ .join("") +
352
+ (hidden > 0
353
+ ? '<span class="pill-attachment-more" title="' +
354
+ hidden +
355
+ " more image" +
356
+ (hidden === 1 ? "" : "s") +
357
+ '">+' +
358
+ hidden +
359
+ "</span>"
360
+ : "") +
361
+ "</span>"
362
+ );
363
+ }
364
+
365
+ const DEFAULT_SEND_HINT = "Write a message or annotate an element first.";
366
+
367
+ function showSendHint(message = DEFAULT_SEND_HINT, holdMs = 2600) {
368
+ sendHint.textContent = message;
223
369
  sendHint.hidden = false;
224
370
  clearTimeout(sendHintTimer);
225
371
  sendHintTimer = setTimeout(() => {
226
372
  sendHint.hidden = true;
227
- }, 2600);
373
+ sendHint.textContent = DEFAULT_SEND_HINT;
374
+ }, holdMs);
228
375
  chatInput.focus();
229
376
  }
230
377
 
@@ -354,8 +501,9 @@ function promptQueueKey(prompt) {
354
501
  return prompt && typeof prompt[internalQueueKeyField] === "string" ? prompt[internalQueueKeyField].trim() : "";
355
502
  }
356
503
 
357
- function enqueuePrompt(prompt) {
358
- if (!prompt || typeof prompt !== "object") return;
504
+ function enqueuePrompt(rawPrompt) {
505
+ const prompt = sanitizeQueuedPrompt(rawPrompt);
506
+ if (!prompt) return;
359
507
 
360
508
  const queueKey = promptQueueKey(prompt);
361
509
  if (queueKey) {
@@ -457,6 +605,15 @@ async function submitQueuedOnce() {
457
605
  endAfterSubmit = false;
458
606
  return false;
459
607
  }
608
+ // C4: the server persisted nothing (atomic reject) - the queue below is left
609
+ // intact because the splice only runs on success. Surface exactly what failed
610
+ // so the user can fix the offending attachment(s) rather than losing them.
611
+ if (response.status === 400) {
612
+ const detail = await response.json().catch(() => ({}));
613
+ if (Array.isArray(detail.rejected) && detail.rejected.length) {
614
+ showSendHint(describeAttachmentRejection(detail.rejected, detail.caps), 6000);
615
+ }
616
+ }
460
617
  throw new Error("failed to submit queued prompts");
461
618
  }
462
619
  for (const prompt of prompts) {
@@ -1293,7 +1450,8 @@ function showWhiteboardOverlay(index) {
1293
1450
  postToFrame({ type: "lavish:suspendWhiteboard", diagramIndex: index });
1294
1451
  // A fresh document per open: the frame boots, posts ready, and receives its
1295
1452
  // init - no stale editor state can leak between opens.
1296
- whiteboardFrame.src = "/whiteboard-frame?diagramIndex=" + encodeURIComponent(String(index));
1453
+ whiteboardFrame.src =
1454
+ "/whiteboard-frame?diagramIndex=" + encodeURIComponent(String(index)) + "&key=" + encodeURIComponent(key);
1297
1455
  }
1298
1456
 
1299
1457
  function finishWhiteboardClose(index) {
@@ -1573,10 +1731,30 @@ function handleAuthenticatedWhiteboardMessage(index, message, mode) {
1573
1731
  if (message.type === "lavish-whiteboard:flushComplete") finishWhiteboardFlush(index, message, mode);
1574
1732
  }
1575
1733
 
1734
+ // Inline whiteboard frames are created by the SDK inside the artifact document,
1735
+ // so a genuine one is always a direct child of the *current* artifact window.
1736
+ // Descent - not the channel token - is what proves the sender is ours: the
1737
+ // frame page is framable by any origin, so a token is not a secret an attacker
1738
+ // cannot obtain. Without this, any window that could postMessage to this chrome
1739
+ // (a page that framed it, or one holding a window.open handle) could open a
1740
+ // channel and queue a fabricated prompt. Mirrors the artifact-message handler's
1741
+ // `event.source !== frame.contentWindow` guard.
1742
+ function isArtifactChildWindow(source) {
1743
+ if (!source) return false;
1744
+ try {
1745
+ // Reading `parent` on a cross-origin WindowProxy is permitted; the frame's
1746
+ // sandbox makes everything else about it opaque.
1747
+ return source.parent === frame.contentWindow;
1748
+ } catch {
1749
+ return false;
1750
+ }
1751
+ }
1752
+
1576
1753
  function handleInlineWhiteboardMessage(event, message) {
1577
1754
  if (ended) return;
1755
+ if (!isArtifactChildWindow(event.source)) return;
1578
1756
  const index = validWhiteboardIndex(message.diagramIndex);
1579
- if (index === null || !event.source) return;
1757
+ if (index === null) return;
1580
1758
  if (message.type === "lavish-whiteboard:ready") {
1581
1759
  if (inlineWhiteboardChannels.has(index)) return;
1582
1760
  const channelId = String(message.channelToken || "");
@@ -1736,11 +1914,139 @@ window.addEventListener("message", (event) => {
1736
1914
  messageToken,
1737
1915
  ).catch(() => {});
1738
1916
  }
1917
+ if (msg.type === "lavish:uploadAttachment") uploadAttachment(msg);
1918
+ // There is deliberately no attachment-delete message. See removeAttachment's
1919
+ // removal note below: the iframe cannot be trusted to decide a delete, and the
1920
+ // chrome cannot see every live reference, so reclamation is the sweeper's job.
1739
1921
  if (msg.type === "lavish:sendQueuedPrompts") sendQueued();
1740
1922
  if (msg.type === "lavish:endSession") endSession();
1741
1923
  if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
1742
1924
  });
1743
1925
 
1926
+ // The sandboxed artifact iframe can't reach the loopback server (opaque origin),
1927
+ // so it hands captured image bytes here and the chrome performs the same-origin
1928
+ // upload, then reports the server-vetted id back to the card.
1929
+ async function uploadAttachment(message) {
1930
+ const localId = String(message.localId || "");
1931
+ if (!localId) return;
1932
+ // Echoed verbatim on every result so the artifact can tell a reply to ITS upload
1933
+ // from one still in flight for a previous document (E1). The chrome never
1934
+ // interprets it; it only round-trips it.
1935
+ const nonce = message.nonce;
1936
+ const bytes = message.bytes;
1937
+ let size;
1938
+ if (ArrayBuffer.isView(bytes)) {
1939
+ size = bytes.byteLength;
1940
+ } else {
1941
+ try {
1942
+ const byteLengthGetter = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")?.get;
1943
+ size = byteLengthGetter ? byteLengthGetter.call(bytes) : NaN;
1944
+ } catch {
1945
+ size = NaN;
1946
+ }
1947
+ }
1948
+ if (!Number.isFinite(size) || size < 0) {
1949
+ postToFrame({
1950
+ type: "lavish:attachmentResult",
1951
+ nonce,
1952
+ localId,
1953
+ ok: false,
1954
+ error: "invalid upload payload",
1955
+ });
1956
+ return;
1957
+ }
1958
+ // Reject over-cap images before they hit the network: an over-cap upload aborts
1959
+ // mid-stream, and the browser can hang or reset instead of surfacing the 413, so
1960
+ // the chip would never leave "uploading". Catching it here guarantees the card
1961
+ // reaches its error+retry state. The server still enforces the cap authoritatively.
1962
+ if (attachmentMaxBytes > 0 && size > attachmentMaxBytes) {
1963
+ postToFrame({
1964
+ type: "lavish:attachmentResult",
1965
+ nonce,
1966
+ localId,
1967
+ ok: false,
1968
+ error: "Image is larger than the " + formatByteLimit(attachmentMaxBytes) + " limit",
1969
+ });
1970
+ return;
1971
+ }
1972
+ // Confused-deputy guard: rate + cumulative-byte ceiling before touching the network.
1973
+ const now = Date.now();
1974
+ while (uploadTimestamps.length && now - uploadTimestamps[0] > UPLOAD_RATE_WINDOW_MS) uploadTimestamps.shift();
1975
+ if (uploadTimestamps.length >= UPLOAD_RATE_MAX) {
1976
+ postToFrame({
1977
+ type: "lavish:attachmentResult",
1978
+ nonce,
1979
+ localId,
1980
+ ok: false,
1981
+ error: "Too many uploads. Wait a moment and retry.",
1982
+ });
1983
+ return;
1984
+ }
1985
+ if (uploadedBytesTotal + size > UPLOAD_SESSION_BYTE_QUOTA) {
1986
+ postToFrame({
1987
+ type: "lavish:attachmentResult",
1988
+ nonce,
1989
+ localId,
1990
+ ok: false,
1991
+ error: "Upload limit reached for this session (" + formatByteLimit(UPLOAD_SESSION_BYTE_QUOTA) + ").",
1992
+ });
1993
+ return;
1994
+ }
1995
+ // In-flight ceiling: refuse rather than pile another large body onto the network
1996
+ // while the bound is full. The card keeps its retry affordance, and a settled
1997
+ // upload (below) frees a slot for the next.
1998
+ if (uploadsInFlight >= UPLOAD_MAX_IN_FLIGHT) {
1999
+ postToFrame({
2000
+ type: "lavish:attachmentResult",
2001
+ nonce,
2002
+ localId,
2003
+ ok: false,
2004
+ error: "Too many uploads in flight. Wait a moment and retry.",
2005
+ });
2006
+ return;
2007
+ }
2008
+ uploadTimestamps.push(now);
2009
+ uploadedBytesTotal += size;
2010
+ uploadsInFlight += 1;
2011
+ try {
2012
+ const response = await fetch("/api/" + key + "/attachments", {
2013
+ method: "POST",
2014
+ headers: { "content-type": String(message.mime || "application/octet-stream") },
2015
+ body: bytes,
2016
+ });
2017
+ const data = await response.json().catch(() => ({}));
2018
+ if (!response.ok) throw new Error(data.error || "Upload failed");
2019
+ postToFrame({
2020
+ type: "lavish:attachmentResult",
2021
+ nonce,
2022
+ localId,
2023
+ ok: true,
2024
+ id: (data.attachment && data.attachment.id) || "",
2025
+ });
2026
+ } catch (error) {
2027
+ postToFrame({
2028
+ type: "lavish:attachmentResult",
2029
+ nonce,
2030
+ localId,
2031
+ ok: false,
2032
+ error: error instanceof Error ? error.message : String(error),
2033
+ });
2034
+ } finally {
2035
+ uploadsInFlight -= 1;
2036
+ }
2037
+ }
2038
+
2039
+ // There is intentionally no eager attachment delete here. Removing a chip used to
2040
+ // ask the chrome to DELETE the stored file once no queued prompt referenced it,
2041
+ // but that check is not authoritative: attachments are content-addressed, so two
2042
+ // tabs (or two cards) can hold the SAME id, and a chip that is ready but not yet
2043
+ // queued in another tab is invisible from here. The delete was also driven by the
2044
+ // untrusted iframe, making the chrome a confused deputy - a malicious artifact
2045
+ // could destroy bytes a live card still needed, which then failed as `not-found`
2046
+ // on send. Unreferenced files are reclaimed by the server's reference-aware TTL
2047
+ // sweeper and the disk-cap backstop, which see every session's pending prompts
2048
+ // at once. Deleting late is cheap; deleting bytes someone still needs is not.
2049
+
1744
2050
  loadFrame();
1745
2051
 
1746
2052
  function toggleAnnotationMode() {
package/dist/chrome.css CHANGED
@@ -1058,6 +1058,36 @@ body.lavish {
1058
1058
  white-space: nowrap;
1059
1059
  text-overflow: ellipsis;
1060
1060
  }
1061
+ .pill-attachments {
1062
+ display: inline-flex;
1063
+ align-items: center;
1064
+ gap: 4px;
1065
+ flex: 0 0 auto;
1066
+ }
1067
+ .pill-attachment {
1068
+ width: 22px;
1069
+ height: 22px;
1070
+ border-radius: 5px;
1071
+ object-fit: cover;
1072
+ background: var(--border);
1073
+ border: 1px solid var(--border-strong);
1074
+ }
1075
+ .pill-attachment-more {
1076
+ display: inline-flex;
1077
+ align-items: center;
1078
+ justify-content: center;
1079
+ min-width: 22px;
1080
+ height: 22px;
1081
+ padding: 0 4px;
1082
+ border-radius: 5px;
1083
+ background: var(--border);
1084
+ border: 1px solid var(--border-strong);
1085
+ color: var(--fg-muted);
1086
+ font-size: 10px;
1087
+ font-weight: 700;
1088
+ font-variant-numeric: tabular-nums;
1089
+ line-height: 1;
1090
+ }
1061
1091
  .pill-close {
1062
1092
  display: flex;
1063
1093
  align-items: center;