lavish-axi 0.1.82 → 0.1.84

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
@@ -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
@@ -21,7 +21,7 @@ import { access, readFile as readFile6, writeFile as writeFile4 } from "node:fs/
21
21
  import { get as httpGet2 } from "node:http";
22
22
  import { isIP as isIP4 } from "node:net";
23
23
  import os4 from "node:os";
24
- import path8 from "node:path";
24
+ import path9 from "node:path";
25
25
  import { fileURLToPath as fileURLToPath4 } from "node:url";
26
26
  import { AxiError, installSessionStartHooks, RESERVED_COMMANDS, runAxiCli } from "axi-sdk-js";
27
27
 
@@ -4427,7 +4427,7 @@ function htmlAppRequestError(message, details = {}) {
4427
4427
  if (status !== void 0) Object.defineProperty(error, "status", { value: status, enumerable: true });
4428
4428
  return error;
4429
4429
  }
4430
- async function requestHtmlApp({ method, path: path9, body, bearer, options, env, action = "publish" }) {
4430
+ async function requestHtmlApp({ method, path: path10, body, bearer, options, env, action = "publish" }) {
4431
4431
  const apiUrl = (options.apiUrl ? String(options.apiUrl).replace(/\/+$/, "") : "") || htmlAppApiUrl(env);
4432
4432
  const fetchImpl = options.fetch || fetch;
4433
4433
  const headers = { "content-type": "application/json", "user-agent": "lavish-axi" };
@@ -4437,7 +4437,7 @@ async function requestHtmlApp({ method, path: path9, body, bearer, options, env,
4437
4437
  let response;
4438
4438
  let text;
4439
4439
  try {
4440
- response = await fetchImpl(`${apiUrl}${path9}`, {
4440
+ response = await fetchImpl(`${apiUrl}${path10}`, {
4441
4441
  method,
4442
4442
  headers,
4443
4443
  body: JSON.stringify(body),
@@ -4704,10 +4704,10 @@ function writeTextFileAtomically(file, content, operations = {}) {
4704
4704
  const rename3 = operations.renameSync || renameSync;
4705
4705
  const remove = operations.rmSync || rmSync;
4706
4706
  const chmod2 = operations.chmodSync || chmodSync;
4707
- const stat3 = operations.statSync || statSync;
4707
+ const stat4 = operations.statSync || statSync;
4708
4708
  let mode;
4709
4709
  try {
4710
- mode = stat3(file).mode & 511;
4710
+ mode = stat4(file).mode & 511;
4711
4711
  } catch {
4712
4712
  }
4713
4713
  try {
@@ -4829,11 +4829,11 @@ function attrValue(attrs, name) {
4829
4829
  import crypto6 from "node:crypto";
4830
4830
  import { EventEmitter } from "node:events";
4831
4831
  import { existsSync as existsSync2 } from "node:fs";
4832
- import { readFile as readFile5, realpath as realpath3 } from "node:fs/promises";
4832
+ import { readFile as readFile5, realpath as realpath4 } from "node:fs/promises";
4833
4833
  import { createServer, get as httpGet } from "node:http";
4834
4834
  import { isIP as isIP3 } from "node:net";
4835
4835
  import { homedir } from "node:os";
4836
- import path7 from "node:path";
4836
+ import path8 from "node:path";
4837
4837
  import { fileURLToPath as fileURLToPath3 } from "node:url";
4838
4838
  import chokidar from "chokidar";
4839
4839
  import express from "express";
@@ -5925,15 +5925,15 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
5925
5925
  return node.parentElement || document.body;
5926
5926
  }
5927
5927
  function nodePath(node, root) {
5928
- const path9 = [];
5928
+ const path10 = [];
5929
5929
  let current = node;
5930
5930
  while (current && current !== root) {
5931
5931
  const parentNode = current.parentNode;
5932
5932
  if (!parentNode) break;
5933
- path9.unshift([...parentNode.childNodes].indexOf(current));
5933
+ path10.unshift([...parentNode.childNodes].indexOf(current));
5934
5934
  current = parentNode;
5935
5935
  }
5936
- return path9;
5936
+ return path10;
5937
5937
  }
5938
5938
  function rangeBoundary(node, offset) {
5939
5939
  const el = closestElement(node);
@@ -7146,6 +7146,11 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
7146
7146
  (event) => {
7147
7147
  if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
7148
7148
  return;
7149
+ if ((event.ctrlKey || event.metaKey) && /** @type {Element} */
7150
+ event.target?.closest?.("a[href]")) {
7151
+ ignoreNextClick = false;
7152
+ return;
7153
+ }
7149
7154
  event.preventDefault();
7150
7155
  event.stopPropagation();
7151
7156
  if (ignoreNextClick) {
@@ -8276,6 +8281,62 @@ function serializeChatSync(session) {
8276
8281
  chat_revision: Number.isSafeInteger(revision) && revision >= 0 ? revision : 0
8277
8282
  };
8278
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
+ }
8279
8340
 
8280
8341
  // src/server-log.js
8281
8342
  function formatServerLogLine(line, now = /* @__PURE__ */ new Date()) {
@@ -8398,10 +8459,53 @@ function isIncompleteRunningTailscaleStatus(raw) {
8398
8459
  return addresses.some((address) => typeof address === "string" && isIP2(address.trim()) === 4);
8399
8460
  }
8400
8461
 
8462
+ // src/artifact-path.js
8463
+ import { realpath as realpath2, stat as stat2 } from "node:fs/promises";
8464
+ import path5 from "node:path";
8465
+ var ARTIFACT_HTML_ERROR = "Lavish Editor expects an HTML file";
8466
+ function isHtmlPath(file) {
8467
+ const lower = String(file || "").toLowerCase();
8468
+ return lower.endsWith(".html") || lower.endsWith(".htm");
8469
+ }
8470
+ function artifactTreeRoot(file) {
8471
+ return path5.dirname(file);
8472
+ }
8473
+ var ArtifactPathError = class extends Error {
8474
+ /** @type {"VALIDATION_ERROR"} */
8475
+ code = "VALIDATION_ERROR";
8476
+ /** @type {400} */
8477
+ statusCode = 400;
8478
+ /**
8479
+ * @param {string} [message]
8480
+ */
8481
+ constructor(message = ARTIFACT_HTML_ERROR) {
8482
+ super(message);
8483
+ this.name = "ArtifactPathError";
8484
+ }
8485
+ };
8486
+ function isArtifactPathError(error) {
8487
+ return error instanceof Error && error.name === "ArtifactPathError";
8488
+ }
8489
+ async function resolveAllowedArtifactFile(file) {
8490
+ const requested = typeof file === "string" ? file : "";
8491
+ if (!isHtmlPath(requested)) {
8492
+ throw new ArtifactPathError();
8493
+ }
8494
+ const canonical = await realpath2(path5.resolve(requested));
8495
+ if (!isHtmlPath(canonical)) {
8496
+ throw new ArtifactPathError();
8497
+ }
8498
+ const info = await stat2(canonical);
8499
+ if (!info.isFile()) {
8500
+ throw new ArtifactPathError();
8501
+ }
8502
+ return canonical;
8503
+ }
8504
+
8401
8505
  // src/session-store.js
8402
8506
  import crypto4 from "node:crypto";
8403
- import { readFile as readFile3, realpath as realpath2, writeFile as writeFile2 } from "node:fs/promises";
8404
- import path5 from "node:path";
8507
+ import { readFile as readFile3, realpath as realpath3, writeFile as writeFile2 } from "node:fs/promises";
8508
+ import path6 from "node:path";
8405
8509
 
8406
8510
  // src/async-mutex.js
8407
8511
  var AsyncMutex = class {
@@ -8504,6 +8608,14 @@ var SessionStore = class {
8504
8608
  // Compact prompt_id acks for bubbles evicted by the stored-chat byte bound. Reopening
8505
8609
  // must keep them: they are the settlement/dedup source once the visible entry is gone.
8506
8610
  chat_ack_ids: Array.isArray(existing.chat_ack_ids) ? existing.chat_ack_ids : [],
8611
+ // Counts the takes that stamped Seen on chat entries (docs/delivery-acks.md). Reset on reopen
8612
+ // and a later restore could un-stamp an older take's entries, so it is carried like chat.
8613
+ delivery_seq: normalizeRevision(existing.delivery_seq),
8614
+ // The last take of the rounds that have ended. Reopening starts a new round: a note an ended
8615
+ // round left at Seen stays Seen, because a later round's edit or reply is not about it.
8616
+ closed_delivery_seq: normalizeRevision(
8617
+ existing.status === "ended" ? existing.delivery_seq : existing.closed_delivery_seq
8618
+ ),
8507
8619
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
8508
8620
  };
8509
8621
  state.sessions[key] = session;
@@ -8558,7 +8670,11 @@ var SessionStore = class {
8558
8670
  else delete prompt.attachments;
8559
8671
  }
8560
8672
  }
8673
+ const deliverySeq = restoring ? normalizeRevision(payload.delivery_seq) : 0;
8674
+ const unstamped = deliverySeq > 0 && unstampDelivery(session.chat, deliverySeq);
8675
+ if (unstamped) session.chat_revision = normalizeRevision(session.chat_revision) + 1;
8561
8676
  if (rejected.length) {
8677
+ if (unstamped) await this.writeState(state);
8562
8678
  return {
8563
8679
  rejected: rejected.slice(0, MAX_REPORTED_ATTACHMENT_REJECTIONS),
8564
8680
  caps: {
@@ -8885,12 +9001,14 @@ var SessionStore = class {
8885
9001
  if (prompts.length === 0 && artifactFailures.length === 0) {
8886
9002
  return alreadyEnded ? { status: "ended", ended_by: session.ended_by } : { status: "waiting" };
8887
9003
  }
9004
+ const deliverySeq = normalizeRevision(session.delivery_seq) + 1;
8888
9005
  const result = {
8889
9006
  status: "feedback",
8890
9007
  dom_snapshot: session.dom_snapshot || "",
8891
9008
  prompts,
8892
9009
  ...artifactFailures.length > 0 ? { artifact_failures: artifactFailures } : {},
8893
- ...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {}
9010
+ ...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {},
9011
+ ...prompts.length > 0 ? { delivery_seq: deliverySeq } : {}
8894
9012
  };
8895
9013
  const deliveredNow = Date.now();
8896
9014
  const deliveredIds = /* @__PURE__ */ new Set();
@@ -8905,6 +9023,12 @@ var SessionStore = class {
8905
9023
  const current = [...deliveredIds].map((id) => ({ id, at: deliveredNow }));
8906
9024
  const historyRoom = Math.max(0, MAX_DELIVERED_ATTACHMENTS - current.length);
8907
9025
  session.delivered_attachments = [...carried.slice(-historyRoom), ...current];
9026
+ if (prompts.length > 0) {
9027
+ session.delivery_seq = deliverySeq;
9028
+ if (stampDelivered(session.chat, deliverySeq, new Date(deliveredNow).toISOString())) {
9029
+ session.chat_revision = normalizeRevision(session.chat_revision) + 1;
9030
+ }
9031
+ }
8908
9032
  session.prompts = [];
8909
9033
  session.artifact_failures = [];
8910
9034
  session.pending_prompts = 0;
@@ -8945,6 +9069,7 @@ var SessionStore = class {
8945
9069
  }
8946
9070
  if (requireOpen && session.status === "ended") return session;
8947
9071
  const at = (/* @__PURE__ */ new Date()).toISOString();
9072
+ stampDone(session.chat, at, normalizeRevision(session.closed_delivery_seq));
8948
9073
  session.chat = [...session.chat || [], { role: "agent", text: String(text || ""), at }];
8949
9074
  applyTranscriptBound(session);
8950
9075
  session.chat_revision = normalizeRevision(session.chat_revision) + 1;
@@ -8953,6 +9078,22 @@ var SessionStore = class {
8953
9078
  return session;
8954
9079
  });
8955
9080
  }
9081
+ // Working (docs/delivery-acks.md): the artifact changed after a delivery, so the agent is acting on
9082
+ // what it was handed. Returns the session when a stamp landed and null when there was nothing to
9083
+ // stamp, in which case state.json is not rewritten - the watcher fires on every save.
9084
+ async markWorking(key) {
9085
+ return this.runExclusive(async () => {
9086
+ const state = await this.readState();
9087
+ const session = state.sessions[key];
9088
+ if (!session) return null;
9089
+ const at = (/* @__PURE__ */ new Date()).toISOString();
9090
+ if (!stampWorking(session.chat, at, normalizeRevision(session.closed_delivery_seq))) return null;
9091
+ session.chat_revision = normalizeRevision(session.chat_revision) + 1;
9092
+ session.updated_at = at;
9093
+ await this.writeState(state);
9094
+ return session;
9095
+ });
9096
+ }
8956
9097
  /**
8957
9098
  * @template T
8958
9099
  * @param {() => Promise<T>} operation
@@ -8995,7 +9136,13 @@ var SessionStore = class {
8995
9136
  const state = { sessions: parsed.sessions || {} };
8996
9137
  let changed = false;
8997
9138
  for (const session of Object.values(state.sessions)) {
8998
- if (!session || typeof session !== "object" || !applyTranscriptBound(session)) continue;
9139
+ if (!session || typeof session !== "object") continue;
9140
+ if (!("delivery_seq" in session)) {
9141
+ session.delivery_seq = 0;
9142
+ markPreReceipt(session.chat);
9143
+ changed = true;
9144
+ }
9145
+ if (!applyTranscriptBound(session)) continue;
8999
9146
  session.chat_revision = normalizeRevision(session.chat_revision) + 1;
9000
9147
  changed = true;
9001
9148
  }
@@ -9014,8 +9161,8 @@ var SessionStore = class {
9014
9161
  }
9015
9162
  };
9016
9163
  async function canonicalFile(file) {
9017
- const absolute = path5.resolve(file);
9018
- return realpath2(absolute);
9164
+ const absolute = path6.resolve(file);
9165
+ return realpath3(absolute);
9019
9166
  }
9020
9167
  function sessionKey(file) {
9021
9168
  return crypto4.createHash("sha256").update(file).digest("hex").slice(0, 16);
@@ -9255,8 +9402,8 @@ function generateSharePassword() {
9255
9402
 
9256
9403
  // src/attachment-store.js
9257
9404
  import crypto5 from "node:crypto";
9258
- import { chmod, mkdir as mkdir3, readdir, readFile as readFile4, rename as rename2, rm as rm2, stat as stat2, utimes, writeFile as writeFile3 } from "node:fs/promises";
9259
- import path6 from "node:path";
9405
+ import { chmod, mkdir as mkdir3, readdir, readFile as readFile4, rename as rename2, rm as rm2, stat as stat3, utimes, writeFile as writeFile3 } from "node:fs/promises";
9406
+ import path7 from "node:path";
9260
9407
  var KEY_RE2 = /^[0-9a-f]{16}$/;
9261
9408
  var ID_RE = /^[0-9a-f]{64}\.(png|jpg|webp)$/;
9262
9409
  var MIME_BY_EXT = { png: "image/png", jpg: "image/jpeg", webp: "image/webp" };
@@ -9284,10 +9431,10 @@ function isValidAttachmentId(id) {
9284
9431
  return ID_RE.test(String(id || ""));
9285
9432
  }
9286
9433
  function attachmentsDir(stateDir2, key) {
9287
- return path6.join(stateDir2, "attachments", String(key));
9434
+ return path7.join(stateDir2, "attachments", String(key));
9288
9435
  }
9289
9436
  function attachmentFile(stateDir2, key, id) {
9290
- return path6.join(attachmentsDir(stateDir2, key), id);
9437
+ return path7.join(attachmentsDir(stateDir2, key), id);
9291
9438
  }
9292
9439
  function resolveAttachmentConfig(env = process.env) {
9293
9440
  const maxDiskBytes = diskCapEnv(env.LAVISH_AXI_MAX_ATTACHMENT_DISK_MB);
@@ -9422,7 +9569,7 @@ function buildMetadata(id, file, mime, bytes, dims) {
9422
9569
  }
9423
9570
  async function pathExists(file) {
9424
9571
  try {
9425
- await stat2(file);
9572
+ await stat3(file);
9426
9573
  return true;
9427
9574
  } catch (error) {
9428
9575
  if (error && error.code === "ENOENT") return false;
@@ -9462,7 +9609,7 @@ async function writeFileAtomically2(file, content) {
9462
9609
  }
9463
9610
  }
9464
9611
  async function ensureAttachmentDir(stateDir2, key) {
9465
- const root = path6.join(stateDir2, "attachments");
9612
+ const root = path7.join(stateDir2, "attachments");
9466
9613
  const dir = attachmentsDir(stateDir2, key);
9467
9614
  await mkdir3(dir, { recursive: true, mode: ATTACHMENT_DIR_MODE });
9468
9615
  for (const target of [root, dir]) {
@@ -9543,7 +9690,7 @@ async function resolveAttachment(stateDir2, key, id) {
9543
9690
  const file = attachmentFile(stateDir2, key, id);
9544
9691
  let info;
9545
9692
  try {
9546
- info = await stat2(file);
9693
+ info = await stat3(file);
9547
9694
  } catch (error) {
9548
9695
  if (error && error.code === "ENOENT") return null;
9549
9696
  throw error;
@@ -9557,7 +9704,7 @@ async function statAttachmentForServe(stateDir2, key, id) {
9557
9704
  if (!isValidAttachmentKey(key) || !isValidAttachmentId(id)) return null;
9558
9705
  const file = attachmentFile(stateDir2, key, id);
9559
9706
  try {
9560
- const info = await stat2(file);
9707
+ const info = await stat3(file);
9561
9708
  if (!info.isFile()) return null;
9562
9709
  } catch (error) {
9563
9710
  if (error && error.code === "ENOENT") return null;
@@ -9586,20 +9733,20 @@ async function removeAttachment(stateDir2, key, id) {
9586
9733
  }
9587
9734
  }
9588
9735
  async function listAttachments(stateDir2) {
9589
- const root = path6.join(stateDir2, "attachments");
9736
+ const root = path7.join(stateDir2, "attachments");
9590
9737
  const sessionDirs = await readdirSafe(root);
9591
9738
  const files = [];
9592
9739
  for (const dirent of sessionDirs) {
9593
9740
  if (!dirent.isDirectory() || !isValidAttachmentKey(dirent.name)) continue;
9594
- const dir = path6.join(root, dirent.name);
9741
+ const dir = path7.join(root, dirent.name);
9595
9742
  for (const entry of await readdirSafe(dir)) {
9596
9743
  if (!entry.isFile() || !isValidAttachmentId(entry.name)) continue;
9597
- const filePath = path6.join(dir, entry.name);
9744
+ const filePath = path7.join(dir, entry.name);
9598
9745
  try {
9599
- const info = await stat2(filePath);
9746
+ const info = await stat3(filePath);
9600
9747
  let sidecarBytes = 0;
9601
9748
  try {
9602
- sidecarBytes = (await stat2(sidecarPath(filePath))).size;
9749
+ sidecarBytes = (await stat3(sidecarPath(filePath))).size;
9603
9750
  } catch {
9604
9751
  }
9605
9752
  files.push({
@@ -9676,19 +9823,19 @@ async function sweepAttachments(stateDir2, options = {}) {
9676
9823
  const orphans = await reapOrphanFiles(stateDir2, now);
9677
9824
  deleted += orphans.deleted;
9678
9825
  freedBytes += orphans.freedBytes;
9679
- await pruneEmptyDirs(path6.join(stateDir2, "attachments"));
9826
+ await pruneEmptyDirs(path7.join(stateDir2, "attachments"));
9680
9827
  return { deleted, freedBytes };
9681
9828
  }
9682
9829
  async function committedChargedBytes(stateDir2) {
9683
- const root = path6.join(stateDir2, "attachments");
9830
+ const root = path7.join(stateDir2, "attachments");
9684
9831
  let total = 0;
9685
9832
  for (const dirent of await readdirSafe(root)) {
9686
9833
  if (!dirent.isDirectory() || !isValidAttachmentKey(dirent.name)) continue;
9687
- const dir = path6.join(root, dirent.name);
9834
+ const dir = path7.join(root, dirent.name);
9688
9835
  for (const entry of await readdirSafe(dir)) {
9689
9836
  if (!entry.isFile()) continue;
9690
9837
  try {
9691
- total += allocatedBytes((await stat2(path6.join(dir, entry.name))).size);
9838
+ total += allocatedBytes((await stat3(path7.join(dir, entry.name))).size);
9692
9839
  } catch {
9693
9840
  }
9694
9841
  }
@@ -9696,28 +9843,28 @@ async function committedChargedBytes(stateDir2) {
9696
9843
  return total;
9697
9844
  }
9698
9845
  async function reapOrphanFiles(stateDir2, now) {
9699
- const root = path6.join(stateDir2, "attachments");
9846
+ const root = path7.join(stateDir2, "attachments");
9700
9847
  let deleted = 0;
9701
9848
  let freedBytes = 0;
9702
9849
  for (const dirent of await readdirSafe(root)) {
9703
9850
  if (!dirent.isDirectory() || !isValidAttachmentKey(dirent.name)) continue;
9704
- const dir = path6.join(root, dirent.name);
9851
+ const dir = path7.join(root, dirent.name);
9705
9852
  const entries = await readdirSafe(dir);
9706
9853
  const present = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
9707
9854
  for (const entry of entries) {
9708
9855
  if (!entry.isFile()) continue;
9709
- const filePath = path6.join(dir, entry.name);
9856
+ const filePath = path7.join(dir, entry.name);
9710
9857
  const sidecarMatch = SIDECAR_ORPHAN_RE.exec(entry.name);
9711
9858
  try {
9712
9859
  if (TEMP_FILE_RE.test(entry.name)) {
9713
- const info = await stat2(filePath);
9860
+ const info = await stat3(filePath);
9714
9861
  if (now - info.mtimeMs <= ATTACHMENT_TEMP_GRACE_MS) continue;
9715
9862
  if (await removeFile(filePath)) {
9716
9863
  deleted += 1;
9717
9864
  freedBytes += info.size;
9718
9865
  }
9719
9866
  } else if (sidecarMatch && !present.has(sidecarMatch[1])) {
9720
- const info = await stat2(filePath);
9867
+ const info = await stat3(filePath);
9721
9868
  if (await removeFile(filePath)) {
9722
9869
  deleted += 1;
9723
9870
  freedBytes += info.size;
@@ -9750,7 +9897,7 @@ async function removeFile(file) {
9750
9897
  async function pruneEmptyDirs(root) {
9751
9898
  for (const dirent of await readdirSafe(root)) {
9752
9899
  if (!dirent.isDirectory()) continue;
9753
- const dir = path6.join(root, dirent.name);
9900
+ const dir = path7.join(root, dirent.name);
9754
9901
  try {
9755
9902
  if ((await readdir(dir)).length === 0) await rm2(dir, { recursive: true, force: true });
9756
9903
  } catch {
@@ -9950,7 +10097,12 @@ async function serve({
9950
10097
  if (clientKey === key) client.sendEvent(type, data);
9951
10098
  }
9952
10099
  }
9953
- events.on("reload", (key) => broadcastLiveEvent("reload", key));
10100
+ events.on("reload", (key) => {
10101
+ broadcastLiveEvent("reload", key);
10102
+ store.markWorking(key).then((session) => {
10103
+ if (session) events.emit("chat-sync", key, session);
10104
+ }).catch((error) => writeLog(`[lavish] working stamp after reload failed: ${error?.message || error}`));
10105
+ });
9954
10106
  events.on("agent-reply", (key, entry) => broadcastLiveEvent("agent-reply", key, entry));
9955
10107
  events.on("chat-sync", (key, session) => broadcastLiveEvent("chat-sync", key, serializeChatSync(session)));
9956
10108
  events.on(
@@ -10064,6 +10216,21 @@ async function serve({
10064
10216
  function finishFeedbackDelivery(key, result) {
10065
10217
  if (result.status !== "feedback") return;
10066
10218
  markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
10219
+ void publishChatSync(key);
10220
+ }
10221
+ async function publishChatSync(key) {
10222
+ try {
10223
+ const session = await store.findByKey(key);
10224
+ if (session) events.emit("chat-sync", key, session);
10225
+ } catch (error) {
10226
+ writeLog(`[lavish] chat-sync after delivery failed: ${error?.message || error}`);
10227
+ }
10228
+ }
10229
+ function agentFacingFeedback(result) {
10230
+ if (!result || result.status !== "feedback" || !("delivery_seq" in result)) return result;
10231
+ const rest = { ...result };
10232
+ delete rest.delivery_seq;
10233
+ return rest;
10067
10234
  }
10068
10235
  async function restoreClosedFeedback(key, result) {
10069
10236
  if (result.status !== "feedback") return;
@@ -10076,7 +10243,9 @@ async function serve({
10076
10243
  {
10077
10244
  dom_snapshot: result.dom_snapshot || "",
10078
10245
  prompts,
10079
- ...Array.isArray(result.artifact_failures) ? { artifact_failures: result.artifact_failures } : {}
10246
+ ...Array.isArray(result.artifact_failures) ? { artifact_failures: result.artifact_failures } : {},
10247
+ // Lets the store take this take's Seen stamps off again (docs/delivery-acks.md).
10248
+ ...Number.isInteger(result.delivery_seq) ? { delivery_seq: result.delivery_seq } : {}
10080
10249
  },
10081
10250
  {
10082
10251
  restore: true,
@@ -10099,14 +10268,15 @@ async function serve({
10099
10268
  `[lavish] closed poll feedback restore failed; the batch was lost: ${restoreError?.message || restoreError}`
10100
10269
  );
10101
10270
  } else if (persistedNothing) {
10102
- writeLog("[lavish] closed poll feedback restore was refused; nothing was persisted and the batch was lost");
10271
+ writeLog("[lavish] closed poll feedback restore was refused; nothing was re-queued and the batch was lost");
10103
10272
  } else if (!restoredPrompts || JSON.stringify(restoredPrompts) !== JSON.stringify(prompts) || !failuresRestored) {
10104
10273
  writeLog("[lavish] closed poll feedback restore was incomplete; delivery was not marked");
10105
10274
  }
10106
10275
  const pendingAfterRestore = Array.isArray(restoredPrompts) && restoredPrompts.length > 0 || Array.isArray(restoredFailures) && restoredFailures.length > 0;
10107
10276
  if (pendingAfterRestore) events.emit("feedback", key);
10277
+ await publishChatSync(key);
10108
10278
  }
10109
- const whiteboardStateRoot = path7.dirname(stateFile2);
10279
+ const whiteboardStateRoot = path8.dirname(stateFile2);
10110
10280
  const allowedHostnames = buildAllowedHostnames({
10111
10281
  host: requestedListenHosts[0],
10112
10282
  hosts: [...requestedListenHosts, ...listenHosts],
@@ -10138,6 +10308,11 @@ async function serve({
10138
10308
  workingUrl: workingUrlFor(req, { includeSessionPath: false })
10139
10309
  });
10140
10310
  }
10311
+ function refuseNonHtmlArtifact(session, res) {
10312
+ if (isHtmlPath(session.file)) return false;
10313
+ res.status(403).send("Forbidden");
10314
+ return true;
10315
+ }
10141
10316
  if (!allowAnyHostname) {
10142
10317
  app.use((req, res, next) => {
10143
10318
  const requestHost = { host: req.headers.host, forwardedHost: req.headers["x-forwarded-host"] };
@@ -10168,7 +10343,7 @@ async function serve({
10168
10343
  next();
10169
10344
  });
10170
10345
  const attachmentConfig = resolveAttachmentConfig();
10171
- const attachmentStateRoot = path7.dirname(stateFile2);
10346
+ const attachmentStateRoot = path8.dirname(stateFile2);
10172
10347
  const defaultJsonParser = express.json({ limit: "2mb" });
10173
10348
  const whiteboardJsonParser = express.json({ limit: "20mb" });
10174
10349
  app.use((req, res, next) => {
@@ -10191,7 +10366,7 @@ async function serve({
10191
10366
  app: "lavish-axi",
10192
10367
  version,
10193
10368
  state_id: serverStateId,
10194
- state_dir: path7.dirname(stateFile2),
10369
+ state_dir: path8.dirname(stateFile2),
10195
10370
  // Where this process is listening and every address it was asked to serve (bound or still
10196
10371
  // retrying). The CLI uses both to find one daemon per port whatever host it was configured
10197
10372
  // with, and to tell a same-port daemon at another address apart from this one.
@@ -10219,7 +10394,7 @@ async function serve({
10219
10394
  });
10220
10395
  app.post("/api/sessions", async (req, res, next) => {
10221
10396
  try {
10222
- const file = await canonicalFile(req.body.file);
10397
+ const file = await resolveAllowedArtifactFile(req.body?.file);
10223
10398
  const key = sessionKey(file);
10224
10399
  const reopen = Boolean(req.body.reopen);
10225
10400
  const existing = await store.findByKey(key);
@@ -10251,6 +10426,14 @@ async function serve({
10251
10426
  ...networkWarningField()
10252
10427
  });
10253
10428
  } catch (error) {
10429
+ if (isArtifactPathError(error)) {
10430
+ res.status(error.statusCode).json({
10431
+ status: "error",
10432
+ code: error.code,
10433
+ error: error.message
10434
+ });
10435
+ return;
10436
+ }
10254
10437
  next(error);
10255
10438
  }
10256
10439
  });
@@ -10374,7 +10557,7 @@ async function serve({
10374
10557
  releasePollListener(holder, activePolls, deliveredFeedback, events);
10375
10558
  if (immediate.session_ended) clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
10376
10559
  detachRequestClose();
10377
- res.json(immediate);
10560
+ res.json(agentFacingFeedback(immediate));
10378
10561
  return;
10379
10562
  }
10380
10563
  if (holder.replaced) {
@@ -10453,10 +10636,11 @@ async function serve({
10453
10636
  return;
10454
10637
  }
10455
10638
  finishFeedbackDelivery(key, responseResult);
10639
+ const agentResponse = agentFacingFeedback(responseResult);
10456
10640
  if (streamHeartbeat) {
10457
- res.end(JSON.stringify(responseResult));
10641
+ res.end(JSON.stringify(agentResponse));
10458
10642
  } else {
10459
- res.json(responseResult);
10643
+ res.json(agentResponse);
10460
10644
  }
10461
10645
  } finally {
10462
10646
  cleanup();
@@ -10678,8 +10862,9 @@ async function serve({
10678
10862
  res.status(404).json({ error: "session not found" });
10679
10863
  return;
10680
10864
  }
10865
+ if (refuseNonHtmlArtifact(session, res)) return;
10681
10866
  const source = await readFile5(session.file, "utf8");
10682
- const root = path7.dirname(session.file);
10867
+ const root = path8.dirname(session.file);
10683
10868
  const { html, warnings } = await buildSelfContainedHtml(source, {
10684
10869
  baseDir: root,
10685
10870
  confineDir: root,
@@ -10706,11 +10891,12 @@ async function serve({
10706
10891
  res.status(404).json({ error: "session not found" });
10707
10892
  return;
10708
10893
  }
10894
+ if (refuseNonHtmlArtifact(session, res)) return;
10709
10895
  const body = req.body || {};
10710
10896
  const generatePassword = body.generate_password === true;
10711
10897
  const password = generatePassword ? generateSharePassword() : optionalBodyString(body.password);
10712
10898
  const source = await readFile5(session.file, "utf8");
10713
- const root = path7.dirname(session.file);
10899
+ const root = path8.dirname(session.file);
10714
10900
  const { html, warnings } = await buildSelfContainedHtml(source, {
10715
10901
  baseDir: root,
10716
10902
  confineDir: root,
@@ -10781,6 +10967,7 @@ async function serve({
10781
10967
  return;
10782
10968
  }
10783
10969
  const session = chromeLoad.session;
10970
+ if (refuseNonHtmlArtifact(session, res)) return;
10784
10971
  await watchSession(session, watchers, events, logEvent, reloadDebounceMs);
10785
10972
  const artifactHtml = await readFile5(session.file, "utf8").catch(() => "");
10786
10973
  const { faviconTag, title } = extractArtifactHead(artifactHtml);
@@ -10858,6 +11045,7 @@ async function serve({
10858
11045
  sendSessionNotFound(req, res);
10859
11046
  return;
10860
11047
  }
11048
+ if (refuseNonHtmlArtifact(beforeRead.session, res)) return;
10861
11049
  if (!beforeRead.valid) {
10862
11050
  res.status(409).type("html").send(
10863
11051
  "<!doctype html><title>Artifact load expired</title><p>This artifact load is no longer current. Reload Lavish to continue.</p>"
@@ -10887,7 +11075,8 @@ async function serve({
10887
11075
  sendSessionNotFound(req, res);
10888
11076
  return;
10889
11077
  }
10890
- const root = path7.dirname(session.file);
11078
+ if (refuseNonHtmlArtifact(session, res)) return;
11079
+ const root = artifactTreeRoot(session.file);
10891
11080
  const file = await resolveArtifactAsset(root, assetPath);
10892
11081
  if (!file) {
10893
11082
  res.status(403).send("Forbidden");
@@ -10994,6 +11183,7 @@ data: ${JSON.stringify({ reason: "server-restarted" })}
10994
11183
  res.status(404).json({ error: "session not found" });
10995
11184
  return;
10996
11185
  }
11186
+ if (refuseNonHtmlArtifact(session, res)) return;
10997
11187
  const html = await readFile5(session.file, "utf8").catch(() => "");
10998
11188
  const sources = extractMermaidSources(html).map(({ index, source }) => ({
10999
11189
  index,
@@ -11709,14 +11899,14 @@ function optionalBodyString(value) {
11709
11899
  return trimmed || void 0;
11710
11900
  }
11711
11901
  async function resolveArtifactAsset(root, assetPath) {
11712
- const file = path7.resolve(root, assetPath);
11713
- const relative = path7.relative(root, file);
11714
- if (relative.startsWith("..") || path7.isAbsolute(relative)) {
11902
+ const file = path8.resolve(root, assetPath);
11903
+ const relative = path8.relative(root, file);
11904
+ if (relative.startsWith("..") || path8.isAbsolute(relative)) {
11715
11905
  return null;
11716
11906
  }
11717
11907
  let real;
11718
11908
  try {
11719
- real = await realpath3(file);
11909
+ real = await realpath4(file);
11720
11910
  } catch (error) {
11721
11911
  if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
11722
11912
  return file;
@@ -11725,12 +11915,12 @@ async function resolveArtifactAsset(root, assetPath) {
11725
11915
  }
11726
11916
  let realRoot;
11727
11917
  try {
11728
- realRoot = await realpath3(root);
11918
+ realRoot = await realpath4(root);
11729
11919
  } catch {
11730
- realRoot = path7.resolve(root);
11920
+ realRoot = path8.resolve(root);
11731
11921
  }
11732
- const relativeReal = path7.relative(realRoot, real);
11733
- if (relativeReal === ".." || relativeReal.startsWith(`..${path7.sep}`) || path7.isAbsolute(relativeReal)) {
11922
+ const relativeReal = path8.relative(realRoot, real);
11923
+ if (relativeReal === ".." || relativeReal.startsWith(`..${path8.sep}`) || path8.isAbsolute(relativeReal)) {
11734
11924
  return null;
11735
11925
  }
11736
11926
  return real;
@@ -11766,7 +11956,7 @@ async function resolveWatchTarget(session) {
11766
11956
  const html = await readFile5(session.file, "utf8");
11767
11957
  if (hasLiveReloadRootOptIn(html)) {
11768
11958
  return {
11769
- path: path7.dirname(session.file),
11959
+ path: path8.dirname(session.file),
11770
11960
  scope: "directory",
11771
11961
  options: {
11772
11962
  ...baseOptions,
@@ -12220,9 +12410,9 @@ var HttpTelemetryClient = class {
12220
12410
  if (!trimmed) return;
12221
12411
  this.send(trimmed, eventURL(this.app, trimmed), fields);
12222
12412
  }
12223
- pageview(path9, fields = {}) {
12413
+ pageview(path10, fields = {}) {
12224
12414
  if (this.closed) return;
12225
- this.send("", normalizePagePath(path9), fields);
12415
+ this.send("", normalizePagePath(path10), fields);
12226
12416
  }
12227
12417
  async close(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
12228
12418
  this.closed = true;
@@ -12297,8 +12487,8 @@ function eventURL(app, name) {
12297
12487
  if (!name) return `app://${app}`;
12298
12488
  return `app://${app}/${name.replace(/\./g, "/")}`;
12299
12489
  }
12300
- function normalizePagePath(path9) {
12301
- const trimmed = String(path9 || "").trim();
12490
+ function normalizePagePath(path10) {
12491
+ const trimmed = String(path10 || "").trim();
12302
12492
  if (!trimmed) return "/";
12303
12493
  return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
12304
12494
  }
@@ -12340,7 +12530,7 @@ var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_J
12340
12530
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
12341
12531
  var POLL_STATE_HEADER = "lavish-poll-state";
12342
12532
  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.";
12343
- var VERSION = "0.1.82";
12533
+ var VERSION = "0.1.84";
12344
12534
  function detectInvokingAgent(env = process.env) {
12345
12535
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
12346
12536
  }
@@ -12818,8 +13008,8 @@ async function exportCommand(args) {
12818
13008
  }
12819
13009
  await assertHtmlFile(file);
12820
13010
  const absolute = await canonicalFile(file);
12821
- const root = path8.dirname(absolute);
12822
- const output = path8.resolve(flagValue(args, "--out") || path8.join(root, exportFileName(absolute)));
13011
+ const root = path9.dirname(absolute);
13012
+ const output = path9.resolve(flagValue(args, "--out") || path9.join(root, exportFileName(absolute)));
12823
13013
  const source = await readFile6(absolute, "utf8");
12824
13014
  const { html, warnings } = await buildSelfContainedHtml(source, {
12825
13015
  baseDir: root,
@@ -12874,7 +13064,7 @@ async function shareCommand(args) {
12874
13064
  }
12875
13065
  await assertHtmlFile(request.file);
12876
13066
  const absolute = await canonicalFile(request.file);
12877
- const root = path8.dirname(absolute);
13067
+ const root = path9.dirname(absolute);
12878
13068
  const source = await readFile6(absolute, "utf8");
12879
13069
  const { html, warnings } = await buildSelfContainedHtml(source, {
12880
13070
  baseDir: root,
@@ -13297,7 +13487,7 @@ async function setupPluginCommand() {
13297
13487
  const manifest = readPluginManifest(pluginRoot);
13298
13488
  if (!manifest) {
13299
13489
  throw new AxiError("No plugin.json found in the lavish-axi package", "SERVER_ERROR", [
13300
- `Expected a manifest at ${path8.join(pluginRoot, "plugin.json")}`,
13490
+ `Expected a manifest at ${path9.join(pluginRoot, "plugin.json")}`,
13301
13491
  "Reinstall lavish-axi, or run `npm run build:plugin` when working from a source checkout"
13302
13492
  ]);
13303
13493
  }
@@ -13321,7 +13511,7 @@ function collapseHome(target) {
13321
13511
  }
13322
13512
  function registerVsCodePlugin(pluginRoot, pluginName) {
13323
13513
  const settingsFile = resolveVsCodeSettingsFile(process.env, resolveHookHomeDir());
13324
- const settingsDir = path8.dirname(settingsFile);
13514
+ const settingsDir = path9.dirname(settingsFile);
13325
13515
  const hasSettingsFile = existsSync3(settingsFile);
13326
13516
  if (!hasSettingsFile && !existsSync3(settingsDir)) {
13327
13517
  return { client: "vscode", status: "absent", detail: "no VS Code user configuration found" };
@@ -13350,7 +13540,7 @@ function registerVsCodePlugin(pluginRoot, pluginName) {
13350
13540
  return { client: "vscode", status: "registered", detail: collapseHome(settingsFile) };
13351
13541
  }
13352
13542
  function registerCursorPlugin(pluginRoot, pluginName) {
13353
- const cursorDir = path8.join(resolveHookHomeDir(), ".cursor");
13543
+ const cursorDir = path9.join(resolveHookHomeDir(), ".cursor");
13354
13544
  if (!existsSync3(cursorDir)) {
13355
13545
  return { client: "cursor", status: "absent", detail: "no ~/.cursor directory found" };
13356
13546
  }
@@ -13423,9 +13613,9 @@ function parseCopilotPluginRecords(output) {
13423
13613
  }
13424
13614
  }
13425
13615
  function installedCopilotPluginSourcePath(pluginName) {
13426
- const configDir = process.env.COPILOT_HOME || path8.join(resolveHookHomeDir(), ".copilot");
13616
+ const configDir = process.env.COPILOT_HOME || path9.join(resolveHookHomeDir(), ".copilot");
13427
13617
  try {
13428
- const config = JSON.parse(readFileSync2(path8.join(configDir, "config.json"), "utf8"));
13618
+ const config = JSON.parse(readFileSync2(path9.join(configDir, "config.json"), "utf8"));
13429
13619
  const record = Array.isArray(config.installedPlugins) ? config.installedPlugins.find((candidate) => candidate?.name === pluginName) : null;
13430
13620
  return record ? copilotPluginSourcePath(record) : null;
13431
13621
  } catch {
@@ -13450,7 +13640,7 @@ function copilotPluginSourcePath(record) {
13450
13640
  continue;
13451
13641
  }
13452
13642
  }
13453
- if (path8.isAbsolute(candidate)) return candidate;
13643
+ if (path9.isAbsolute(candidate)) return candidate;
13454
13644
  }
13455
13645
  return null;
13456
13646
  }
@@ -13458,14 +13648,14 @@ function sameResolvedPath(left, right) {
13458
13648
  try {
13459
13649
  return realpathSync2(left) === realpathSync2(right);
13460
13650
  } catch {
13461
- return path8.resolve(left) === path8.resolve(right);
13651
+ return path9.resolve(left) === path9.resolve(right);
13462
13652
  }
13463
13653
  }
13464
13654
  function resolveHookHomeDir(env = process.env, fallback = os4.homedir()) {
13465
13655
  return env.HOME || fallback;
13466
13656
  }
13467
13657
  function resolveCopilotHookDir(env = process.env, homeDir = resolveHookHomeDir(env)) {
13468
- return path8.join(env.COPILOT_HOME || path8.join(homeDir, ".copilot"), "hooks");
13658
+ return path9.join(env.COPILOT_HOME || path9.join(homeDir, ".copilot"), "hooks");
13469
13659
  }
13470
13660
  function createCopilotCliAmbientContextScript(command = "lavish-axi") {
13471
13661
  return [
@@ -13514,9 +13704,9 @@ function installCopilotCliSessionStartHook({
13514
13704
  timeoutSec = 10,
13515
13705
  onError = void 0
13516
13706
  } = {}) {
13517
- const target = path8.join(hookDir, "lavish-axi.json");
13707
+ const target = path9.join(hookDir, "lavish-axi.json");
13518
13708
  try {
13519
- mkdirSync2(path8.dirname(target), { recursive: true });
13709
+ mkdirSync2(path9.dirname(target), { recursive: true });
13520
13710
  const current = existsSync3(target) ? JSON.parse(readFileSync2(target, "utf8")) : {};
13521
13711
  const [updated, changed] = computeCopilotCliHookUpdate(
13522
13712
  current,
@@ -13579,9 +13769,6 @@ async function assertHtmlFile(file) {
13579
13769
  ]);
13580
13770
  }
13581
13771
  }
13582
- function isHtmlPath(file) {
13583
- return file.toLowerCase().endsWith(".html") || file.toLowerCase().endsWith(".htm");
13584
- }
13585
13772
  var HEALTH_PROBE_TIMEOUT_MS = 500;
13586
13773
  var DEFAULT_HEALTH_TIMEOUT_MS = 2e3;
13587
13774
  var MAX_HEALTH_BODY_BYTES = 256 * 1024;
@@ -13794,7 +13981,7 @@ function serverReplacementReason(currentVersion, healthBody, forceRestart = fals
13794
13981
  }
13795
13982
  function shouldForceRestartForLocalBuild(executablePath, sourceServerExists = localSourceServerExists()) {
13796
13983
  const localBuildEntry = fileURLToPath4(new URL("../dist/cli.mjs", import.meta.url));
13797
- return sourceServerExists && path8.resolve(executablePath) === path8.resolve(localBuildEntry);
13984
+ return sourceServerExists && path9.resolve(executablePath) === path9.resolve(localBuildEntry);
13798
13985
  }
13799
13986
  function localSourceServerExists() {
13800
13987
  return existsSync3(fileURLToPath4(new URL("../src/server.js", import.meta.url)));
@@ -14269,7 +14456,7 @@ ${POLL_SEND_AND_END_RULE}
14269
14456
  `,
14270
14457
  reply: `Usage: lavish-axi reply <html-file> (--agent-reply "..." | --agent-reply-file <path>)
14271
14458
 
14272
- 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.
14459
+ 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.
14273
14460
 
14274
14461
  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.
14275
14462
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.82",
3
+ "version": "0.1.84",
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.82",
4
+ "version": "0.1.84",
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",