lavish-axi 0.1.81 → 0.1.83

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
@@ -198,7 +198,7 @@ pnpm link
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
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
- - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
201
+ - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) and custom widgets with an interactive ARIA role (`button`, `checkbox`, `combobox`, `menuitem`, `menuitemcheckbox`, `menuitemradio`, `option`, `radio`, `switch`, `tab`, `treeitem`) are interactive automatically, so they do not need `data-lavish-action`. A link (`<a href>`) stays annotatable when it carries one of these roles or sits inside such a widget.
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.
@@ -211,7 +211,9 @@ pnpm link
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
212
  While editing a queued note, Enter saves it, Shift+Enter inserts a newline, and Escape discards the edit.
213
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.
214
+ In annotate mode a plain click on a link annotates it; Ctrl+click (Cmd+click on macOS) follows the link instead, opening it in a new tab as the browser normally would, without leaving annotate mode.
214
215
  - **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.
216
+ - **Delivery receipts** - Every note you have sent carries three emoji under it that light up in turn: 👀 **Seen** when a `lavish-axi poll` takes the note, 🛠️ **Working** when the artifact file changes after that, and ✅ **Done** when the agent replies. A step that has not happened yet is the same emoji greyed out. All three come from what the server observes; the agent does nothing extra. Until 👀 lights, the row says why: no agent is listening, or the agent is busy and the note is delivered on its next poll. A note answered without touching the artifact goes Seen to Done with Working still grey. A reply marks every note the agent has seen as Done, not notes still waiting for a poll. Each emoji names its step and time on hover and to screen readers. Notes sent before receipts existed show no receipt row.
215
217
  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.
216
218
  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.
217
219
  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.
@@ -296,7 +298,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
296
298
  | `lavish-axi share` | `--token <t>` | Attach an optional bearer token (`LAVISH_AXI_HTML_APP_TOKEN`) when creating a page; never required, and rejected on a republish or `--unpublish`, where the `update_key` is the credential. |
297
299
  | `lavish-axi poll` | `--owner <label>` | Identify the process listening for this session; the non-empty label appears in the home session listing. `none` is reserved for sessions without a listener. A second concurrent poll is refused instead of silently returning `waiting`. |
298
300
  | `lavish-axi poll` | `--takeover` | Explicitly displace the current poll listener. The displaced poll exits with typed `LISTENER_REPLACED`; without this flag, the new poll fails with typed `LISTENER_ACTIVE`. |
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. |
301
+ | `lavish-axi poll` | `--agent-reply "..."` | Show a concise agent reply in the existing browser chat, conclude delivered work (every note the agent has seen is marked Done), and return presence to waiting before polling again. Keep replies concise by default. |
300
302
  | `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`. |
301
303
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
302
304
  | `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. |
@@ -912,10 +912,75 @@ function chatBubbleHtml(entry) {
912
912
  "<small>You</small>" +
913
913
  anchorHtml(entry.anchor) +
914
914
  userBubbleTextHtml(entry, entry.text) +
915
- bubbleAttachmentsHtml(entry)
915
+ bubbleAttachmentsHtml(entry) +
916
+ receiptHtml(entry)
916
917
  );
917
918
  }
918
919
 
920
+ // Delivery receipt (docs/delivery-acks.md): the per-note view of what the server observed. Seen,
921
+ // Working, and Done are stamps on the entry, so the row is derived from it on every render and
922
+ // never tracked here. Before Seen, the row says why, from the presence the live stream reports.
923
+ // The three steps are the emoji of the Telegram ack protocol the receipt mirrors (seen, working,
924
+ // done); a step that has not happened is the same glyph dimmed, and every step carries its label
925
+ // and time as `title` and `aria-label`, so hover and screen readers both say the word.
926
+ const RECEIPT_EMOJI = { Seen: "\u{1F440}", Working: "\u{1F6E0}️", Done: "✅" };
927
+
928
+ function receiptTime(at) {
929
+ const date = new Date(String(at));
930
+ return Number.isNaN(date.getTime())
931
+ ? String(at)
932
+ : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
933
+ }
934
+
935
+ function receiptStepHtml(label, at) {
936
+ const ticked = Boolean(at);
937
+ const text = ticked ? label + " " + receiptTime(at) : label + ": not yet";
938
+ return (
939
+ '<span class="receipt-step' +
940
+ (ticked ? " is-done" : "") +
941
+ '" role="img" aria-label="' +
942
+ escapeHtml(text) +
943
+ '" title="' +
944
+ escapeHtml(text) +
945
+ '">' +
946
+ RECEIPT_EMOJI[label] +
947
+ "</span>"
948
+ );
949
+ }
950
+
951
+ // Every branch must be true where it is shown: `waiting` is no poll at all, `listening` is a poll
952
+ // that will take the note within milliseconds, and `working` or an external listener is an agent
953
+ // whose next poll will take it.
954
+ function receiptNoteText() {
955
+ if (agentPresence === "waiting") return "No agent is listening";
956
+ if (agentPresence === "listening") return "Delivering";
957
+ return "Agent is busy; delivered on its next poll";
958
+ }
959
+
960
+ // A transcript entry from before receipts existed (`receipt: "none"`, marked by the server once at
961
+ // load) gets no row: nothing was ever observed about it, and an empty row would read as "never seen".
962
+ function hasReceipt(entry) {
963
+ return Boolean(entry) && entry.receipt !== "none";
964
+ }
965
+
966
+ function receiptHtml(entry) {
967
+ if (!hasReceipt(entry)) return "";
968
+ return (
969
+ '<div class="receipt">' +
970
+ receiptStepHtml("Seen", entry.delivered_at) +
971
+ receiptStepHtml("Working", entry.working_at) +
972
+ receiptStepHtml("Done", entry.done_at) +
973
+ (entry.delivered_at ? "" : '<span class="receipt-note">' + escapeHtml(receiptNoteText()) + "</span>") +
974
+ "</div>"
975
+ );
976
+ }
977
+
978
+ // Only the pre-Seen line names the live presence, so a presence change rewrites that line alone.
979
+ // Rebuilding the bubble would also rebuild its thumbnails and undo an "Image expired" placeholder.
980
+ function refreshUnseenReceipts() {
981
+ for (const note of chatLog.querySelectorAll(".receipt-note")) note.textContent = receiptNoteText();
982
+ }
983
+
919
984
  function addChat(entry, shouldScroll = true) {
920
985
  if (!entry || typeof entry !== "object") return;
921
986
  const role = entry.role === "agent" ? "agent" : "user";
@@ -1067,6 +1132,7 @@ function setAgentPresence(state) {
1067
1132
  agentPresence = state === "listening" || state === "external" || state === "working" ? state : "waiting";
1068
1133
  updateSendState();
1069
1134
  renderSheetSummary();
1135
+ refreshUnseenReceipts();
1070
1136
  if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
1071
1137
 
1072
1138
  // A supervisor-owned process-only listener is busy on the agent's behalf. It must not make the
package/dist/chrome.css CHANGED
@@ -1301,6 +1301,38 @@ body.lavish {
1301
1301
  border-radius: 3px;
1302
1302
  padding: 0 3px;
1303
1303
  }
1304
+ /* Delivery receipt: the per-note view of agent presence (docs/delivery-acks.md). Three emoji
1305
+ (seen, working, done) that light up as the server observes each step; a step that has not
1306
+ happened is the same glyph greyed and dimmed, so the row always shows what is still to come. */
1307
+ .receipt {
1308
+ display: flex;
1309
+ flex-wrap: wrap;
1310
+ align-items: center;
1311
+ justify-content: flex-end;
1312
+ gap: 4px 8px;
1313
+ margin-top: 6px;
1314
+ font-size: 13px;
1315
+ line-height: 1.3;
1316
+ color: var(--fg-faint);
1317
+ }
1318
+ .receipt-step {
1319
+ display: inline-block;
1320
+ filter: grayscale(1);
1321
+ opacity: 0.3;
1322
+ transition:
1323
+ opacity 0.3s ease,
1324
+ filter 0.3s ease;
1325
+ }
1326
+ .receipt-step.is-done {
1327
+ filter: none;
1328
+ opacity: 1;
1329
+ }
1330
+ .receipt-note {
1331
+ font-size: 11px;
1332
+ flex-basis: 100%;
1333
+ text-align: right;
1334
+ font-style: italic;
1335
+ }
1304
1336
  .bubble-attachments {
1305
1337
  display: flex;
1306
1338
  flex-wrap: wrap;
package/dist/cli.mjs CHANGED
@@ -224,7 +224,7 @@ var PLAYBOOKS = [
224
224
  "Show selected state separately from queued state so the user trusts what will be sent back."
225
225
  ],
226
226
  design_rules: [
227
- "Native controls - radios, checkboxes, text inputs, selects, textareas, buttons, options, labels, disclosure summaries, and contenteditable regions - are interactive automatically: clicks toggle, focus, and type instead of annotating, so they do not need data-lavish-action. Build choice and option UIs from these whenever you can.",
227
+ "Native controls - radios, checkboxes, text inputs, selects, textareas, buttons, options, labels, disclosure summaries, and contenteditable regions - and custom widgets with an interactive ARIA role (button, checkbox, combobox, menuitem, option, radio, switch, tab, treeitem, and the menuitem variants) are interactive automatically: clicks toggle, focus, and type instead of annotating, so they do not need data-lavish-action. An <a href> stays annotatable when it carries one of these roles or sits inside such a widget. Build choice and option UIs from these whenever you can.",
228
228
  "For reversible choices, do not call window.lavish.queuePrompt() from radio change handlers or option click handlers. Those handlers should only update local selected state.",
229
229
  "Use a per-question form submit or explicit Queue answer button to read the current values and call window.lavish.queuePrompt() exactly once for the final answer.",
230
230
  "Put data-lavish-action only on custom (non-native) elements that should act like a feedback control - typically a styled div or span you made clickable - so Lavish does not annotate it and shows a pointer cursor instead.",
@@ -5284,9 +5284,18 @@ function deriveLavishQueueKey(element, options = {}) {
5284
5284
  return "";
5285
5285
  }
5286
5286
  function isNativeInteractiveControl(el) {
5287
- return !!(el && el.closest && el.closest(
5287
+ if (!el || !el.closest) return false;
5288
+ if (el.closest(
5288
5289
  "button,input,select,textarea,option,optgroup,label,summary,[contenteditable]:not([contenteditable='false'])"
5289
- ));
5290
+ )) {
5291
+ return true;
5292
+ }
5293
+ const widget = el.closest(
5294
+ "[role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']"
5295
+ );
5296
+ if (!widget) return false;
5297
+ const link = el.closest("a[href]");
5298
+ return !(link && widget.contains(link));
5290
5299
  }
5291
5300
  function classifySevereTextOverflow({
5292
5301
  fragments,
@@ -6001,7 +6010,7 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
6001
6010
  if (annotationMode && !style) {
6002
6011
  style = document.createElement("style");
6003
6012
  style.id = "lavish-cursor-style";
6004
- style.textContent = ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}input,textarea,[contenteditable]:not([contenteditable='false']){cursor:text!important}button,select,label,option,input[type='button'],input[type='submit'],input[type='reset'],input[type='checkbox'],input[type='radio'],input[type='file'],input[type='color'],input[type='range'],input[type='image']{cursor:pointer!important}";
6013
+ style.textContent = ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']):not(a[href])),:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']):not(a[href])) *{cursor:pointer!important}:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']) a[href]),:where(:is([role='button'],[role='checkbox'],[role='combobox'],[role='menuitem'],[role='menuitemcheckbox'],[role='menuitemradio'],[role='option'],[role='radio'],[role='switch'],[role='tab'],[role='treeitem']) a[href]) *{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}input,textarea,[contenteditable]:not([contenteditable='false']){cursor:text!important}button,select,label,option,input[type='button'],input[type='submit'],input[type='reset'],input[type='checkbox'],input[type='radio'],input[type='file'],input[type='color'],input[type='range'],input[type='image']{cursor:pointer!important}";
6005
6014
  document.head.appendChild(style);
6006
6015
  }
6007
6016
  if (!annotationMode && style) style.remove();
@@ -7137,6 +7146,11 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
7137
7146
  (event) => {
7138
7147
  if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
7139
7148
  return;
7149
+ if ((event.ctrlKey || event.metaKey) && /** @type {Element} */
7150
+ event.target?.closest?.("a[href]")) {
7151
+ ignoreNextClick = false;
7152
+ return;
7153
+ }
7140
7154
  event.preventDefault();
7141
7155
  event.stopPropagation();
7142
7156
  if (ignoreNextClick) {
@@ -8267,6 +8281,62 @@ function serializeChatSync(session) {
8267
8281
  chat_revision: Number.isSafeInteger(revision) && revision >= 0 ? revision : 0
8268
8282
  };
8269
8283
  }
8284
+ var RECEIPT_NONE = "none";
8285
+ function isUserEntry(entry) {
8286
+ return Boolean(entry) && typeof entry === "object" && entry.role === "user" && entry.receipt !== RECEIPT_NONE;
8287
+ }
8288
+ function markPreReceipt(chat) {
8289
+ let changed = false;
8290
+ for (const entry of Array.isArray(chat) ? chat : []) {
8291
+ if (!entry || typeof entry !== "object" || entry.role !== "user" || entry.receipt === RECEIPT_NONE) continue;
8292
+ entry.receipt = RECEIPT_NONE;
8293
+ changed = true;
8294
+ }
8295
+ return changed;
8296
+ }
8297
+ function stampDelivered(chat, seq, at) {
8298
+ let changed = false;
8299
+ for (const entry of Array.isArray(chat) ? chat : []) {
8300
+ if (!isUserEntry(entry) || entry.delivered_at) continue;
8301
+ entry.delivered_at = String(at);
8302
+ entry.delivered_seq = seq;
8303
+ changed = true;
8304
+ }
8305
+ return changed;
8306
+ }
8307
+ function unstampDelivery(chat, seq) {
8308
+ let changed = false;
8309
+ for (const entry of Array.isArray(chat) ? chat : []) {
8310
+ if (!isUserEntry(entry) || entry.delivered_seq !== seq) continue;
8311
+ delete entry.delivered_at;
8312
+ delete entry.delivered_seq;
8313
+ delete entry.working_at;
8314
+ delete entry.done_at;
8315
+ changed = true;
8316
+ }
8317
+ return changed;
8318
+ }
8319
+ function isOpenDelivery(entry, closedSeq) {
8320
+ return isUserEntry(entry) && Boolean(entry.delivered_at) && !entry.done_at && entry.delivered_seq > closedSeq;
8321
+ }
8322
+ function stampWorking(chat, at, closedSeq = 0) {
8323
+ let changed = false;
8324
+ for (const entry of Array.isArray(chat) ? chat : []) {
8325
+ if (!isOpenDelivery(entry, closedSeq) || entry.working_at) continue;
8326
+ entry.working_at = String(at);
8327
+ changed = true;
8328
+ }
8329
+ return changed;
8330
+ }
8331
+ function stampDone(chat, at, closedSeq = 0) {
8332
+ let changed = false;
8333
+ for (const entry of Array.isArray(chat) ? chat : []) {
8334
+ if (!isOpenDelivery(entry, closedSeq)) continue;
8335
+ entry.done_at = String(at);
8336
+ changed = true;
8337
+ }
8338
+ return changed;
8339
+ }
8270
8340
 
8271
8341
  // src/server-log.js
8272
8342
  function formatServerLogLine(line, now = /* @__PURE__ */ new Date()) {
@@ -8495,6 +8565,14 @@ var SessionStore = class {
8495
8565
  // Compact prompt_id acks for bubbles evicted by the stored-chat byte bound. Reopening
8496
8566
  // must keep them: they are the settlement/dedup source once the visible entry is gone.
8497
8567
  chat_ack_ids: Array.isArray(existing.chat_ack_ids) ? existing.chat_ack_ids : [],
8568
+ // Counts the takes that stamped Seen on chat entries (docs/delivery-acks.md). Reset on reopen
8569
+ // and a later restore could un-stamp an older take's entries, so it is carried like chat.
8570
+ delivery_seq: normalizeRevision(existing.delivery_seq),
8571
+ // The last take of the rounds that have ended. Reopening starts a new round: a note an ended
8572
+ // round left at Seen stays Seen, because a later round's edit or reply is not about it.
8573
+ closed_delivery_seq: normalizeRevision(
8574
+ existing.status === "ended" ? existing.delivery_seq : existing.closed_delivery_seq
8575
+ ),
8498
8576
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
8499
8577
  };
8500
8578
  state.sessions[key] = session;
@@ -8549,7 +8627,11 @@ var SessionStore = class {
8549
8627
  else delete prompt.attachments;
8550
8628
  }
8551
8629
  }
8630
+ const deliverySeq = restoring ? normalizeRevision(payload.delivery_seq) : 0;
8631
+ const unstamped = deliverySeq > 0 && unstampDelivery(session.chat, deliverySeq);
8632
+ if (unstamped) session.chat_revision = normalizeRevision(session.chat_revision) + 1;
8552
8633
  if (rejected.length) {
8634
+ if (unstamped) await this.writeState(state);
8553
8635
  return {
8554
8636
  rejected: rejected.slice(0, MAX_REPORTED_ATTACHMENT_REJECTIONS),
8555
8637
  caps: {
@@ -8876,12 +8958,14 @@ var SessionStore = class {
8876
8958
  if (prompts.length === 0 && artifactFailures.length === 0) {
8877
8959
  return alreadyEnded ? { status: "ended", ended_by: session.ended_by } : { status: "waiting" };
8878
8960
  }
8961
+ const deliverySeq = normalizeRevision(session.delivery_seq) + 1;
8879
8962
  const result = {
8880
8963
  status: "feedback",
8881
8964
  dom_snapshot: session.dom_snapshot || "",
8882
8965
  prompts,
8883
8966
  ...artifactFailures.length > 0 ? { artifact_failures: artifactFailures } : {},
8884
- ...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {}
8967
+ ...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {},
8968
+ ...prompts.length > 0 ? { delivery_seq: deliverySeq } : {}
8885
8969
  };
8886
8970
  const deliveredNow = Date.now();
8887
8971
  const deliveredIds = /* @__PURE__ */ new Set();
@@ -8896,6 +8980,12 @@ var SessionStore = class {
8896
8980
  const current = [...deliveredIds].map((id) => ({ id, at: deliveredNow }));
8897
8981
  const historyRoom = Math.max(0, MAX_DELIVERED_ATTACHMENTS - current.length);
8898
8982
  session.delivered_attachments = [...carried.slice(-historyRoom), ...current];
8983
+ if (prompts.length > 0) {
8984
+ session.delivery_seq = deliverySeq;
8985
+ if (stampDelivered(session.chat, deliverySeq, new Date(deliveredNow).toISOString())) {
8986
+ session.chat_revision = normalizeRevision(session.chat_revision) + 1;
8987
+ }
8988
+ }
8899
8989
  session.prompts = [];
8900
8990
  session.artifact_failures = [];
8901
8991
  session.pending_prompts = 0;
@@ -8936,6 +9026,7 @@ var SessionStore = class {
8936
9026
  }
8937
9027
  if (requireOpen && session.status === "ended") return session;
8938
9028
  const at = (/* @__PURE__ */ new Date()).toISOString();
9029
+ stampDone(session.chat, at, normalizeRevision(session.closed_delivery_seq));
8939
9030
  session.chat = [...session.chat || [], { role: "agent", text: String(text || ""), at }];
8940
9031
  applyTranscriptBound(session);
8941
9032
  session.chat_revision = normalizeRevision(session.chat_revision) + 1;
@@ -8944,6 +9035,22 @@ var SessionStore = class {
8944
9035
  return session;
8945
9036
  });
8946
9037
  }
9038
+ // Working (docs/delivery-acks.md): the artifact changed after a delivery, so the agent is acting on
9039
+ // what it was handed. Returns the session when a stamp landed and null when there was nothing to
9040
+ // stamp, in which case state.json is not rewritten - the watcher fires on every save.
9041
+ async markWorking(key) {
9042
+ return this.runExclusive(async () => {
9043
+ const state = await this.readState();
9044
+ const session = state.sessions[key];
9045
+ if (!session) return null;
9046
+ const at = (/* @__PURE__ */ new Date()).toISOString();
9047
+ if (!stampWorking(session.chat, at, normalizeRevision(session.closed_delivery_seq))) return null;
9048
+ session.chat_revision = normalizeRevision(session.chat_revision) + 1;
9049
+ session.updated_at = at;
9050
+ await this.writeState(state);
9051
+ return session;
9052
+ });
9053
+ }
8947
9054
  /**
8948
9055
  * @template T
8949
9056
  * @param {() => Promise<T>} operation
@@ -8986,7 +9093,13 @@ var SessionStore = class {
8986
9093
  const state = { sessions: parsed.sessions || {} };
8987
9094
  let changed = false;
8988
9095
  for (const session of Object.values(state.sessions)) {
8989
- if (!session || typeof session !== "object" || !applyTranscriptBound(session)) continue;
9096
+ if (!session || typeof session !== "object") continue;
9097
+ if (!("delivery_seq" in session)) {
9098
+ session.delivery_seq = 0;
9099
+ markPreReceipt(session.chat);
9100
+ changed = true;
9101
+ }
9102
+ if (!applyTranscriptBound(session)) continue;
8990
9103
  session.chat_revision = normalizeRevision(session.chat_revision) + 1;
8991
9104
  changed = true;
8992
9105
  }
@@ -9941,7 +10054,12 @@ async function serve({
9941
10054
  if (clientKey === key) client.sendEvent(type, data);
9942
10055
  }
9943
10056
  }
9944
- events.on("reload", (key) => broadcastLiveEvent("reload", key));
10057
+ events.on("reload", (key) => {
10058
+ broadcastLiveEvent("reload", key);
10059
+ store.markWorking(key).then((session) => {
10060
+ if (session) events.emit("chat-sync", key, session);
10061
+ }).catch((error) => writeLog(`[lavish] working stamp after reload failed: ${error?.message || error}`));
10062
+ });
9945
10063
  events.on("agent-reply", (key, entry) => broadcastLiveEvent("agent-reply", key, entry));
9946
10064
  events.on("chat-sync", (key, session) => broadcastLiveEvent("chat-sync", key, serializeChatSync(session)));
9947
10065
  events.on(
@@ -10055,6 +10173,21 @@ async function serve({
10055
10173
  function finishFeedbackDelivery(key, result) {
10056
10174
  if (result.status !== "feedback") return;
10057
10175
  markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
10176
+ void publishChatSync(key);
10177
+ }
10178
+ async function publishChatSync(key) {
10179
+ try {
10180
+ const session = await store.findByKey(key);
10181
+ if (session) events.emit("chat-sync", key, session);
10182
+ } catch (error) {
10183
+ writeLog(`[lavish] chat-sync after delivery failed: ${error?.message || error}`);
10184
+ }
10185
+ }
10186
+ function agentFacingFeedback(result) {
10187
+ if (!result || result.status !== "feedback" || !("delivery_seq" in result)) return result;
10188
+ const rest = { ...result };
10189
+ delete rest.delivery_seq;
10190
+ return rest;
10058
10191
  }
10059
10192
  async function restoreClosedFeedback(key, result) {
10060
10193
  if (result.status !== "feedback") return;
@@ -10067,7 +10200,9 @@ async function serve({
10067
10200
  {
10068
10201
  dom_snapshot: result.dom_snapshot || "",
10069
10202
  prompts,
10070
- ...Array.isArray(result.artifact_failures) ? { artifact_failures: result.artifact_failures } : {}
10203
+ ...Array.isArray(result.artifact_failures) ? { artifact_failures: result.artifact_failures } : {},
10204
+ // Lets the store take this take's Seen stamps off again (docs/delivery-acks.md).
10205
+ ...Number.isInteger(result.delivery_seq) ? { delivery_seq: result.delivery_seq } : {}
10071
10206
  },
10072
10207
  {
10073
10208
  restore: true,
@@ -10090,12 +10225,13 @@ async function serve({
10090
10225
  `[lavish] closed poll feedback restore failed; the batch was lost: ${restoreError?.message || restoreError}`
10091
10226
  );
10092
10227
  } else if (persistedNothing) {
10093
- writeLog("[lavish] closed poll feedback restore was refused; nothing was persisted and the batch was lost");
10228
+ writeLog("[lavish] closed poll feedback restore was refused; nothing was re-queued and the batch was lost");
10094
10229
  } else if (!restoredPrompts || JSON.stringify(restoredPrompts) !== JSON.stringify(prompts) || !failuresRestored) {
10095
10230
  writeLog("[lavish] closed poll feedback restore was incomplete; delivery was not marked");
10096
10231
  }
10097
10232
  const pendingAfterRestore = Array.isArray(restoredPrompts) && restoredPrompts.length > 0 || Array.isArray(restoredFailures) && restoredFailures.length > 0;
10098
10233
  if (pendingAfterRestore) events.emit("feedback", key);
10234
+ await publishChatSync(key);
10099
10235
  }
10100
10236
  const whiteboardStateRoot = path7.dirname(stateFile2);
10101
10237
  const allowedHostnames = buildAllowedHostnames({
@@ -10365,7 +10501,7 @@ async function serve({
10365
10501
  releasePollListener(holder, activePolls, deliveredFeedback, events);
10366
10502
  if (immediate.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
10367
10503
  detachRequestClose();
10368
- res.json(immediate);
10504
+ res.json(agentFacingFeedback(immediate));
10369
10505
  return;
10370
10506
  }
10371
10507
  if (holder.replaced) {
@@ -10444,10 +10580,11 @@ async function serve({
10444
10580
  return;
10445
10581
  }
10446
10582
  finishFeedbackDelivery(key, responseResult);
10583
+ const agentResponse = agentFacingFeedback(responseResult);
10447
10584
  if (streamHeartbeat) {
10448
- res.end(JSON.stringify(responseResult));
10585
+ res.end(JSON.stringify(agentResponse));
10449
10586
  } else {
10450
- res.json(responseResult);
10587
+ res.json(agentResponse);
10451
10588
  }
10452
10589
  } finally {
10453
10590
  cleanup();
@@ -12331,7 +12468,7 @@ var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_J
12331
12468
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
12332
12469
  var POLL_STATE_HEADER = "lavish-poll-state";
12333
12470
  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.";
12334
- var VERSION = "0.1.81";
12471
+ var VERSION = "0.1.83";
12335
12472
  function detectInvokingAgent(env = process.env) {
12336
12473
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
12337
12474
  }
@@ -14260,7 +14397,7 @@ ${POLL_SEND_AND_END_RULE}
14260
14397
  `,
14261
14398
  reply: `Usage: lavish-axi reply <html-file> (--agent-reply "..." | --agent-reply-file <path>)
14262
14399
 
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.
14400
+ 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. A reply also marks every note the poll delivered to you as Done in the reviewer's panel, so reply once you have handled them.
14264
14401
 
14265
14402
  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
14403
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
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.81",
4
+ "version": "0.1.83",
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",