lavish-axi 0.1.64 → 0.1.65

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
@@ -8806,6 +8806,7 @@ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
8806
8806
  var WHITEBOARD_CHANNEL_TOKEN_TTL_MS = 5 * 6e4;
8807
8807
  var NETWORK_RECONCILE_CACHE_MS = 1e3;
8808
8808
  var TAILSCALE_BIND_RETRY_DELAYS_MS = [100, 250, 500];
8809
+ var WEBSOCKET_CLOSE_GRACE_MS = 250;
8809
8810
  var ARTIFACT_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads";
8810
8811
  var ATTACHMENT_SWEEP_INTERVAL_MS = 60 * 6e4;
8811
8812
  var SHUTDOWN_REASONS = /* @__PURE__ */ new Set(["upgrade", "local-build", "stop"]);
@@ -8912,6 +8913,7 @@ async function serve({
8912
8913
  lookupHost,
8913
8914
  whiteboardAssetsDir = defaultWhiteboardAssetsDir()
8914
8915
  } = {}) {
8916
+ const { WebSocket, WebSocketServer } = await import("ws");
8915
8917
  const extraHosts = allowedHosts ?? extraAllowedHosts(env);
8916
8918
  const envHost = env.LAVISH_AXI_HOST?.trim();
8917
8919
  const autoTailscale = !envHost;
@@ -8933,7 +8935,7 @@ async function serve({
8933
8935
  const watchers = /* @__PURE__ */ new Map();
8934
8936
  const activePolls = /* @__PURE__ */ new Map();
8935
8937
  const deliveredFeedback = /* @__PURE__ */ new Set();
8936
- const sseClients = /* @__PURE__ */ new Map();
8938
+ const liveEventClients = /* @__PURE__ */ new Map();
8937
8939
  const whiteboardChannelSecret = crypto6.randomBytes(32);
8938
8940
  const outstandingRepairBatches = /* @__PURE__ */ new Set();
8939
8941
  const diagnosticViewportClasses = resolveDiagnosticViewportClasses();
@@ -8947,6 +8949,39 @@ async function serve({
8947
8949
  let networkReconcileCheckedAt = 0;
8948
8950
  let cachedNetworkStale = false;
8949
8951
  let networkReconcilePromise = null;
8952
+ function broadcastLiveEvent(type, key, data = {}) {
8953
+ for (const [client, clientKey] of liveEventClients) {
8954
+ if (clientKey === key) client.sendEvent(type, data);
8955
+ }
8956
+ }
8957
+ events.on("reload", (key) => broadcastLiveEvent("reload", key));
8958
+ events.on("agent-reply", (key, text) => broadcastLiveEvent("agent-reply", key, { text }));
8959
+ events.on("agent-presence", (key, state) => broadcastLiveEvent("agent-presence", key, { state }));
8960
+ events.on("layout-warnings", (key, warnings) => broadcastLiveEvent("layout-warnings", key, { warnings }));
8961
+ events.on("ended", (key, endedBy) => broadcastLiveEvent("ended", key, { ended_by: endedBy || null }));
8962
+ function attachLiveEventClient(client, key, onClose) {
8963
+ liveEventClients.set(client, key);
8964
+ refreshIdleTimer();
8965
+ let cleanedUp = false;
8966
+ const cleanup = () => {
8967
+ if (cleanedUp) return;
8968
+ cleanedUp = true;
8969
+ liveEventClients.delete(client);
8970
+ refreshIdleTimer();
8971
+ };
8972
+ onClose(cleanup);
8973
+ return cleanup;
8974
+ }
8975
+ async function sendInitialLiveEventState(client, key, cleanup) {
8976
+ const session = await store.findByKey(key);
8977
+ if (client.isClosed()) {
8978
+ cleanup();
8979
+ return;
8980
+ }
8981
+ client.sendEvent("chat-sync", { chat: session?.chat || [] });
8982
+ client.sendEvent("agent-presence", { state: computePresence(key, activePolls, deliveredFeedback) });
8983
+ if (session?.status === "ended") client.sendEvent("ended", { ended_by: session.ended_by || null });
8984
+ }
8950
8985
  async function reconcileTailscaleNetwork() {
8951
8986
  if (Date.now() - networkReconcileCheckedAt < NETWORK_RECONCILE_CACHE_MS) return cachedNetworkStale;
8952
8987
  if (networkReconcilePromise) return networkReconcilePromise;
@@ -9637,93 +9672,16 @@ async function serve({
9637
9672
  next(error);
9638
9673
  }
9639
9674
  });
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 || [] })}
9675
+ app.get("/events/:key", (_req, res) => {
9676
+ res.writeHead(200, {
9677
+ "content-type": "text/event-stream",
9678
+ "cache-control": "no-cache",
9679
+ connection: "close"
9680
+ });
9681
+ res.end(`event: chrome-reload
9682
+ data: ${JSON.stringify({ reason: "server-restarted" })}
9714
9683
 
9715
9684
  `);
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
9685
  });
9728
9686
  app.get("/chrome-client.js", async (req, res, next) => {
9729
9687
  try {
@@ -9965,6 +9923,74 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
9965
9923
  const status = Number(error?.statusCode || error?.status) || 500;
9966
9924
  res.status(status).json({ error: error instanceof Error ? error.message : String(error) });
9967
9925
  });
9926
+ const eventWebSocketServer = new WebSocketServer({ noServer: true, maxPayload: 1024 });
9927
+ function rejectEventUpgrade(socket, status, message) {
9928
+ const body = `${message}
9929
+ `;
9930
+ socket.end(
9931
+ `HTTP/1.1 ${status} ${message}\r
9932
+ Connection: close\r
9933
+ Content-Type: text/plain; charset=utf-8\r
9934
+ Content-Length: ${Buffer.byteLength(body)}\r
9935
+ \r
9936
+ ${body}`
9937
+ );
9938
+ }
9939
+ function handleEventUpgrade(req, socket, head) {
9940
+ let pathname;
9941
+ try {
9942
+ pathname = new URL(String(req.url || ""), "http://lavish.local").pathname;
9943
+ } catch {
9944
+ rejectEventUpgrade(socket, 400, "Bad Request");
9945
+ return;
9946
+ }
9947
+ const match = pathname.match(/^\/events\/([^/]+)$/);
9948
+ if (!match) {
9949
+ rejectEventUpgrade(socket, 404, "Not Found");
9950
+ return;
9951
+ }
9952
+ const hostAllowed = allowAnyHostname ? parseHostAuthority(req.headers.host) !== null : isAllowedRequestHost(
9953
+ { host: req.headers.host, forwardedHost: req.headers["x-forwarded-host"] },
9954
+ allowedHostnames
9955
+ );
9956
+ if (!hostAllowed || !req.headers.origin || !isSameOriginRequest(req, allowedHostnames, allowAnyHostname)) {
9957
+ rejectEventUpgrade(socket, 403, "Forbidden");
9958
+ return;
9959
+ }
9960
+ let key;
9961
+ try {
9962
+ key = decodeURIComponent(match[1]);
9963
+ } catch {
9964
+ rejectEventUpgrade(socket, 400, "Bad Request");
9965
+ return;
9966
+ }
9967
+ eventWebSocketServer.handleUpgrade(req, socket, head, (webSocket) => {
9968
+ const client = {
9969
+ sendEvent(type, data) {
9970
+ if (webSocket.readyState === WebSocket.OPEN) webSocket.send(JSON.stringify({ type, data }));
9971
+ },
9972
+ close(code = 1001, reason = "Lavish server shutdown") {
9973
+ webSocket.close(code, reason);
9974
+ const terminateTimer = setTimeout(() => {
9975
+ if (webSocket.readyState !== WebSocket.CLOSED) webSocket.terminate();
9976
+ }, WEBSOCKET_CLOSE_GRACE_MS);
9977
+ terminateTimer.unref?.();
9978
+ webSocket.once("close", () => clearTimeout(terminateTimer));
9979
+ },
9980
+ isClosed() {
9981
+ return webSocket.readyState === WebSocket.CLOSING || webSocket.readyState === WebSocket.CLOSED;
9982
+ }
9983
+ };
9984
+ webSocket.on("error", () => {
9985
+ });
9986
+ const cleanup = attachLiveEventClient(client, key, (remove) => webSocket.once("close", remove));
9987
+ sendInitialLiveEventState(client, key, cleanup).catch((error) => {
9988
+ client.close(1011, "Failed to initialize live events");
9989
+ cleanup();
9990
+ logEvent?.(`event WebSocket initialization failed session=${key}: ${error?.message || error}`);
9991
+ });
9992
+ });
9993
+ }
9968
9994
  const httpServers = [];
9969
9995
  const boundHosts = [];
9970
9996
  let boundPort = port;
@@ -9974,6 +10000,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
9974
10000
  while (true) {
9975
10001
  try {
9976
10002
  const httpServer = await listenHttp(app, boundPort, listenHost);
10003
+ httpServer.on("upgrade", handleEventUpgrade);
9977
10004
  if (boundPort === 0) boundPort = httpServer.address().port;
9978
10005
  httpServers.push(httpServer);
9979
10006
  boundHosts.push(listenHost);
@@ -10024,25 +10051,19 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
10024
10051
  clearInterval(attachmentSweepTimer);
10025
10052
  attachmentSweepTimer = null;
10026
10053
  }
10027
- const shutdownData = JSON.stringify({ reason });
10028
- for (const [res, clientKey] of sseClients) {
10054
+ const shutdownData = { reason };
10055
+ for (const [client, clientKey] of liveEventClients) {
10029
10056
  try {
10030
10057
  if (reloadKey && clientKey === reloadKey) {
10031
- res.write(`event: chrome-reload
10032
- data: ${shutdownData}
10033
-
10034
- `);
10058
+ client.sendEvent("chrome-reload", shutdownData);
10035
10059
  } else {
10036
- res.write(`event: chrome-outdated
10037
- data: ${shutdownData}
10038
-
10039
- `);
10060
+ client.sendEvent("chrome-outdated", shutdownData);
10040
10061
  }
10041
- res.end();
10062
+ client.close();
10042
10063
  } catch {
10043
10064
  }
10044
10065
  }
10045
- sseClients.clear();
10066
+ liveEventClients.clear();
10046
10067
  for (const w of watchers.values()) {
10047
10068
  w.close().catch(() => {
10048
10069
  });
@@ -10067,10 +10088,10 @@ data: ${shutdownData}
10067
10088
  idleTimer = null;
10068
10089
  }
10069
10090
  if (shuttingDown || idleTimeoutMs == null) return;
10070
- if (sseClients.size > 0 || activePolls.size > 0) return;
10091
+ if (liveEventClients.size > 0 || activePolls.size > 0) return;
10071
10092
  idleTimer = setTimeout(() => {
10072
10093
  idleTimer = null;
10073
- if (!shuttingDown && sseClients.size === 0 && activePolls.size === 0) {
10094
+ if (!shuttingDown && liveEventClients.size === 0 && activePolls.size === 0) {
10074
10095
  logEvent?.(`idle for ${idleTimeoutMs}ms with no connections, shutting down`);
10075
10096
  shutdown();
10076
10097
  }
@@ -10078,7 +10099,7 @@ data: ${shutdownData}
10078
10099
  idleTimer.unref?.();
10079
10100
  }
10080
10101
  async function shutdownIfNoLiveSessions() {
10081
- if (sseClients.size > 0 || activePolls.size > 0) return;
10102
+ if (liveEventClients.size > 0 || activePolls.size > 0) return;
10082
10103
  try {
10083
10104
  const sessions = await store.listSessions();
10084
10105
  if (sessions.every((session) => session.status === "ended")) {
@@ -10273,24 +10294,24 @@ function hasPresentOriginOrReferer(req) {
10273
10294
  function isSameOriginRequest(req, allowedHostnames, allowAnyHostname = false) {
10274
10295
  const host = parseHostAuthority(req.headers.host);
10275
10296
  if (!host) return false;
10276
- let protocol = req.protocol;
10297
+ let protocol = req.protocol || "http";
10277
10298
  let authority = host;
10278
- const forwardedHost = String(req.get("x-forwarded-host") || "").split(",").pop().trim();
10299
+ const forwardedHost = String(req.headers["x-forwarded-host"] || "").split(",").pop().trim();
10279
10300
  if (forwardedHost) {
10280
10301
  const forwardedAuthority = parseHostAuthority(forwardedHost);
10281
10302
  if (!forwardedAuthority || !allowAnyHostname && (!allowedHostnames.has(host.hostname) || !allowedHostnames.has(forwardedAuthority.hostname)))
10282
10303
  return false;
10283
- protocol = String(req.get("x-forwarded-proto") || req.protocol).split(",").pop().trim().toLowerCase();
10304
+ protocol = String(req.headers["x-forwarded-proto"] || protocol).split(",").pop().trim().toLowerCase();
10284
10305
  if (protocol !== "http" && protocol !== "https") return false;
10285
10306
  authority = forwardedAuthority;
10286
10307
  }
10287
10308
  const expectedOrigin = normalizeOrigin(`${protocol}://${authority.authority}`);
10288
10309
  if (!expectedOrigin) return false;
10289
- const origin = req.get("origin");
10310
+ const origin = req.headers.origin;
10290
10311
  if (origin) {
10291
10312
  return normalizeOrigin(origin) === expectedOrigin;
10292
10313
  }
10293
- const referer = req.get("referer");
10314
+ const referer = req.headers.referer;
10294
10315
  return Boolean(referer) && normalizeOrigin(referer) === expectedOrigin;
10295
10316
  }
10296
10317
  function normalizeOrigin(value) {
@@ -10584,7 +10605,7 @@ function createChromeHtml(session, {
10584
10605
  const sessionJson = jsonScript({
10585
10606
  key: session.key,
10586
10607
  file: session.file,
10587
- // A page loaded (or reloaded) after the session already ended has no future SSE `ended`
10608
+ // A page loaded (or reloaded) after the session already ended has no future live `ended`
10588
10609
  // event to wait for - it must start read-only instead of looking live until the user tries
10589
10610
  // to send and gets refused (#171).
10590
10611
  initialEnded: session.status === "ended",
@@ -10889,7 +10910,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
10889
10910
  ]);
10890
10911
  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
10912
  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";
10913
+ var VERSION = "0.1.65";
10893
10914
  function detectInvokingAgent(env = process.env) {
10894
10915
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
10895
10916
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
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.65",
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",