lavish-axi 0.1.79 → 0.1.81

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
@@ -53,7 +53,7 @@ npx skills add kunchenguid/lavish-axi --skill lavish
53
53
 
54
54
  That is the entire setup - no npm install needed.
55
55
  The skill teaches your agent to run Lavish through `npx -y lavish-axi`, so the CLI comes along on demand.
56
- It stays a short stub and sends the agent to `npx -y lavish-axi --help`, `design`, and `playbook` for current instructions, so an installed copy cannot go stale against a newer CLI.
56
+ It stays a short stub and sends the agent to `npx -y lavish-axi --help`, `reply --help`, `design`, and `playbook` for current instructions, so an installed copy cannot go stale against a newer CLI.
57
57
  Its frontmatter also includes Hermes Agent metadata, so Hermes-compatible harnesses can categorize and surface it as a first-class productivity skill.
58
58
  This installs the public `lavish` skill.
59
59
  The repository also contains an internal `lavish-design` brand skill for maintainers; default `npx skills add ... --list` and skills.sh discovery hide it unless `INSTALL_INTERNAL_SKILLS=1` is set.
@@ -197,20 +197,21 @@ pnpm link
197
197
  Unresolved local assets or export notices such as author-set CSP meta tags and redacted file URLs are surfaced in command or browser output.
198
198
  Use `--token` or `LAVISH_AXI_HTML_APP_TOKEN` for an optional bearer token when publishing a new page (a republish or `--unpublish` authorizes with the `update_key` instead and rejects `--token`); set `LAVISH_AXI_HTML_APP_API_URL` to override the ht-ml.app API base and point `share` at a backend you control (see [Self-hosting the share backend](docs/self-hosting-share.md) for the contract it must implement).
199
199
  - **Live reload** - Lavish watches the HTML artifact file by default and preserves review context across reloads: the artifact iframe scroll position, an open annotation card's unsent text, and answers to `data-lavish-question` controls (application-owned form state is left alone). Unsent annotation text also survives a full reload of the review page itself. While a queued layout-issue batch is outstanding, closely spaced saves coalesce so one batch of fixes costs one refresh. To also reload on sibling asset changes, add `data-lavish-live-reload-root` to the root element or `<meta name="lavish-live-reload" content="root">`.
200
- If the element an unsent annotation was attached to is gone from the artifact for good, Lavish cannot reopen that card, so it writes your text into the conversation panel under **Unsent annotation** - selectable, never written over anything you have typed, and kept there across reloads; no note is ever dropped to make room for a newer one, and a note the browser refuses to store says so where it is shown.
200
+ If the element an unsent annotation was attached to is gone from the artifact for good, Lavish cannot reopen that card, and if the artifact replaces a queued note while you are editing it, the replacement is kept as the artifact sent it. Either way, Lavish writes your edited text into the conversation panel under **Unsent annotation** - selectable, never written over anything you have typed, and kept there across reloads; no note is ever dropped to make room for a newer one, and a note the browser refuses to store says so where it is shown.
201
201
  - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
202
202
  For reversible choices, let option clicks update local state, then queue exactly one final answer from a per-question submit or Queue answer button with `window.lavish.queuePrompt()`.
203
203
  If someone may open a decision artifact as a standalone HTML file, `lavish-axi playbook input` provides an optional Copy all answers control they can use to paste their form answers back into chat.
204
204
  Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them, and use `data-lavish-question` or `queueKey` when pre-send updates for the same question should replace each other.
205
- Everything you send is part of the conversation: a note on an element, a text selection, a table cell, a diagram node, a whiteboard, a layout-issue batch, or a composer message each becomes a bubble on your side of the Conversation panel, with an anchor line naming what it was attached to (the element tag, text selection, table cell, diagram node, whiteboard, or layout issues), a quoted excerpt, and, when available, the selector on hover. A note you have queued but not sent sits at the end of the conversation as a dashed bubble labelled **Queued** with a remove control; while its batch is in flight it reads **Sending**, and once the server accepts it, it settles in place as a sent bubble in every tab of the session. The panel keeps the newest stored transcript up to 5 MiB; older bubbles drop from view, but an accepted note still settles and is not sent twice. Agent replies render a small Markdown subset - paragraphs, line breaks, headings as bold lead lines, bullet and numbered lists, fenced and inline code, bold, italic, and http(s) links - while tables, images, and raw HTML stay as the text the agent wrote; your own messages keep their line breaks and are never parsed.
205
+ Everything you send is part of the conversation: a note on an element, a text selection, a table cell, a diagram node, a whiteboard, a layout-issue batch, or a composer message each becomes a bubble on your side of the Conversation panel, with an anchor line naming what it was attached to (the element tag, text selection, table cell, diagram node, whiteboard, or layout issues), a quoted excerpt, and, when available, the selector on hover. A note you have queued but not sent sits at the end of the conversation as a dashed bubble labelled **Queued** with edit and remove controls. Clicking the bubble or its edit control opens its words for editing in place and scrolls the artifact to the element it points at; clicking that element again in annotate mode opens the same note rather than a fresh card. Saving a note with no words and no images removes it, and **Send** delivers an open edit as typed. While its batch is in flight it reads **Sending** and can no longer be edited, and once the server accepts it, it settles in place as a sent bubble in every tab of the session. The panel keeps the newest stored transcript up to 5 MiB; older bubbles drop from view, but an accepted note still settles and is not sent twice. Agent replies render a small Markdown subset - paragraphs, line breaks, headings as bold lead lines, bullet and numbered lists, fenced and inline code, bold, italic, and http(s) links - while tables, images, and raw HTML stay as the text the agent wrote; your own messages keep their line breaks and are never parsed.
206
206
  On wider screens, the conversation and the queued notes share one scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
207
207
  The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session), while the composer exposes **Send & End** beside **Send to Agent** to submit queued prompts and user-ended attribution together. Once **Send & End** starts, Lavish pauses new feedback, waits up to 5 seconds for feedback preparation already underway, keeps any open annotation draft intact until delivery succeeds, then holds that exact terminal batch. A completed reservation survives a page reload, including when its prompts were already delivered and only the pending end remains; incomplete preparation is not restored. If preparation times out, Lavish keeps the review open and its existing queue editable with a visible error. A transient delivery failure permits only retrying the same terminal batch through **Send & End** until it succeeds. If a snapshot makes the request too large (HTTP 413), Lavish retries that exact batch once without the optional snapshot. An actionable attachment rejection (HTTP 400), recoverable layout-selection conflict (HTTP 409), or unrecoverable 413 instead cancels the terminal reservation without ending the session, keeps the prompts queued, and restores editing, removal, **Send to Agent**, **Send & End**, and **End session** so the reviewer can revise or remove stale feedback before submitting again.
208
208
  Composer feedback is visibly queued before the text box clears and stays queued until acknowledged. Send waits up to 5 seconds for the artifact's DOM snapshot, then delivers without that optional context if the frame no longer answers; a late snapshot cannot submit the batch twice. A failed or still-unacknowledged POST keeps the queue and shows persistent recovery guidance unless a transcript sync proves the server already accepted that feedback.
209
209
  - **Reviewing on a phone** - Below 860px wide, the artifact takes the whole screen above a **Conversation** dock, and the conversation opens as a bottom sheet over it: tap the dock, swipe it up, or press the chevron to raise it; tap the dimmed artifact, swipe the sheet down, press the chevron, or press Escape to lower it. The dock reports what matters while the sheet is down - how many prompts are queued, a reply that arrived while you were reading, or whether the agent is listening - and the sheet stays open across a reload of the review page. The sheet sizes itself to the visible viewport and respects safe-area insets; if the keyboard or attachments leave little room, conversation content yields or scrolls while the send actions remain pinned above the bottom edge. In landscape the sheet covers the top bar as well. Wider screens keep the side-by-side layout.
210
210
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
211
211
  In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it.
212
+ While editing a queued note, Enter saves it, Shift+Enter inserts a newline, and Escape discards the edit.
212
213
  Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control.
213
- - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting. A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
214
+ - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting (see the CLI Reference for reply and poll commands). A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
214
215
  In a Herdr-managed pane, set `LAVISH_AXI_HERDR_CHIME=1` to show a Herdr request notification and play its chime once an indefinite poll enters the waiting state. Immediate queued feedback and timed debug polls do not trigger a notification. The integration is off unless both `LAVISH_AXI_HERDR_CHIME` and Herdr's `HERDR_ENV` session marker equal `1`; leaving either unset or using any other value opts out. Notification delivery runs without blocking feedback: if Herdr is unavailable, rejects the notification, stalls, or fails, the poll continues unchanged.
215
216
  Closing the last review tab while a poll is active starts a 10-second reconnect grace period. A reload or quick reconnect keeps an active poll waiting; if every review stays disconnected, the poll returns `browser_disconnected` without ending the resumable session, and the agent asks whether to reopen or end it instead of acting uninvited.
216
217
  The no-timeout poll always writes an immediate stderr banner so it is visibly not hung; it adds the periodic stderr wait ticks only in an interactive terminal, so when stderr is piped (as under agent harnesses) the captured output carries no tick noise. Stdout always stays reserved for the final response; if the poll is interrupted or times out before feedback arrives, re-run it because feedback remains queued until delivery. Poll delivery consumes the response, so read the complete response before truncating or filtering it.
@@ -263,6 +264,7 @@ pnpm link
263
264
  | `lavish-axi update` | Check for or apply the latest npm release through the AXI SDK self-updater. |
264
265
  | `lavish-axi <html-file>` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. Unresolved layout issues from earlier in the session are preserved. Refuses to reopen a session the user explicitly ended from the browser unless `--reopen` is passed. |
265
266
  | `lavish-axi poll <html-file>` | Long-poll until the user sends feedback, ends the session, or leaves every review disconnected past the reconnect grace period; detected layout issues arrive only when queued. On `browser_disconnected`, ask before reopening or ending the still-resumable session. On `ended`, stop polling and do not reopen uninvited. |
267
+ | `lavish-axi reply <html-file>` | Post an agent reply and exit 0 only after the server accepts it, so the review page stops showing Working. Exits non-zero if the server does not confirm within 10 seconds. Use this when handing a result back without starting a long-poll. `poll --agent-reply` still posts a reply and then waits. |
266
268
  | `lavish-axi end <html-file>` | End a session as the agent; unlike a user-initiated end from the browser, this still allows a plain reopen later. |
267
269
  | `lavish-axi export <html-file>` | Write a portable copy of the artifact: one HTML file with its local assets inlined, so it opens with no server and no sibling files. Remote CDN/font references are left as links. |
268
270
  | `lavish-axi share <html-file>` | Publish the artifact (local assets inlined) to [ht-ml.app](https://ht-ml.app), a third-party host not part of Lavish, and print a visitable URL plus a secret update key; shares are public by default, `--private` locks one behind a generated password, and the same command republishes or unpublishes an existing page with `--site`/`--update-key`. |
@@ -297,6 +299,8 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
297
299
  | `lavish-axi poll` | `--agent-reply "..."` | Show a concise agent reply in the existing browser chat, conclude delivered work, and return presence to waiting before polling again. Keep replies concise by default. |
298
300
  | `lavish-axi poll` | `--agent-reply-file <path>` | When a longer reply is genuinely necessary, read Markdown from a file (`-` for stdin) so newlines survive quoting. Use blank-line paragraphs, `- ` / `1. ` lists, and `## ` headings so the Conversation panel renders it scannably. The Markdown subset is under Feedback controls. Cannot be combined with `--agent-reply`. |
299
301
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
302
+ | `lavish-axi reply` | `--agent-reply "..."` | Concise reply text. Required unless `--agent-reply-file` is set. The command exits 0 only after the server accepts it. Empty text is refused. Prefer this over `poll --agent-reply` when you are not about to long-poll. |
303
+ | `lavish-axi reply` | `--agent-reply-file <path>` | Read the reply from a file (`-` for stdin) so newlines survive quoting. Cannot be combined with `--agent-reply`. Same Markdown guidance as `poll --agent-reply-file`. |
300
304
  | `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
301
305
  | `lavish-axi server` | `--verbose` | Log session and watcher events to stderr; can also be enabled with `LAVISH_AXI_DEBUG=1`. Detached server output is appended with UTC timestamps to `~/.lavish-axi/server.log` (or `LAVISH_AXI_STATE_DIR/server.log`) for startup, shutdown-cause, and crash diagnostics. |
302
306
  | `lavish-axi server` | `--also-listen <host>` | Also listen on this concrete address (repeatable). The CLI passes it when it replaces a running server, so the replacement keeps every address the old one served; see One server per port. |
@@ -261,6 +261,12 @@ let terminalSubmission =
261
261
  /** @type {ReturnType<typeof setTimeout> | undefined} */
262
262
  let sendAcknowledgementTimer;
263
263
  let lastScroll = { x: 0, y: 0 };
264
+ // The queued note open for editing in the panel, by prompt identity, and its unsaved words.
265
+ let editingPromptId = "";
266
+ let editingDraft = "";
267
+ let editFocusPending = false;
268
+ // The selector list last told to the artifact, so a render that changes nothing stays quiet.
269
+ let postedQueuedAnchors = "[]";
264
270
  // In-iframe review context (an open annotation card's unsent text, Lavish-owned question
265
271
  // answers). The sandbox means the chrome cannot read it back after a reload, so the SDK reports
266
272
  // it as it changes and the chrome replays it once the new document is up. It is persisted per
@@ -486,6 +492,8 @@ function persistTerminalReservation(reserved) {
486
492
 
487
493
  const REMOVE_ICON_SVG =
488
494
  '<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>';
495
+ const EDIT_ICON_SVG =
496
+ '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M6.5 1.5L8.5 3.5L3.5 8.5H1.5V6.5L6.5 1.5Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
489
497
  const ANCHOR_EXCERPT_MAX = 120;
490
498
  const ANCHOR_SELECTOR_MAX = 512;
491
499
  const ANCHOR_LABEL_MAX = 40;
@@ -587,14 +595,23 @@ function userBubbleTextHtml(entry, text) {
587
595
  }
588
596
 
589
597
  // A queued note is the user bubble in its not-yet-sent state: dashed, labelled Queued (Sending
590
- // while its batch is in flight), and removable until then. It settles in place as a sent bubble
591
- // once the server's transcript carries it, so nothing moves between regions.
598
+ // while its batch is in flight), and editable and removable until then. It settles in place as a
599
+ // sent bubble once the server's transcript carries it, so nothing moves between regions.
592
600
  function queuedBubbleHtml(prompt, index) {
593
601
  const sending = isPromptSending(prompt);
602
+ if (!sending && editingPromptId && promptIdentity(prompt) === editingPromptId) {
603
+ return queuedEditorHtml(prompt, index);
604
+ }
594
605
  return (
595
- '<div class="bubble user queued"><small>' +
606
+ '<div class="bubble user queued" data-index="' +
607
+ index +
608
+ '"><small>' +
596
609
  (sending ? "Sending\u2026" : "Queued") +
597
- ' <button class="queued-remove" type="button" aria-label="Remove queued prompt" data-index="' +
610
+ ' <button class="queued-edit" type="button" aria-label="Edit queued prompt" data-index="' +
611
+ index +
612
+ '">' +
613
+ EDIT_ICON_SVG +
614
+ '</button><button class="queued-remove" type="button" aria-label="Remove queued prompt" data-index="' +
598
615
  index +
599
616
  '">' +
600
617
  REMOVE_ICON_SVG +
@@ -606,6 +623,25 @@ function queuedBubbleHtml(prompt, index) {
606
623
  );
607
624
  }
608
625
 
626
+ // The same bubble while its words are being revised. The anchor and images stay as they were:
627
+ // an edit changes what the note says, not what it points at.
628
+ function queuedEditorHtml(prompt, index) {
629
+ return (
630
+ '<div class="bubble user queued editing" data-index="' +
631
+ index +
632
+ '"><small>Editing</small>' +
633
+ anchorHtml(promptAnchor(prompt)) +
634
+ '<textarea class="queued-edit-input" aria-label="Edit queued prompt" data-index="' +
635
+ index +
636
+ '">' +
637
+ escapeHtml(editingDraft) +
638
+ '</textarea><div class="queued-edit-actions"><button class="queued-edit-cancel" type="button">Cancel</button>' +
639
+ '<button class="queued-edit-save" type="button">Save</button></div>' +
640
+ bubbleAttachmentsHtml(prompt) +
641
+ "</div>"
642
+ );
643
+ }
644
+
609
645
  // Whether a queued prompt is committed to a send that has not been answered yet: waiting for its
610
646
  // snapshot, queued behind another submission, in flight, or held by the terminal reservation.
611
647
  // Derived from the existing send bookkeeping rather than tracked separately, so no failure path
@@ -624,6 +660,23 @@ function isPromptSending(prompt) {
624
660
  }
625
661
 
626
662
  function render() {
663
+ const editing = editingPromptId ? queued.find((prompt) => promptIdentity(prompt) === editingPromptId) : null;
664
+ // A note that left the queue or started sending takes its editor with it.
665
+ if (editingPromptId && (!editing || !isPromptEditable(editing))) endQueuedEdit();
666
+ const keepEditFocus = editFocusPending || queuedLog.contains(document.activeElement);
667
+ // A re-render while the reviewer is typing keeps their caret and scroll; only a freshly opened
668
+ // edit starts at the end of its words.
669
+ const previousInput = /** @type {HTMLTextAreaElement | null} */ (queuedLog.querySelector(".queued-edit-input"));
670
+ const caret =
671
+ !editFocusPending && previousInput && previousInput === document.activeElement
672
+ ? {
673
+ start: previousInput.selectionStart,
674
+ end: previousInput.selectionEnd,
675
+ direction: previousInput.selectionDirection,
676
+ scrollTop: previousInput.scrollTop,
677
+ }
678
+ : null;
679
+ editFocusPending = false;
627
680
  queuedLog.innerHTML = queued.map((prompt, index) => queuedBubbleHtml(prompt, index)).join("");
628
681
 
629
682
  for (const button of queuedLog.querySelectorAll(".queued-remove")) {
@@ -632,6 +685,23 @@ function render() {
632
685
  removeButton.disabled = terminalSubmission !== null || isPromptSending(prompt);
633
686
  removeButton.addEventListener("click", (event) => removeQueuedPrompt(Number(removeButton.dataset.index), event));
634
687
  }
688
+ for (const button of queuedLog.querySelectorAll(".queued-edit")) {
689
+ const editButton = /** @type {HTMLButtonElement} */ (button);
690
+ editButton.disabled = !isPromptEditable(queued[Number(editButton.dataset.index)]);
691
+ }
692
+ if (editingPromptId && keepEditFocus) {
693
+ const input = /** @type {HTMLTextAreaElement | null} */ (queuedLog.querySelector(".queued-edit-input"));
694
+ if (input) {
695
+ input.focus();
696
+ if (caret) {
697
+ input.setSelectionRange?.(caret.start, caret.end, caret.direction);
698
+ input.scrollTop = caret.scrollTop;
699
+ } else {
700
+ input.setSelectionRange?.(input.value.length, input.value.length);
701
+ }
702
+ }
703
+ }
704
+ postQueuedAnchors();
635
705
  updateSendState();
636
706
  scrollPanelToBottom();
637
707
  renderSheetSummary();
@@ -1060,6 +1130,7 @@ function setReviewState(state) {
1060
1130
  }
1061
1131
 
1062
1132
  function hasUnsentDraft() {
1133
+ if (editingPromptId) return true;
1063
1134
  return Boolean(lastReviewState && lastReviewState.card && String(lastReviewState.card.text || "").trim());
1064
1135
  }
1065
1136
 
@@ -1108,7 +1179,7 @@ function renderRetiredDraft(text, stored = true) {
1108
1179
  const el = document.createElement("div");
1109
1180
  el.className = "bubble note";
1110
1181
  el.innerHTML =
1111
- "<small>Unsent annotation</small><div>The element this note was attached to is no longer in the artifact, so Lavish could not reopen the card. Your text is kept here:</div>" +
1182
+ "<small>Unsent annotation</small><div>The artifact removed or replaced what this note was attached to, so Lavish could not keep it open. Your text is kept here:</div>" +
1112
1183
  '<div class="note-draft">' +
1113
1184
  escapeHtml(text) +
1114
1185
  "</div>" +
@@ -1338,16 +1409,149 @@ function scrollElementIntoView(el) {
1338
1409
  el.scrollIntoView({ block: "nearest", inline: "nearest" });
1339
1410
  }
1340
1411
 
1412
+ function isPromptEditable(prompt) {
1413
+ return Boolean(prompt) && !terminalSubmission && !isPromptSending(prompt);
1414
+ }
1415
+
1416
+ function editQueuedPrompt(index, { reveal = true } = {}) {
1417
+ const prompt = queued[index];
1418
+ if (ended || !isPromptEditable(prompt)) return;
1419
+ const id = promptIdentity(prompt);
1420
+ if (!id || id === editingPromptId) return;
1421
+ // Moving to another note keeps what was typed into the first, the way leaving a field does.
1422
+ commitQueuedEdit();
1423
+ editingPromptId = id;
1424
+ editingDraft = String(prompt.prompt || "");
1425
+ editFocusPending = true;
1426
+ if (isMobileSheet()) setSheetOpen(true);
1427
+ render();
1428
+ if (reveal && prompt.selector) postToFrame({ type: "lavish:revealElement", selector: String(prompt.selector) });
1429
+ }
1430
+
1431
+ function endQueuedEdit() {
1432
+ editingPromptId = "";
1433
+ editingDraft = "";
1434
+ }
1435
+
1436
+ // Writes the open edit into its note without rendering. Returns whether the queue changed. A note
1437
+ // cleared of words and images is removed, as if its remove control had been used.
1438
+ function commitQueuedEdit() {
1439
+ if (!editingPromptId) return false;
1440
+ const index = queued.findIndex((prompt) => promptIdentity(prompt) === editingPromptId);
1441
+ const text = editingDraft.trim();
1442
+ endQueuedEdit();
1443
+ const prompt = queued[index];
1444
+ if (!isPromptEditable(prompt)) return false;
1445
+ if (!text && !attachmentCount(prompt)) {
1446
+ queued.splice(index, 1);
1447
+ afterQueuedPromptRemoved();
1448
+ } else {
1449
+ // In place, not replaced: the send bookkeeping tracks notes by object identity.
1450
+ prompt.prompt = text;
1451
+ persistQueuedPrompts();
1452
+ }
1453
+ return true;
1454
+ }
1455
+
1456
+ function saveQueuedEdit() {
1457
+ commitQueuedEdit();
1458
+ render();
1459
+ }
1460
+
1461
+ function cancelQueuedEdit() {
1462
+ endQueuedEdit();
1463
+ render();
1464
+ }
1465
+
1466
+ // A click on an element the artifact knows carries a queued note. The artifact names only the
1467
+ // selector, so the most a forged message can do is open one of the user's own notes for editing.
1468
+ function editQueuedAnchor(selector) {
1469
+ if (!selector) return;
1470
+ for (let index = queued.length - 1; index >= 0; index--) {
1471
+ if (isElementAnchoredPrompt(queued[index]) && queuedAnchorSelectorsOf(queued[index]).includes(selector)) {
1472
+ if (isPromptEditable(queued[index])) {
1473
+ editQueuedPrompt(index, { reveal: false });
1474
+ return;
1475
+ }
1476
+ break;
1477
+ }
1478
+ }
1479
+ // The note is already on its way, or gone: the click still deserves the card it would have had.
1480
+ postToFrame({ type: "lavish:annotateElement", selector });
1481
+ }
1482
+
1483
+ // Notes that point at a whole element, cell, or diagram node, which a later click on that same
1484
+ // target can find again. A text selection's note is about the words, not the element around them.
1485
+ function isElementAnchoredPrompt(prompt) {
1486
+ const kind = promptAnchor(prompt)?.kind;
1487
+ return Boolean(prompt.selector) && (kind === "element" || kind === "cell" || kind === "node");
1488
+ }
1489
+
1490
+ // A diagram node is one target however deep the click lands, so its note answers to the node's
1491
+ // own selector as well as the clicked element's. Other notes name only the exact element.
1492
+ function queuedAnchorSelectorsOf(prompt) {
1493
+ const target = prompt.target && typeof prompt.target === "object" ? prompt.target : null;
1494
+ const whole = target?.type === "mermaid-node" ? String(target.selector || "") : "";
1495
+ return whole ? [String(prompt.selector), whole] : [String(prompt.selector)];
1496
+ }
1497
+
1498
+ // Tells the artifact which element selectors carry a queued note. Only selectors cross into the
1499
+ // frame: the artifact is agent-authored, and the note text stays in the chrome.
1500
+ function postQueuedAnchors(force = false) {
1501
+ const selectors = [
1502
+ ...new Set(queued.filter((prompt) => isElementAnchoredPrompt(prompt)).flatMap(queuedAnchorSelectorsOf)),
1503
+ ];
1504
+ const signature = JSON.stringify(selectors);
1505
+ if (!force && signature === postedQueuedAnchors) return;
1506
+ postedQueuedAnchors = signature;
1507
+ postToFrame({ type: "lavish:queuedAnchors", selectors });
1508
+ }
1509
+
1510
+ queuedLog.addEventListener("click", (event) => {
1511
+ const target = /** @type {Element | null} */ (event.target);
1512
+ if (!target || typeof target.closest !== "function") return;
1513
+ // The remove control owns its own click, and an editor's textarea is for typing.
1514
+ if (target.closest(".queued-remove") || target.closest(".queued-edit-input")) return;
1515
+ if (target.closest(".queued-edit-save")) return saveQueuedEdit();
1516
+ if (target.closest(".queued-edit-cancel")) return cancelQueuedEdit();
1517
+ const bubble = /** @type {HTMLElement | null} */ (target.closest(".queued-edit") || target.closest(".bubble.queued"));
1518
+ if (bubble) editQueuedPrompt(Number(bubble.dataset.index));
1519
+ });
1520
+ queuedLog.addEventListener("input", (event) => {
1521
+ const target = /** @type {HTMLTextAreaElement | null} */ (event.target);
1522
+ if (target && typeof target.closest === "function" && target.closest(".queued-edit-input")) {
1523
+ editingDraft = String(target.value || "");
1524
+ }
1525
+ });
1526
+ queuedLog.addEventListener("keydown", (event) => {
1527
+ const target = /** @type {Element | null} */ (event.target);
1528
+ if (!target || typeof target.closest !== "function" || !target.closest(".queued-edit-input")) return;
1529
+ // The annotation card's keys: Enter keeps the words, Shift+Enter breaks the line.
1530
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
1531
+ event.preventDefault();
1532
+ event.stopPropagation();
1533
+ saveQueuedEdit();
1534
+ } else if (event.key === "Escape" && !event.isComposing) {
1535
+ event.preventDefault();
1536
+ event.stopPropagation();
1537
+ cancelQueuedEdit();
1538
+ }
1539
+ });
1540
+
1341
1541
  function removeQueuedPrompt(index, event) {
1342
1542
  if (event) event.stopPropagation();
1343
1543
  if (terminalSubmission || isPromptSending(queued[index])) return;
1344
1544
  queued.splice(index, 1);
1545
+ afterQueuedPromptRemoved();
1546
+ render();
1547
+ }
1548
+
1549
+ function afterQueuedPromptRemoved() {
1345
1550
  persistQueuedPrompts();
1346
1551
  if (!queued.length) {
1347
1552
  clearSendAcknowledgementWarning();
1348
1553
  if (sendFailureOwner?.kind !== "preparation") clearPersistentSendFailure();
1349
1554
  }
1350
- render();
1351
1555
  }
1352
1556
 
1353
1557
  function promptQueueKey(prompt) {
@@ -1382,6 +1586,13 @@ function enqueuePrompt(rawPrompt, /** @type {FeedbackPreparation | null} */ prep
1382
1586
  if (queueKey) {
1383
1587
  const index = queued.findIndex((item) => promptQueueKey(item) === queueKey);
1384
1588
  if (index !== -1) {
1589
+ // The replacement is the artifact's newer answer. Words typed against the old answer never
1590
+ // move onto it; they are handed back to the reviewer instead.
1591
+ if (editingPromptId && promptIdentity(queued[index]) === editingPromptId) {
1592
+ const draft = editingDraft.trim();
1593
+ endQueuedEdit();
1594
+ if (draft !== String(queued[index].prompt || "").trim()) keepRetiredDraft(draft);
1595
+ }
1385
1596
  queued[index] = prompt;
1386
1597
  } else {
1387
1598
  queued.push(prompt);
@@ -1660,6 +1871,8 @@ function sendQueued(endAfter) {
1660
1871
  return;
1661
1872
  }
1662
1873
  closeMenus();
1874
+ // Send delivers what the panel shows, so an open edit is kept rather than sent stale.
1875
+ if (commitQueuedEdit()) render();
1663
1876
 
1664
1877
  // A pending or failed chip holds back only the COMPOSER message (and an
1665
1878
  // explicit end, which would strand the chips) - queued annotation prompts
@@ -3965,6 +4178,7 @@ window.addEventListener("message", (event) => {
3965
4178
  if (msg.type === "lavish:endSession") endSession();
3966
4179
  if (msg.type === "lavish:revisions") applyRevisionMessage(msg);
3967
4180
  if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
4181
+ if (msg.type === "lavish:editQueuedAnchor") editQueuedAnchor(String(msg.selector || ""));
3968
4182
  });
3969
4183
 
3970
4184
  // The sandboxed artifact iframe can't reach the loopback server (opaque origin),
@@ -4293,6 +4507,7 @@ frame.addEventListener("load", () => {
4293
4507
  // Replay the pre-reload scroll position so hot reloads don't jump the artifact to the top.
4294
4508
  postToFrame({ type: "lavish:restoreScroll", x: lastScroll.x, y: lastScroll.y });
4295
4509
  if (lastReviewState) postToFrame({ type: "lavish:restoreReviewState", state: lastReviewState });
4510
+ postQueuedAnchors(true);
4296
4511
  if (overlayIndex !== null) {
4297
4512
  inlineWhiteboardChannels.delete(overlayIndex);
4298
4513
  postToFrame({ type: "lavish:suspendWhiteboard", diagramIndex: overlayIndex });
package/dist/chrome.css CHANGED
@@ -1199,6 +1199,15 @@ body.lavish {
1199
1199
  align-items: center;
1200
1200
  gap: 8px;
1201
1201
  }
1202
+ .bubble.queued:not(.editing) {
1203
+ cursor: pointer;
1204
+ }
1205
+ .bubble.queued.editing {
1206
+ align-self: stretch;
1207
+ max-width: 100%;
1208
+ border-color: var(--accent);
1209
+ }
1210
+ .queued-edit,
1202
1211
  .queued-remove {
1203
1212
  display: flex;
1204
1213
  align-items: center;
@@ -1212,13 +1221,51 @@ body.lavish {
1212
1221
  color: var(--fg-muted);
1213
1222
  cursor: pointer;
1214
1223
  }
1224
+ .queued-edit:disabled,
1215
1225
  .queued-remove:disabled {
1216
1226
  opacity: 0.55;
1217
1227
  cursor: not-allowed;
1218
1228
  }
1229
+ .queued-edit svg,
1219
1230
  .queued-remove svg {
1220
1231
  display: block;
1221
1232
  }
1233
+ .queued-edit-input {
1234
+ display: block;
1235
+ width: 100%;
1236
+ min-height: 64px;
1237
+ resize: vertical;
1238
+ border-radius: var(--radius-md);
1239
+ border: 1px solid var(--border);
1240
+ background: var(--bg);
1241
+ color: var(--fg);
1242
+ padding: 8px;
1243
+ font: inherit;
1244
+ box-sizing: border-box;
1245
+ }
1246
+ .queued-edit-actions {
1247
+ display: flex;
1248
+ justify-content: flex-end;
1249
+ gap: 8px;
1250
+ margin-top: 8px;
1251
+ }
1252
+ .queued-edit-actions button {
1253
+ border: 0;
1254
+ border-radius: var(--radius-md);
1255
+ padding: 6px 10px;
1256
+ font-family: inherit;
1257
+ font-size: 12px;
1258
+ font-weight: var(--w-bold);
1259
+ cursor: pointer;
1260
+ }
1261
+ .queued-edit-cancel {
1262
+ background: var(--border);
1263
+ color: var(--fg);
1264
+ }
1265
+ .queued-edit-save {
1266
+ background: var(--accent);
1267
+ color: var(--accent-ink);
1268
+ }
1222
1269
  /* The anchor line names what a note is attached to, in the annotation card's words. */
1223
1270
  .anchor {
1224
1271
  display: flex;
package/dist/cli.mjs CHANGED
@@ -5487,6 +5487,7 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
5487
5487
  let hovered = null;
5488
5488
  let selected = null;
5489
5489
  let ignoreNextClick = false;
5490
+ let queuedAnchorSelectors = /* @__PURE__ */ new Set();
5490
5491
  let shadow = null;
5491
5492
  let counter = 0;
5492
5493
  const ids = /* @__PURE__ */ new WeakMap();
@@ -7051,6 +7052,13 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
7051
7052
  }
7052
7053
  if (msg.type === "lavish:restoreReviewState") restoreReviewState(msg.state);
7053
7054
  if (msg.type === "lavish:revealElement") revealElement(msg.selector);
7055
+ if (msg.type === "lavish:queuedAnchors") {
7056
+ queuedAnchorSelectors = new Set(Array.isArray(msg.selectors) ? msg.selectors.map(String) : []);
7057
+ }
7058
+ if (msg.type === "lavish:annotateElement" && annotationMode) {
7059
+ const target = safeQuerySelector(msg.selector);
7060
+ if (target) showAnnotationCard(target);
7061
+ }
7054
7062
  });
7055
7063
  function revealElement(selector2) {
7056
7064
  const target = selector2 === "html" ? document.documentElement : safeQuerySelector(selector2);
@@ -7135,6 +7143,14 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
7135
7143
  ignoreNextClick = false;
7136
7144
  return;
7137
7145
  }
7146
+ const clicked = queuedAnchorSelectors.size ? context(event.target) : null;
7147
+ const node = clicked?.target?.type === "mermaid-node" ? clicked.target.selector : "";
7148
+ const selector2 = [clicked?.selector, node].find((candidate) => candidate && queuedAnchorSelectors.has(candidate));
7149
+ if (selector2) {
7150
+ closeCard();
7151
+ postArtifactMessage("lavish:editQueuedAnchor", { selector: selector2 });
7152
+ return;
7153
+ }
7138
7154
  showAnnotationCard(event.target);
7139
7155
  },
7140
7156
  true
@@ -8911,13 +8927,14 @@ var SessionStore = class {
8911
8927
  return session;
8912
8928
  });
8913
8929
  }
8914
- async addAgentReply(key, text) {
8930
+ async addAgentReply(key, text, { requireOpen = false } = {}) {
8915
8931
  return this.runExclusive(async () => {
8916
8932
  const state = await this.readState();
8917
8933
  const session = state.sessions[key];
8918
8934
  if (!session) {
8919
8935
  return null;
8920
8936
  }
8937
+ if (requireOpen && session.status === "ended") return session;
8921
8938
  const at = (/* @__PURE__ */ new Date()).toISOString();
8922
8939
  session.chat = [...session.chat || [], { role: "agent", text: String(text || ""), at }];
8923
8940
  applyTranscriptBound(session);
@@ -10228,9 +10245,9 @@ async function serve({
10228
10245
  next(error);
10229
10246
  }
10230
10247
  });
10231
- async function publishAgentReply(key, text) {
10232
- const session = await store.addAgentReply(key, text);
10233
- if (!session) return null;
10248
+ async function publishAgentReply(key, text, { requireOpen = false } = {}) {
10249
+ const session = await store.addAgentReply(key, text, { requireOpen });
10250
+ if (!session || requireOpen && session.status === "ended") return session;
10234
10251
  const lastEntry = session.chat?.at(-1);
10235
10252
  const entry = serializeChat([
10236
10253
  lastEntry?.role === "agent" ? lastEntry : { role: "agent", text, at: session.updated_at }
@@ -10629,11 +10646,17 @@ async function serve({
10629
10646
  });
10630
10647
  app.post("/api/:key/agent-reply", async (req, res, next) => {
10631
10648
  try {
10632
- const session = await publishAgentReply(req.params.key, String(req.body?.text || ""));
10649
+ const session = await publishAgentReply(req.params.key, String(req.body?.text || ""), {
10650
+ requireOpen: true
10651
+ });
10633
10652
  if (!session) {
10634
10653
  res.status(404).json({ error: "session not found" });
10635
10654
  return;
10636
10655
  }
10656
+ if (session.status === "ended") {
10657
+ res.status(409).json({ status: "ended", ended_by: session.ended_by });
10658
+ return;
10659
+ }
10637
10660
  res.json({ status: "sent" });
10638
10661
  } catch (error) {
10639
10662
  next(error);
@@ -12273,7 +12296,19 @@ function normalizePagePath(path9) {
12273
12296
 
12274
12297
  // src/cli.js
12275
12298
  var SHARE_VALUE_FLAGS = ["--password", "--token", "--site", "--update-key"];
12276
- var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup", "export", "share"]);
12299
+ var COMMANDS = /* @__PURE__ */ new Set([
12300
+ "open",
12301
+ "poll",
12302
+ "reply",
12303
+ "end",
12304
+ "stop",
12305
+ "server",
12306
+ "playbook",
12307
+ "design",
12308
+ "setup",
12309
+ "export",
12310
+ "share"
12311
+ ]);
12277
12312
  var RESERVED = new Set(RESERVED_COMMANDS);
12278
12313
  var DESCRIPTION = "Lavish Editor helps agents turn rich HTML artifacts into collaborative human review surfaces. Whenever you are about to give user a complex response that will be easier to understand via a rich / interactive page, consider using Lavish Editor. First generate an interactive HTML artifact according to user request, then run `lavish-axi <html-file>` so the user can visually review it, annotate elements or selected text, queue prompts, and send feedback back through `lavish-axi poll`.";
12279
12314
  var POLL_WAKE_PATH_RULES = Object.freeze([
@@ -12296,7 +12331,7 @@ var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_J
12296
12331
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
12297
12332
  var POLL_STATE_HEADER = "lavish-poll-state";
12298
12333
  var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
12299
- var VERSION = "0.1.79";
12334
+ var VERSION = "0.1.81";
12300
12335
  function detectInvokingAgent(env = process.env) {
12301
12336
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
12302
12337
  }
@@ -12386,6 +12421,7 @@ async function run(argv) {
12386
12421
  commands: {
12387
12422
  open: openCommand,
12388
12423
  poll: pollCommand,
12424
+ reply: replyCommand,
12389
12425
  end: endCommand,
12390
12426
  stop: stopCommand,
12391
12427
  playbook: playbookCommand,
@@ -12456,6 +12492,7 @@ function createHomeOutput({ bin, sessions, includeSessions = true, agent = "gene
12456
12492
  "Unless the user specifies another location, create HTML artifacts in the current working directory under `.lavish/`",
12457
12493
  "Lavish serves the html file through a local express.js server. If your html needs to reference other filesystem assets such as images, CSS, fonts, and local scripts, copy them into the same directory as the HTML file, then reference them with relative paths from that directory. Never prepend `/` to those asset paths - root paths won't work",
12458
12494
  `Run \`lavish-axi poll <html-file>\` to wait for user feedback. It long-polls and stays silent until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period, so leave it running - never kill it. Detected layout issues never return this poll: the browser files them in the user's Layout issues inbox in the Lavish top bar, and they arrive as an ordinary tag "layout-warnings" prompt only when the user selects them and queues the fixes. Never edit the artifact to chase a layout issue the user has not queued. The only exception is a fatal artifact_failures response, which means the review surface itself could not be used. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}`,
12495
+ 'Run `lavish-axi reply <html-file> --agent-reply "<message>"` to show a concise reply and exit once Lavish Editor accepts it, when you are handing a result back and are not about to long-poll. The board stops showing Working only after this command exits 0. Use `lavish-axi poll <html-file> --agent-reply "<message>"` when that reply should be followed by another wait for feedback. A longer reply uses `--agent-reply-file <path>` (`-` reads stdin) on either command. Run `lavish-axi reply --help` for the receipt contract',
12459
12496
  'Mermaid is the whiteboard opt-in, not the diagram default: only when the user asks for an editable whiteboard, author that diagram as Mermaid in a `.mermaid` container. Rendered Mermaid diagrams there become embedded, editable Excalidraw whiteboards in the browser (click a diagram to unlock editing; a Fullscreen action opens it over the whole viewport) - flowchart, sequence, class, ER, and state diagrams convert to editable shapes; other types embed as an image to draw on. Scenes autosave locally; an unmodified autosave silently re-converts when a reload changes the Mermaid source. If the reviewer edited the scene, they choose to re-convert and discard saved edits or keep editing the saved scene. Standalone and exported copies still render plain Mermaid. Queue feedback adds a prompt to the Conversation panel; when the user sends it, poll returns a tag "whiteboard" prompt carrying a bounded edit summary plus local scenePath (.excalidraw JSON) and previewPath (PNG) files - read the summary first, open the files only when needed, then apply the edits by updating the Mermaid source in the artifact (never try to write the scene back)',
12460
12497
  "Run `lavish-axi end <html-file>` to end a session as the agent - ending it this way still allows a plain reopen later. When the user ends it from the browser instead, a later `lavish-axi <html-file>` refuses to reopen it without `--reopen`",
12461
12498
  "Run `lavish-axi export <html-file> [--out <path>]` to write a portable copy of the artifact - one HTML file with its LOCAL assets inlined - so it opens with no Lavish server and no sibling files. Remote CDN/font references are left as links, so it needs network to render those. Users can also export from the browser chrome's overflow menu",
@@ -12497,7 +12534,7 @@ function createOpenOutput({
12497
12534
  session: { file, url, status },
12498
12535
  ...networkWarning ? { network_warning: networkWarning } : {},
12499
12536
  ...selfPaintWarning ? { self_paint_warning: selfPaintWarning } : {},
12500
- next_step: `${networkPrefix}${selfPaintPrefix}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show a concise response in Lavish Editor and wait for more feedback. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_NEXT_POINTER} If the user ends the session, stop polling and do not reopen it by re-running \`lavish-axi ${file}\` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`
12537
+ next_step: `${networkPrefix}${selfPaintPrefix}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show a concise response in Lavish Editor and keep waiting for more feedback. If instead you are handing back a result without starting another long-poll, run \`lavish-axi reply ${file} --agent-reply "<message for the user>"\` to get an acceptance receipt. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_NEXT_POINTER} If the user ends the session, stop polling and do not reopen it by re-running \`lavish-axi ${file}\` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`
12501
12538
  };
12502
12539
  }
12503
12540
  function createUserEndedOpenOutput({ file, url, networkWarning = void 0 }) {
@@ -12622,6 +12659,41 @@ ${pollInterruptedText(absolute)}
12622
12659
  }
12623
12660
  }
12624
12661
  }
12662
+ var REPLY_VALUE_FLAGS = ["--agent-reply", "--agent-reply-file"];
12663
+ var REPLY_TOO_LARGE_HELP = "Shorten the reply, then retry `lavish-axi reply <html-file>`";
12664
+ async function replyCommand(args) {
12665
+ const file = firstPositionalArg(args, REPLY_VALUE_FLAGS);
12666
+ if (!file) {
12667
+ throw new AxiError("HTML file path is required", "VALIDATION_ERROR", [
12668
+ 'Run `lavish-axi reply <html-file> --agent-reply "<message>"`'
12669
+ ]);
12670
+ }
12671
+ const inline = inspectValueFlag(args, "--agent-reply");
12672
+ const fromFile = inspectValueFlag(args, "--agent-reply-file");
12673
+ if (!inline.present && !fromFile.present) {
12674
+ throw new AxiError("An agent reply is required", "VALIDATION_ERROR", [
12675
+ 'Pass exactly one of --agent-reply "<message>" or --agent-reply-file <path> (`-` reads stdin)',
12676
+ "Use `lavish-axi poll <html-file> --agent-reply` when the reply should be followed by a wait for feedback"
12677
+ ]);
12678
+ }
12679
+ const text = await resolveAgentReply(args, { tooLargeHelp: REPLY_TOO_LARGE_HELP });
12680
+ if (!String(text || "").trim()) {
12681
+ throw new AxiError("Agent reply text was empty", "VALIDATION_ERROR", [
12682
+ 'Pass --agent-reply "<message>" or --agent-reply-file <path> with a non-empty body'
12683
+ ]);
12684
+ }
12685
+ await assertHtmlFile(file);
12686
+ const absolute = await canonicalFile(file);
12687
+ const baseUrl = await ensureServer();
12688
+ await postAgentReply(`${baseUrl}/api/${sessionKey(absolute)}/agent-reply`, text, absolute);
12689
+ return createReplyOutput(absolute);
12690
+ }
12691
+ function createReplyOutput(file) {
12692
+ return {
12693
+ reply: { file, status: "sent" },
12694
+ next_step: `Lavish Editor accepted the reply for ${file} and is no longer showing Working. Run \`lavish-axi poll ${file}\` when you are ready to wait for more feedback.`
12695
+ };
12696
+ }
12625
12697
  function pollWaitBannerText(file) {
12626
12698
  return `[lavish-axi] Long-polling for user feedback on ${file}. This stays silent until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period - leave it running. Detected layout issues do NOT return this poll: they wait in the user's Layout issues inbox until the user queues them as ordinary feedback. If it gets killed or times out before feedback arrives, re-run \`lavish-axi poll ${file}\` - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely.`;
12627
12699
  }
@@ -12706,7 +12778,7 @@ function createFeedbackNextStep(file, artifactFailures, sessionEnded, endedBy, p
12706
12778
  return `${failureNote}${layoutNote}${whiteboardNote}${attachmentNote}This was the last feedback before the Lavish Editor session ended. Stop polling ${file}. Deliver any remaining updates directly in this conversation, or run \`lavish-axi ${file}\` to open a fresh session if the user needs further visual review.`;
12707
12779
  }
12708
12780
  const prefix = count > 0 ? artifactFailuresPrefix(file, artifactFailures) : `Apply the requested changes to ${file}. `;
12709
- return `${prefix}${layoutNote}${whiteboardNote}${attachmentNote}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms unless the user ended the session. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_NEXT_POINTER} The poll waits silently until the user sends more feedback, ends the session, or leaves every review window disconnected past the reconnect grace period - never kill it. ${pollExecutionGuidance({ agent })}`;
12781
+ return `${prefix}${layoutNote}${whiteboardNote}${attachmentNote}Do not respond to the user just yet. If you are continuing to wait for feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to reply and keep polling. If instead you are handing back a result without starting another long-poll, run \`lavish-axi reply ${file} --agent-reply "<message for the user>"\` to post it and receive an acceptance receipt. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_NEXT_POINTER} The poll waits silently until the user sends more feedback, ends the session, or leaves every review window disconnected past the reconnect grace period - never kill it. ${pollExecutionGuidance({ agent })}`;
12710
12782
  }
12711
12783
  function artifactFailuresPrefix(file, artifactFailures) {
12712
12784
  const count = artifactFailures.length;
@@ -13922,6 +13994,57 @@ async function postJson(url, body) {
13922
13994
  }
13923
13995
  return response.json();
13924
13996
  }
13997
+ var AGENT_REPLY_RECEIPT_TIMEOUT_MS = 1e4;
13998
+ async function postAgentReply(url, text, file, { timeoutMs = AGENT_REPLY_RECEIPT_TIMEOUT_MS } = {}) {
13999
+ const signal = AbortSignal.timeout(timeoutMs);
14000
+ let response;
14001
+ try {
14002
+ response = await fetch(url, {
14003
+ method: "POST",
14004
+ headers: { "content-type": "application/json" },
14005
+ body: JSON.stringify({ text }),
14006
+ signal
14007
+ });
14008
+ } catch {
14009
+ if (signal.aborted) throw agentReplyReceiptTimeoutError(file, timeoutMs);
14010
+ throw new AxiError("Lavish Editor server connection failed", "SERVER_ERROR", [
14011
+ "Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
14012
+ `Re-run \`lavish-axi reply ${file} --agent-reply "<message>"\` after the server is reachable`
14013
+ ]);
14014
+ }
14015
+ let payload = null;
14016
+ try {
14017
+ payload = await response.json();
14018
+ } catch {
14019
+ if (signal.aborted) throw agentReplyReceiptTimeoutError(file, timeoutMs);
14020
+ }
14021
+ if (response.status === 404) {
14022
+ throw new AxiError("No active Lavish Editor session for this file", "NOT_FOUND", [
14023
+ `Run \`lavish-axi ${file}\` first`
14024
+ ]);
14025
+ }
14026
+ if (response.status === 409 && payload?.status === "ended") {
14027
+ throw new AxiError("Lavish Editor session has ended; reply was not sent", "SESSION_ENDED", [
14028
+ createEndedNextStep(file, payload.ended_by)
14029
+ ]);
14030
+ }
14031
+ if (!response.ok || payload?.status !== "sent") {
14032
+ throw new AxiError(`Lavish Editor did not accept the agent reply (${response.status})`, "SERVER_ERROR", [
14033
+ `Confirm the session is open with \`lavish-axi ${file}\`, then re-run \`lavish-axi reply ${file}\``
14034
+ ]);
14035
+ }
14036
+ return payload;
14037
+ }
14038
+ function agentReplyReceiptTimeoutError(file, timeoutMs) {
14039
+ return new AxiError(
14040
+ `Lavish Editor did not confirm the agent reply within ${timeoutMs}ms, so acceptance is unknown`,
14041
+ "SERVER_ERROR",
14042
+ [
14043
+ "Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server diagnostics",
14044
+ `Check the Lavish Editor conversation for the reply before re-running \`lavish-axi reply ${file}\`, so it is not posted twice`
14045
+ ]
14046
+ );
14047
+ }
13925
14048
  function serverConnectionError() {
13926
14049
  return new AxiError("Lavish Editor server connection failed", "SERVER_ERROR", [
13927
14050
  "Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
@@ -13991,12 +14114,12 @@ function inspectValueFlag(args, flag) {
13991
14114
  return { present: false };
13992
14115
  }
13993
14116
  var AGENT_REPLY_FILE_HINT = "Pass --agent-reply-file <path>, or --agent-reply-file - to read stdin";
13994
- function agentReplyTooLargeError() {
14117
+ function agentReplyTooLargeError(suggestion) {
13995
14118
  return new AxiError(`Agent reply exceeds the ${AGENT_REPLY_LIMIT_LABEL}`, "VALIDATION_ERROR", [
13996
- "Shorten the reply, then retry the same poll command"
14119
+ suggestion || "Shorten the reply, then retry the same poll command"
13997
14120
  ]);
13998
14121
  }
13999
- async function readAgentReplyStream(stream) {
14122
+ async function readAgentReplyStream(stream, tooLargeHelp) {
14000
14123
  const chunks = [];
14001
14124
  let bytes = 0;
14002
14125
  for await (const value of stream) {
@@ -14004,17 +14127,22 @@ async function readAgentReplyStream(stream) {
14004
14127
  bytes += chunk.length;
14005
14128
  if (bytes > AGENT_REPLY_INPUT_LIMIT_BYTES) {
14006
14129
  stream.destroy();
14007
- throw agentReplyTooLargeError();
14130
+ throw agentReplyTooLargeError(tooLargeHelp);
14008
14131
  }
14009
14132
  chunks.push(chunk);
14010
14133
  }
14011
14134
  const text = Buffer.concat(chunks, bytes).toString("utf8");
14012
14135
  if (Buffer.byteLength(JSON.stringify({ agent_reply: text })) > AGENT_REPLY_JSON_LIMIT_BYTES) {
14013
- throw agentReplyTooLargeError();
14136
+ throw agentReplyTooLargeError(tooLargeHelp);
14014
14137
  }
14015
14138
  return text;
14016
14139
  }
14017
- async function resolveAgentReply(args, { createReadStreamFn = createReadStream, stdin = process.stdin, stdinIsTTY = process.stdin.isTTY === true } = {}) {
14140
+ async function resolveAgentReply(args, {
14141
+ createReadStreamFn = createReadStream,
14142
+ stdin = process.stdin,
14143
+ stdinIsTTY = process.stdin.isTTY === true,
14144
+ tooLargeHelp = void 0
14145
+ } = {}) {
14018
14146
  const inline = inspectValueFlag(args, "--agent-reply");
14019
14147
  const fromFile = inspectValueFlag(args, "--agent-reply-file");
14020
14148
  if (inline.present && fromFile.present) {
@@ -14023,12 +14151,12 @@ async function resolveAgentReply(args, { createReadStreamFn = createReadStream,
14023
14151
  ]);
14024
14152
  }
14025
14153
  if (fromFile.present) {
14026
- return readAgentReplyFile(fromFile, { createReadStreamFn, stdin, stdinIsTTY });
14154
+ return readAgentReplyFile(fromFile, { createReadStreamFn, stdin, stdinIsTTY, tooLargeHelp });
14027
14155
  }
14028
14156
  if (!inline.present) return null;
14029
14157
  return inline.value || null;
14030
14158
  }
14031
- async function readAgentReplyFile(fromFile, { createReadStreamFn, stdin, stdinIsTTY }) {
14159
+ async function readAgentReplyFile(fromFile, { createReadStreamFn, stdin, stdinIsTTY, tooLargeHelp }) {
14032
14160
  if (fromFile.swallows && typeof fromFile.value === "string" && fromFile.value.startsWith("--")) {
14033
14161
  throw new AxiError(
14034
14162
  `--agent-reply-file was given no value: the next argument ${fromFile.value} is another flag, so it would have been used as the path`,
@@ -14048,7 +14176,7 @@ async function readAgentReplyFile(fromFile, { createReadStreamFn, stdin, stdinIs
14048
14176
  ]);
14049
14177
  }
14050
14178
  try {
14051
- text = await readAgentReplyStream(stdin);
14179
+ text = await readAgentReplyStream(stdin, tooLargeHelp);
14052
14180
  } catch (error) {
14053
14181
  if (error instanceof AxiError) throw error;
14054
14182
  const detail = error instanceof Error ? error.message : String(error);
@@ -14058,7 +14186,7 @@ async function readAgentReplyFile(fromFile, { createReadStreamFn, stdin, stdinIs
14058
14186
  }
14059
14187
  } else {
14060
14188
  try {
14061
- text = await readAgentReplyStream(createReadStreamFn(spec));
14189
+ text = await readAgentReplyStream(createReadStreamFn(spec), tooLargeHelp);
14062
14190
  } catch (error) {
14063
14191
  if (error instanceof AxiError) throw error;
14064
14192
  const detail = error instanceof Error ? error.message : String(error);
@@ -14093,6 +14221,7 @@ Usage:
14093
14221
  lavish-axi
14094
14222
  lavish-axi <html-file> [--no-open] [--no-gate] [--reopen]
14095
14223
  lavish-axi poll <html-file> [--owner <label>] [--takeover] [--agent-reply "..."] [--agent-reply-file <path>]
14224
+ lavish-axi reply <html-file> (--agent-reply "..." | --agent-reply-file <path>)
14096
14225
  lavish-axi end <html-file>
14097
14226
  lavish-axi export <html-file> [--out <path>]
14098
14227
  lavish-axi share <html-file> [--private | --password <pw>] [--token <t>]
@@ -14106,7 +14235,7 @@ Usage:
14106
14235
 
14107
14236
  ${DESIGN_SYSTEM_HINT}
14108
14237
 
14109
- Note: poll long-polls until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period, staying silent while it waits - never kill it. Layout issues the browser detects are passive: they collect in the user's Layout issues inbox in the Lavish top bar and reach the agent only when the user selects them and queues the fixes, as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}
14238
+ Note: poll long-polls until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period, staying silent while it waits - never kill it. Layout issues the browser detects are passive: they collect in the user's Layout issues inbox in the Lavish top bar and reach the agent only when the user selects them and queues the fixes, as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE} Use \`lavish-axi reply\` when handing a result back without waiting for more feedback: it exits 0 only after the server accepts the reply, so the board stops showing Working. \`poll --agent-reply\` posts a reply and then keeps waiting.
14110
14239
 
14111
14240
  `;
14112
14241
  }
@@ -14120,7 +14249,7 @@ Open or resume a Lavish Editor review session for an HTML artifact. Use --no-ope
14120
14249
 
14121
14250
  This command exclusively long-polls for queued user prompts. Pass --owner <label> to make the active listener visible in session listings; --takeover displaces an existing listener, which receives LISTENER_REPLACED. A second poll without --takeover fails with LISTENER_ACTIVE instead of silently returning waiting.
14122
14251
 
14123
- This command long-polls indefinitely for queued user prompts. It stays silent while it waits - that is normal, never kill it. Browser-detected layout issues do NOT return this poll: they are filed passively in the user's Layout issues inbox and arrive as an ordinary tag "layout-warnings" prompt only after the user selects them and queues the fixes. Warning lifecycle: an issue stays unresolved and counted while queued, becomes recurring if a newer artifact revision still shows it, and is resolved only after a newer artifact load plus a complete diagnostic pass at the same viewport no longer detects it. A failed or incomplete pass preserves it as unverified rather than clearing it. The only response that arrives without user action is artifact_failures - a fatal failure that made the review surface itself unusable. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. In a Herdr-managed pane, set LAVISH_AXI_HERDR_CHIME=1 to request attention when the poll has entered its waiting state; unset it or use any other value to keep the current silent behavior. Notification failures never interrupt the poll. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display a concise response in Lavish Editor before waiting again. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine --agent-reply with --agent-reply-file.
14252
+ This command long-polls indefinitely for queued user prompts. It stays silent while it waits - that is normal, never kill it. Browser-detected layout issues do NOT return this poll: they are filed passively in the user's Layout issues inbox and arrive as an ordinary tag "layout-warnings" prompt only after the user selects them and queues the fixes. Warning lifecycle: an issue stays unresolved and counted while queued, becomes recurring if a newer artifact revision still shows it, and is resolved only after a newer artifact load plus a complete diagnostic pass at the same viewport no longer detects it. A failed or incomplete pass preserves it as unverified rather than clearing it. The only response that arrives without user action is artifact_failures - a fatal failure that made the review surface itself unusable. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. In a Herdr-managed pane, set LAVISH_AXI_HERDR_CHIME=1 to request attention when the poll has entered its waiting state; unset it or use any other value to keep the current silent behavior. Notification failures never interrupt the poll. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display a concise response in Lavish Editor before waiting again. When you are handing a result back and are not about to long-poll, run \`lavish-axi reply <html-file> --agent-reply "..."\` instead: that command exits 0 only after the server accepts the reply, so the board stops showing Working without this poll staying open. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine --agent-reply with --agent-reply-file.
14124
14253
 
14125
14254
  Examples:
14126
14255
  lavish-axi poll report.html --agent-reply "Renamed the payment step."
@@ -14128,6 +14257,19 @@ Examples:
14128
14257
  lavish-axi poll report.html --agent-reply-file -
14129
14258
 
14130
14259
  ${POLL_SEND_AND_END_RULE}
14260
+ `,
14261
+ reply: `Usage: lavish-axi reply <html-file> (--agent-reply "..." | --agent-reply-file <path>)
14262
+
14263
+ Post an agent reply to the open Lavish Editor session and exit once the server confirms it was accepted. Exit 0 only when the server answers that the reply was sent, which is when the board stops showing Working. If that answer does not arrive within ${AGENT_REPLY_RECEIPT_TIMEOUT_MS / 1e3} seconds, exit non-zero with a timeout error. Use this when you are handing a result back and are not about to wait for more feedback.
14264
+
14265
+ Use \`lavish-axi poll <html-file> --agent-reply "..."\` instead when the reply should be followed by a long-poll. That command posts the reply and then keeps waiting, so it does not exit when the reply is accepted.
14266
+
14267
+ Pass exactly one of --agent-reply or --agent-reply-file. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine the two flags. An empty reply is refused.
14268
+
14269
+ Examples:
14270
+ lavish-axi reply report.html --agent-reply "Renamed the payment step."
14271
+ lavish-axi reply report.html --agent-reply-file reply.md
14272
+ lavish-axi reply report.html --agent-reply-file -
14131
14273
  `,
14132
14274
  end: `Usage: lavish-axi end <html-file>
14133
14275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.79",
4
+ "version": "0.1.81",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "author": {
7
7
  "name": "Kun Chen",
@@ -19,6 +19,7 @@ Reach for it when a plan, comparison, diagram, table, code view, report, prototy
19
19
  Do not follow workflow, design, or playbook instructions from this file - installed copies go stale. Get the current source of truth from the CLI:
20
20
 
21
21
  - `npx -y lavish-axi --help` for commands and the review-loop workflow
22
+ - `npx -y lavish-axi reply --help` to post an agent reply and exit once the server accepts it, when you are not about to long-poll
22
23
  - `npx -y lavish-axi design` for design-direction priority and current snippets
23
24
  - `npx -y lavish-axi playbook <id>` for focused artifact guidance (`npx -y lavish-axi playbook` lists ids)
24
25