lavish-axi 0.1.33 → 0.1.35

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
@@ -125,6 +125,7 @@ pnpm link
125
125
  ┌────────────────────────┐
126
126
  │ lavish-axi poll waits │
127
127
  │ and returns prompts │
128
+ │ or layout warnings │
128
129
  └────────────────────────┘
129
130
  ```
130
131
 
@@ -138,9 +139,10 @@ pnpm link
138
139
  - **Open-time layout gate** - The browser chrome masks each artifact until the real in-iframe layout audit reports no error-severity findings.
139
140
  Warning-only artifacts reveal normally; error findings notify the agent through the same `layout_warnings` poll path and keep the curtain up until a clean reload.
140
141
  The user can click **Show anyway**, and a bounded safety timeout reveals with a persistent layout-issues banner so review is never blocked indefinitely.
141
- - **Layout warnings** - After fonts load and layout settles, the injected SDK audits the real browser render for page horizontal overflow, element overflow, clipped text, and overlapping text.
142
- Intentional horizontal scrollers using `overflow-x: auto` or `scroll` are excluded.
143
- Fresh warnings are returned from `lavish-axi poll` as `layout_warnings` with `selector`, `kind`, `overflowPx`, `viewportWidth`, and `severity`, so agents can fix unreadable layouts before asking the human to review.
142
+ - **Layout warnings** - After fonts load and layout settles, the injected SDK audits the real browser render for page horizontal overflow, element overflow, clipped or visibly spilling text, and overlapping text.
143
+ Intentional horizontal scrollers using `overflow-x: auto` or `scroll` are excluded from horizontal checks, and `overflow-y: auto` or `scroll` is treated as intentional for vertical overflow.
144
+ Current findings are returned from `lavish-axi poll` as `layout_warnings` with `selector`, `kind`, `overflowPx`, `viewportWidth`, `severity`, and `persistent`.
145
+ Fresh error-severity findings should be fixed and rechecked before asking the human to review; repeated or warning-only findings can be surfaced to the human with a note when the cause is not obvious.
144
146
  - **Local assets** - Copy local images, CSS, fonts, and scripts next to the HTML artifact and reference them with relative paths from that directory; root-prefixed paths such as `/assets/logo.png` will not resolve through Lavish's artifact route.
145
147
  - **Export and sharing** - `lavish-axi export` writes `<name>.export.html` by inlining local assets only, stripping the annotation SDK, and leaving remote CDN/font references as links that still need network access.
146
148
  `lavish-axi share` publishes the same local-inlined HTML to [ht-ml.app](https://ht-ml.app), a third-party hosting service not part of Lavish.
@@ -153,10 +155,14 @@ pnpm link
153
155
  - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
154
156
  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()`.
155
157
  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.
156
- 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.
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 sends the prompts and user-ended attribution together.
157
159
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
158
160
  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.
159
161
  - **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.
162
+ - **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.
163
+ 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.
164
+ Agent-initiated ends keep reopening normally, same as before.
165
+ `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.
160
166
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
161
167
  - **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.
162
168
  Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
@@ -169,9 +175,9 @@ pnpm link
169
175
  | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
170
176
  | `lavish-axi` | Show current sessions and usage guidance. |
171
177
  | `lavish-axi update` | Check for or apply the latest npm release through the AXI SDK self-updater. |
172
- | `lavish-axi <html-file>` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. |
173
- | `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. |
174
- | `lavish-axi end <html-file>` | End a session. |
178
+ | `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. |
179
+ | `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. |
180
+ | `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. |
175
181
  | `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. |
176
182
  | `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. |
177
183
  | `lavish-axi stop` | Shut down the background server. |
@@ -190,6 +196,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
190
196
  | ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
191
197
  | `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
192
198
  | `lavish-axi <html-file>` | `--no-gate` | Skip the open-time layout curtain for this browser open. |
199
+ | `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. |
193
200
  | `lavish-axi update` | `--check` | Report current vs latest npm version without installing an update. |
194
201
  | `lavish-axi export` | `--out <path>` | Write the export to a specific path instead of `<name>.export.html` next to the source. |
195
202
  | `lavish-axi share` | `--password <pw>` | Make the third-party ht-ml.app page private; viewers must supply the password. |
@@ -328,21 +328,26 @@ async function submitQueued() {
328
328
  submitQueuedAgain = false;
329
329
  if (!succeeded) {
330
330
  endAfterSubmit = false;
331
- } else if (shouldSubmitAgain && queued.length) {
332
- submitQueued();
333
- } else if (endAfterSubmit) {
334
- endAfterSubmit = false;
335
- await endSession();
331
+ } else if (!ended && shouldSubmitAgain) {
332
+ if (queued.length) {
333
+ submitQueued();
334
+ } else if (endAfterSubmit) {
335
+ endAfterSubmit = false;
336
+ endSession();
337
+ }
336
338
  }
337
339
  }
338
340
  }
339
341
 
340
342
  async function submitQueuedOnce() {
341
343
  const prompts = queued.slice();
344
+ const shouldEndSession = endAfterSubmit;
345
+ const body = { prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot };
346
+ if (shouldEndSession) body.endSession = true;
342
347
  const response = await fetch("/api/" + key + "/prompts", {
343
348
  method: "POST",
344
349
  headers: { "content-type": "application/json" },
345
- body: JSON.stringify({ prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot }),
350
+ body: JSON.stringify(body),
346
351
  });
347
352
  if (!response.ok) throw new Error("failed to submit queued prompts");
348
353
  for (const prompt of prompts) {
@@ -351,6 +356,11 @@ async function submitQueuedOnce() {
351
356
  }
352
357
  persistQueuedPrompts();
353
358
  render();
359
+ if (shouldEndSession) {
360
+ endAfterSubmit = false;
361
+ markSessionEnded();
362
+ return;
363
+ }
354
364
  if (agentPresence === "listening") setAgentPresence("working");
355
365
  }
356
366
 
@@ -474,6 +484,11 @@ async function endSession() {
474
484
  if (ended) return;
475
485
  const response = await fetch("/api/" + key + "/end", { method: "POST" });
476
486
  if (!response.ok) throw new Error("failed to end session");
487
+ markSessionEnded();
488
+ }
489
+
490
+ function markSessionEnded() {
491
+ if (ended) return;
477
492
  ended = true;
478
493
  closeMenus();
479
494
  annotationSwitch.disabled = true;
package/dist/cli.mjs CHANGED
@@ -4277,6 +4277,57 @@ function isNativeInteractiveControl(el) {
4277
4277
  "button,input,select,textarea,option,optgroup,label,summary,[contenteditable]:not([contenteditable='false'])"
4278
4278
  ));
4279
4279
  }
4280
+ function fragmentsSignificantlyOverlap(fragmentsA, fragmentsB, { minAreaRatio = 0.25, minAreaPx = 24 } = {}) {
4281
+ function rectAreaOf(rect) {
4282
+ return Math.max(0, rect.width) * Math.max(0, rect.height);
4283
+ }
4284
+ function intersectionAreaOf(a, b) {
4285
+ const width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
4286
+ const height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
4287
+ return width * height;
4288
+ }
4289
+ for (const a of fragmentsA) {
4290
+ const threshold = Math.min(rectAreaOf(a) * minAreaRatio, minAreaPx);
4291
+ for (const b of fragmentsB) {
4292
+ if (intersectionAreaOf(a, b) >= threshold) return true;
4293
+ }
4294
+ }
4295
+ return false;
4296
+ }
4297
+ function classifyHorizontalOverflow({ scrollWidth, clientWidth, overflowX, hasText, isTruncated, epsilon = 1 }) {
4298
+ const overflowPx = clientWidth > 0 ? scrollWidth - clientWidth : 0;
4299
+ if (overflowPx <= epsilon) return null;
4300
+ const clipsText = hasText && (overflowX === "hidden" || overflowX === "clip") && !isTruncated;
4301
+ return { overflowPx, kind: clipsText ? "clipped-text" : "element-scroll-overflow" };
4302
+ }
4303
+ function classifyVerticalOverflow({ scrollHeight, clientHeight, overflowY, hasText, isTruncated, epsilon = 1 }) {
4304
+ const overflowPx = clientHeight > 0 ? scrollHeight - clientHeight : 0;
4305
+ if (overflowPx <= epsilon) return null;
4306
+ const scrollable = overflowY === "auto" || overflowY === "scroll";
4307
+ if (scrollable || !hasText || isTruncated) return null;
4308
+ const clips = overflowY === "hidden" || overflowY === "clip";
4309
+ return { overflowPx, kind: "clipped-text", clips };
4310
+ }
4311
+ function resolveVisibleSpillCandidates(spillCandidates, { epsilon = 1 } = {}) {
4312
+ function spillBottomEdge(candidate) {
4313
+ const explicit = Number(candidate.spillBottom);
4314
+ if (Number.isFinite(explicit)) return explicit;
4315
+ const rectBottom = Number(candidate.rect?.bottom);
4316
+ const overflowPx = Number(candidate.overflowPx);
4317
+ if (!Number.isFinite(rectBottom) || !Number.isFinite(overflowPx)) return null;
4318
+ return rectBottom + overflowPx;
4319
+ }
4320
+ function sameSpillEdge(candidate, other) {
4321
+ const candidateBottom = spillBottomEdge(candidate);
4322
+ const otherBottom = spillBottomEdge(other);
4323
+ return candidateBottom !== null && otherBottom !== null && Math.abs(candidateBottom - otherBottom) <= epsilon;
4324
+ }
4325
+ return spillCandidates.filter(
4326
+ (candidate) => !spillCandidates.some(
4327
+ (other) => other.el !== candidate.el && candidate.el.contains(other.el) && sameSpillEdge(candidate, other)
4328
+ )
4329
+ );
4330
+ }
4280
4331
  function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl) {
4281
4332
  let annotationMode = true;
4282
4333
  let hovered = null;
@@ -4475,11 +4526,6 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4475
4526
  function rectArea(rect) {
4476
4527
  return Math.max(0, rect.width) * Math.max(0, rect.height);
4477
4528
  }
4478
- function intersectionArea(a, b) {
4479
- const width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
4480
- const height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
4481
- return width * height;
4482
- }
4483
4529
  function isVisibleForLayoutAudit(el, rect = el.getBoundingClientRect()) {
4484
4530
  if (!el || isLavishUi(el) || rect.width <= 0 || rect.height <= 0) return false;
4485
4531
  const style = getComputedStyle(el);
@@ -4543,31 +4589,56 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4543
4589
  function isIntentionalTextTruncation(style) {
4544
4590
  return style.textOverflow === "ellipsis" || Number.parseInt(style.webkitLineClamp || "0", 10) > 0;
4545
4591
  }
4546
- function auditElementOverflow(el, viewportWidth, findings, seen) {
4592
+ function auditElementOverflow(el, viewportWidth, findings, seen, spillCandidates) {
4547
4593
  if (el === document.body || el === document.documentElement || hasIntentionalHorizontalScrollerAncestor(el)) return;
4548
4594
  const rect = el.getBoundingClientRect();
4549
4595
  if (!isVisibleForLayoutAudit(el, rect)) return;
4550
4596
  const style = getComputedStyle(el);
4551
- const scrollOverflowPx = el.clientWidth > 0 ? el.scrollWidth - el.clientWidth : 0;
4552
- if (scrollOverflowPx > layoutAuditOverflowEpsilon) {
4553
- const clipsText = hasReadableText(el) && (style.overflowX === "hidden" || style.overflowX === "clip") && !isIntentionalTextTruncation(style);
4597
+ const hasText = hasReadableText(el);
4598
+ const isTruncated = isIntentionalTextTruncation(style);
4599
+ const horizontal = classifyHorizontalOverflow({
4600
+ scrollWidth: el.scrollWidth,
4601
+ clientWidth: el.clientWidth,
4602
+ overflowX: style.overflowX,
4603
+ hasText,
4604
+ isTruncated,
4605
+ epsilon: layoutAuditOverflowEpsilon
4606
+ });
4607
+ if (horizontal) {
4554
4608
  pushLayoutFinding(findings, seen, {
4555
4609
  selector: selector(el),
4556
- kind: clipsText ? "clipped-text" : "element-scroll-overflow",
4557
- overflowPx: scrollOverflowPx,
4610
+ kind: horizontal.kind,
4611
+ overflowPx: horizontal.overflowPx,
4558
4612
  viewportWidth,
4559
- severity: clipsText ? "error" : overflowSeverity(scrollOverflowPx)
4613
+ severity: horizontal.kind === "clipped-text" ? "error" : overflowSeverity(horizontal.overflowPx)
4560
4614
  });
4561
4615
  }
4562
- const verticalClipPx = el.clientHeight > 0 ? el.scrollHeight - el.clientHeight : 0;
4563
- if (verticalClipPx > layoutAuditOverflowEpsilon && hasReadableText(el) && (style.overflowY === "hidden" || style.overflowY === "clip") && !isIntentionalTextTruncation(style)) {
4564
- pushLayoutFinding(findings, seen, {
4565
- selector: selector(el),
4566
- kind: "clipped-text",
4567
- overflowPx: verticalClipPx,
4568
- viewportWidth,
4569
- severity: "error"
4570
- });
4616
+ const vertical = classifyVerticalOverflow({
4617
+ scrollHeight: el.scrollHeight,
4618
+ clientHeight: el.clientHeight,
4619
+ overflowY: style.overflowY,
4620
+ hasText,
4621
+ isTruncated,
4622
+ epsilon: layoutAuditOverflowEpsilon
4623
+ });
4624
+ if (vertical) {
4625
+ if (vertical.clips) {
4626
+ pushLayoutFinding(findings, seen, {
4627
+ selector: selector(el),
4628
+ kind: vertical.kind,
4629
+ overflowPx: vertical.overflowPx,
4630
+ viewportWidth,
4631
+ severity: "error"
4632
+ });
4633
+ } else {
4634
+ spillCandidates.push({
4635
+ el,
4636
+ selector: selector(el),
4637
+ overflowPx: vertical.overflowPx,
4638
+ viewportWidth,
4639
+ spillBottom: rect.bottom + vertical.overflowPx
4640
+ });
4641
+ }
4571
4642
  }
4572
4643
  const parent2 = el.parentElement;
4573
4644
  if (!parent2 || parent2 === document.body || parent2 === document.documentElement) return;
@@ -4585,36 +4656,56 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4585
4656
  });
4586
4657
  }
4587
4658
  }
4659
+ function resolveSpillCandidates(spillCandidates, findings, seen) {
4660
+ for (const candidate of resolveVisibleSpillCandidates(spillCandidates, { epsilon: layoutAuditOverflowEpsilon })) {
4661
+ pushLayoutFinding(findings, seen, {
4662
+ selector: candidate.selector,
4663
+ kind: "clipped-text",
4664
+ overflowPx: candidate.overflowPx,
4665
+ viewportWidth: candidate.viewportWidth,
4666
+ severity: "error"
4667
+ });
4668
+ }
4669
+ }
4670
+ function elementLineFragments(el) {
4671
+ const rects = [...el.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
4672
+ if (rects.length) return rects;
4673
+ const rect = el.getBoundingClientRect();
4674
+ return rect.width > 0 && rect.height > 0 ? [rect] : [];
4675
+ }
4588
4676
  function auditOverlappingText(elements, viewportWidth, findings, seen) {
4589
- const candidates = elements.filter((el) => el.children.length === 0 && hasReadableText(el)).filter((el) => isVisibleForLayoutAudit(el)).slice(0, 200);
4677
+ const candidates = elements.filter((el) => el.children.length === 0 && hasReadableText(el)).filter((el) => isVisibleForLayoutAudit(el)).filter((el) => getComputedStyle(el).position === "static").slice(0, 200);
4590
4678
  for (const el of candidates) {
4591
- const rect = el.getBoundingClientRect();
4592
- if (rectArea(rect) < 16) continue;
4593
- const points = [
4594
- { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
4595
- { x: rect.left + Math.min(4, rect.width / 2), y: rect.top + Math.min(4, rect.height / 2) },
4596
- { x: rect.right - Math.min(4, rect.width / 2), y: rect.bottom - Math.min(4, rect.height / 2) }
4597
- ];
4598
- for (const point of points) {
4599
- if (point.x < 0 || point.y < 0 || point.x > viewportWidth || point.y > window.innerHeight) continue;
4600
- const top = document.elementFromPoint(point.x, point.y);
4601
- if (!(top instanceof Element) || top === el || el.contains(top) || top.contains(el) || isLavishUi(top))
4602
- continue;
4603
- if (hasIntentionalHorizontalScrollerAncestor(top)) continue;
4604
- const elPosition = getComputedStyle(el).position;
4605
- const topPosition = getComputedStyle(top).position;
4606
- if (elPosition !== "static" || topPosition !== "static") continue;
4607
- const topRect = top.getBoundingClientRect();
4608
- const overlapArea = intersectionArea(rect, topRect);
4609
- if (overlapArea < Math.min(rectArea(rect) * 0.25, 24)) continue;
4610
- pushLayoutFinding(findings, seen, {
4611
- selector: selector(el),
4612
- kind: "overlapping-text",
4613
- overflowPx: 0,
4614
- viewportWidth,
4615
- severity: "error"
4616
- });
4617
- break;
4679
+ const fragments = elementLineFragments(el);
4680
+ let flagged = false;
4681
+ for (const rect of fragments) {
4682
+ if (flagged) break;
4683
+ if (rectArea(rect) < 16) continue;
4684
+ const points = [
4685
+ { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
4686
+ { x: rect.left + Math.min(4, rect.width / 2), y: rect.top + Math.min(4, rect.height / 2) },
4687
+ { x: rect.right - Math.min(4, rect.width / 2), y: rect.bottom - Math.min(4, rect.height / 2) }
4688
+ ];
4689
+ for (const point of points) {
4690
+ if (point.x < 0 || point.y < 0 || point.x > viewportWidth || point.y > window.innerHeight) continue;
4691
+ const top = document.elementFromPoint(point.x, point.y);
4692
+ if (!(top instanceof Element) || top === el || el.contains(top) || top.contains(el) || isLavishUi(top))
4693
+ continue;
4694
+ if (hasIntentionalHorizontalScrollerAncestor(top)) continue;
4695
+ if (getComputedStyle(top).position !== "static") continue;
4696
+ if (!fragmentsSignificantlyOverlap([rect], elementLineFragments(top))) continue;
4697
+ pushLayoutFinding(findings, seen, {
4698
+ selector: selector(el),
4699
+ kind: "overlapping-text",
4700
+ overflowPx: 0,
4701
+ viewportWidth,
4702
+ // Heuristic and sampling-based even after fragment-aware matching, so it stays a
4703
+ // warning rather than holding the open-time gate the way a real clip/overflow does.
4704
+ severity: "warning"
4705
+ });
4706
+ flagged = true;
4707
+ break;
4708
+ }
4618
4709
  }
4619
4710
  }
4620
4711
  }
@@ -4633,7 +4724,9 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4633
4724
  });
4634
4725
  }
4635
4726
  const elements = collectLayoutAuditElements();
4636
- for (const el of elements) auditElementOverflow(el, viewportWidth, findings, seen);
4727
+ const spillCandidates = [];
4728
+ for (const el of elements) auditElementOverflow(el, viewportWidth, findings, seen, spillCandidates);
4729
+ resolveSpillCandidates(spillCandidates, findings, seen);
4637
4730
  auditOverlappingText(elements, viewportWidth, findings, seen);
4638
4731
  return findings;
4639
4732
  }
@@ -4918,6 +5011,7 @@ var SessionStore = class {
4918
5011
  pending_prompts: existing.pending_prompts || 0,
4919
5012
  prompts: existingPrompts,
4920
5013
  layout_warnings: [],
5014
+ delivered_layout_warning_keys: existing.delivered_layout_warning_keys || [],
4921
5015
  dom_snapshot: existing.dom_snapshot || "",
4922
5016
  chat: existing.chat || [],
4923
5017
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -4933,13 +5027,16 @@ var SessionStore = class {
4933
5027
  return null;
4934
5028
  }
4935
5029
  const prompts = Array.isArray(payload.prompts) ? payload.prompts : [];
5030
+ const shouldEndSession = Boolean(payload.endSession || payload.end_session);
5031
+ const alreadyEnded = session.status === "ended";
4936
5032
  const normalizedPrompts = prompts.map(normalizePrompt);
4937
5033
  const userMessages = normalizedPrompts.filter((prompt) => prompt.tag === "message" && prompt.prompt).map((prompt) => ({ role: "user", text: prompt.prompt, at: (/* @__PURE__ */ new Date()).toISOString() }));
4938
5034
  session.prompts = [...session.prompts || [], ...normalizedPrompts];
4939
5035
  session.chat = [...session.chat || [], ...userMessages];
4940
5036
  session.pending_prompts = session.prompts.length;
4941
5037
  session.dom_snapshot = String(payload.domSnapshot || payload.dom_snapshot || "");
4942
- session.status = "feedback";
5038
+ session.status = shouldEndSession || alreadyEnded ? "ended" : "feedback";
5039
+ if (shouldEndSession) session.ended_by = "user";
4943
5040
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
4944
5041
  await this.writeState(state);
4945
5042
  return session;
@@ -4950,13 +5047,23 @@ var SessionStore = class {
4950
5047
  if (!session) {
4951
5048
  return null;
4952
5049
  }
4953
- const layoutWarnings = normalizeLayoutWarnings(payload.layout_warnings || payload.layoutWarnings || []);
5050
+ const deliveredWarningKeys = session.delivered_layout_warning_keys || [];
5051
+ const deliveredKeys = new Set(deliveredWarningKeys);
5052
+ const layoutWarnings = normalizeLayoutWarnings(
5053
+ payload.layout_warnings || payload.layoutWarnings || [],
5054
+ deliveredKeys
5055
+ );
5056
+ const activeWarningKeys = new Set(layoutWarnings.map(layoutWarningKey));
5057
+ const nextDeliveredWarningKeys = deliveredWarningKeys.filter((key2) => activeWarningKeys.has(key2)).slice(-200);
5058
+ const deliveredKeysChanged = nextDeliveredWarningKeys.length !== deliveredWarningKeys.length || nextDeliveredWarningKeys.some((key2, index) => key2 !== deliveredWarningKeys[index]);
4954
5059
  const previousSignature = JSON.stringify(session.layout_warnings || []);
4955
5060
  const nextSignature = JSON.stringify(layoutWarnings);
4956
- if (previousSignature === nextSignature) {
5061
+ const warningsChanged = previousSignature !== nextSignature;
5062
+ if (!warningsChanged && !deliveredKeysChanged) {
4957
5063
  return { session, changed: false, hasWarnings: layoutWarnings.length > 0 };
4958
5064
  }
4959
5065
  session.layout_warnings = layoutWarnings;
5066
+ session.delivered_layout_warning_keys = nextDeliveredWarningKeys;
4960
5067
  if (layoutWarnings.length > 0 && session.status !== "ended") {
4961
5068
  session.status = "feedback";
4962
5069
  } else if ((session.prompts || []).length === 0 && session.status !== "ended") {
@@ -4964,7 +5071,7 @@ var SessionStore = class {
4964
5071
  }
4965
5072
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
4966
5073
  await this.writeState(state);
4967
- return { session, changed: true, hasWarnings: layoutWarnings.length > 0 };
5074
+ return { session, changed: warningsChanged, hasWarnings: layoutWarnings.length > 0 };
4968
5075
  }
4969
5076
  async takeFeedback(key) {
4970
5077
  const state = await this.readState();
@@ -4974,33 +5081,48 @@ var SessionStore = class {
4974
5081
  }
4975
5082
  const prompts = session.prompts || [];
4976
5083
  const layoutWarnings = session.layout_warnings || [];
5084
+ const alreadyEnded = session.status === "ended";
4977
5085
  if (prompts.length === 0 && layoutWarnings.length === 0) {
4978
- return session.status === "ended" ? { status: "ended" } : { status: "waiting" };
5086
+ return alreadyEnded ? { status: "ended", ended_by: session.ended_by } : { status: "waiting" };
4979
5087
  }
4980
5088
  const result = {
4981
5089
  status: "feedback",
4982
5090
  dom_snapshot: session.dom_snapshot || "",
4983
5091
  prompts,
4984
- ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {}
5092
+ ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {},
5093
+ // This is the final delivery before the session shows as ended - flag it so the agent
5094
+ // knows not to expect (or force) a reopened browser afterward.
5095
+ ...alreadyEnded ? { session_ended: true, ended_by: session.ended_by } : {}
4985
5096
  };
4986
5097
  session.prompts = [];
4987
5098
  session.layout_warnings = [];
4988
5099
  session.pending_prompts = 0;
4989
5100
  session.dom_snapshot = "";
4990
- if (session.status !== "ended") {
5101
+ if (layoutWarnings.length > 0) {
5102
+ const deliveredKeys = new Set(session.delivered_layout_warning_keys || []);
5103
+ for (const warning of layoutWarnings) deliveredKeys.add(layoutWarningKey(warning));
5104
+ session.delivered_layout_warning_keys = [...deliveredKeys].slice(-200);
5105
+ }
5106
+ if (!alreadyEnded) {
4991
5107
  session.status = "open";
4992
5108
  }
4993
5109
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
4994
5110
  await this.writeState(state);
4995
5111
  return result;
4996
5112
  }
4997
- async endSession(key) {
5113
+ // `endedBy` distinguishes a human ending review from the browser chrome ("user") from an
5114
+ // agent explicitly closing the loop via `lavish-axi end` ("agent"). Only a user-initiated end
5115
+ // blocks a plain reopen - see `SessionStore` callers in server.js.
5116
+ async endSession(key, endedBy = "agent") {
4998
5117
  const state = await this.readState();
4999
5118
  const session = state.sessions[key];
5000
5119
  if (!session) {
5001
5120
  return null;
5002
5121
  }
5122
+ const existingEndedBy = session.status === "ended" ? session.ended_by : void 0;
5123
+ const nextEndedBy = endedBy === "user" || existingEndedBy === "user" ? "user" : "agent";
5003
5124
  session.status = "ended";
5125
+ session.ended_by = nextEndedBy;
5004
5126
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
5005
5127
  await this.writeState(state);
5006
5128
  return session;
@@ -5052,15 +5174,23 @@ function normalizePrompt(prompt) {
5052
5174
  if (target) normalized.target = target;
5053
5175
  return normalized;
5054
5176
  }
5055
- function normalizeLayoutWarnings(layoutWarnings) {
5177
+ function layoutWarningKey(warning) {
5178
+ return `${warning.kind}:${warning.selector}`;
5179
+ }
5180
+ function normalizeLayoutWarnings(layoutWarnings, deliveredKeys = /* @__PURE__ */ new Set()) {
5056
5181
  if (!Array.isArray(layoutWarnings)) return [];
5057
- return layoutWarnings.filter((warning) => warning && typeof warning === "object" && !Array.isArray(warning)).map((warning) => ({
5058
- selector: String(warning.selector || ""),
5059
- kind: String(warning.kind || "layout-warning"),
5060
- overflowPx: normalizeFiniteNumber(warning.overflowPx),
5061
- viewportWidth: normalizeFiniteNumber(warning.viewportWidth),
5062
- severity: warning.severity === "warning" ? "warning" : "error"
5063
- }));
5182
+ return layoutWarnings.filter((warning) => warning && typeof warning === "object" && !Array.isArray(warning)).map((warning) => {
5183
+ const selector = String(warning.selector || "");
5184
+ const kind = String(warning.kind || "layout-warning");
5185
+ return {
5186
+ selector,
5187
+ kind,
5188
+ overflowPx: normalizeFiniteNumber(warning.overflowPx),
5189
+ viewportWidth: normalizeFiniteNumber(warning.viewportWidth),
5190
+ severity: warning.severity === "warning" ? "warning" : "error",
5191
+ persistent: deliveredKeys.has(layoutWarningKey({ kind, selector }))
5192
+ };
5193
+ });
5064
5194
  }
5065
5195
  function normalizeFiniteNumber(value) {
5066
5196
  const number = Number(value);
@@ -5139,9 +5269,15 @@ async function serve({
5139
5269
  try {
5140
5270
  const file = await canonicalFile(req.body.file);
5141
5271
  const key = sessionKey(file);
5272
+ const reopen = Boolean(req.body.reopen);
5273
+ const existing = await store.findByKey(key);
5274
+ if (existing?.status === "ended" && existing.ended_by === "user" && !reopen) {
5275
+ logEvent?.(`session open blocked (user-ended) key=${key} file=${file}`);
5276
+ res.json({ key, file, url: existing.url, status: "user-ended" });
5277
+ return;
5278
+ }
5142
5279
  const sessionUrl = `http://${hostForUrl(linkHostName)}:${publicPort}/session/${key}`;
5143
5280
  const url = shouldDisableLayoutGateOpen(req.body || {}) ? appendNoGateParam(sessionUrl) : sessionUrl;
5144
- const existing = await store.findByKey(key);
5145
5281
  const session = await store.upsertSession(file, sessionUrl);
5146
5282
  if (existing?.status === "ended") {
5147
5283
  clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
@@ -5227,13 +5363,16 @@ async function serve({
5227
5363
  });
5228
5364
  app.post("/api/:key/prompts", async (req, res, next) => {
5229
5365
  try {
5366
+ const shouldEndSession = Boolean(req.body?.endSession || req.body?.end_session);
5230
5367
  const session = await store.queuePrompts(req.params.key, req.body || {});
5231
5368
  if (!session) {
5232
5369
  res.status(404).json({ error: "session not found" });
5233
5370
  return;
5234
5371
  }
5235
- events.emit("feedback", req.params.key);
5372
+ if (shouldEndSession) clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
5373
+ events.emit(shouldEndSession ? "ended" : "feedback", req.params.key);
5236
5374
  res.json({ status: "queued", pending_prompts: session.pending_prompts });
5375
+ if (shouldEndSession) await shutdownIfNoLiveSessions();
5237
5376
  } catch (error) {
5238
5377
  next(error);
5239
5378
  }
@@ -5255,7 +5394,7 @@ async function serve({
5255
5394
  });
5256
5395
  app.post("/api/:key/end", async (req, res, next) => {
5257
5396
  try {
5258
- await store.endSession(req.params.key);
5397
+ await store.endSession(req.params.key, "user");
5259
5398
  clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
5260
5399
  events.emit("ended", req.params.key);
5261
5400
  res.json({ status: "ended" });
@@ -5342,7 +5481,7 @@ async function serve({
5342
5481
  try {
5343
5482
  const file = await canonicalFile(req.body.file);
5344
5483
  const key = sessionKey(file);
5345
- await store.endSession(key);
5484
+ await store.endSession(key, "agent");
5346
5485
  clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
5347
5486
  events.emit("ended", key);
5348
5487
  res.json({ status: "ended" });
@@ -5818,6 +5957,10 @@ const key=${JSON.stringify(key)};
5818
5957
  void key;
5819
5958
  const deriveQueueKey=${deriveLavishQueueKey.toString()};
5820
5959
  const isNativeInteractiveControl=${isNativeInteractiveControl.toString()};
5960
+ const fragmentsSignificantlyOverlap=${fragmentsSignificantlyOverlap.toString()};
5961
+ const resolveVisibleSpillCandidates=${resolveVisibleSpillCandidates.toString()};
5962
+ const classifyHorizontalOverflow=${classifyHorizontalOverflow.toString()};
5963
+ const classifyVerticalOverflow=${classifyVerticalOverflow.toString()};
5821
5964
  (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl);
5822
5965
  })();`;
5823
5966
  }
@@ -6004,7 +6147,7 @@ function normalizePagePath(path6) {
6004
6147
  var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup", "export", "share"]);
6005
6148
  var RESERVED = new Set(RESERVED_COMMANDS);
6006
6149
  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`.";
6007
- var VERSION = "0.1.33";
6150
+ var VERSION = "0.1.35";
6008
6151
  async function run(argv) {
6009
6152
  await ensureStateDir();
6010
6153
  const normalizedArgv = normalizeArgv(argv);
@@ -6096,11 +6239,11 @@ function createHomeOutput({ bin, sessions, includeSessions = true }) {
6096
6239
  ],
6097
6240
  playbooks: listPlaybooks(),
6098
6241
  help: [
6099
- "Run `lavish-axi <html-file>` to open or resume a Lavish Editor session",
6242
+ "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",
6100
6243
  "Unless the user specifies another location, create HTML artifacts in the current working directory under `.lavish/`",
6101
6244
  "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",
6102
- "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 layout_warnings before involving the human. 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",
6103
- "Run `lavish-axi end <html-file>` to end a session",
6245
+ "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",
6246
+ "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`",
6104
6247
  "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",
6105
6248
  "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",
6106
6249
  "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)",
@@ -6129,7 +6272,13 @@ function createPlaybookOutput(args) {
6129
6272
  function createOpenOutput({ file, url, status }) {
6130
6273
  return {
6131
6274
  session: { file, url, status },
6132
- 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, fix overflow, clipped text, or overlapping unreadable content and re-check before involving the human. 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.`
6275
+ 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\`.`
6276
+ };
6277
+ }
6278
+ function createUserEndedOpenOutput({ file, url }) {
6279
+ return {
6280
+ session: { file, url, status: "user-ended" },
6281
+ 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\`.`
6133
6282
  };
6134
6283
  }
6135
6284
  async function openCommand(args) {
@@ -6140,8 +6289,12 @@ async function openCommand(args) {
6140
6289
  await assertHtmlFile(file);
6141
6290
  const absolute = await canonicalFile(file);
6142
6291
  const noGate = args.includes("--no-gate");
6292
+ const reopen = args.includes("--reopen");
6143
6293
  const baseUrl = await ensureServer({ forceRestart: shouldForceRestartForLocalBuild(process.argv[1] || "") });
6144
- const response = await postJson(`${baseUrl}/api/sessions`, { file: absolute, noGate });
6294
+ const response = await postJson(`${baseUrl}/api/sessions`, { file: absolute, noGate, reopen });
6295
+ if (response.status === "user-ended") {
6296
+ return createUserEndedOpenOutput({ file: absolute, url: response.url });
6297
+ }
6145
6298
  if (shouldOpenBrowser(args, process.env)) {
6146
6299
  try {
6147
6300
  const open = (await import("open")).default;
@@ -6229,26 +6382,66 @@ function createPollOutput({ file, response }) {
6229
6382
  }
6230
6383
  if (response.status === "feedback") {
6231
6384
  const layoutWarnings = Array.isArray(response.layout_warnings) ? response.layout_warnings : [];
6385
+ const sessionEnded = Boolean(response.session_ended);
6386
+ const endedBy = typeof response.ended_by === "string" ? response.ended_by : void 0;
6232
6387
  return {
6233
- session: { file, status: "feedback" },
6388
+ session: {
6389
+ file,
6390
+ status: "feedback",
6391
+ ...sessionEnded ? { session_ended: true, ...endedBy ? { ended_by: endedBy } : {} } : {}
6392
+ },
6234
6393
  dom_snapshot: response.dom_snapshot || "",
6235
6394
  prompts: response.prompts || [],
6236
6395
  ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {},
6237
- next_step: createFeedbackNextStep(file, layoutWarnings.length)
6396
+ next_step: createFeedbackNextStep(file, layoutWarnings, sessionEnded, endedBy)
6238
6397
  };
6239
6398
  }
6240
6399
  if (response.status === "ended") {
6241
- return { session: { file, status: "ended" } };
6400
+ return {
6401
+ session: { file, status: "ended", ...response.ended_by ? { ended_by: response.ended_by } : {} },
6402
+ next_step: createEndedNextStep(file, response.ended_by)
6403
+ };
6242
6404
  }
6243
6405
  return {
6244
6406
  session: { file, status: response.status || "waiting" },
6245
6407
  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.`
6246
6408
  };
6247
6409
  }
6248
- function createFeedbackNextStep(file, layoutWarningCount) {
6249
- const layoutPrefix = layoutWarningCount > 0 ? `${layoutWarningCount} layout warning${layoutWarningCount === 1 ? "" : "s"} detected - fix horizontal overflow, clipped text, or overlapping unreadable content in ${file}, then reload or re-open the artifact and re-check before involving the human. ` : `Apply the requested changes to ${file}. `;
6410
+ function createFeedbackNextStep(file, layoutWarnings, sessionEnded, endedBy) {
6411
+ const count = layoutWarnings.length;
6412
+ if (sessionEnded) {
6413
+ const layoutNote = count > 0 ? `${count} layout warning${count === 1 ? "" : "s"} arrived alongside this final feedback. ` : "";
6414
+ if (endedBy === "user") {
6415
+ 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.`;
6416
+ }
6417
+ 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.`;
6418
+ }
6419
+ const layoutPrefix = count > 0 ? layoutWarningsPrefix(file, layoutWarnings) : `Apply the requested changes to ${file}. `;
6250
6420
  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.`;
6251
6421
  }
6422
+ function layoutWarningsPrefix(file, layoutWarnings) {
6423
+ const count = layoutWarnings.length;
6424
+ const plural = count === 1 ? "" : "s";
6425
+ const allPersistent = layoutWarnings.every((warning) => warning.persistent);
6426
+ const allLowSeverity = layoutWarnings.every((warning) => warning.severity !== "error");
6427
+ const allRepeatOrLowSeverity = layoutWarnings.every((warning) => warning.persistent || warning.severity !== "error");
6428
+ if (allPersistent) {
6429
+ return `${count} layout warning${plural} detected, and every one was already reported in a prior poll and is still unresolved - if you already attempted a fix, it is fine to proceed to the human with a short note about what remains instead of looping further edits and reloads. `;
6430
+ }
6431
+ if (allLowSeverity) {
6432
+ return `${count} low-severity layout warning${plural} detected (no error-severity findings) - fix them if the cause is obvious in ${file}, otherwise it is fine to proceed to the human with a note instead of iterating further. `;
6433
+ }
6434
+ if (allRepeatOrLowSeverity) {
6435
+ 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. `;
6436
+ }
6437
+ 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. `;
6438
+ }
6439
+ function createEndedNextStep(file, endedBy) {
6440
+ if (endedBy === "user") {
6441
+ 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.`;
6442
+ }
6443
+ 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.`;
6444
+ }
6252
6445
  async function endCommand(args) {
6253
6446
  const file = firstPositionalArg(args);
6254
6447
  if (!file) {
@@ -6771,7 +6964,7 @@ var TOP_LEVEL_HELP = `lavish-axi - Lavish Editor AXI
6771
6964
 
6772
6965
  Usage:
6773
6966
  lavish-axi
6774
- lavish-axi <html-file> [--no-open] [--no-gate]
6967
+ lavish-axi <html-file> [--no-open] [--no-gate] [--reopen]
6775
6968
  lavish-axi poll <html-file> [--agent-reply "..."]
6776
6969
  lavish-axi end <html-file>
6777
6970
  lavish-axi export <html-file> [--out <path>]
@@ -6783,21 +6976,21 @@ Usage:
6783
6976
 
6784
6977
  ${DESIGN_SYSTEM_HINT}
6785
6978
 
6786
- 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 layout_warnings before involving the human. 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.
6979
+ 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.
6787
6980
 
6788
6981
  `;
6789
6982
  var COMMAND_HELP = {
6790
- open: `Usage: lavish-axi <html-file> [--no-open] [--no-gate]
6983
+ open: `Usage: lavish-axi <html-file> [--no-open] [--no-gate] [--reopen]
6791
6984
 
6792
- 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.
6985
+ 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.
6793
6986
  `,
6794
6987
  poll: `Usage: lavish-axi poll <html-file> [--agent-reply "..."]
6795
6988
 
6796
- 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 layout_warnings before involving the human. 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.
6989
+ 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.
6797
6990
  `,
6798
6991
  end: `Usage: lavish-axi end <html-file>
6799
6992
 
6800
- End a Lavish Editor session.
6993
+ 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.
6801
6994
  `,
6802
6995
  export: `Usage: lavish-axi export <html-file> [--out <path>]
6803
6996
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
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",
@@ -34,9 +34,10 @@ Use lavish-axi when the user asks for a visual artifact, HTML explainer, interac
34
34
  3. Run `npx -y lavish-axi poll <html-file>` to long-poll for the user's annotations, queued prompts, and browser-reported `layout_warnings`.
35
35
  The poll stays silent until the user acts or the real browser reports fresh layout warnings - leave it running, never kill it.
36
36
  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.
37
- 4. If poll returns `layout_warnings`, fix overflow, clipped text, or overlapping unreadable content and re-check before involving the human.
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 layout_warnings before involving the human. 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)