lavish-axi 0.1.11 → 0.1.13

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
@@ -96,9 +96,10 @@ pnpm link
96
96
 
97
97
  - **File-path identity** - Sessions are keyed by the canonical HTML file path, so agents do not need opaque IDs.
98
98
  - **Portable artifacts** - The artifact runs in an iframe while Lavish injects a small SDK for annotations, snapshots, and feedback controls. Lavish does not inject any design system, so the saved HTML file renders identically whether you open it through `lavish-axi` or directly in a browser. Run `lavish-axi design` for a copy-pasteable CDN snippet to opt in to Tailwind CSS v4 + DaisyUI v5, or use any other design system (or none).
99
+ - **Local assets** - Copy local images, CSS, fonts, and scripts next to the HTML artifact and reference them with relative paths from that directory; root-prefixed paths such as `/assets/logo.png` will not resolve through Lavish's artifact route.
99
100
  - **Live reload** - Lavish watches the HTML artifact file by default. To also reload on sibling asset changes, add `data-lavish-live-reload-root` to the root element or `<meta name="lavish-live-reload" content="root">`.
100
101
  - **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.
101
- - **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.
102
+ - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and only blocks sending while the agent is working on delivered feedback.
102
103
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
103
104
  - **Local-first state** - Session state stays under `.lavish-axi/` in the workspace.
104
105
 
@@ -118,12 +119,12 @@ Known playbook IDs: `diagram`, `table`, `comparison`, `plan`, `diff`, `input`, `
118
119
 
119
120
  ### Flags
120
121
 
121
- | Command | Flag | Description |
122
- | ------------------------ | --------------------- | ---------------------------------------------------------------------------------------- |
123
- | `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
124
- | `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat before polling again. |
125
- | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it. |
126
- | `lavish-axi server` | `--verbose` | Log session and watcher events to stderr; can also be enabled with `LAVISH_AXI_DEBUG=1`. |
122
+ | Command | Flag | Description |
123
+ | ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
+ | `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
125
+ | `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat before polling again. |
126
+ | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it. |
127
+ | `lavish-axi server` | `--verbose` | Log session and watcher events to stderr; can also be enabled with `LAVISH_AXI_DEBUG=1`. Detached server output is appended to `~/.lavish-axi/server.log` (or `LAVISH_AXI_STATE_DIR/server.log`) for startup and crash diagnostics. |
127
128
 
128
129
  ## Development
129
130
 
@@ -3,6 +3,7 @@
3
3
  const sessionDataElement = document.getElementById("lavish-session");
4
4
  const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
5
5
  const key = String(sessionData.key || "");
6
+ const queueStorageKey = "lavish-axi:queued:" + key;
6
7
  const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
7
8
 
8
9
  const frame = /** @type {HTMLIFrameElement} */ (document.getElementById("artifact"));
@@ -16,11 +17,13 @@ const filePathInput = /** @type {HTMLInputElement} */ (document.getElementById("
16
17
  const copyPathButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyPath"));
17
18
  const presenceBanner = /** @type {HTMLDivElement} */ (document.getElementById("presenceBanner"));
18
19
 
19
- const queued = [];
20
+ const queued = loadQueuedPrompts();
20
21
  let annotation = true;
21
22
  let agentPresence = "waiting";
22
23
  let pendingSnapshot = "";
23
24
  let workingBubble = null;
25
+ let submitQueuedPromise = null;
26
+ let submitQueuedAgain = false;
24
27
 
25
28
  function escapeHtml(value) {
26
29
  return String(value).replace(
@@ -36,6 +39,27 @@ function escapeHtml(value) {
36
39
  );
37
40
  }
38
41
 
42
+ function loadQueuedPrompts() {
43
+ try {
44
+ const parsed = JSON.parse(sessionStorage.getItem(queueStorageKey) || "[]");
45
+ return Array.isArray(parsed) ? parsed.filter((prompt) => prompt && typeof prompt === "object") : [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ function persistQueuedPrompts() {
52
+ try {
53
+ if (queued.length) {
54
+ sessionStorage.setItem(queueStorageKey, JSON.stringify(queued));
55
+ } else {
56
+ sessionStorage.removeItem(queueStorageKey);
57
+ }
58
+ } catch {
59
+ // The in-memory queue still works if browser storage is unavailable.
60
+ }
61
+ }
62
+
39
63
  function render() {
40
64
  annotationPills.innerHTML = queued
41
65
  .map(
@@ -105,6 +129,7 @@ function setAgentPresence(state) {
105
129
  function removeQueuedPrompt(index, event) {
106
130
  if (event) event.stopPropagation();
107
131
  queued.splice(index, 1);
132
+ persistQueuedPrompts();
108
133
  render();
109
134
  }
110
135
 
@@ -118,6 +143,7 @@ function sendQueued() {
118
143
  const text = chatInput.value.trim();
119
144
  if (text) {
120
145
  queued.push({ uid: "", prompt: text, selector: "", tag: "message", text: "Freeform message" });
146
+ persistQueuedPrompts();
121
147
  addChat("user", text);
122
148
  chatInput.value = "";
123
149
  render();
@@ -128,14 +154,40 @@ function sendQueued() {
128
154
  }
129
155
 
130
156
  async function submitQueued() {
131
- const prompts = queued.splice(0, queued.length);
132
- render();
133
- if (agentPresence === "listening") setAgentPresence("working");
134
- await fetch("/api/" + key + "/prompts", {
157
+ if (submitQueuedPromise) {
158
+ submitQueuedAgain = true;
159
+ return submitQueuedPromise;
160
+ }
161
+
162
+ let succeeded = false;
163
+ submitQueuedPromise = submitQueuedOnce();
164
+ try {
165
+ const result = await submitQueuedPromise;
166
+ succeeded = true;
167
+ return result;
168
+ } finally {
169
+ submitQueuedPromise = null;
170
+ const shouldSubmitAgain = submitQueuedAgain;
171
+ submitQueuedAgain = false;
172
+ if (succeeded && shouldSubmitAgain && queued.length) submitQueued();
173
+ }
174
+ }
175
+
176
+ async function submitQueuedOnce() {
177
+ const prompts = queued.slice();
178
+ const response = await fetch("/api/" + key + "/prompts", {
135
179
  method: "POST",
136
180
  headers: { "content-type": "application/json" },
137
181
  body: JSON.stringify({ prompts, domSnapshot: pendingSnapshot }),
138
182
  });
183
+ if (!response.ok) throw new Error("failed to submit queued prompts");
184
+ for (const prompt of prompts) {
185
+ const index = queued.indexOf(prompt);
186
+ if (index !== -1) queued.splice(index, 1);
187
+ }
188
+ persistQueuedPrompts();
189
+ render();
190
+ if (agentPresence === "listening") setAgentPresence("working");
139
191
  }
140
192
 
141
193
  async function endSession() {
@@ -185,6 +237,7 @@ window.addEventListener("message", (event) => {
185
237
  const msg = event.data || {};
186
238
  if (msg.type === "lavish:queuePrompt") {
187
239
  queued.push(msg.prompt);
240
+ persistQueuedPrompts();
188
241
  render();
189
242
  }
190
243
  if (msg.type === "lavish:snapshot") {
package/dist/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.js
4
4
  import { spawn, spawnSync } from "node:child_process";
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
6
6
  import { access } from "node:fs/promises";
7
7
  import os2 from "node:os";
8
8
  import path4 from "node:path";
@@ -472,12 +472,16 @@ function createDesignOutput() {
472
472
  import { mkdir } from "node:fs/promises";
473
473
  import os from "node:os";
474
474
  import path from "node:path";
475
+ var LOOPBACK_HOST = "127.0.0.1";
475
476
  function stateDir() {
476
477
  return process.env.LAVISH_AXI_STATE_DIR || path.join(os.homedir(), ".lavish-axi");
477
478
  }
478
479
  function stateFile() {
479
480
  return path.join(stateDir(), "state.json");
480
481
  }
482
+ function serverLogFile() {
483
+ return path.join(stateDir(), "server.log");
484
+ }
481
485
  async function ensureStateDir() {
482
486
  await mkdir(stateDir(), { recursive: true });
483
487
  }
@@ -1179,7 +1183,7 @@ var designAssetUrls = {
1179
1183
  type: "application/javascript"
1180
1184
  }
1181
1185
  };
1182
- async function serve({ port, stateFile: stateFile2, version = "", debug = false, log = null }) {
1186
+ async function serve({ port, stateFile: stateFile2, version = "", debug = false, log = null, pollHeartbeatMs = 15e3 }) {
1183
1187
  const app = express();
1184
1188
  const store = new SessionStore(stateFile2);
1185
1189
  const events = new EventEmitter();
@@ -1191,6 +1195,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1191
1195
  const writeLog = typeof log === "function" ? log : (line) => process.stderr.write(`${line}
1192
1196
  `);
1193
1197
  const logEvent = verbose ? (line) => writeLog(`[lavish] ${line}`) : null;
1198
+ let publicPort = port;
1194
1199
  app.use(express.json({ limit: "2mb" }));
1195
1200
  app.get("/health", (req, res) => {
1196
1201
  res.json({ ok: true, app: "lavish-axi", version });
@@ -1207,7 +1212,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1207
1212
  try {
1208
1213
  const file = await canonicalFile(req.body.file);
1209
1214
  const key = sessionKey(file);
1210
- const url = `http://localhost:${port}/session/${key}`;
1215
+ const url = `http://${LOOPBACK_HOST}:${publicPort}/session/${key}`;
1211
1216
  const existing = await store.findByKey(key);
1212
1217
  const session = await store.upsertSession(file, url);
1213
1218
  if (existing?.status === "ended") {
@@ -1222,6 +1227,14 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1222
1227
  });
1223
1228
  app.get("/api/poll", async (req, res, next) => {
1224
1229
  try {
1230
+ let handleRespondError = function(error) {
1231
+ if (streamHeartbeat) {
1232
+ cleanup();
1233
+ if (!res.writableEnded) res.destroy(error);
1234
+ return;
1235
+ }
1236
+ next(error);
1237
+ };
1225
1238
  const file = await canonicalFile(String(req.query.file || ""));
1226
1239
  const key = sessionKey(file);
1227
1240
  const timeoutMs = req.query.timeoutMs === void 0 ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
@@ -1231,34 +1244,49 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1231
1244
  res.json(immediate);
1232
1245
  return;
1233
1246
  }
1247
+ const streamHeartbeat = timeoutMs === null;
1248
+ let heartbeat = null;
1249
+ if (streamHeartbeat) {
1250
+ res.status(200).type("application/json");
1251
+ res.write(" ");
1252
+ heartbeat = setInterval(() => {
1253
+ if (!res.writableEnded) res.write(" ");
1254
+ }, pollHeartbeatMs);
1255
+ heartbeat.unref?.();
1256
+ }
1234
1257
  setPollActive(key, activePolls, deliveredFeedback, events, true);
1235
- const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(next), timeoutMs);
1258
+ const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
1236
1259
  let cleaned = false;
1237
1260
  let responding = false;
1238
1261
  const cleanup = () => {
1239
1262
  if (cleaned) return;
1240
1263
  cleaned = true;
1241
1264
  if (timer) clearTimeout(timer);
1265
+ if (heartbeat) clearInterval(heartbeat);
1242
1266
  events.off("feedback", onFeedback);
1243
1267
  events.off("ended", onFeedback);
1244
1268
  setPollActive(key, activePolls, deliveredFeedback, events, false);
1245
1269
  };
1246
1270
  const respond = async () => {
1247
- if (responding || res.headersSent) return;
1271
+ if (responding || res.writableEnded) return;
1248
1272
  responding = true;
1249
1273
  try {
1250
1274
  const result = await store.takeFeedback(key);
1251
1275
  if (result.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
1252
- res.json(result);
1276
+ if (streamHeartbeat) {
1277
+ res.end(JSON.stringify(result));
1278
+ } else {
1279
+ res.json(result);
1280
+ }
1253
1281
  } finally {
1254
1282
  cleanup();
1255
1283
  }
1256
1284
  };
1257
1285
  const onFeedback = (changedKey) => {
1258
- if (changedKey !== key || res.headersSent) {
1286
+ if (changedKey !== key || res.writableEnded) {
1259
1287
  return;
1260
1288
  }
1261
- respond().catch(next);
1289
+ respond().catch(handleRespondError);
1262
1290
  };
1263
1291
  events.on("feedback", onFeedback);
1264
1292
  events.on("ended", onFeedback);
@@ -1361,7 +1389,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1361
1389
  res.status(403).send("Forbidden");
1362
1390
  return;
1363
1391
  }
1364
- res.sendFile(file);
1392
+ res.sendFile(file, { dotfiles: "allow" });
1365
1393
  } catch (error) {
1366
1394
  next(error);
1367
1395
  }
@@ -1452,8 +1480,9 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
1452
1480
  res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
1453
1481
  });
1454
1482
  const httpServer = await new Promise((resolve) => {
1455
- const s = app.listen(port, "127.0.0.1", () => resolve(s));
1483
+ const s = app.listen(port, LOOPBACK_HOST, () => resolve(s));
1456
1484
  });
1485
+ publicPort = httpServer.address().port;
1457
1486
  let shuttingDown = false;
1458
1487
  function shutdown() {
1459
1488
  if (shuttingDown) return;
@@ -1793,7 +1822,7 @@ function normalizePagePath(path5) {
1793
1822
  // src/cli.js
1794
1823
  var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "server", "playbook", "design"]);
1795
1824
  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`.";
1796
- var VERSION = "0.1.11";
1825
+ var VERSION = "0.1.13";
1797
1826
  async function run(argv) {
1798
1827
  await ensureStateDir();
1799
1828
  const normalizedArgv = normalizeArgv(argv);
@@ -1883,6 +1912,7 @@ function createHomeOutput({ bin, sessions, includeSessions = true }) {
1883
1912
  help: [
1884
1913
  "Run `lavish-axi <html-file>` to open or resume a Lavish Editor session",
1885
1914
  "Unless the user specifies another location, create HTML artifacts in the current working directory under `.lavish/`",
1915
+ "Lavish serves the html file through a local express.js server. If your html needs to reference other filesystem assets such as images, CSS, fonts, and local scripts, copy them into the same directory as the HTML file, then reference them with relative paths from that directory. Never prepend `/` to those asset paths - root paths won't work",
1886
1916
  "Run `lavish-axi poll <html-file>` to wait for user feedback",
1887
1917
  "Run `lavish-axi end <html-file>` to end a session",
1888
1918
  "Run `lavish-axi playbook <playbook_id>` for focused artifact guidance",
@@ -1948,7 +1978,10 @@ async function pollCommand(args) {
1948
1978
  }
1949
1979
  const timeoutMs = flagValue(args, "--timeout-ms");
1950
1980
  const timeoutQuery = timeoutMs ? `&timeoutMs=${encodeURIComponent(timeoutMs)}` : "";
1951
- const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}`);
1981
+ const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}`, {
1982
+ retries: 3,
1983
+ retryDelayMs: 500
1984
+ });
1952
1985
  return createPollOutput({ file: absolute, response });
1953
1986
  }
1954
1987
  function createPollOutput({ file, response }) {
@@ -2017,7 +2050,7 @@ function isHtmlPath(file) {
2017
2050
  }
2018
2051
  async function ensureServer({ forceRestart = false } = {}) {
2019
2052
  const port = defaultPort();
2020
- const baseUrl = `http://localhost:${port}`;
2053
+ const baseUrl = `http://${LOOPBACK_HOST}:${port}`;
2021
2054
  const existing = await fetchHealth(baseUrl);
2022
2055
  if (existing && !shouldRestartServer(VERSION, existing, forceRestart)) {
2023
2056
  return baseUrl;
@@ -2106,36 +2139,84 @@ function killProcessOnPort(port) {
2106
2139
  async function startServer(port) {
2107
2140
  await ensureStateDir();
2108
2141
  const entry = resolveServerEntry();
2109
- const child = spawn(process.execPath, [entry, "server", "--port", String(port)], {
2110
- detached: true,
2111
- stdio: "ignore",
2112
- env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
2113
- });
2114
- child.unref();
2142
+ let logFd = null;
2143
+ try {
2144
+ logFd = openSync(serverLogFile(), "a");
2145
+ } catch {
2146
+ }
2147
+ try {
2148
+ const child = spawn(process.execPath, [entry, "server", "--port", String(port)], createServerSpawnOptions(logFd));
2149
+ child.unref();
2150
+ } finally {
2151
+ if (logFd !== null) closeSync(logFd);
2152
+ }
2115
2153
  }
2116
2154
  function resolveServerEntry() {
2117
2155
  const binEntry = fileURLToPath(new URL("../bin/lavish-axi.js", import.meta.url));
2118
2156
  if (existsSync(binEntry)) return binEntry;
2119
2157
  return fileURLToPath(import.meta.url);
2120
2158
  }
2121
- async function fetchJson(url) {
2122
- const response = await fetch(url);
2159
+ function createServerSpawnOptions(logFd = null) {
2160
+ const stdio = (
2161
+ /** @type {import("node:child_process").StdioOptions} */
2162
+ logFd === null ? "ignore" : ["ignore", logFd, logFd]
2163
+ );
2164
+ return {
2165
+ detached: true,
2166
+ stdio,
2167
+ env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
2168
+ };
2169
+ }
2170
+ async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) {
2171
+ let response;
2172
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
2173
+ try {
2174
+ response = await fetch(url);
2175
+ break;
2176
+ } catch (error) {
2177
+ if (error instanceof AxiError) throw error;
2178
+ if (attempt >= retries) throw serverConnectionError();
2179
+ await delay(retryDelayMs);
2180
+ }
2181
+ }
2182
+ if (!response) throw serverConnectionError();
2123
2183
  if (!response.ok) {
2124
2184
  throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
2125
2185
  }
2126
- return response.json();
2186
+ try {
2187
+ return await response.json();
2188
+ } catch {
2189
+ throw pollResponseInterruptedError();
2190
+ }
2127
2191
  }
2128
2192
  async function postJson(url, body) {
2129
- const response = await fetch(url, {
2130
- method: "POST",
2131
- headers: { "content-type": "application/json" },
2132
- body: JSON.stringify(body)
2133
- });
2193
+ let response;
2194
+ try {
2195
+ response = await fetch(url, {
2196
+ method: "POST",
2197
+ headers: { "content-type": "application/json" },
2198
+ body: JSON.stringify(body)
2199
+ });
2200
+ } catch {
2201
+ throw serverConnectionError();
2202
+ }
2134
2203
  if (!response.ok) {
2135
2204
  throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
2136
2205
  }
2137
2206
  return response.json();
2138
2207
  }
2208
+ function serverConnectionError() {
2209
+ return new AxiError("Lavish Editor server connection failed", "SERVER_ERROR", [
2210
+ "Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
2211
+ "Re-run the last `lavish-axi poll <html-file>` command after the server is healthy"
2212
+ ]);
2213
+ }
2214
+ function pollResponseInterruptedError() {
2215
+ return new AxiError("Lavish Editor poll response was interrupted", "SERVER_ERROR", [
2216
+ "Run `lavish-axi server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
2217
+ "Re-run the last `lavish-axi poll <html-file>` command after the server is healthy"
2218
+ ]);
2219
+ }
2139
2220
  function flagValue(args, flag) {
2140
2221
  const index = args.indexOf(flag);
2141
2222
  if (index === -1) {
@@ -2192,7 +2273,7 @@ Show a copy-pasteable CDN snippet for Tailwind CSS browser runtime v4 + DaisyUI
2192
2273
  `,
2193
2274
  server: `Usage: lavish-axi server [--port 4387] [--verbose]
2194
2275
 
2195
- Run the local Lavish Editor server. Pass --verbose (or set LAVISH_AXI_DEBUG=1) to log session and watcher events to stderr.
2276
+ Run the local Lavish Editor server. Pass --verbose (or set LAVISH_AXI_DEBUG=1) to log session and watcher events to stderr. Detached server output is appended to ~/.lavish-axi/server.log, or LAVISH_AXI_STATE_DIR/server.log when set, for startup and crash diagnostics.
2196
2277
  `
2197
2278
  };
2198
2279
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",