lavish-axi 0.1.34 → 0.1.36

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
@@ -31,10 +31,10 @@ HTML is the new markdown. Lavish is the new editor for your HTML artifacts.
31
31
  Agents are good at producing rich HTML artifacts, but the human-agent collaboration loop on such artifacts is lacking and falls back into screenshots and long responses for “tell me what to change.”
32
32
  That loses the thing HTML is best at: interactivity.
33
33
 
34
- Lavish Editor opens agent-generated HTML files in a local browser, lets you pinpoint elements or selected text and send feedback to the agent to address.
34
+ Lavish Editor opens agent-generated HTML files in a local browser, lets you pinpoint elements, selected text, or Mermaid diagram nodes and send feedback to the agent to address.
35
35
 
36
36
  - **Local-first** - Review local HTML artifacts with a local CLI and no cloud dependency in the core feedback loop; hosted sharing through third-party ht-ml.app is explicit and opt-in.
37
- - **Human-AI collaboration** - Annotate elements, selected text ranges, and send messages to the agent without leaving Lavish Editor.
37
+ - **Human-AI collaboration** - Annotate elements, selected text ranges, and Mermaid diagram nodes, and send messages to the agent without leaving Lavish Editor.
38
38
  - **Battery included** - Lavish Editor teaches your agent good visualization for common use cases such as product or technical plans, design explorations and more out of the box.
39
39
 
40
40
  Lavish Editor is an [AXI](https://axi.md), which means -
@@ -54,6 +54,8 @@ npx skills add kunchenguid/lavish-axi --skill lavish
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
56
  Its frontmatter also includes Hermes Agent metadata, so Hermes-compatible harnesses can categorize and surface it as a first-class productivity skill.
57
+ This installs the public `lavish` skill.
58
+ 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.
57
59
 
58
60
  Then, in agents that expose skills as slash commands (Claude Code, for example), invoke it directly:
59
61
 
@@ -155,11 +157,17 @@ pnpm link
155
157
  - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
156
158
  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()`.
157
159
  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.
158
- The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session) and can submit queued prompts with **Send & end session**, which delivers the prompts before ending the session.
160
+ The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session) and can submit queued prompts with **Send & end session**, which sends the prompts and user-ended attribution together.
159
161
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
160
162
  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.
163
+ 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.
161
164
  - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback and fresh layout warnings for the next successful `lavish-axi poll` send even across reloads, and only blocks human sends while the agent is working on delivered feedback. The no-timeout poll writes an immediate stderr banner and periodic stderr heartbeats while stdout stays reserved for the final response; if the poll is interrupted or times out, re-run it because queued feedback is never lost.
165
+ - **Session end etiquette** - Lavish tracks who ended a session: a human clicking **End session** (or **Send & end session**) in the browser is a user-initiated end, while `lavish-axi end <html-file>` is agent-initiated.
166
+ A plain `lavish-axi <html-file>` after a user-initiated end refuses to reopen the browser and returns guidance instead; pass `--reopen` only when the user asks for further review or something important needs their visual attention.
167
+ Agent-initiated ends keep reopening normally, same as before.
168
+ `lavish-axi poll`'s `ended` response and the `feedback` response for the final batch before an end both carry `next_step` guidance telling the agent to stop polling and deliver remaining updates in chat instead of reopening.
162
169
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
170
+ - **Mermaid diagrams** - Rendered Mermaid diagrams become pannable and zoomable while you explore (drag to pan, scroll to zoom) and freeze when you turn on annotation so a click lands on a single node. Clicking a node annotates the whole node and sends the agent its diagram id, node id, and rendered label instead of just a CSS selector. Lavish only enhances the live render, so the saved HTML still opens identically anywhere.
163
171
  - **Server cleanup** - The detached server stops after the last session ends when nothing is connected, or after `LAVISH_AXI_IDLE_TIMEOUT_MS` (default 30 minutes) with no browser or poll connections.
164
172
  Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
165
173
  - **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set.
@@ -171,9 +179,9 @@ pnpm link
171
179
  | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
172
180
  | `lavish-axi` | Show current sessions and usage guidance. |
173
181
  | `lavish-axi update` | Check for or apply the latest npm release through the AXI SDK self-updater. |
174
- | `lavish-axi <html-file>` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. |
175
- | `lavish-axi poll <html-file>` | Long-poll until the user sends feedback, ends the session, or the browser reports fresh `layout_warnings`; leave no-timeout polls running, or re-run them if interrupted. |
176
- | `lavish-axi end <html-file>` | End a session. |
182
+ | `lavish-axi <html-file>` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. Refuses to reopen a session the user explicitly ended from the browser unless `--reopen` is passed. |
183
+ | `lavish-axi poll <html-file>` | Long-poll until the user sends feedback, ends the session, or the browser reports fresh `layout_warnings`; leave no-timeout polls running, or re-run them if interrupted. On `status: ended`, stop polling and do not reopen uninvited. |
184
+ | `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. |
177
185
  | `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. |
178
186
  | `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, and `--password` makes viewers enter the password before viewing. |
179
187
  | `lavish-axi stop` | Shut down the background server. |
@@ -192,6 +200,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
192
200
  | ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
193
201
  | `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
194
202
  | `lavish-axi <html-file>` | `--no-gate` | Skip the open-time layout curtain for this browser open. |
203
+ | `lavish-axi <html-file>` | `--reopen` | Reopen a session the user explicitly ended from the browser; without it, a plain open refuses and explains why instead of reopening uninvited. |
195
204
  | `lavish-axi update` | `--check` | Report current vs latest npm version without installing an update. |
196
205
  | `lavish-axi export` | `--out <path>` | Write the export to a specific path instead of `<name>.export.html` next to the source. |
197
206
  | `lavish-axi share` | `--password <pw>` | Make the third-party ht-ml.app page private; viewers must supply the password. |
@@ -7,6 +7,12 @@ const filePath = String(sessionData.file || "");
7
7
  const queueStorageKey = "lavish-axi:queued:" + key;
8
8
  const internalQueueKeyField = "_lavishQueueKey";
9
9
  const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
10
+ const MODE_TOGGLE_HOTKEY_KEY = String(sessionData.modeToggleHotkeyKey || "").toLowerCase();
11
+
12
+ function isModeToggleHotkeyEvent(event) {
13
+ if (event.shiftKey || event.altKey) return false;
14
+ return Boolean(event.metaKey || event.ctrlKey) && String(event.key || "").toLowerCase() === MODE_TOGGLE_HOTKEY_KEY;
15
+ }
10
16
 
11
17
  const frame = /** @type {HTMLIFrameElement} */ (document.getElementById("artifact"));
12
18
  const annotationPills = /** @type {HTMLDivElement} */ (document.getElementById("annotationPills"));
@@ -328,21 +334,26 @@ async function submitQueued() {
328
334
  submitQueuedAgain = false;
329
335
  if (!succeeded) {
330
336
  endAfterSubmit = false;
331
- } else if (shouldSubmitAgain && queued.length) {
332
- submitQueued();
333
- } else if (endAfterSubmit) {
334
- endAfterSubmit = false;
335
- await endSession();
337
+ } else if (!ended && shouldSubmitAgain) {
338
+ if (queued.length) {
339
+ submitQueued();
340
+ } else if (endAfterSubmit) {
341
+ endAfterSubmit = false;
342
+ endSession();
343
+ }
336
344
  }
337
345
  }
338
346
  }
339
347
 
340
348
  async function submitQueuedOnce() {
341
349
  const prompts = queued.slice();
350
+ const shouldEndSession = endAfterSubmit;
351
+ const body = { prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot };
352
+ if (shouldEndSession) body.endSession = true;
342
353
  const response = await fetch("/api/" + key + "/prompts", {
343
354
  method: "POST",
344
355
  headers: { "content-type": "application/json" },
345
- body: JSON.stringify({ prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot }),
356
+ body: JSON.stringify(body),
346
357
  });
347
358
  if (!response.ok) throw new Error("failed to submit queued prompts");
348
359
  for (const prompt of prompts) {
@@ -351,6 +362,11 @@ async function submitQueuedOnce() {
351
362
  }
352
363
  persistQueuedPrompts();
353
364
  render();
365
+ if (shouldEndSession) {
366
+ endAfterSubmit = false;
367
+ markSessionEnded();
368
+ return;
369
+ }
354
370
  if (agentPresence === "listening") setAgentPresence("working");
355
371
  }
356
372
 
@@ -474,6 +490,11 @@ async function endSession() {
474
490
  if (ended) return;
475
491
  const response = await fetch("/api/" + key + "/end", { method: "POST" });
476
492
  if (!response.ok) throw new Error("failed to end session");
493
+ markSessionEnded();
494
+ }
495
+
496
+ function markSessionEnded() {
497
+ if (ended) return;
477
498
  ended = true;
478
499
  closeMenus();
479
500
  annotationSwitch.disabled = true;
@@ -686,15 +707,19 @@ window.addEventListener("message", (event) => {
686
707
  }
687
708
  if (msg.type === "lavish:sendQueuedPrompts") sendQueued();
688
709
  if (msg.type === "lavish:endSession") endSession();
710
+ if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
689
711
  });
690
712
 
691
713
  loadFrame();
692
714
 
693
- annotationSwitch.onclick = () => {
715
+ function toggleAnnotationMode() {
716
+ if (ended) return;
694
717
  annotation = !annotation;
695
718
  annotationSwitch.setAttribute("aria-pressed", String(annotation));
696
719
  postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation });
697
- };
720
+ }
721
+
722
+ annotationSwitch.onclick = toggleAnnotationMode;
698
723
 
699
724
  sendButton.onclick = () => sendQueued(false);
700
725
  sendFromMenuButton.onclick = () => sendQueued(false);
@@ -739,6 +764,17 @@ document.addEventListener("keydown", (event) => {
739
764
  }
740
765
  }
741
766
  });
767
+ // Capture phase so the mode hotkey fires no matter where focus is in the chrome - including
768
+ // mid-keystroke in chatInput or an annotation-card textarea - without disturbing normal typing.
769
+ document.addEventListener(
770
+ "keydown",
771
+ (event) => {
772
+ if (!isModeToggleHotkeyEvent(event)) return;
773
+ event.preventDefault();
774
+ toggleAnnotationMode();
775
+ },
776
+ true,
777
+ );
742
778
  frame.addEventListener("load", () => {
743
779
  postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation && !ended });
744
780
  // Replay the pre-reload scroll position so hot reloads don't jump the artifact to the top.
package/dist/cli.mjs CHANGED
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
2
7
 
3
8
  // src/cli.js
4
9
  import { spawn, spawnSync } from "node:child_process";
@@ -4188,7 +4193,67 @@ import { fileURLToPath as fileURLToPath2 } from "node:url";
4188
4193
  import chokidar from "chokidar";
4189
4194
  import express from "express";
4190
4195
 
4196
+ // src/mermaid-node.js
4197
+ var mermaid_node_exports = {};
4198
+ __export(mermaid_node_exports, {
4199
+ isMermaidSvg: () => isMermaidSvg,
4200
+ mermaidNodeElement: () => mermaidNodeElement,
4201
+ mermaidNodeFrom: () => mermaidNodeFrom,
4202
+ normalizeMermaidNodeTarget: () => normalizeMermaidNodeTarget,
4203
+ readNodeLabel: () => readNodeLabel
4204
+ });
4205
+ function isMermaidSvg(svg) {
4206
+ if (!svg) return false;
4207
+ const id = svg.id || "";
4208
+ if (id.startsWith("mermaid-") || id.startsWith("mermaid_")) return true;
4209
+ if (svg.getAttribute?.("aria-roledescription")) return true;
4210
+ return !!(svg.closest && svg.closest(".mermaid, [data-lavish-mermaid]"));
4211
+ }
4212
+ function readNodeLabel(labelEl) {
4213
+ if (!labelEl) return "";
4214
+ let source = labelEl;
4215
+ if (labelEl.querySelector?.("br") && labelEl.cloneNode) {
4216
+ source = labelEl.cloneNode(true);
4217
+ for (const br of source.querySelectorAll("br")) br.replaceWith(document.createTextNode(" "));
4218
+ }
4219
+ return (source.textContent || "").trim().replace(/\s+/g, " ").slice(0, 120);
4220
+ }
4221
+ function mermaidNodeElement(el) {
4222
+ if (!el || !el.closest) return null;
4223
+ const node = el.closest("g.node, g.nodes > g");
4224
+ if (!node) return null;
4225
+ const svg = node.closest("svg");
4226
+ return svg && isMermaidSvg(svg) ? node : null;
4227
+ }
4228
+ function mermaidNodeFrom(el, selector) {
4229
+ const node = mermaidNodeElement(el);
4230
+ if (!node) return null;
4231
+ const svg = node.closest("svg");
4232
+ const labelEl = node.querySelector(".nodeLabel, .label, foreignObject span, text");
4233
+ return {
4234
+ type: "mermaid-node",
4235
+ diagramId: svg.id || "",
4236
+ nodeId: node.id || "",
4237
+ label: readNodeLabel(labelEl),
4238
+ selector: typeof selector === "function" ? selector(node) : ""
4239
+ };
4240
+ }
4241
+ function normalizeMermaidNodeTarget(target) {
4242
+ return {
4243
+ type: "mermaid-node",
4244
+ diagramId: String(target.diagramId || ""),
4245
+ nodeId: String(target.nodeId || ""),
4246
+ label: String(target.label || ""),
4247
+ selector: String(target.selector || "")
4248
+ };
4249
+ }
4250
+
4191
4251
  // src/artifact-sdk.js
4252
+ var MODE_TOGGLE_HOTKEY_KEY = "i";
4253
+ function isModeToggleHotkeyEvent(event) {
4254
+ if (event.shiftKey || event.altKey) return false;
4255
+ return Boolean(event.metaKey || event.ctrlKey) && String(event.key || "").toLowerCase() === MODE_TOGGLE_HOTKEY_KEY;
4256
+ }
4192
4257
  function deriveLavishQueueKey(element, options = {}) {
4193
4258
  function stringValue(value) {
4194
4259
  return value === null || value === void 0 ? "" : String(value);
@@ -4328,7 +4393,8 @@ function resolveVisibleSpillCandidates(spillCandidates, { epsilon = 1 } = {}) {
4328
4393
  )
4329
4394
  );
4330
4395
  }
4331
- function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl) {
4396
+ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl, mermaid = mermaid_node_exports) {
4397
+ const { isMermaidSvg: isMermaidSvg2, mermaidNodeFrom: mermaidNodeFrom2, mermaidNodeElement: mermaidNodeElement2 } = mermaid;
4332
4398
  let annotationMode = true;
4333
4399
  let hovered = null;
4334
4400
  let selected = null;
@@ -4340,6 +4406,12 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4340
4406
  if (!ids.has(el)) ids.set(el, String(++counter));
4341
4407
  return ids.get(el);
4342
4408
  }
4409
+ function escapeAnnotationText(value) {
4410
+ return String(value).replace(
4411
+ /[&<>"']/g,
4412
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]
4413
+ );
4414
+ }
4343
4415
  function selector(el) {
4344
4416
  if (!el || !el.tagName) return "";
4345
4417
  const parts = [];
@@ -4362,12 +4434,138 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4362
4434
  return parts.join(" > ");
4363
4435
  }
4364
4436
  function context(el) {
4365
- return {
4437
+ const base = {
4366
4438
  uid: uid(el),
4367
4439
  selector: selector(el),
4368
4440
  tag: (el.tagName || "").toLowerCase(),
4369
4441
  text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 240)
4370
4442
  };
4443
+ const mermaidNode = mermaidNodeFrom2(el, selector);
4444
+ if (mermaidNode) {
4445
+ base.tag = "mermaid-node";
4446
+ base.text = mermaidNode.label || base.text;
4447
+ base.target = mermaidNode;
4448
+ }
4449
+ return base;
4450
+ }
4451
+ function annotationTargetEl(el) {
4452
+ return mermaidNodeElement2(el) || el;
4453
+ }
4454
+ const mermaidViewports = /* @__PURE__ */ new WeakMap();
4455
+ function findMermaidSvgs() {
4456
+ const svgs = /* @__PURE__ */ new Set();
4457
+ for (const svg of document.querySelectorAll("svg")) {
4458
+ if (isMermaidSvg2(svg)) svgs.add(svg);
4459
+ }
4460
+ return [...svgs];
4461
+ }
4462
+ function createViewport(svg) {
4463
+ const bbox = svg.getBBox ? safeBBox(svg) : null;
4464
+ const initial = readViewBox(svg) || (bbox ? { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height } : null);
4465
+ if (!initial) return null;
4466
+ svg.setAttribute("viewBox", `${initial.x} ${initial.y} ${initial.w} ${initial.h}`);
4467
+ const view = { ...initial };
4468
+ let frozen = false;
4469
+ let panning = null;
4470
+ function apply() {
4471
+ svg.setAttribute("viewBox", `${view.x} ${view.y} ${view.w} ${view.h}`);
4472
+ }
4473
+ function reset() {
4474
+ Object.assign(view, initial);
4475
+ apply();
4476
+ }
4477
+ function zoomAt(clientX, clientY, factor) {
4478
+ const rect = svg.getBoundingClientRect();
4479
+ if (!rect.width || !rect.height) return;
4480
+ const px = (clientX - rect.left) / rect.width;
4481
+ const py = (clientY - rect.top) / rect.height;
4482
+ const fx = view.x + view.w * px;
4483
+ const fy = view.y + view.h * py;
4484
+ const next = Math.min(Math.max(view.w * factor, initial.w / 40), initial.w * 8);
4485
+ const scale = next / view.w;
4486
+ view.w = next;
4487
+ view.h *= scale;
4488
+ view.x = fx - (fx - view.x) * scale;
4489
+ view.y = fy - (fy - view.y) * scale;
4490
+ apply();
4491
+ }
4492
+ function onWheel(event) {
4493
+ if (frozen) return;
4494
+ event.preventDefault();
4495
+ zoomAt(event.clientX, event.clientY, event.deltaY > 0 ? 1.15 : 1 / 1.15);
4496
+ }
4497
+ function onPointerDown(event) {
4498
+ if (frozen || event.button !== 0) return;
4499
+ panning = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y };
4500
+ svg.setPointerCapture?.(event.pointerId);
4501
+ svg.style.cursor = "grabbing";
4502
+ }
4503
+ function onPointerMove(event) {
4504
+ if (!panning) return;
4505
+ const rect = svg.getBoundingClientRect();
4506
+ if (!rect.width || !rect.height) return;
4507
+ view.x = panning.vx - (event.clientX - panning.x) / rect.width * view.w;
4508
+ view.y = panning.vy - (event.clientY - panning.y) / rect.height * view.h;
4509
+ apply();
4510
+ }
4511
+ function onPointerUp(event) {
4512
+ panning = null;
4513
+ svg.releasePointerCapture?.(event.pointerId);
4514
+ svg.style.cursor = frozen ? "" : "grab";
4515
+ }
4516
+ svg.addEventListener("wheel", onWheel, { passive: false });
4517
+ svg.addEventListener("pointerdown", onPointerDown);
4518
+ svg.addEventListener("pointermove", onPointerMove);
4519
+ svg.addEventListener("pointerup", onPointerUp);
4520
+ svg.addEventListener("pointercancel", onPointerUp);
4521
+ function setFrozen(next) {
4522
+ frozen = !!next;
4523
+ panning = null;
4524
+ svg.style.cursor = frozen ? "" : "grab";
4525
+ svg.style.touchAction = frozen ? "" : "none";
4526
+ }
4527
+ setFrozen(false);
4528
+ return { reset, setFrozen };
4529
+ }
4530
+ function safeBBox(svg) {
4531
+ try {
4532
+ return svg.getBBox();
4533
+ } catch {
4534
+ return null;
4535
+ }
4536
+ }
4537
+ function readViewBox(svg) {
4538
+ const raw = svg.getAttribute?.("viewBox");
4539
+ if (!raw) return null;
4540
+ const parts = raw.trim().split(/[\s,]+/).map(Number);
4541
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
4542
+ return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
4543
+ }
4544
+ function enhanceMermaid() {
4545
+ for (const svg of findMermaidSvgs()) {
4546
+ if (mermaidViewports.has(svg)) continue;
4547
+ const viewport = createViewport(svg);
4548
+ if (viewport) {
4549
+ viewport.setFrozen(annotationMode);
4550
+ mermaidViewports.set(svg, viewport);
4551
+ }
4552
+ }
4553
+ }
4554
+ let mermaidEnhanceScheduled = false;
4555
+ function scheduleMermaidEnhance() {
4556
+ if (mermaidEnhanceScheduled) return;
4557
+ mermaidEnhanceScheduled = true;
4558
+ const run2 = () => {
4559
+ mermaidEnhanceScheduled = false;
4560
+ enhanceMermaid();
4561
+ };
4562
+ if (typeof window.requestAnimationFrame === "function") window.requestAnimationFrame(run2);
4563
+ else window.setTimeout(run2, 50);
4564
+ }
4565
+ function setMermaidFrozen(frozen) {
4566
+ for (const svg of findMermaidSvgs()) {
4567
+ mermaidViewports.get(svg)?.setFrozen(frozen);
4568
+ }
4371
4569
  }
4372
4570
  function closestElement(node) {
4373
4571
  if (!node) return document.body;
@@ -4465,6 +4663,7 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4465
4663
  }
4466
4664
  if (!annotationMode && style) style.remove();
4467
4665
  if (!annotationMode) closeCard();
4666
+ setMermaidFrozen(annotationMode);
4468
4667
  }
4469
4668
  function queuePrompt(prompt, options = {}) {
4470
4669
  const originElement = options.element || document.activeElement || document.body;
@@ -4832,17 +5031,20 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4832
5031
  const root = ensureShadow();
4833
5032
  closeCard();
4834
5033
  const c = options.context || context(target);
5034
+ let anchor = target;
4835
5035
  if (options.range) {
4836
5036
  highlightTextRange(options.range);
4837
5037
  } else {
4838
- selected = target;
5038
+ anchor = annotationTargetEl(target);
5039
+ selected = anchor;
4839
5040
  highlightElement(selected);
4840
5041
  }
4841
- const rect = options.range ? options.range.getBoundingClientRect() : target.getBoundingClientRect();
5042
+ const rect = options.range ? options.range.getBoundingClientRect() : anchor.getBoundingClientRect();
4842
5043
  const card = document.createElement("div");
4843
5044
  card.className = "lavish-annotation-card";
4844
- const heading = c.tag === "text" ? "Annotate text" : "Annotate &lt;" + c.tag + "&gt;";
4845
- const placeholder = c.tag === "text" ? "Tell the agent what to change about this text..." : "Tell the agent what to change about this element...";
5045
+ const nodeLabel = c.tag === "mermaid-node" ? c.target?.label || c.text || "" : "";
5046
+ const heading = c.tag === "text" ? "Annotate text" : c.tag === "mermaid-node" ? "Annotate node" + (nodeLabel ? ": " + escapeAnnotationText(nodeLabel) : "") : "Annotate &lt;" + c.tag + "&gt;";
5047
+ const placeholder = c.tag === "text" ? "Tell the agent what to change about this text..." : c.tag === "mermaid-node" ? "Tell the agent what to change about this diagram node..." : "Tell the agent what to change about this element...";
4846
5048
  card.innerHTML = '<div class="lavish-heading">' + heading + '</div><textarea placeholder="' + placeholder + '"></textarea><div class="lavish-hint">Enter to queue &middot; ' + (/Mac|iP(hone|ad|od)/.test(navigator.platform) ? "\u2318" : "Ctrl") + '+Enter to send now</div><div class="lavish-row"><button class="lavish-cancel" type="button">Cancel</button><button class="lavish-send" type="button">Queue</button></div>';
4847
5049
  root.appendChild(card);
4848
5050
  const left = Math.min(Math.max(12, rect.left), window.innerWidth - card.offsetWidth - 12);
@@ -4896,6 +5098,15 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4896
5098
  window.scrollTo(Number(msg.x) || 0, Number(msg.y) || 0);
4897
5099
  }
4898
5100
  });
5101
+ document.addEventListener(
5102
+ "keydown",
5103
+ (event) => {
5104
+ if (!isModeToggleHotkeyEvent(event)) return;
5105
+ event.preventDefault();
5106
+ parent.postMessage({ type: "lavish:toggleAnnotationMode" }, "*");
5107
+ },
5108
+ true
5109
+ );
4899
5110
  let scrollFrame = 0;
4900
5111
  window.addEventListener(
4901
5112
  "scroll",
@@ -4913,9 +5124,10 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4913
5124
  (event) => {
4914
5125
  if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
4915
5126
  return;
4916
- if (event.target === selected) return;
5127
+ const target = annotationTargetEl(event.target);
5128
+ if (target === selected) return;
4917
5129
  if (hovered && hovered !== selected) clearHighlight(hovered);
4918
- hovered = event.target;
5130
+ hovered = target;
4919
5131
  highlightElement(hovered);
4920
5132
  },
4921
5133
  true
@@ -4963,6 +5175,12 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4963
5175
  } else {
4964
5176
  startLayoutAudit();
4965
5177
  }
5178
+ enhanceMermaid();
5179
+ if (document.readyState === "loading") {
5180
+ document.addEventListener("DOMContentLoaded", enhanceMermaid, { once: true });
5181
+ }
5182
+ const mermaidObserver = new MutationObserver(() => scheduleMermaidEnhance());
5183
+ mermaidObserver.observe(document.documentElement, { childList: true, subtree: true });
4966
5184
  }
4967
5185
 
4968
5186
  // src/html-transform.js
@@ -5027,13 +5245,16 @@ var SessionStore = class {
5027
5245
  return null;
5028
5246
  }
5029
5247
  const prompts = Array.isArray(payload.prompts) ? payload.prompts : [];
5248
+ const shouldEndSession = Boolean(payload.endSession || payload.end_session);
5249
+ const alreadyEnded = session.status === "ended";
5030
5250
  const normalizedPrompts = prompts.map(normalizePrompt);
5031
5251
  const userMessages = normalizedPrompts.filter((prompt) => prompt.tag === "message" && prompt.prompt).map((prompt) => ({ role: "user", text: prompt.prompt, at: (/* @__PURE__ */ new Date()).toISOString() }));
5032
5252
  session.prompts = [...session.prompts || [], ...normalizedPrompts];
5033
5253
  session.chat = [...session.chat || [], ...userMessages];
5034
5254
  session.pending_prompts = session.prompts.length;
5035
5255
  session.dom_snapshot = String(payload.domSnapshot || payload.dom_snapshot || "");
5036
- session.status = "feedback";
5256
+ session.status = shouldEndSession || alreadyEnded ? "ended" : "feedback";
5257
+ if (shouldEndSession) session.ended_by = "user";
5037
5258
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
5038
5259
  await this.writeState(state);
5039
5260
  return session;
@@ -5078,14 +5299,18 @@ var SessionStore = class {
5078
5299
  }
5079
5300
  const prompts = session.prompts || [];
5080
5301
  const layoutWarnings = session.layout_warnings || [];
5302
+ const alreadyEnded = session.status === "ended";
5081
5303
  if (prompts.length === 0 && layoutWarnings.length === 0) {
5082
- return session.status === "ended" ? { status: "ended" } : { status: "waiting" };
5304
+ return alreadyEnded ? { status: "ended", ended_by: session.ended_by } : { status: "waiting" };
5083
5305
  }
5084
5306
  const result = {
5085
5307
  status: "feedback",
5086
5308
  dom_snapshot: session.dom_snapshot || "",
5087
5309
  prompts,
5088
- ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {}
5310
+ ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {},
5311
+ // This is the final delivery before the session shows as ended - flag it so the agent
5312
+ // knows not to expect (or force) a reopened browser afterward.
5313
+ ...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {}
5089
5314
  };
5090
5315
  session.prompts = [];
5091
5316
  session.layout_warnings = [];
@@ -5096,20 +5321,26 @@ var SessionStore = class {
5096
5321
  for (const warning of layoutWarnings) deliveredKeys.add(layoutWarningKey(warning));
5097
5322
  session.delivered_layout_warning_keys = [...deliveredKeys].slice(-200);
5098
5323
  }
5099
- if (session.status !== "ended") {
5324
+ if (!alreadyEnded) {
5100
5325
  session.status = "open";
5101
5326
  }
5102
5327
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
5103
5328
  await this.writeState(state);
5104
5329
  return result;
5105
5330
  }
5106
- async endSession(key) {
5331
+ // `endedBy` distinguishes a human ending review from the browser chrome ("user") from an
5332
+ // agent explicitly closing the loop via `lavish-axi end` ("agent"). Only a user-initiated end
5333
+ // blocks a plain reopen - see `SessionStore` callers in server.js.
5334
+ async endSession(key, endedBy = "agent") {
5107
5335
  const state = await this.readState();
5108
5336
  const session = state.sessions[key];
5109
5337
  if (!session) {
5110
5338
  return null;
5111
5339
  }
5340
+ const existingEndedBy = session.status === "ended" ? session.ended_by : void 0;
5341
+ const nextEndedBy = endedBy === "user" || existingEndedBy === "user" ? "user" : "agent";
5112
5342
  session.status = "ended";
5343
+ session.ended_by = nextEndedBy;
5113
5344
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
5114
5345
  await this.writeState(state);
5115
5346
  return session;
@@ -5185,6 +5416,7 @@ function normalizeFiniteNumber(value) {
5185
5416
  }
5186
5417
  function normalizeTarget(target) {
5187
5418
  if (!target || typeof target !== "object" || Array.isArray(target)) return null;
5419
+ if (target.type === "mermaid-node") return normalizeMermaidNodeTarget(target);
5188
5420
  return JSON.parse(JSON.stringify(target));
5189
5421
  }
5190
5422
 
@@ -5256,9 +5488,15 @@ async function serve({
5256
5488
  try {
5257
5489
  const file = await canonicalFile(req.body.file);
5258
5490
  const key = sessionKey(file);
5491
+ const reopen = Boolean(req.body.reopen);
5492
+ const existing = await store.findByKey(key);
5493
+ if (existing?.status === "ended" && existing.ended_by === "user" && !reopen) {
5494
+ logEvent?.(`session open blocked (user-ended) key=${key} file=${file}`);
5495
+ res.json({ key, file, url: existing.url, status: "user-ended" });
5496
+ return;
5497
+ }
5259
5498
  const sessionUrl = `http://${hostForUrl(linkHostName)}:${publicPort}/session/${key}`;
5260
5499
  const url = shouldDisableLayoutGateOpen(req.body || {}) ? appendNoGateParam(sessionUrl) : sessionUrl;
5261
- const existing = await store.findByKey(key);
5262
5500
  const session = await store.upsertSession(file, sessionUrl);
5263
5501
  if (existing?.status === "ended") {
5264
5502
  clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
@@ -5344,13 +5582,16 @@ async function serve({
5344
5582
  });
5345
5583
  app.post("/api/:key/prompts", async (req, res, next) => {
5346
5584
  try {
5585
+ const shouldEndSession = Boolean(req.body?.endSession || req.body?.end_session);
5347
5586
  const session = await store.queuePrompts(req.params.key, req.body || {});
5348
5587
  if (!session) {
5349
5588
  res.status(404).json({ error: "session not found" });
5350
5589
  return;
5351
5590
  }
5352
- events.emit("feedback", req.params.key);
5591
+ if (shouldEndSession) clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
5592
+ events.emit(shouldEndSession ? "ended" : "feedback", req.params.key);
5353
5593
  res.json({ status: "queued", pending_prompts: session.pending_prompts });
5594
+ if (shouldEndSession) await shutdownIfNoLiveSessions();
5354
5595
  } catch (error) {
5355
5596
  next(error);
5356
5597
  }
@@ -5372,7 +5613,7 @@ async function serve({
5372
5613
  });
5373
5614
  app.post("/api/:key/end", async (req, res, next) => {
5374
5615
  try {
5375
- await store.endSession(req.params.key);
5616
+ await store.endSession(req.params.key, "user");
5376
5617
  clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
5377
5618
  events.emit("ended", req.params.key);
5378
5619
  res.json({ status: "ended" });
@@ -5459,7 +5700,7 @@ async function serve({
5459
5700
  try {
5460
5701
  const file = await canonicalFile(req.body.file);
5461
5702
  const key = sessionKey(file);
5462
- await store.endSession(key);
5703
+ await store.endSession(key, "agent");
5463
5704
  clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
5464
5705
  events.emit("ended", key);
5465
5706
  res.json({ status: "ended" });
@@ -5905,11 +6146,14 @@ function createChromeHtml(session, { layoutGateEnabled = true } = {}) {
5905
6146
  key: session.key,
5906
6147
  file: session.file,
5907
6148
  initialChat: session.chat || [],
5908
- layoutGateEnabled
6149
+ layoutGateEnabled,
6150
+ modeToggleHotkeyKey: MODE_TOGGLE_HOTKEY_KEY
5909
6151
  });
5910
6152
  const { head: pathHead, tail: pathTail } = displayPathParts(session.file);
5911
6153
  const bodyClass = layoutGateEnabled ? "lavish layout-gate-active" : "lavish";
5912
6154
  const layoutGateHidden = layoutGateEnabled ? "" : " hidden";
6155
+ const modeHotkeyUpper = MODE_TOGGLE_HOTKEY_KEY.toUpperCase();
6156
+ const modeToggleHint = `Toggle annotate/explore mode (\u2318${modeHotkeyUpper} / Ctrl+${modeHotkeyUpper})`;
5913
6157
  return `<!doctype html>
5914
6158
  <html>
5915
6159
  <head>
@@ -5919,7 +6163,7 @@ function createChromeHtml(session, { layoutGateEnabled = true } = {}) {
5919
6163
  <link rel="stylesheet" href="/chrome.css">
5920
6164
  </head>
5921
6165
  <body class="${bodyClass}">
5922
- <div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="spacer" aria-hidden="true"></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true"><span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span><span>Annotate</span></button><div class="more-wrap" id="moreWrap"><button class="more-button" id="moreButton" type="button" title="More" aria-haspopup="menu" aria-expanded="false">${chromeIcons.more}</button><div class="menu more-menu" id="moreMenu" hidden><div class="menu-head"><div class="menu-label">Editing</div><button class="menu-file" id="copyPath" type="button" title="Copy path \xB7 ${escapeHtml(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml(pathHead)}</span><span class="path-tail">${escapeHtml(pathTail)}</span></span><span class="copy-hint" id="copyHint"><span class="icon-copy">${chromeIcons.copy}</span><span class="icon-check">${chromeIcons.check}</span><span id="copyHintText">Copy</span></span></button></div><div class="menu-rule"></div><button class="menu-item" id="reloadArtifact" type="button">${chromeIcons.refresh}<span>Reload artifact</span></button><button class="menu-item" id="copySnapshot" type="button">${chromeIcons.camera}<span>Copy DOM snapshot</span></button><button class="menu-item" id="exportArtifact" type="button">${chromeIcons.download}<span>Export standalone HTML</span></button><button class="menu-item" id="shareArtifact" type="button">${chromeIcons.globe}<span>Publish link</span></button><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button">${chromeIcons.exit}<span>End session</span></button></div></div></div>
6166
+ <div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="spacer" aria-hidden="true"></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true" title="${escapeHtml(modeToggleHint)}"><span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span><span>Annotate</span></button><div class="more-wrap" id="moreWrap"><button class="more-button" id="moreButton" type="button" title="More" aria-haspopup="menu" aria-expanded="false">${chromeIcons.more}</button><div class="menu more-menu" id="moreMenu" hidden><div class="menu-head"><div class="menu-label">Editing</div><button class="menu-file" id="copyPath" type="button" title="Copy path \xB7 ${escapeHtml(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml(pathHead)}</span><span class="path-tail">${escapeHtml(pathTail)}</span></span><span class="copy-hint" id="copyHint"><span class="icon-copy">${chromeIcons.copy}</span><span class="icon-check">${chromeIcons.check}</span><span id="copyHintText">Copy</span></span></button></div><div class="menu-rule"></div><button class="menu-item" id="reloadArtifact" type="button">${chromeIcons.refresh}<span>Reload artifact</span></button><button class="menu-item" id="copySnapshot" type="button">${chromeIcons.camera}<span>Copy DOM snapshot</span></button><button class="menu-item" id="exportArtifact" type="button">${chromeIcons.download}<span>Export standalone HTML</span></button><button class="menu-item" id="shareArtifact" type="button">${chromeIcons.globe}<span>Publish link</span></button><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button">${chromeIcons.exit}<span>End session</span></button></div></div></div>
5923
6167
  <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" data-artifact-src="/artifact/${session.key}/index.html"></iframe><div class="layout-issue-banner" id="layoutIssueBanner" hidden>This surface may have layout issues. Your agent has been notified.</div></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Lavish.</div><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions" id="sendActions"><span class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</span><div class="split"><button class="button send-main" id="send">Send to Agent</button><button class="button send-caret" id="sendCaret" type="button" title="Send options" aria-haspopup="menu" aria-expanded="false">${chromeIcons.caret}</button></div><div class="menu send-menu" id="sendMenu" hidden><button class="menu-item" id="sendFromMenu" type="button">${chromeIcons.send}<span>Send to Agent</span></button><button class="menu-item danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; end session</span></button></div></div></div></aside></div>
5924
6168
  <div class="share-overlay" id="shareDialog" role="dialog" aria-modal="true" aria-labelledby="shareTitleText" hidden><form class="share-card" id="shareForm"><div class="share-head"><div><div class="share-kicker">Publish to <a class="share-link" href="https://ht-ml.app" target="_blank" rel="noopener noreferrer">ht-ml.app</a></div><h2 id="shareTitleText">Publish artifact</h2></div><button class="share-close" id="shareClose" type="button" aria-label="Close publish dialog"><svg width="14" height="14" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div><p class="share-note">ht-ml.app is a separate, third-party hosting service, not part of Lavish. Publishing sends this artifact to its servers.</p><p class="share-copy">This uploads this artifact to ht-ml.app with local assets inlined. Without a password, the page is PUBLIC and anyone with the link can open it. With a password, the page is PRIVATE and viewers must supply the password to view.</p><p class="share-note">Do not publish secrets. The Lavish annotation SDK is not included.</p><div class="share-grid"><label>Password (optional)<input id="sharePassword" name="password" type="password" autocomplete="new-password" placeholder="Leave blank for a public page"></label></div><div class="share-status" id="shareStatus" role="status"></div><div class="share-result" id="shareResult" hidden><label>Share URL<div class="share-copy-row"><input id="shareUrl" readonly><button class="share-copy-btn" id="copyShareUrl" type="button">Copy URL</button></div></label><label>Update key (secret)<div class="share-copy-row"><input id="shareUpdateKey" readonly><button class="share-copy-btn" id="copyUpdateKey" type="button">Copy key</button></div></label><p class="share-note">Keep the update key private. ht-ml.app returns it once and it is the only way to update or delete this page later.</p></div><div class="share-actions"><button class="share-cancel" id="shareCancel" type="button">Cancel</button><button class="button" id="sharePublish" type="submit">Publish</button></div></form></div>
5925
6169
  <div class="ended-overlay layout-gate-overlay" id="layoutGateOverlay"${layoutGateHidden}><div class="ended-card"><div class="ended-title" id="layoutGateTitle">Checking layout.<br>One moment.</div><p class="ended-copy" id="layoutGateCopy">Lavish is waiting for fonts and final geometry before revealing this artifact.</p><button class="button ended-action" id="layoutGateAction" type="button">Show anyway</button></div></div>
@@ -5930,16 +6174,23 @@ function createChromeHtml(session, { layoutGateEnabled = true } = {}) {
5930
6174
  </html>`;
5931
6175
  }
5932
6176
  function createSdkJs(key) {
6177
+ const mermaidHelperEntries = Object.entries(mermaid_node_exports).filter(([, value]) => typeof value === "function");
6178
+ const mermaidHelperDecls = mermaidHelperEntries.map(([name, fn]) => `const ${name}=${fn.toString()};`).join("\n");
6179
+ const mermaidHelperKeys = mermaidHelperEntries.map(([name]) => name).join(", ");
5933
6180
  return `(() => {
5934
6181
  const key=${JSON.stringify(key)};
5935
6182
  void key;
5936
6183
  const deriveQueueKey=${deriveLavishQueueKey.toString()};
5937
6184
  const isNativeInteractiveControl=${isNativeInteractiveControl.toString()};
6185
+ const MODE_TOGGLE_HOTKEY_KEY=${JSON.stringify(MODE_TOGGLE_HOTKEY_KEY)};
6186
+ const isModeToggleHotkeyEvent=${isModeToggleHotkeyEvent.toString()};
5938
6187
  const fragmentsSignificantlyOverlap=${fragmentsSignificantlyOverlap.toString()};
5939
6188
  const resolveVisibleSpillCandidates=${resolveVisibleSpillCandidates.toString()};
5940
6189
  const classifyHorizontalOverflow=${classifyHorizontalOverflow.toString()};
5941
6190
  const classifyVerticalOverflow=${classifyVerticalOverflow.toString()};
5942
- (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl);
6191
+ ${mermaidHelperDecls}
6192
+ const mermaidHelpers={ ${mermaidHelperKeys} };
6193
+ (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl, mermaidHelpers);
5943
6194
  })();`;
5944
6195
  }
5945
6196
  function escapeHtml(value) {
@@ -6125,7 +6376,7 @@ function normalizePagePath(path6) {
6125
6376
  var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup", "export", "share"]);
6126
6377
  var RESERVED = new Set(RESERVED_COMMANDS);
6127
6378
  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`.";
6128
- var VERSION = "0.1.34";
6379
+ var VERSION = "0.1.36";
6129
6380
  async function run(argv) {
6130
6381
  await ensureStateDir();
6131
6382
  const normalizedArgv = normalizeArgv(argv);
@@ -6217,11 +6468,11 @@ function createHomeOutput({ bin, sessions, includeSessions = true }) {
6217
6468
  ],
6218
6469
  playbooks: listPlaybooks(),
6219
6470
  help: [
6220
- "Run `lavish-axi <html-file>` to open or resume a Lavish Editor session",
6471
+ "Run `lavish-axi <html-file>` to open or resume a Lavish Editor session. If the user explicitly ended the session from the browser, this refuses to reopen it and explains why instead of reopening uninvited - pass `--reopen` only when the user asks for further review or something important needs their visual attention",
6221
6472
  "Unless the user specifies another location, create HTML artifacts in the current working directory under `.lavish/`",
6222
6473
  "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",
6223
- "Run `lavish-axi poll <html-file>` to wait for user feedback or browser-reported layout_warnings. It long-polls and stays silent until the user sends feedback, ends the session, or the real browser reports fresh layout_warnings, so leave it running - never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; if the poll says every current warning is persistent or low-severity, proceed with a note instead of looping. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost",
6224
- "Run `lavish-axi end <html-file>` to end a session",
6474
+ "Run `lavish-axi poll <html-file>` to wait for user feedback or browser-reported layout_warnings. It long-polls and stays silent until the user sends feedback, ends the session, or the real browser reports fresh layout_warnings, so leave it running - never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; if the poll says every current warning is persistent or low-severity, proceed with a note instead of looping. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost. When it reports the session ended, stop polling and do not reopen it uninvited - deliver remaining updates in this conversation instead",
6475
+ "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`",
6225
6476
  "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",
6226
6477
  "Run `lavish-axi share <html-file> [--password <pw>] [--token <t>]` to publish the artifact on ht-ml.app (https://ht-ml.app), a third-party hosting service not part of Lavish, and get back a visitable URL. Shares are PUBLIC by default, so anyone with the link can open them. Pass --password to publish a PRIVATE password-protected page; viewers must supply the password to view. Local assets are inlined; remote refs load over the network. It returns the url plus a secret update_key for managing the page later. Use --token or LAVISH_AXI_HTML_APP_TOKEN only when you have an optional bearer token; it is never required. Users can also publish from the browser chrome's overflow menu",
6227
6478
  "Run `lavish-axi stop` to shut down the background server (it also self-stops when idle or after the last session ends with nothing connected)",
@@ -6250,7 +6501,13 @@ function createPlaybookOutput(args) {
6250
6501
  function createOpenOutput({ file, url, status }) {
6251
6502
  return {
6252
6503
  session: { file, url, status },
6253
- next_step: `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 the real browser reports layout_warnings from the in-iframe layout audit, and it stays silent the whole time - that is normal, never kill it. If layout_warnings arrive, follow the poll response's next_step: fix and re-check fresh error-severity overflow or clipped-text findings before involving the human, but persistent or low-severity warnings may be surfaced with a note when the cause is not obvious. Do not pass --timeout-ms during normal agent use. If your harness limits how long a foreground command may run, run the poll as a background task and wait for it to finish; if the poll still gets killed or times out, just re-run it - queued feedback is never lost. After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show your response in Lavish Editor and wait for more feedback.`
6504
+ next_step: `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 the real browser reports layout_warnings from the in-iframe layout audit, and it stays silent the whole time - that is normal, never kill it. If layout_warnings arrive, follow the poll response's next_step: fix and re-check fresh error-severity overflow or clipped-text findings before involving the human, but persistent or low-severity warnings may be surfaced with a note when the cause is not obvious. Do not pass --timeout-ms during normal agent use. If your harness limits how long a foreground command may run, run the poll as a background task and wait for it to finish; if the poll still gets killed or times out, just re-run it - queued feedback is never lost. After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show your response in Lavish Editor and wait for more feedback. 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\`.`
6505
+ };
6506
+ }
6507
+ function createUserEndedOpenOutput({ file, url }) {
6508
+ return {
6509
+ session: { file, url, status: "user-ended" },
6510
+ next_step: `The user explicitly ended this Lavish Editor session from the browser, so \`lavish-axi ${file}\` did not reopen it. Do not reopen 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\`.`
6254
6511
  };
6255
6512
  }
6256
6513
  async function openCommand(args) {
@@ -6261,8 +6518,12 @@ async function openCommand(args) {
6261
6518
  await assertHtmlFile(file);
6262
6519
  const absolute = await canonicalFile(file);
6263
6520
  const noGate = args.includes("--no-gate");
6521
+ const reopen = args.includes("--reopen");
6264
6522
  const baseUrl = await ensureServer({ forceRestart: shouldForceRestartForLocalBuild(process.argv[1] || "") });
6265
- const response = await postJson(`${baseUrl}/api/sessions`, { file: absolute, noGate });
6523
+ const response = await postJson(`${baseUrl}/api/sessions`, { file: absolute, noGate, reopen });
6524
+ if (response.status === "user-ended") {
6525
+ return createUserEndedOpenOutput({ file: absolute, url: response.url });
6526
+ }
6266
6527
  if (shouldOpenBrowser(args, process.env)) {
6267
6528
  try {
6268
6529
  const open = (await import("open")).default;
@@ -6350,24 +6611,40 @@ function createPollOutput({ file, response }) {
6350
6611
  }
6351
6612
  if (response.status === "feedback") {
6352
6613
  const layoutWarnings = Array.isArray(response.layout_warnings) ? response.layout_warnings : [];
6614
+ const sessionEnded = Boolean(response.session_ended);
6615
+ const endedBy = typeof response.ended_by === "string" ? response.ended_by : void 0;
6353
6616
  return {
6354
- session: { file, status: "feedback" },
6617
+ session: {
6618
+ file,
6619
+ status: "feedback",
6620
+ ...sessionEnded ? { session_ended: true, ...endedBy ? { ended_by: endedBy } : {} } : {}
6621
+ },
6355
6622
  dom_snapshot: response.dom_snapshot || "",
6356
6623
  prompts: response.prompts || [],
6357
6624
  ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {},
6358
- next_step: createFeedbackNextStep(file, layoutWarnings)
6625
+ next_step: createFeedbackNextStep(file, layoutWarnings, sessionEnded, endedBy)
6359
6626
  };
6360
6627
  }
6361
6628
  if (response.status === "ended") {
6362
- return { session: { file, status: "ended" } };
6629
+ return {
6630
+ session: { file, status: "ended", ...response.ended_by ? { ended_by: response.ended_by } : {} },
6631
+ next_step: createEndedNextStep(file, response.ended_by)
6632
+ };
6363
6633
  }
6364
6634
  return {
6365
6635
  session: { file, status: response.status || "waiting" },
6366
6636
  next_step: `No user feedback arrived before the optional timeout. Run \`lavish-axi poll ${file}\` without --timeout-ms to wait indefinitely - queued feedback is never lost, so re-running the poll is always safe.`
6367
6637
  };
6368
6638
  }
6369
- function createFeedbackNextStep(file, layoutWarnings) {
6639
+ function createFeedbackNextStep(file, layoutWarnings, sessionEnded, endedBy) {
6370
6640
  const count = layoutWarnings.length;
6641
+ if (sessionEnded) {
6642
+ const layoutNote = count > 0 ? `${count} layout warning${count === 1 ? "" : "s"} arrived alongside this final feedback. ` : "";
6643
+ if (endedBy === "user") {
6644
+ return `${layoutNote}This was the last feedback before the user ended the session. Stop polling ${file} and do not reopen it - deliver any remaining updates directly in this conversation instead. Only run \`lavish-axi ${file} --reopen\` if the user explicitly asks for further review or something genuinely important needs their visual attention.`;
6645
+ }
6646
+ return `${layoutNote}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.`;
6647
+ }
6371
6648
  const layoutPrefix = count > 0 ? layoutWarningsPrefix(file, layoutWarnings) : `Apply the requested changes to ${file}. `;
6372
6649
  return `${layoutPrefix}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. The poll waits silently until the user sends more feedback, ends the session, or reports fresh layout_warnings - never kill it. If your harness limits how long a foreground command may run, run the poll as a background task; if it still gets killed or times out, just re-run it - queued feedback is never lost.`;
6373
6650
  }
@@ -6386,7 +6663,13 @@ function layoutWarningsPrefix(file, layoutWarnings) {
6386
6663
  if (allRepeatOrLowSeverity) {
6387
6664
  return `${count} layout warning${plural} detected, with no fresh error-severity findings - fix any obvious low-severity issue in ${file}, otherwise it is fine to proceed to the human with a note instead of iterating further. `;
6388
6665
  }
6389
- return `${count} layout warning${plural} detected - fix horizontal overflow or clipped text in ${file}, then reload or re-open the artifact and re-check before involving the human. `;
6666
+ return `${count} layout warning${plural} detected - fix horizontal overflow or clipped text in ${file}, then re-check in the browser before involving the human. Lavish live-reloads the artifact automatically after you save, so you do not need to re-run \`lavish-axi ${file}\` for this. `;
6667
+ }
6668
+ function createEndedNextStep(file, endedBy) {
6669
+ if (endedBy === "user") {
6670
+ return `The user ended this Lavish Editor session. Stop polling ${file} - do not run \`lavish-axi ${file}\` to reopen it. Deliver any remaining updates directly in this conversation instead. Only reopen with \`lavish-axi ${file} --reopen\` if the user explicitly asks for further review or something genuinely important needs their visual attention.`;
6671
+ }
6672
+ return `This Lavish Editor session for ${file} has ended. Stop polling. 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.`;
6390
6673
  }
6391
6674
  async function endCommand(args) {
6392
6675
  const file = firstPositionalArg(args);
@@ -6910,7 +7193,7 @@ var TOP_LEVEL_HELP = `lavish-axi - Lavish Editor AXI
6910
7193
 
6911
7194
  Usage:
6912
7195
  lavish-axi
6913
- lavish-axi <html-file> [--no-open] [--no-gate]
7196
+ lavish-axi <html-file> [--no-open] [--no-gate] [--reopen]
6914
7197
  lavish-axi poll <html-file> [--agent-reply "..."]
6915
7198
  lavish-axi end <html-file>
6916
7199
  lavish-axi export <html-file> [--out <path>]
@@ -6922,21 +7205,21 @@ Usage:
6922
7205
 
6923
7206
  ${DESIGN_SYSTEM_HINT}
6924
7207
 
6925
- Note: poll long-polls indefinitely by default until the user sends feedback, ends the session, or the browser reports fresh layout_warnings, staying silent while it waits - never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; persistent or low-severity findings may be surfaced with a note when the cause is not obvious. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost.
7208
+ Note: poll long-polls indefinitely by default until the user sends feedback, ends the session, or the browser reports fresh layout_warnings, staying silent while it waits - never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; persistent or low-severity findings may be surfaced with a note when the cause is not obvious. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost. When the user ends a session from the browser, stop polling and do not reopen it uninvited - pass --reopen to <html-file> only when the user asks for further review or something important needs their visual attention.
6926
7209
 
6927
7210
  `;
6928
7211
  var COMMAND_HELP = {
6929
- open: `Usage: lavish-axi <html-file> [--no-open] [--no-gate]
7212
+ open: `Usage: lavish-axi <html-file> [--no-open] [--no-gate] [--reopen]
6930
7213
 
6931
- Open or resume a Lavish Editor review session for an HTML artifact. Use --no-open when you need to ensure the server/session exists without opening another browser window. Use --no-gate to skip the open-time layout curtain for this browser open.
7214
+ Open or resume a Lavish Editor review session for an HTML artifact. Use --no-open when you need to ensure the server/session exists without opening another browser window. Use --no-gate to skip the open-time layout curtain for this browser open. If the user explicitly ended the session from the browser, this refuses to reopen it and returns guidance instead - pass --reopen to force it open when the user asks for further review or something important needs their visual attention. Sessions ended by the agent (\`lavish-axi end\`) reopen normally without the flag.
6932
7215
  `,
6933
7216
  poll: `Usage: lavish-axi poll <html-file> [--agent-reply "..."]
6934
7217
 
6935
- This command long-polls indefinitely for queued user prompts and browser-reported layout_warnings, then returns them to the agent. It stays silent while it waits - that is normal, never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; persistent or low-severity findings may be surfaced with a note when the cause is not obvious. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. If your harness limits how long a foreground command may run, run the poll as a background task and wait for it to finish; if it still gets killed or times out, just re-run it - queued feedback is never lost. Use --agent-reply after applying prior feedback to display your response in Lavish Editor before waiting again.
7218
+ This command long-polls indefinitely for queued user prompts and browser-reported layout_warnings, then returns them to the agent. It stays silent while it waits - that is normal, never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; persistent or low-severity findings may be surfaced with a note when the cause is not obvious. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. If your harness limits how long a foreground command may run, run the poll as a background task and wait for it to finish; if it still gets killed or times out, just re-run it - queued feedback is never lost. Use --agent-reply after applying prior feedback to display your response in Lavish Editor before waiting again. When status is ended, stop polling and do not reopen the session uninvited - deliver remaining updates directly in this conversation instead.
6936
7219
  `,
6937
7220
  end: `Usage: lavish-axi end <html-file>
6938
7221
 
6939
- End a Lavish Editor session.
7222
+ End a Lavish Editor session as the agent. A session ended this way still reopens normally on the next \`lavish-axi <html-file>\`, unlike a user ending it from the browser, which requires --reopen.
6940
7223
  `,
6941
7224
  export: `Usage: lavish-axi export <html-file> [--out <path>]
6942
7225
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
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",
@@ -37,6 +37,7 @@ Use lavish-axi when the user asks for a visual artifact, HTML explainer, interac
37
37
  4. If poll returns `layout_warnings`, follow the returned `next_step`: fix and re-check fresh error-severity findings, but proceed with a note instead of looping when every current warning is persistent or low-severity.
38
38
  5. Apply human feedback, then poll again with `--agent-reply "<message>"` to reply in the browser and keep the loop going.
39
39
  6. Run `npx -y lavish-axi end <html-file>` when the review is finished.
40
+ 7. If the user ends the session from the browser instead, `npx -y lavish-axi <html-file>` refuses to reopen it and says so - only pass `--reopen` when the user asks for further review or something genuinely important needs their visual attention. Otherwise deliver remaining updates directly in this conversation.
40
41
 
41
42
  ## Visual guidance
42
43
 
@@ -62,11 +63,11 @@ For flows, architecture, state, or sequence diagrams, do not hand-build boxes-an
62
63
 
63
64
  ## Commands & rules
64
65
 
65
- - Run `npx -y lavish-axi <html-file>` to open or resume a Lavish Editor session
66
+ - Run `npx -y lavish-axi <html-file>` to open or resume a Lavish Editor session. If the user explicitly ended the session from the browser, this refuses to reopen it and explains why instead of reopening uninvited - pass `--reopen` only when the user asks for further review or something important needs their visual attention
66
67
  - Unless the user specifies another location, create HTML artifacts in the current working directory under `.lavish/`
67
68
  - 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
68
- - Run `npx -y lavish-axi poll <html-file>` to wait for user feedback or browser-reported layout_warnings. It long-polls and stays silent until the user sends feedback, ends the session, or the real browser reports fresh layout_warnings, so leave it running - never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; if the poll says every current warning is persistent or low-severity, proceed with a note instead of looping. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost
69
- - Run `npx -y lavish-axi end <html-file>` to end a session
69
+ - Run `npx -y lavish-axi poll <html-file>` to wait for user feedback or browser-reported layout_warnings. It long-polls and stays silent until the user sends feedback, ends the session, or the real browser reports fresh layout_warnings, so leave it running - never kill it. Fix and re-check fresh error-severity layout_warnings before involving the human; if the poll says every current warning is persistent or low-severity, proceed with a note instead of looping. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost. When it reports the session ended, stop polling and do not reopen it uninvited - deliver remaining updates in this conversation instead
70
+ - Run `npx -y 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 `npx -y lavish-axi <html-file>` refuses to reopen it without `--reopen`
70
71
  - Run `npx -y 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
71
72
  - Run `npx -y lavish-axi share <html-file> [--password <pw>] [--token <t>]` to publish the artifact on ht-ml.app (https://ht-ml.app), a third-party hosting service not part of Lavish, and get back a visitable URL. Shares are PUBLIC by default, so anyone with the link can open them. Pass --password to publish a PRIVATE password-protected page; viewers must supply the password to view. Local assets are inlined; remote refs load over the network. It returns the url plus a secret update_key for managing the page later. Use --token or LAVISH_AXI_HTML_APP_TOKEN only when you have an optional bearer token; it is never required. Users can also publish from the browser chrome's overflow menu
72
73
  - Run `npx -y lavish-axi stop` to shut down the background server (it also self-stops when idle or after the last session ends with nothing connected)