lavish-axi 0.1.56 → 0.1.57

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
@@ -204,6 +204,7 @@ pnpm link
204
204
  The no-timeout poll always writes an immediate stderr banner so it is visibly not hung; it adds the periodic stderr wait ticks only in an interactive terminal, so when stderr is piped (as under agent harnesses) the captured output carries no tick noise. Stdout always stays reserved for the final response; if the poll is interrupted or times out before feedback arrives, re-run it because feedback remains queued until delivery. Poll delivery consumes the response, so read the complete response before truncating or filtering it.
205
205
  Codex-specific guidance keeps that poll attached to the active turn instead of hiding it in a background task, because completed background tasks may not resume the agent.
206
206
  - **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.
207
+ When either side ends the session, every open review tab becomes visibly read-only and disables its feedback controls; feedback submitted after the end is refused instead of being accepted without an agent to receive it.
207
208
  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.
208
209
  Agent-initiated ends keep reopening normally, same as before.
209
210
  `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.
@@ -746,16 +746,16 @@ function setSheetOpen(open) {
746
746
  if (sheetOpen) scrollPanelToBottom();
747
747
  }
748
748
 
749
- // Re-derives every sheet attribute from the two facts that matter - whether the phone layout is
750
- // active and whether the sheet is open - so a viewport crossing the breakpoint in either direction
751
- // leaves nothing stale: a desktop panel is never inert, and a closed dock never traps focus.
749
+ // Re-derives every sheet attribute from the phone layout, sheet-open, and session-ended state so a
750
+ // viewport crossing the breakpoint in either direction cannot make an ended panel interactive or
751
+ // leave a closed dock trapping focus.
752
752
  function applySheetState() {
753
753
  const mobile = isMobileSheet();
754
754
  const open = mobile && sheetOpen;
755
755
  document.body.classList.toggle("sheet-open", open);
756
756
  const docked = mobile && !open;
757
- panelScroll.inert = docked;
758
- chatComposer.inert = docked;
757
+ panelScroll.inert = ended || docked;
758
+ chatComposer.inert = ended || docked;
759
759
  const activeElement = document.activeElement;
760
760
  if (docked && activeElement && (panelScroll.contains(activeElement) || chatComposer.contains(activeElement))) {
761
761
  panelToggle.focus();
@@ -1217,6 +1217,14 @@ async function submitQueuedOnce() {
1217
1217
  if (!response.ok) {
1218
1218
  if (response.status === 409) {
1219
1219
  const data = await response.json().catch(() => null);
1220
+ // The session already ended before this batch arrived - most likely this chrome missed the
1221
+ // SSE `ended` event (a dropped connection). Go read-only now instead of leaving Send enabled
1222
+ // for another attempt that will be refused the same way.
1223
+ if (data?.status === "ended") {
1224
+ endAfterSubmit = false;
1225
+ markSessionEnded();
1226
+ return false;
1227
+ }
1220
1228
  if (Array.isArray(data?.warnings)) setLayoutWarnings(data.warnings);
1221
1229
  endAfterSubmit = false;
1222
1230
  return false;
@@ -1778,6 +1786,7 @@ function markSessionEnded() {
1778
1786
  ended = true;
1779
1787
  cancelArtifactLoadRecovery();
1780
1788
  closeMenus();
1789
+ closeShareDialog();
1781
1790
  closeWarningsDrawer();
1782
1791
  renderWarnings();
1783
1792
  closeWhiteboard();
@@ -1785,7 +1794,7 @@ function markSessionEnded() {
1785
1794
  moreButton.disabled = true;
1786
1795
  chatInput.disabled = true;
1787
1796
  updateSendState();
1788
- renderSheetSummary();
1797
+ applySheetState();
1789
1798
  if (presenceBanner) presenceBanner.hidden = true;
1790
1799
  if (handoffBanner) handoffBanner.hidden = true;
1791
1800
  if (outdatedBanner) outdatedBanner.hidden = true;
@@ -3123,6 +3132,7 @@ events.addEventListener("agent-reply", (event) => {
3123
3132
  events.addEventListener("chat-sync", (event) => syncChat(JSON.parse(event.data).chat || []));
3124
3133
  events.addEventListener("agent-presence", (event) => setAgentPresence(JSON.parse(event.data).state));
3125
3134
  events.addEventListener("layout-warnings", (event) => setLayoutWarnings(JSON.parse(event.data).warnings || []));
3135
+ events.addEventListener("ended", () => markSessionEnded());
3126
3136
  // A reconnecting stream means this chrome may have missed updates while it was away.
3127
3137
  events.addEventListener("open", () => refreshLayoutWarnings());
3128
3138
 
@@ -3134,6 +3144,9 @@ renderWarnings();
3134
3144
  initialChat.forEach((item) => addChat(item.role, item.text));
3135
3145
  retiredDrafts.forEach((text) => renderRetiredDraft(text));
3136
3146
  setAgentPresence("waiting");
3147
+ // The session already ended before this page (re)loaded, so there is no future SSE `ended` event
3148
+ // to wait for - start read-only instead of looking live until a Send gets silently refused.
3149
+ if (sessionData.initialEnded) markSessionEnded();
3137
3150
 
3138
3151
  // Reaching this line is the only proof that this file parsed and ran to completion. The page it
3139
3152
  // bootstraps ships with the layout-gate overlay already covering the artifact, and only this
package/dist/cli.mjs CHANGED
@@ -7379,6 +7379,9 @@ var SessionStore = class {
7379
7379
  const shouldEndSession = Boolean(payload.endSession || payload.end_session);
7380
7380
  const restoring = options.restore === true;
7381
7381
  const alreadyEnded = session.status === "ended";
7382
+ if (alreadyEnded && !restoring) {
7383
+ return { ended: true, ended_by: session.ended_by };
7384
+ }
7382
7385
  const normalized = prompts.map(normalizePrompt);
7383
7386
  const normalizedPrompts = normalized.map((entry) => entry.prompt);
7384
7387
  const rejected = boundAttachmentRefs(normalized, options);
@@ -8881,6 +8884,10 @@ async function serve({
8881
8884
  res.status(404).json({ error: "session not found" });
8882
8885
  return;
8883
8886
  }
8887
+ if (result.ended) {
8888
+ res.status(409).json({ status: "ended", error: "session already ended", ended_by: result.ended_by });
8889
+ return;
8890
+ }
8884
8891
  if (result.rejected) {
8885
8892
  res.status(400).json({
8886
8893
  error: "some attachments could not be delivered",
@@ -8904,7 +8911,7 @@ async function serve({
8904
8911
  await syncOutstandingRepairs(req.params.key);
8905
8912
  events.emit("layout-warnings", req.params.key, serializeLayoutWarnings(session.layout_warnings));
8906
8913
  }
8907
- events.emit(shouldEndSession ? "ended" : "feedback", req.params.key);
8914
+ events.emit(shouldEndSession ? "ended" : "feedback", req.params.key, session.ended_by);
8908
8915
  res.json({ status: "queued", pending_prompts: session.pending_prompts });
8909
8916
  if (shouldEndSession) await shutdownIfNoLiveSessions();
8910
8917
  } catch (error) {
@@ -8991,9 +8998,9 @@ async function serve({
8991
8998
  });
8992
8999
  app.post("/api/:key/end", async (req, res, next) => {
8993
9000
  try {
8994
- await store.endSession(req.params.key, "user");
9001
+ const session = await store.endSession(req.params.key, "user");
8995
9002
  clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
8996
- events.emit("ended", req.params.key);
9003
+ events.emit("ended", req.params.key, session?.ended_by);
8997
9004
  res.json({ status: "ended" });
8998
9005
  await shutdownIfNoLiveSessions();
8999
9006
  } catch (error) {
@@ -9080,9 +9087,9 @@ async function serve({
9080
9087
  try {
9081
9088
  const file = await canonicalFile(req.body.file);
9082
9089
  const key = sessionKey(file);
9083
- await store.endSession(key, "agent");
9090
+ const session = await store.endSession(key, "agent");
9084
9091
  clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
9085
- events.emit("ended", key);
9092
+ events.emit("ended", key, session?.ended_by);
9086
9093
  res.json({ status: "ended" });
9087
9094
  await shutdownIfNoLiveSessions();
9088
9095
  } catch (error) {
@@ -9215,6 +9222,8 @@ async function serve({
9215
9222
  }
9216
9223
  });
9217
9224
  app.get("/events/:key", async (req, res, next) => {
9225
+ let cleanup = () => {
9226
+ };
9218
9227
  try {
9219
9228
  res.writeHead(200, {
9220
9229
  "content-type": "text/event-stream",
@@ -9223,7 +9232,6 @@ async function serve({
9223
9232
  });
9224
9233
  sseClients.set(res, String(req.params.key || ""));
9225
9234
  refreshIdleTimer();
9226
- const session = await store.findByKey(req.params.key);
9227
9235
  const sendReload = (key) => {
9228
9236
  if (key === req.params.key) {
9229
9237
  res.write("event: reload\ndata: {}\n\n");
@@ -9253,29 +9261,51 @@ data: ${JSON.stringify({ warnings })}
9253
9261
  `);
9254
9262
  }
9255
9263
  };
9256
- res.write(`event: chat-sync
9257
- data: ${JSON.stringify({ chat: session?.chat || [] })}
9264
+ const sendEnded = (key, endedBy) => {
9265
+ if (key === req.params.key) {
9266
+ res.write(`event: ended
9267
+ data: ${JSON.stringify({ ended_by: endedBy || null })}
9258
9268
 
9259
9269
  `);
9260
- res.write(
9261
- `event: agent-presence
9262
- data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
9263
-
9264
- `
9265
- );
9270
+ }
9271
+ };
9266
9272
  events.on("reload", sendReload);
9267
9273
  events.on("agent-reply", sendAgentReply);
9268
9274
  events.on("agent-presence", sendPresence);
9269
9275
  events.on("layout-warnings", sendLayoutWarnings);
9270
- req.on("close", () => {
9276
+ events.on("ended", sendEnded);
9277
+ let cleanedUp = false;
9278
+ cleanup = () => {
9279
+ if (cleanedUp) return;
9280
+ cleanedUp = true;
9281
+ req.off("close", cleanup);
9271
9282
  sseClients.delete(res);
9272
9283
  events.off("reload", sendReload);
9273
9284
  events.off("agent-reply", sendAgentReply);
9274
9285
  events.off("agent-presence", sendPresence);
9275
9286
  events.off("layout-warnings", sendLayoutWarnings);
9287
+ events.off("ended", sendEnded);
9276
9288
  refreshIdleTimer();
9277
- });
9289
+ };
9290
+ req.once("close", cleanup);
9291
+ const session = await store.findByKey(req.params.key);
9292
+ if (req.destroyed || res.writableEnded) {
9293
+ cleanup();
9294
+ return;
9295
+ }
9296
+ res.write(`event: chat-sync
9297
+ data: ${JSON.stringify({ chat: session?.chat || [] })}
9298
+
9299
+ `);
9300
+ res.write(
9301
+ `event: agent-presence
9302
+ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
9303
+
9304
+ `
9305
+ );
9306
+ if (session?.status === "ended") sendEnded(req.params.key, session.ended_by);
9278
9307
  } catch (error) {
9308
+ cleanup();
9279
9309
  next(error);
9280
9310
  }
9281
9311
  });
@@ -10035,6 +10065,11 @@ function createChromeHtml(session, {
10035
10065
  const sessionJson = jsonScript({
10036
10066
  key: session.key,
10037
10067
  file: session.file,
10068
+ // A page loaded (or reloaded) after the session already ended has no future SSE `ended`
10069
+ // event to wait for - it must start read-only instead of looking live until the user tries
10070
+ // to send and gets refused (#171).
10071
+ initialEnded: session.status === "ended",
10072
+ initialEndedBy: session.ended_by || null,
10038
10073
  initialChat: session.chat || [],
10039
10074
  // Bootstrapping the inbox from the server is what makes it survive a browser refresh or a
10040
10075
  // reconnect: the chrome never owns warning state, it only renders it.
@@ -10334,7 +10369,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
10334
10369
  ]);
10335
10370
  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.";
10336
10371
  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.";
10337
- var VERSION = "0.1.56";
10372
+ var VERSION = "0.1.57";
10338
10373
  function detectInvokingAgent(env = process.env) {
10339
10374
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
10340
10375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.56",
3
+ "version": "0.1.57",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.56",
4
+ "version": "0.1.57",
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",