lavish-axi 0.1.64 → 0.1.66

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
@@ -199,6 +199,7 @@ pnpm link
199
199
  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.
200
200
  On wider screens, queued annotation preview pills and chat history share a scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
201
201
  The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session), while the composer exposes **Send & End** beside **Send to Agent** to submit queued prompts and user-ended attribution together.
202
+ Composer feedback is visibly queued before the text box clears and stays queued until acknowledged, with persistent recovery guidance if sending fails or takes longer than 10 seconds.
202
203
  - **Reviewing on a phone** - Below 860px wide, the artifact takes the whole screen above a **Conversation** dock, and the conversation opens as a bottom sheet over it: tap the dock, swipe it up, or press the chevron to raise it; tap the dimmed artifact, swipe the sheet down, press the chevron, or press Escape to lower it. The dock reports what matters while the sheet is down - how many prompts are queued, a reply that arrived while you were reading, or whether the agent is listening - and the sheet stays open across a reload of the review page. The sheet sizes itself to the visible viewport and respects safe-area insets; if the keyboard or attachments leave little room, conversation content yields or scrolls while the send actions remain pinned above the bottom edge. In landscape the sheet covers the top bar as well. Wider screens keep the side-by-side layout.
203
204
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
204
205
  In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it.
@@ -234,6 +235,7 @@ pnpm link
234
235
  - **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.
235
236
  Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
236
237
  - **Server upgrades** - One background server serves every session, so upgrading `lavish-axi` while reviews are open makes the next `lavish-axi <html-file>` replace that server. Only the review page for the artifact being opened reloads itself once the replacement answers - and not even that one while you have unsent annotation text open, which gets the same banner instead so the reload is yours to make. Every other open review page keeps working and shows a banner reading "Lavish was updated. This page is running the previous version.", with a Check and reload button and a Dismiss button, so no page you are reading reloads on its own. After `lavish-axi stop` those pages say Lavish was stopped and to reload after you start it again, and a restart that only picks up a local build says that rather than claiming an update.
238
+ A page still using the legacy event stream reloads once when it reconnects to the replacement server, moving itself onto the current transport without retaining an HTTP connection. If unsent annotation text is open, it shows the neutral server-restarted banner instead and waits for you to reload.
237
239
  Every **Check and reload** control asks the server whether it is running before it navigates; while nothing answers, the page stays where it is and says so, and a check that gets no answer at all says that instead of guessing.
238
240
  In-flight `lavish-axi poll` commands end with an interrupted-poll error and are safe to re-run; queued feedback is never lost, and annotation text you have typed but not queued yet survives the reload as described under **Live reload**.
239
241
  A page waits for the replacement rather than reloading into a port nothing is listening on, and tells the user to restart Lavish if it never returns.
@@ -1,4 +1,4 @@
1
- /* global EventSource, document, location, window */
1
+ /* global document, location, window */
2
2
 
3
3
  const sessionDataElement = document.getElementById("lavish-session");
4
4
  const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
@@ -216,6 +216,8 @@ let endAfterSubmit = false;
216
216
  let workingBubble = null;
217
217
  let submitQueuedPromise = null;
218
218
  let submitQueuedAgain = false;
219
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
220
+ let sendAcknowledgementTimer;
219
221
  let lastScroll = { x: 0, y: 0 };
220
222
  // In-iframe review context (an open annotation card's unsent text, Lavish-owned question
221
223
  // answers). The sandbox means the chrome cannot read it back after a reload, so the SDK reports
@@ -243,6 +245,14 @@ const CHROME_RESTART_PROBE_MS = 100;
243
245
  const CHROME_RESTART_SLOW_PROBE_MS = 500;
244
246
  // A probe must always settle, so the control that is waiting on it always comes back.
245
247
  const HEALTH_PROBE_TIMEOUT_MS = 4000;
248
+ // A send starts before the artifact snapshot arrives and ends only when /prompts acknowledges the
249
+ // batch. Bound that whole wait so a missing SDK response or a browser/network stall can never look
250
+ // like a dead button while the user's queue remains safely stored in this tab.
251
+ const SEND_ACKNOWLEDGEMENT_WARNING_MS = 10_000;
252
+ const SEND_STALLED_COPY =
253
+ "Still trying to send. Your feedback is saved in this tab. Keep this tab open while Lavish catches up, and check that the server is running.";
254
+ const SEND_FAILED_COPY =
255
+ "Could not send. Your feedback is still queued in this tab. Check that Lavish is running, then click Send to Agent to retry.";
246
256
  const HEALTH_NO_ANSWER_TITLE = "Lavish did not answer.";
247
257
  const HEALTH_NO_ANSWER_COPY =
248
258
  "Lavish did not answer the check, so this page cannot tell whether it is running. Try again in a moment.";
@@ -263,6 +273,7 @@ let artifactSilenceTimer;
263
273
  let copyHintTimer;
264
274
  /** @type {ReturnType<typeof setTimeout> | undefined} */
265
275
  let sendHintTimer;
276
+ let sendHintPersistent = false;
266
277
 
267
278
  function artifactFrameSrcForLoad(load) {
268
279
  const separator = artifactSrc.includes("?") ? "&" : "?";
@@ -476,20 +487,50 @@ function pillAttachmentsHtml(prompt) {
476
487
 
477
488
  const DEFAULT_SEND_HINT = "Write a message or annotate an element first.";
478
489
 
479
- function showSendHint(message = DEFAULT_SEND_HINT, holdMs = 2600) {
490
+ function showSendHint(message = DEFAULT_SEND_HINT, holdMs = 2600, focusInput = true) {
480
491
  sendHint.textContent = message;
481
492
  sendHint.hidden = false;
482
493
  clearTimeout(sendHintTimer);
494
+ sendHintPersistent = holdMs === null;
495
+ sendHint.classList.toggle("persistent", sendHintPersistent);
496
+ if (sendHintPersistent) {
497
+ sendHintTimer = undefined;
498
+ if (focusInput) chatInput.focus();
499
+ return;
500
+ }
483
501
  sendHintTimer = setTimeout(() => {
484
502
  sendHint.hidden = true;
485
503
  sendHint.textContent = DEFAULT_SEND_HINT;
504
+ sendHintPersistent = false;
486
505
  }, holdMs);
487
- chatInput.focus();
506
+ if (focusInput) chatInput.focus();
488
507
  }
489
508
 
490
- function hideSendHint() {
509
+ function hideSendHint(force = false) {
510
+ if (sendHintPersistent && force !== true) return;
491
511
  clearTimeout(sendHintTimer);
492
512
  sendHint.hidden = true;
513
+ sendHint.textContent = DEFAULT_SEND_HINT;
514
+ sendHint.classList.remove("persistent");
515
+ sendHintPersistent = false;
516
+ }
517
+
518
+ function armSendAcknowledgementWarning() {
519
+ if (sendAcknowledgementTimer || !queued.length) return;
520
+ sendAcknowledgementTimer = setTimeout(() => {
521
+ sendAcknowledgementTimer = undefined;
522
+ if (queued.length) showSendHint(SEND_STALLED_COPY, null, false);
523
+ }, SEND_ACKNOWLEDGEMENT_WARNING_MS);
524
+ }
525
+
526
+ function clearSendAcknowledgementWarning() {
527
+ clearTimeout(sendAcknowledgementTimer);
528
+ sendAcknowledgementTimer = undefined;
529
+ }
530
+
531
+ function showQueuedSendFailure(message = SEND_FAILED_COPY) {
532
+ clearSendAcknowledgementWarning();
533
+ if (queued.length) showSendHint(message, null, false);
493
534
  }
494
535
 
495
536
  function setMenuOpen(button, menu, open) {
@@ -583,15 +624,6 @@ function setHandoffSuperseded(visible) {
583
624
  // the shutdown names its reason and each one gets its own line - a page told "Lavish was updated"
584
625
  // after a deliberate stop is being told something false. An unnamed reason (SIGTERM, or any
585
626
  // caller that names none) claims neither.
586
- // Both shutdown events carry the reason the same way, so both render from one rule.
587
- function shutdownEventReason(event) {
588
- try {
589
- return String(JSON.parse(event?.data || "{}").reason || "");
590
- } catch {
591
- return "";
592
- }
593
- }
594
-
595
627
  function chromeOutdatedCopy(reason) {
596
628
  if (reason === "upgrade") return "Lavish was updated. This page is running the previous version.";
597
629
  if (reason === "local-build") {
@@ -909,6 +941,10 @@ function removeQueuedPrompt(index, event) {
909
941
  if (event) event.stopPropagation();
910
942
  queued.splice(index, 1);
911
943
  persistQueuedPrompts();
944
+ if (!queued.length) {
945
+ clearSendAcknowledgementWarning();
946
+ hideSendHint(true);
947
+ }
912
948
  render();
913
949
  }
914
950
 
@@ -1170,17 +1206,20 @@ function sendQueued(endAfter) {
1170
1206
  if (attachments.length) prompt.attachments = attachments;
1171
1207
  queued.push(prompt);
1172
1208
  persistQueuedPrompts();
1209
+ // Render the durable queue pill before clearing the editor. If anything after this point
1210
+ // fails, the user's words are already both stored and visibly recoverable in the tab.
1211
+ render();
1173
1212
  addChat("user", text || "Image message");
1174
1213
  chatInput.value = "";
1175
1214
  chatAttachmentController.reset();
1176
- render();
1177
1215
  }
1178
1216
  }
1179
1217
  if (!queued.length) {
1180
1218
  if (!chipsBlocked) showSendHint();
1181
1219
  return;
1182
1220
  }
1183
- hideSendHint();
1221
+ hideSendHint(true);
1222
+ armSendAcknowledgementWarning();
1184
1223
 
1185
1224
  if (endAfter && !chipsBlocked) endAfterSubmit = true;
1186
1225
  requestSnapshot("submit");
@@ -1220,24 +1259,34 @@ async function submitQueuedOnce() {
1220
1259
  const shouldEndSession = endAfterSubmit;
1221
1260
  const body = { prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot };
1222
1261
  if (shouldEndSession) body.endSession = true;
1223
- const response = await fetch("/api/" + key + "/prompts", {
1224
- method: "POST",
1225
- headers: { "content-type": "application/json" },
1226
- body: JSON.stringify(body),
1227
- });
1262
+ let response;
1263
+ try {
1264
+ response = await fetch("/api/" + key + "/prompts", {
1265
+ method: "POST",
1266
+ headers: { "content-type": "application/json" },
1267
+ body: JSON.stringify(body),
1268
+ });
1269
+ } catch (error) {
1270
+ showQueuedSendFailure();
1271
+ throw error;
1272
+ }
1228
1273
  if (!response.ok) {
1229
1274
  if (response.status === 409) {
1230
1275
  const data = await response.json().catch(() => null);
1231
1276
  // The session already ended before this batch arrived - most likely this chrome missed the
1232
- // SSE `ended` event (a dropped connection). Go read-only now instead of leaving Send enabled
1277
+ // live `ended` event (a dropped connection). Go read-only now instead of leaving Send enabled
1233
1278
  // for another attempt that will be refused the same way.
1234
1279
  if (data?.status === "ended") {
1280
+ clearSendAcknowledgementWarning();
1235
1281
  endAfterSubmit = false;
1236
1282
  markSessionEnded();
1237
1283
  return false;
1238
1284
  }
1239
1285
  if (Array.isArray(data?.warnings)) setLayoutWarnings(data.warnings);
1240
1286
  endAfterSubmit = false;
1287
+ showQueuedSendFailure(
1288
+ "Could not send because the layout issue selection changed. Your feedback is still queued. Review the current issues, then click Send to Agent to retry.",
1289
+ );
1241
1290
  return false;
1242
1291
  }
1243
1292
  // C4: the server persisted nothing (atomic reject) - the queue below is left
@@ -1246,8 +1295,12 @@ async function submitQueuedOnce() {
1246
1295
  if (response.status === 400) {
1247
1296
  const detail = await response.json().catch(() => ({}));
1248
1297
  if (Array.isArray(detail.rejected) && detail.rejected.length) {
1249
- showSendHint(describeAttachmentRejection(detail.rejected, detail.caps), 6000);
1298
+ showQueuedSendFailure(describeAttachmentRejection(detail.rejected, detail.caps));
1299
+ } else {
1300
+ showQueuedSendFailure();
1250
1301
  }
1302
+ } else {
1303
+ showQueuedSendFailure();
1251
1304
  }
1252
1305
  throw new Error("failed to submit queued prompts");
1253
1306
  }
@@ -1257,6 +1310,9 @@ async function submitQueuedOnce() {
1257
1310
  }
1258
1311
  persistQueuedPrompts();
1259
1312
  render();
1313
+ clearSendAcknowledgementWarning();
1314
+ hideSendHint(true);
1315
+ if (queued.length) armSendAcknowledgementWarning();
1260
1316
  if (shouldEndSession) {
1261
1317
  endAfterSubmit = false;
1262
1318
  markSessionEnded();
@@ -3178,7 +3234,7 @@ chatComposer.addEventListener("drop", (event) => {
3178
3234
  chatAttachmentController.rejectUnsupported(files);
3179
3235
  });
3180
3236
  // A file drop that misses the composer must not navigate the chrome away from
3181
- // the session (losing chips, uploads, and the SSE connection). Text drags stay
3237
+ // the session (losing chips, uploads, and the live-event connection). Text drags stay
3182
3238
  // untouched so dropping text into the textarea keeps working.
3183
3239
  document.addEventListener("dragover", (event) => {
3184
3240
  if (Array.from(event.dataTransfer?.types || []).includes("Files")) event.preventDefault();
@@ -3199,7 +3255,7 @@ chatInput.addEventListener("keydown", (event) => {
3199
3255
  sendQueued(false);
3200
3256
  }
3201
3257
  });
3202
- chatInput.addEventListener("input", hideSendHint);
3258
+ chatInput.addEventListener("input", () => hideSendHint());
3203
3259
  copyPathButton.onclick = copyFilePath;
3204
3260
  reloadArtifactButton.onclick = reloadArtifact;
3205
3261
  copySnapshotButton.onclick = copyDomSnapshot;
@@ -3278,27 +3334,48 @@ frame.addEventListener("load", () => {
3278
3334
 
3279
3335
  initializeLayoutGate();
3280
3336
 
3281
- const events = new EventSource("/events/" + key);
3282
- events.addEventListener("reload", () => {
3337
+ // WebSockets leave the browser's HTTP connection pool free for sends and artifact loads.
3338
+ const events = new Map();
3339
+ let eventReconnectDelayMs = 500;
3340
+ function connectLiveEvents() {
3341
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
3342
+ const socket = new WebSocket(protocol + "//" + location.host + "/events/" + encodeURIComponent(key));
3343
+ socket.addEventListener("open", () => {
3344
+ eventReconnectDelayMs = 500;
3345
+ refreshLayoutWarnings();
3346
+ });
3347
+ socket.addEventListener("message", (message) => {
3348
+ try {
3349
+ const { type, data } = JSON.parse(message.data);
3350
+ return events.get(type)?.(data || {});
3351
+ } catch {
3352
+ // Ignore malformed frames; a later event or reconnect can recover the stream.
3353
+ }
3354
+ });
3355
+ socket.addEventListener("close", () => {
3356
+ setTimeout(connectLiveEvents, eventReconnectDelayMs);
3357
+ eventReconnectDelayMs = Math.min(eventReconnectDelayMs * 2, 5000);
3358
+ });
3359
+ }
3360
+
3361
+ events.set("reload", () => {
3283
3362
  resetFrame().then((reloaded) => {
3284
3363
  if (reloaded) refreshWhiteboardSource();
3285
3364
  });
3286
3365
  });
3287
- events.addEventListener("chrome-reload", (event) => reloadAfterServerRestart(shutdownEventReason(event)));
3366
+ events.set("chrome-reload", (data) => reloadAfterServerRestart(String(data.reason || "")));
3288
3367
  // The replacement server serves a different artifact's review. This page keeps working against
3289
3368
  // it; it is only running the previous version of the chrome, which is the user's to act on.
3290
- events.addEventListener("chrome-outdated", (event) => setChromeOutdated(true, shutdownEventReason(event)));
3291
- events.addEventListener("agent-reply", (event) => {
3292
- const text = JSON.parse(event.data).text;
3369
+ events.set("chrome-outdated", (data) => setChromeOutdated(true, String(data.reason || "")));
3370
+ events.set("agent-reply", ({ text }) => {
3293
3371
  addChat("agent", text);
3294
3372
  noteAgentReply(text);
3295
3373
  });
3296
- events.addEventListener("chat-sync", (event) => syncChat(JSON.parse(event.data).chat || []));
3297
- events.addEventListener("agent-presence", (event) => setAgentPresence(JSON.parse(event.data).state));
3298
- events.addEventListener("layout-warnings", (event) => setLayoutWarnings(JSON.parse(event.data).warnings || []));
3299
- events.addEventListener("ended", () => markSessionEnded());
3300
- // A reconnecting stream means this chrome may have missed updates while it was away.
3301
- events.addEventListener("open", () => refreshLayoutWarnings());
3374
+ events.set("chat-sync", (data) => syncChat(data.chat || []));
3375
+ events.set("agent-presence", (data) => setAgentPresence(data.state));
3376
+ events.set("layout-warnings", (data) => setLayoutWarnings(data.warnings || []));
3377
+ events.set("ended", () => markSessionEnded());
3378
+ connectLiveEvents();
3302
3379
 
3303
3380
  applySheetState();
3304
3381
  render();
@@ -3308,7 +3385,7 @@ renderWarnings();
3308
3385
  initialChat.forEach((item) => addChat(item.role, item.text));
3309
3386
  retiredDrafts.forEach((text) => renderRetiredDraft(text));
3310
3387
  setAgentPresence("waiting");
3311
- // The session already ended before this page (re)loaded, so there is no future SSE `ended` event
3388
+ // The session already ended before this page (re)loaded, so there is no future live `ended` event
3312
3389
  // to wait for - start read-only instead of looking live until a Send gets silently refused.
3313
3390
  if (sessionData.initialEnded) markSessionEnded();
3314
3391
 
package/dist/chrome.css CHANGED
@@ -1300,6 +1300,13 @@ body.lavish {
1300
1300
  font-size: 11px;
1301
1301
  line-height: 1.35;
1302
1302
  }
1303
+ .send-hint.persistent {
1304
+ padding: var(--space-4);
1305
+ border: 1px solid var(--amber-700);
1306
+ border-radius: var(--radius-sm);
1307
+ background: var(--amber-900);
1308
+ color: var(--accent-hover);
1309
+ }
1303
1310
  .send-hint[hidden] {
1304
1311
  display: none;
1305
1312
  }
package/dist/cli.mjs CHANGED
@@ -78,7 +78,8 @@ var PLAYBOOKS = [
78
78
  ],
79
79
  lavish_notes: [
80
80
  "A Lavish table should make individual rows easy annotation targets.",
81
- "If a row implies a follow-up change, include an action control that queues a specific prompt."
81
+ "If a row implies a follow-up change, include an action control that queues a specific prompt.",
82
+ "When one action covers multiple rows and completeness matters, also follow the input playbook's tracked batch pattern so the reviewer submits one explicit ID set for item-by-item accounting."
82
83
  ]
83
84
  },
84
85
  {
@@ -201,10 +202,12 @@ var PLAYBOOKS = [
201
202
  choose: [
202
203
  "Use this when the user needs to select, tune, triage, annotate, or edit a structured choice.",
203
204
  "Use controls for decisions the user can make faster visually than by writing a prompt.",
205
+ "Use an opt-in tracked batch when the agent must preserve completeness across a multi-item decision, such as findings to fixes, constraints to implementation, or recommendations to follow-up work.",
204
206
  "Use plain annotations when the artifact only needs open-ended feedback."
205
207
  ],
206
208
  structure: [
207
209
  "Make each decision surface visible: what is being chosen, what the options mean, and what happens next.",
210
+ "For a tracked batch, give every candidate item a short, stable, visible ID and let the reviewer select or disposition items with editable native controls.",
208
211
  "Keep reversible selection state local in the artifact until the user explicitly submits that question.",
209
212
  "Pair each question with a Submit or Queue answer control that sends exactly one prompt for the final answer.",
210
213
  "Show selected state separately from queued state so the user trusts what will be sent back."
@@ -216,6 +219,7 @@ var PLAYBOOKS = [
216
219
  "Put data-lavish-action only on custom (non-native) elements that should act like a feedback control - typically a styled div or span you made clickable - so Lavish does not annotate it and shows a pointer cursor instead.",
217
220
  "Use data-lavish-question on a question wrapper or pass queueKey when multiple pre-send updates should replace the prior unsent answer for the same question.",
218
221
  "Pass options such as tag, text, selector, target, data, queueKey, or element when they help the agent understand exactly what the user chose.",
222
+ "For a tracked batch, queue the final selected set once in a concise, bounded data.items array; include each item's stable ID, concise label, and requested disposition, and explicitly tell the agent to account for every submitted ID before reporting completion.",
219
223
  "Call window.lavish.sendQueuedPrompts() only when the control should immediately send committed feedback instead of waiting for the user to press Send to Agent.",
220
224
  "Make queued prompts specific enough that the agent can act without asking a follow-up question.",
221
225
  "Keep native browser controls accessible and readable on mobile."
@@ -229,6 +233,8 @@ var PLAYBOOKS = [
229
233
  lavish_notes: [
230
234
  "Lavish is strongest when the artifact becomes a focused review surface and not just a static page.",
231
235
  `A native single-choice question should submit the final value: \`<form data-lavish-question="plan" onsubmit="event.preventDefault(); const choice = new FormData(event.currentTarget).get('plan'); if (choice) window.lavish.queuePrompt('Use the ' + choice + ' plan', { tag: 'choice', text: 'Plan: ' + choice, element: event.currentTarget, data: { question: 'plan', answer: choice } });"><label><input type="radio" name="plan" value="Starter"> Starter</label><label><input type="radio" name="plan" value="Pro"> Pro</label><button type="submit">Queue this answer</button></form>\`.`,
236
+ `A tracked batch should submit the final selected set once: <form data-lavish-question="tracked-review" onsubmit="event.preventDefault(); const selected = [...event.currentTarget.querySelectorAll('input[name=items]:checked')].map((input) => ({ id: input.value, label: input.dataset.label, disposition: input.dataset.disposition })); if (selected.length) window.lavish.queuePrompt('Act on every selected item and return an item-by-item receipt. Account for every submitted ID before reporting completion.', { tag: 'tracked-batch', text: 'Apply ' + selected.length + ' selected review items', element: event.currentTarget, data: { items: selected } });"><label><input type="checkbox" name="items" value="R-03" data-label="Preserve rollback behavior" data-disposition="must-address"> R-03 \u2014 Preserve rollback behavior</label><label><input type="checkbox" name="items" value="R-08" data-label="Reuse the existing error surface" data-disposition="must-address"> R-08 \u2014 Reuse the existing error surface</label><button type="submit">Queue selected items</button></form>`,
237
+ "The agent's tracked-batch completion receipt must give every submitted ID exactly one outcome: addressed with concrete evidence, deferred with a reason, or rejected with a reason. Evidence may cover several IDs. Before declaring completion, compare the submitted ID set with the receipt ID set and surface every missing ID.",
232
238
  "A custom choice UI should make option buttons update local state, then use a separate Queue answer button with data-lavish-action to queue the final selected value.",
233
239
  "Use window.lavish.queuePrompt for user intent, not internal analytics or UI-only state changes.",
234
240
  "End input paths with an obvious way for the user to send feedback back to the agent."
@@ -8806,6 +8812,7 @@ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
8806
8812
  var WHITEBOARD_CHANNEL_TOKEN_TTL_MS = 5 * 6e4;
8807
8813
  var NETWORK_RECONCILE_CACHE_MS = 1e3;
8808
8814
  var TAILSCALE_BIND_RETRY_DELAYS_MS = [100, 250, 500];
8815
+ var WEBSOCKET_CLOSE_GRACE_MS = 250;
8809
8816
  var ARTIFACT_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads";
8810
8817
  var ATTACHMENT_SWEEP_INTERVAL_MS = 60 * 6e4;
8811
8818
  var SHUTDOWN_REASONS = /* @__PURE__ */ new Set(["upgrade", "local-build", "stop"]);
@@ -8912,6 +8919,7 @@ async function serve({
8912
8919
  lookupHost,
8913
8920
  whiteboardAssetsDir = defaultWhiteboardAssetsDir()
8914
8921
  } = {}) {
8922
+ const { WebSocket, WebSocketServer } = await import("ws");
8915
8923
  const extraHosts = allowedHosts ?? extraAllowedHosts(env);
8916
8924
  const envHost = env.LAVISH_AXI_HOST?.trim();
8917
8925
  const autoTailscale = !envHost;
@@ -8933,7 +8941,7 @@ async function serve({
8933
8941
  const watchers = /* @__PURE__ */ new Map();
8934
8942
  const activePolls = /* @__PURE__ */ new Map();
8935
8943
  const deliveredFeedback = /* @__PURE__ */ new Set();
8936
- const sseClients = /* @__PURE__ */ new Map();
8944
+ const liveEventClients = /* @__PURE__ */ new Map();
8937
8945
  const whiteboardChannelSecret = crypto6.randomBytes(32);
8938
8946
  const outstandingRepairBatches = /* @__PURE__ */ new Set();
8939
8947
  const diagnosticViewportClasses = resolveDiagnosticViewportClasses();
@@ -8947,6 +8955,39 @@ async function serve({
8947
8955
  let networkReconcileCheckedAt = 0;
8948
8956
  let cachedNetworkStale = false;
8949
8957
  let networkReconcilePromise = null;
8958
+ function broadcastLiveEvent(type, key, data = {}) {
8959
+ for (const [client, clientKey] of liveEventClients) {
8960
+ if (clientKey === key) client.sendEvent(type, data);
8961
+ }
8962
+ }
8963
+ events.on("reload", (key) => broadcastLiveEvent("reload", key));
8964
+ events.on("agent-reply", (key, text) => broadcastLiveEvent("agent-reply", key, { text }));
8965
+ events.on("agent-presence", (key, state) => broadcastLiveEvent("agent-presence", key, { state }));
8966
+ events.on("layout-warnings", (key, warnings) => broadcastLiveEvent("layout-warnings", key, { warnings }));
8967
+ events.on("ended", (key, endedBy) => broadcastLiveEvent("ended", key, { ended_by: endedBy || null }));
8968
+ function attachLiveEventClient(client, key, onClose) {
8969
+ liveEventClients.set(client, key);
8970
+ refreshIdleTimer();
8971
+ let cleanedUp = false;
8972
+ const cleanup = () => {
8973
+ if (cleanedUp) return;
8974
+ cleanedUp = true;
8975
+ liveEventClients.delete(client);
8976
+ refreshIdleTimer();
8977
+ };
8978
+ onClose(cleanup);
8979
+ return cleanup;
8980
+ }
8981
+ async function sendInitialLiveEventState(client, key, cleanup) {
8982
+ const session = await store.findByKey(key);
8983
+ if (client.isClosed()) {
8984
+ cleanup();
8985
+ return;
8986
+ }
8987
+ client.sendEvent("chat-sync", { chat: session?.chat || [] });
8988
+ client.sendEvent("agent-presence", { state: computePresence(key, activePolls, deliveredFeedback) });
8989
+ if (session?.status === "ended") client.sendEvent("ended", { ended_by: session.ended_by || null });
8990
+ }
8950
8991
  async function reconcileTailscaleNetwork() {
8951
8992
  if (Date.now() - networkReconcileCheckedAt < NETWORK_RECONCILE_CACHE_MS) return cachedNetworkStale;
8952
8993
  if (networkReconcilePromise) return networkReconcilePromise;
@@ -9637,93 +9678,16 @@ async function serve({
9637
9678
  next(error);
9638
9679
  }
9639
9680
  });
9640
- app.get("/events/:key", async (req, res, next) => {
9641
- let cleanup = () => {
9642
- };
9643
- try {
9644
- res.writeHead(200, {
9645
- "content-type": "text/event-stream",
9646
- "cache-control": "no-cache",
9647
- connection: "keep-alive"
9648
- });
9649
- sseClients.set(res, String(req.params.key || ""));
9650
- refreshIdleTimer();
9651
- const sendReload = (key) => {
9652
- if (key === req.params.key) {
9653
- res.write("event: reload\ndata: {}\n\n");
9654
- }
9655
- };
9656
- const sendAgentReply = (key, text) => {
9657
- if (key === req.params.key) {
9658
- res.write(`event: agent-reply
9659
- data: ${JSON.stringify({ text })}
9660
-
9661
- `);
9662
- }
9663
- };
9664
- const sendPresence = (key, state) => {
9665
- if (key === req.params.key) {
9666
- res.write(`event: agent-presence
9667
- data: ${JSON.stringify({ state })}
9668
-
9669
- `);
9670
- }
9671
- };
9672
- const sendLayoutWarnings = (key, warnings) => {
9673
- if (key === req.params.key) {
9674
- res.write(`event: layout-warnings
9675
- data: ${JSON.stringify({ warnings })}
9676
-
9677
- `);
9678
- }
9679
- };
9680
- const sendEnded = (key, endedBy) => {
9681
- if (key === req.params.key) {
9682
- res.write(`event: ended
9683
- data: ${JSON.stringify({ ended_by: endedBy || null })}
9684
-
9685
- `);
9686
- }
9687
- };
9688
- events.on("reload", sendReload);
9689
- events.on("agent-reply", sendAgentReply);
9690
- events.on("agent-presence", sendPresence);
9691
- events.on("layout-warnings", sendLayoutWarnings);
9692
- events.on("ended", sendEnded);
9693
- let cleanedUp = false;
9694
- cleanup = () => {
9695
- if (cleanedUp) return;
9696
- cleanedUp = true;
9697
- req.off("close", cleanup);
9698
- sseClients.delete(res);
9699
- events.off("reload", sendReload);
9700
- events.off("agent-reply", sendAgentReply);
9701
- events.off("agent-presence", sendPresence);
9702
- events.off("layout-warnings", sendLayoutWarnings);
9703
- events.off("ended", sendEnded);
9704
- refreshIdleTimer();
9705
- };
9706
- req.once("close", cleanup);
9707
- const session = await store.findByKey(req.params.key);
9708
- if (req.destroyed || res.writableEnded) {
9709
- cleanup();
9710
- return;
9711
- }
9712
- res.write(`event: chat-sync
9713
- data: ${JSON.stringify({ chat: session?.chat || [] })}
9681
+ app.get("/events/:key", (_req, res) => {
9682
+ res.writeHead(200, {
9683
+ "content-type": "text/event-stream",
9684
+ "cache-control": "no-cache",
9685
+ connection: "close"
9686
+ });
9687
+ res.end(`event: chrome-reload
9688
+ data: ${JSON.stringify({ reason: "server-restarted" })}
9714
9689
 
9715
9690
  `);
9716
- res.write(
9717
- `event: agent-presence
9718
- data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
9719
-
9720
- `
9721
- );
9722
- if (session?.status === "ended") sendEnded(req.params.key, session.ended_by);
9723
- } catch (error) {
9724
- cleanup();
9725
- next(error);
9726
- }
9727
9691
  });
9728
9692
  app.get("/chrome-client.js", async (req, res, next) => {
9729
9693
  try {
@@ -9965,6 +9929,74 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
9965
9929
  const status = Number(error?.statusCode || error?.status) || 500;
9966
9930
  res.status(status).json({ error: error instanceof Error ? error.message : String(error) });
9967
9931
  });
9932
+ const eventWebSocketServer = new WebSocketServer({ noServer: true, maxPayload: 1024 });
9933
+ function rejectEventUpgrade(socket, status, message) {
9934
+ const body = `${message}
9935
+ `;
9936
+ socket.end(
9937
+ `HTTP/1.1 ${status} ${message}\r
9938
+ Connection: close\r
9939
+ Content-Type: text/plain; charset=utf-8\r
9940
+ Content-Length: ${Buffer.byteLength(body)}\r
9941
+ \r
9942
+ ${body}`
9943
+ );
9944
+ }
9945
+ function handleEventUpgrade(req, socket, head) {
9946
+ let pathname;
9947
+ try {
9948
+ pathname = new URL(String(req.url || ""), "http://lavish.local").pathname;
9949
+ } catch {
9950
+ rejectEventUpgrade(socket, 400, "Bad Request");
9951
+ return;
9952
+ }
9953
+ const match = pathname.match(/^\/events\/([^/]+)$/);
9954
+ if (!match) {
9955
+ rejectEventUpgrade(socket, 404, "Not Found");
9956
+ return;
9957
+ }
9958
+ const hostAllowed = allowAnyHostname ? parseHostAuthority(req.headers.host) !== null : isAllowedRequestHost(
9959
+ { host: req.headers.host, forwardedHost: req.headers["x-forwarded-host"] },
9960
+ allowedHostnames
9961
+ );
9962
+ if (!hostAllowed || !req.headers.origin || !isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
9963
+ rejectEventUpgrade(socket, 403, "Forbidden");
9964
+ return;
9965
+ }
9966
+ let key;
9967
+ try {
9968
+ key = decodeURIComponent(match[1]);
9969
+ } catch {
9970
+ rejectEventUpgrade(socket, 400, "Bad Request");
9971
+ return;
9972
+ }
9973
+ eventWebSocketServer.handleUpgrade(req, socket, head, (webSocket) => {
9974
+ const client = {
9975
+ sendEvent(type, data) {
9976
+ if (webSocket.readyState === WebSocket.OPEN) webSocket.send(JSON.stringify({ type, data }));
9977
+ },
9978
+ close(code = 1001, reason = "Lavish server shutdown") {
9979
+ webSocket.close(code, reason);
9980
+ const terminateTimer = setTimeout(() => {
9981
+ if (webSocket.readyState !== WebSocket.CLOSED) webSocket.terminate();
9982
+ }, WEBSOCKET_CLOSE_GRACE_MS);
9983
+ terminateTimer.unref?.();
9984
+ webSocket.once("close", () => clearTimeout(terminateTimer));
9985
+ },
9986
+ isClosed() {
9987
+ return webSocket.readyState === WebSocket.CLOSING || webSocket.readyState === WebSocket.CLOSED;
9988
+ }
9989
+ };
9990
+ webSocket.on("error", () => {
9991
+ });
9992
+ const cleanup = attachLiveEventClient(client, key, (remove) => webSocket.once("close", remove));
9993
+ sendInitialLiveEventState(client, key, cleanup).catch((error) => {
9994
+ client.close(1011, "Failed to initialize live events");
9995
+ cleanup();
9996
+ logEvent?.(`event WebSocket initialization failed session=${key}: ${error?.message || error}`);
9997
+ });
9998
+ });
9999
+ }
9968
10000
  const httpServers = [];
9969
10001
  const boundHosts = [];
9970
10002
  let boundPort = port;
@@ -9974,6 +10006,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
9974
10006
  while (true) {
9975
10007
  try {
9976
10008
  const httpServer = await listenHttp(app, boundPort, listenHost);
10009
+ httpServer.on("upgrade", handleEventUpgrade);
9977
10010
  if (boundPort === 0) boundPort = httpServer.address().port;
9978
10011
  httpServers.push(httpServer);
9979
10012
  boundHosts.push(listenHost);
@@ -10024,25 +10057,19 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
10024
10057
  clearInterval(attachmentSweepTimer);
10025
10058
  attachmentSweepTimer = null;
10026
10059
  }
10027
- const shutdownData = JSON.stringify({ reason });
10028
- for (const [res, clientKey] of sseClients) {
10060
+ const shutdownData = { reason };
10061
+ for (const [client, clientKey] of liveEventClients) {
10029
10062
  try {
10030
10063
  if (reloadKey && clientKey === reloadKey) {
10031
- res.write(`event: chrome-reload
10032
- data: ${shutdownData}
10033
-
10034
- `);
10064
+ client.sendEvent("chrome-reload", shutdownData);
10035
10065
  } else {
10036
- res.write(`event: chrome-outdated
10037
- data: ${shutdownData}
10038
-
10039
- `);
10066
+ client.sendEvent("chrome-outdated", shutdownData);
10040
10067
  }
10041
- res.end();
10068
+ client.close();
10042
10069
  } catch {
10043
10070
  }
10044
10071
  }
10045
- sseClients.clear();
10072
+ liveEventClients.clear();
10046
10073
  for (const w of watchers.values()) {
10047
10074
  w.close().catch(() => {
10048
10075
  });
@@ -10067,10 +10094,10 @@ data: ${shutdownData}
10067
10094
  idleTimer = null;
10068
10095
  }
10069
10096
  if (shuttingDown || idleTimeoutMs == null) return;
10070
- if (sseClients.size > 0 || activePolls.size > 0) return;
10097
+ if (liveEventClients.size > 0 || activePolls.size > 0) return;
10071
10098
  idleTimer = setTimeout(() => {
10072
10099
  idleTimer = null;
10073
- if (!shuttingDown && sseClients.size === 0 && activePolls.size === 0) {
10100
+ if (!shuttingDown && liveEventClients.size === 0 && activePolls.size === 0) {
10074
10101
  logEvent?.(`idle for ${idleTimeoutMs}ms with no connections, shutting down`);
10075
10102
  shutdown();
10076
10103
  }
@@ -10078,7 +10105,7 @@ data: ${shutdownData}
10078
10105
  idleTimer.unref?.();
10079
10106
  }
10080
10107
  async function shutdownIfNoLiveSessions() {
10081
- if (sseClients.size > 0 || activePolls.size > 0) return;
10108
+ if (liveEventClients.size > 0 || activePolls.size > 0) return;
10082
10109
  try {
10083
10110
  const sessions = await store.listSessions();
10084
10111
  if (sessions.every((session) => session.status === "ended")) {
@@ -10273,24 +10300,24 @@ function hasPresentOriginOrReferer(req) {
10273
10300
  function isSameOriginRequest(req, allowedHostnames, allowAnyHostname = false) {
10274
10301
  const host = parseHostAuthority(req.headers.host);
10275
10302
  if (!host) return false;
10276
- let protocol = req.protocol;
10303
+ let protocol = req.protocol || "http";
10277
10304
  let authority = host;
10278
- const forwardedHost = String(req.get("x-forwarded-host") || "").split(",").pop().trim();
10305
+ const forwardedHost = String(req.headers["x-forwarded-host"] || "").split(",").pop().trim();
10279
10306
  if (forwardedHost) {
10280
10307
  const forwardedAuthority = parseHostAuthority(forwardedHost);
10281
10308
  if (!forwardedAuthority || !allowAnyHostname && (!allowedHostnames.has(host.hostname) || !allowedHostnames.has(forwardedAuthority.hostname)))
10282
10309
  return false;
10283
- protocol = String(req.get("x-forwarded-proto") || req.protocol).split(",").pop().trim().toLowerCase();
10310
+ protocol = String(req.headers["x-forwarded-proto"] || protocol).split(",").pop().trim().toLowerCase();
10284
10311
  if (protocol !== "http" && protocol !== "https") return false;
10285
10312
  authority = forwardedAuthority;
10286
10313
  }
10287
10314
  const expectedOrigin = normalizeOrigin(`${protocol}://${authority.authority}`);
10288
10315
  if (!expectedOrigin) return false;
10289
- const origin = req.get("origin");
10316
+ const origin = req.headers.origin;
10290
10317
  if (origin) {
10291
10318
  return normalizeOrigin(origin) === expectedOrigin;
10292
10319
  }
10293
- const referer = req.get("referer");
10320
+ const referer = req.headers.referer;
10294
10321
  return Boolean(referer) && normalizeOrigin(referer) === expectedOrigin;
10295
10322
  }
10296
10323
  function normalizeOrigin(value) {
@@ -10584,7 +10611,7 @@ function createChromeHtml(session, {
10584
10611
  const sessionJson = jsonScript({
10585
10612
  key: session.key,
10586
10613
  file: session.file,
10587
- // A page loaded (or reloaded) after the session already ended has no future SSE `ended`
10614
+ // A page loaded (or reloaded) after the session already ended has no future live `ended`
10588
10615
  // event to wait for - it must start read-only instead of looking live until the user tries
10589
10616
  // to send and gets refused (#171).
10590
10617
  initialEnded: session.status === "ended",
@@ -10889,7 +10916,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
10889
10916
  ]);
10890
10917
  var POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited.";
10891
10918
  var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
10892
- var VERSION = "0.1.64";
10919
+ var VERSION = "0.1.66";
10893
10920
  function detectInvokingAgent(env = process.env) {
10894
10921
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
10895
10922
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
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",
@@ -38,7 +38,8 @@
38
38
  "daisyui": "^5.5.19",
39
39
  "express": "^5.2.1",
40
40
  "open": "^10.2.0",
41
- "parse5": "^8.0.1"
41
+ "parse5": "^8.0.1",
42
+ "ws": "^8.21.3"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@eslint/js": "^10.0.1",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.64",
4
+ "version": "0.1.66",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "author": {
7
7
  "name": "Kun Chen",