lavish-axi 0.1.8 → 0.1.10

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
@@ -97,6 +97,7 @@ pnpm link
97
97
  - **File-path identity** - Sessions are keyed by the canonical HTML file path, so agents do not need opaque IDs.
98
98
  - **Sandboxed artifact** - The artifact runs in an iframe while Lavish injects a small SDK for annotations, snapshots, and feedback controls, plus Tailwind CSS v4 and DaisyUI v5 design assets unless the HTML includes `<meta name="lavish-design" content="off">`.
99
99
  - **Feedback controls** - Mark buttons, choices, and other interactive elements with `data-lavish-action` so Lavish does not annotate them, then call `window.lavish.queuePrompt()` or `window.lavish.sendQueuedPrompts()` from the control handler.
100
+ - **Agent presence** - The browser shows when no agent is listening, still queues feedback for the next `lavish-axi poll`, and only blocks sending while the agent is working on delivered feedback.
100
101
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
101
102
  - **Local-first state** - Session state stays under `.lavish-axi/` in the workspace.
102
103
 
@@ -14,10 +14,11 @@ const annotationButton = /** @type {HTMLButtonElement} */ (document.getElementBy
14
14
  const endButton = /** @type {HTMLButtonElement} */ (document.getElementById("end"));
15
15
  const filePathInput = /** @type {HTMLInputElement} */ (document.getElementById("filePath"));
16
16
  const copyPathButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyPath"));
17
+ const presenceBanner = /** @type {HTMLDivElement} */ (document.getElementById("presenceBanner"));
17
18
 
18
19
  const queued = [];
19
20
  let annotation = true;
20
- let agentPolling = false;
21
+ let agentPresence = "waiting";
21
22
  let pendingSnapshot = "";
22
23
  let workingBubble = null;
23
24
 
@@ -81,11 +82,12 @@ function syncChat(chat) {
81
82
  chatLog.scrollTop = chatLog.scrollHeight;
82
83
  }
83
84
 
84
- function setAgentPolling(active) {
85
- agentPolling = !!active;
86
- sendButton.disabled = !agentPolling;
85
+ function setAgentPresence(state) {
86
+ agentPresence = state === "listening" || state === "working" ? state : "waiting";
87
+ sendButton.disabled = agentPresence === "working";
88
+ if (presenceBanner) presenceBanner.hidden = agentPresence !== "waiting";
87
89
 
88
- if (agentPolling) {
90
+ if (agentPresence !== "working") {
89
91
  if (workingBubble) workingBubble.remove();
90
92
  workingBubble = null;
91
93
  return;
@@ -111,7 +113,7 @@ function postToFrame(message) {
111
113
  }
112
114
 
113
115
  function sendQueued() {
114
- if (!agentPolling) return;
116
+ if (agentPresence === "working") return;
115
117
 
116
118
  const text = chatInput.value.trim();
117
119
  if (text) {
@@ -128,7 +130,7 @@ function sendQueued() {
128
130
  async function submitQueued() {
129
131
  const prompts = queued.splice(0, queued.length);
130
132
  render();
131
- setAgentPolling(false);
133
+ if (agentPresence === "listening") setAgentPresence("working");
132
134
  await fetch("/api/" + key + "/prompts", {
133
135
  method: "POST",
134
136
  headers: { "content-type": "application/json" },
@@ -214,8 +216,8 @@ events.addEventListener("reload", () => {
214
216
  events.addEventListener("chrome-reload", () => reloadAfterServerRestart());
215
217
  events.addEventListener("agent-reply", (event) => addChat("agent", JSON.parse(event.data).text));
216
218
  events.addEventListener("chat-sync", (event) => syncChat(JSON.parse(event.data).chat || []));
217
- events.addEventListener("agent-working", (event) => setAgentPolling(!JSON.parse(event.data).working));
219
+ events.addEventListener("agent-presence", (event) => setAgentPresence(JSON.parse(event.data).state));
218
220
 
219
221
  render();
220
222
  initialChat.forEach((item) => addChat(item.role, item.text));
221
- setAgentPolling(false);
223
+ setAgentPresence("waiting");
package/dist/chrome.css CHANGED
@@ -359,6 +359,19 @@ body.lavish {
359
359
  flex-shrink: 0;
360
360
  box-sizing: border-box;
361
361
  }
362
+ .presence-banner {
363
+ border: 1px solid var(--border);
364
+ border-radius: var(--radius-sm);
365
+ background: var(--bg-elevated);
366
+ color: var(--fg-muted);
367
+ font-size: 11px;
368
+ font-weight: var(--w-medium);
369
+ line-height: 1.4;
370
+ padding: 8px 10px;
371
+ }
372
+ .presence-banner[hidden] {
373
+ display: none;
374
+ }
362
375
  .annotation-pills {
363
376
  display: flex;
364
377
  flex-wrap: wrap;
package/dist/cli.mjs CHANGED
@@ -1187,6 +1187,7 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
1187
1187
  const events = new EventEmitter();
1188
1188
  const watchers = /* @__PURE__ */ new Map();
1189
1189
  const activePolls = /* @__PURE__ */ new Map();
1190
+ const deliveredFeedback = /* @__PURE__ */ new Set();
1190
1191
  const sseClients = /* @__PURE__ */ new Set();
1191
1192
  app.use(express.json({ limit: "2mb" }));
1192
1193
  app.get("/health", (req, res) => {
@@ -1205,7 +1206,11 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
1205
1206
  const file = await canonicalFile(req.body.file);
1206
1207
  const key = sessionKey(file);
1207
1208
  const url = `http://localhost:${port}/session/${key}`;
1209
+ const existing = await store.findByKey(key);
1208
1210
  const session = await store.upsertSession(file, url);
1211
+ if (existing?.status === "ended") {
1212
+ clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
1213
+ }
1209
1214
  watchSession(session, watchers, events);
1210
1215
  res.json({ key, file, url, status: "opened" });
1211
1216
  } catch (error) {
@@ -1219,29 +1224,38 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
1219
1224
  const timeoutMs = req.query.timeoutMs === void 0 ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
1220
1225
  const immediate = await store.takeFeedback(key);
1221
1226
  if (immediate.status !== "waiting") {
1227
+ if (immediate.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
1222
1228
  res.json(immediate);
1223
1229
  return;
1224
1230
  }
1225
- setPollActive(key, activePolls, events, true);
1226
- const timer = timeoutMs === null ? null : setTimeout(async () => {
1227
- cleanup();
1228
- res.json(await store.takeFeedback(key));
1229
- }, timeoutMs);
1230
- const onFeedback = async (changedKey) => {
1231
- if (changedKey !== key || res.headersSent) {
1232
- return;
1233
- }
1234
- cleanup();
1235
- res.json(await store.takeFeedback(key));
1236
- };
1231
+ setPollActive(key, activePolls, deliveredFeedback, events, true);
1232
+ const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(next), timeoutMs);
1237
1233
  let cleaned = false;
1234
+ let responding = false;
1238
1235
  const cleanup = () => {
1239
1236
  if (cleaned) return;
1240
1237
  cleaned = true;
1241
1238
  if (timer) clearTimeout(timer);
1242
1239
  events.off("feedback", onFeedback);
1243
1240
  events.off("ended", onFeedback);
1244
- setPollActive(key, activePolls, events, false);
1241
+ setPollActive(key, activePolls, deliveredFeedback, events, false);
1242
+ };
1243
+ const respond = async () => {
1244
+ if (responding || res.headersSent) return;
1245
+ responding = true;
1246
+ try {
1247
+ const result = await store.takeFeedback(key);
1248
+ if (result.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
1249
+ res.json(result);
1250
+ } finally {
1251
+ cleanup();
1252
+ }
1253
+ };
1254
+ const onFeedback = (changedKey) => {
1255
+ if (changedKey !== key || res.headersSent) {
1256
+ return;
1257
+ }
1258
+ respond().catch(next);
1245
1259
  };
1246
1260
  events.on("feedback", onFeedback);
1247
1261
  events.on("ended", onFeedback);
@@ -1266,6 +1280,7 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
1266
1280
  app.post("/api/:key/end", async (req, res, next) => {
1267
1281
  try {
1268
1282
  await store.endSession(req.params.key);
1283
+ clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
1269
1284
  events.emit("ended", req.params.key);
1270
1285
  res.json({ status: "ended" });
1271
1286
  } catch (error) {
@@ -1291,6 +1306,7 @@ async function serve({ port, stateFile: stateFile2, version = "" }) {
1291
1306
  const file = await canonicalFile(req.body.file);
1292
1307
  const key = sessionKey(file);
1293
1308
  await store.endSession(key);
1309
+ clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
1294
1310
  events.emit("ended", key);
1295
1311
  res.json({ status: "ended" });
1296
1312
  } catch (error) {
@@ -1369,10 +1385,10 @@ data: ${JSON.stringify({ text })}
1369
1385
  `);
1370
1386
  }
1371
1387
  };
1372
- const sendWorking = (key, working) => {
1388
+ const sendPresence = (key, state) => {
1373
1389
  if (key === req.params.key) {
1374
- res.write(`event: agent-working
1375
- data: ${JSON.stringify({ working })}
1390
+ res.write(`event: agent-presence
1391
+ data: ${JSON.stringify({ state })}
1376
1392
 
1377
1393
  `);
1378
1394
  }
@@ -1381,18 +1397,20 @@ data: ${JSON.stringify({ working })}
1381
1397
  data: ${JSON.stringify({ chat: session?.chat || [] })}
1382
1398
 
1383
1399
  `);
1384
- res.write(`event: agent-working
1385
- data: ${JSON.stringify({ working: !activePolls.has(req.params.key) })}
1400
+ res.write(
1401
+ `event: agent-presence
1402
+ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
1386
1403
 
1387
- `);
1404
+ `
1405
+ );
1388
1406
  events.on("reload", sendReload);
1389
1407
  events.on("agent-reply", sendAgentReply);
1390
- events.on("agent-working", sendWorking);
1408
+ events.on("agent-presence", sendPresence);
1391
1409
  req.on("close", () => {
1392
1410
  sseClients.delete(res);
1393
1411
  events.off("reload", sendReload);
1394
1412
  events.off("agent-reply", sendAgentReply);
1395
- events.off("agent-working", sendWorking);
1413
+ events.off("agent-presence", sendPresence);
1396
1414
  });
1397
1415
  } catch (error) {
1398
1416
  next(error);
@@ -1497,7 +1515,8 @@ function watchSession(session, watchers, events) {
1497
1515
  });
1498
1516
  watchers.set(session.key, watcher);
1499
1517
  }
1500
- function setPollActive(key, activePolls, events, active) {
1518
+ function setPollActive(key, activePolls, deliveredFeedback, events, active) {
1519
+ const previousPresence = computePresence(key, activePolls, deliveredFeedback);
1501
1520
  const count = activePolls.get(key) || 0;
1502
1521
  const nextCount = active ? count + 1 : Math.max(0, count - 1);
1503
1522
  if (nextCount === count) return;
@@ -1505,9 +1524,31 @@ function setPollActive(key, activePolls, events, active) {
1505
1524
  activePolls.delete(key);
1506
1525
  } else {
1507
1526
  activePolls.set(key, nextCount);
1527
+ deliveredFeedback.delete(key);
1508
1528
  }
1509
- if (count > 0 === nextCount > 0) return;
1510
- events.emit("agent-working", key, nextCount === 0);
1529
+ const nextPresence = computePresence(key, activePolls, deliveredFeedback);
1530
+ if (nextPresence !== previousPresence) events.emit("agent-presence", key, nextPresence);
1531
+ }
1532
+ function markFeedbackDelivered(key, activePolls, deliveredFeedback, events) {
1533
+ const previousPresence = computePresence(key, activePolls, deliveredFeedback);
1534
+ deliveredFeedback.add(key);
1535
+ const nextPresence = computePresence(key, activePolls, deliveredFeedback);
1536
+ if (nextPresence !== previousPresence) {
1537
+ events.emit("agent-presence", key, nextPresence);
1538
+ }
1539
+ }
1540
+ function clearFeedbackDelivery(key, activePolls, deliveredFeedback, events) {
1541
+ const previousPresence = computePresence(key, activePolls, deliveredFeedback);
1542
+ deliveredFeedback.delete(key);
1543
+ const nextPresence = computePresence(key, activePolls, deliveredFeedback);
1544
+ if (nextPresence !== previousPresence) {
1545
+ events.emit("agent-presence", key, nextPresence);
1546
+ }
1547
+ }
1548
+ function computePresence(key, activePolls, deliveredFeedback) {
1549
+ if (activePolls.has(key)) return "listening";
1550
+ if (deliveredFeedback.has(key)) return "working";
1551
+ return "waiting";
1511
1552
  }
1512
1553
  function createChromeHtml(session) {
1513
1554
  const fileInputSize = Math.max(1, session.file.length);
@@ -1522,7 +1563,7 @@ function createChromeHtml(session) {
1522
1563
  </head>
1523
1564
  <body class="lavish">
1524
1565
  <div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="divider" aria-hidden="true"></div><div class="file-wrap" title="${escapeHtml(session.file)}"><input class="file-input" id="filePath" readonly size="${fileInputSize}" value="${escapeHtml(session.file)}"><button class="copy-button" id="copyPath" type="button">Copy Path</button></div><button class="button secondary annotation-on" id="annotation">Annotation: On</button><button class="button danger" id="end">End Session</button></div>
1525
- <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" src="/artifact/${session.key}/index.html"></iframe></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions"><button class="button" id="send">Send to Agent</button></div></div></aside></div>
1566
+ <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" src="/artifact/${session.key}/index.html"></iframe></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Lavish.</div><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions"><button class="button" id="send">Send to Agent</button></div></div></aside></div>
1526
1567
  <script id="lavish-session" type="application/json">${sessionJson}</script>
1527
1568
  <script src="/chrome-client.js"></script>
1528
1569
  </body>
@@ -1717,7 +1758,7 @@ function normalizePagePath(path5) {
1717
1758
  // src/cli.js
1718
1759
  var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "server", "playbook", "design"]);
1719
1760
  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`.";
1720
- var VERSION = "0.1.8";
1761
+ var VERSION = "0.1.10";
1721
1762
  async function run(argv) {
1722
1763
  await ensureStateDir();
1723
1764
  const normalizedArgv = normalizeArgv(argv);
@@ -1834,7 +1875,7 @@ function createPlaybookOutput(args) {
1834
1875
  function createOpenOutput({ file, url, status }) {
1835
1876
  return {
1836
1877
  session: { file, url, status },
1837
- next_step: `Run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback or ends the session. Do not pass --timeout-ms during normal agent use. Do not set a short shell timeout; either run it without a timeout or set the shell timeout above 10 minutes. 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.`
1878
+ 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 or ends the session. Do not pass --timeout-ms during normal agent use. Do not set a short shell timeout; either run it without a timeout or set the shell timeout above 10 minutes. 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.`
1838
1879
  };
1839
1880
  }
1840
1881
  async function openCommand(args) {
@@ -1886,7 +1927,7 @@ function createPollOutput({ file, response }) {
1886
1927
  session: { file, status: "feedback" },
1887
1928
  dom_snapshot: response.dom_snapshot || "",
1888
1929
  prompts: response.prompts || [],
1889
- next_step: `Apply the requested changes to ${file}, then run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms unless the user ended the session. The poll command waits until the user sends more feedback or ends the session; do not set a short shell timeout, or set the shell timeout above 10 minutes.`
1930
+ next_step: `Apply the requested changes to ${file}. 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 command waits until the user sends more feedback or ends the session; do not set a short shell timeout, or set the shell timeout above 10 minutes.`
1890
1931
  };
1891
1932
  }
1892
1933
  if (response.status === "ended") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",