lavish-axi 0.1.16 → 0.1.18

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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/cli.mjs +135 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,7 +35,7 @@ Lavish Editor opens agent-generated HTML files in a local browser, lets you pinp
35
35
 
36
36
  - **Local only** - Work with your local HTML artifacts with a local CLI. Zero cloud dependency.
37
37
  - **Human-AI collaboration** - Annotate elements, selected text ranges, and send messages to the agent without leaving Lavish Editor.
38
- - **Battery included** - Lavish Editor teaches your agent good visualization for common use cases such as technial plans, design explorations and more out of the box.
38
+ - **Battery included** - Lavish Editor teaches your agent good visualization for common use cases such as product or technical plans, design explorations and more out of the box.
39
39
 
40
40
  Lavish Editor is an [AXI](https://axi.md), which means -
41
41
 
@@ -48,7 +48,7 @@ Lavish Editor is an [AXI](https://axi.md), which means -
48
48
  Just tell your agent:
49
49
 
50
50
  ```sh
51
- Use `npx lavish-axi` to write a technical plan for what we discussed.
51
+ Use `npx lavish-axi` to write a product or technical plan for what we discussed.
52
52
  ```
53
53
 
54
54
  ## Install
@@ -101,6 +101,8 @@ pnpm link
101
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.
102
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.
103
103
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
104
+ - **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.
105
+ Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
104
106
  - **Local-first state** - Session state stays under `.lavish-axi/` in the workspace.
105
107
 
106
108
  ## CLI Reference
@@ -111,6 +113,7 @@ pnpm link
111
113
  | `lavish-axi <html-file>` | Open or resume a Lavish Editor session. |
112
114
  | `lavish-axi poll <html-file>` | Long-poll until the user sends feedback or ends the session. |
113
115
  | `lavish-axi end <html-file>` | End a session. |
116
+ | `lavish-axi stop` | Shut down the background server. |
114
117
  | `lavish-axi playbook [id]` | List focused artifact guidance or show one playbook. |
115
118
  | `lavish-axi design` | Show CDN snippet + DaisyUI component reference (opt-in). |
116
119
  | `lavish-axi setup hooks` | Install or repair optional SessionStart hooks for Claude Code, Codex, and OpenCode; restart the agent session afterward. |
@@ -125,6 +128,7 @@ Known playbook IDs: `diagram`, `table`, `comparison`, `plan`, `diff`, `input`, `
125
128
  | `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
126
129
  | `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat before polling again. |
127
130
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it. |
131
+ | `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
128
132
  | `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. |
129
133
 
130
134
  ## Development
package/dist/cli.mjs CHANGED
@@ -577,31 +577,28 @@ var PLAYBOOKS = [
577
577
  },
578
578
  {
579
579
  id: "plan",
580
- use_when: "Explain a technical plan before implementation",
580
+ use_when: "Explain a product or technical plan before implementation",
581
581
  choose: [
582
582
  "Use this when the user needs to inspect a feature approach before implementation begins.",
583
- "Use it when state, APIs, files, tests, or edge cases are numerous enough to deserve a visual map.",
584
- "Use a lighter comparison or diagram playbook when the plan is only a small design choice."
583
+ "Use it when the user explicitly asked for a PRD, technical design, implementation plan or proposal.",
584
+ "Use a lighter comparison or diagram playbook when the plan is only a single small design choice."
585
585
  ],
586
586
  structure: [
587
- "Start with the problem, the desired behavior, and what is out of scope.",
588
- "Show affected state, commands, functions, files, and user-visible behavior.",
589
- "Include edge cases and tests before implementation notes so risk is visible early."
587
+ "Start with the goal, the current state, and desired behavior.",
588
+ "Then describe a proposed approach, focusing on high level decisions.",
589
+ "At the end, list any risks you see, and open questions you have, and follow the 'comparison' playbook to provide options for the user to choose from."
590
590
  ],
591
591
  design_rules: [
592
592
  "Verify each claim against the codebase before presenting it as fact.",
593
- "Keep code snippets focused on the pattern or seam, not full-file dumps.",
594
- "Make test requirements concrete enough to drive TDD."
593
+ "When discussing frontend experiences, prefer visually mocking the experience under a consistent design system as the real product over describing it with text.",
594
+ "The plan needs to be self-contained enough that another developer can read it and fully implement the proposal."
595
595
  ],
596
596
  pitfalls: [
597
- "Do not invent extension points or APIs that are not present in the repo.",
598
- "Do not turn a plan into a long prose essay when state and file maps would be clearer.",
597
+ "Do not leave resolved open questions in the artifact. Update existing content to reflect the decision and remove the open question.",
598
+ "Do not only focus on ambiguous decisions and omit the actual proposal.",
599
599
  "Do not omit failure modes, migration concerns, or backwards compatibility questions."
600
600
  ],
601
- lavish_notes: [
602
- "A Lavish plan should make uncertainties easy to annotate before code exists.",
603
- "Use controls for scope choices so the user can queue a precise implementation direction."
604
- ]
601
+ lavish_notes: ["A Lavish plan should make a plan and its uncertainties easy to annotate before code exists."]
605
602
  },
606
603
  {
607
604
  id: "diff",
@@ -1198,7 +1195,24 @@ var designAssetUrls = {
1198
1195
  type: "application/javascript"
1199
1196
  }
1200
1197
  };
1201
- async function serve({ port, stateFile: stateFile2, version = "", debug = false, log = null, pollHeartbeatMs = 15e3 }) {
1198
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
1199
+ function resolveIdleTimeoutMs(env = process.env) {
1200
+ const raw = env.LAVISH_AXI_IDLE_TIMEOUT_MS?.trim();
1201
+ if (raw === void 0 || raw === "") return DEFAULT_IDLE_TIMEOUT_MS;
1202
+ if (raw === "0" || raw.toLowerCase() === "off") return null;
1203
+ const value = Number(raw);
1204
+ if (!Number.isFinite(value) || value <= 0) return DEFAULT_IDLE_TIMEOUT_MS;
1205
+ return value;
1206
+ }
1207
+ async function serve({
1208
+ port,
1209
+ stateFile: stateFile2,
1210
+ version = "",
1211
+ debug = false,
1212
+ log = null,
1213
+ pollHeartbeatMs = 15e3,
1214
+ idleTimeoutMs = resolveIdleTimeoutMs()
1215
+ }) {
1202
1216
  const app = express();
1203
1217
  const store = new SessionStore(stateFile2);
1204
1218
  const events = new EventEmitter();
@@ -1270,6 +1284,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1270
1284
  heartbeat.unref?.();
1271
1285
  }
1272
1286
  setPollActive(key, activePolls, deliveredFeedback, events, true);
1287
+ refreshIdleTimer();
1273
1288
  const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
1274
1289
  let cleaned = false;
1275
1290
  let responding = false;
@@ -1281,6 +1296,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1281
1296
  events.off("feedback", onFeedback);
1282
1297
  events.off("ended", onFeedback);
1283
1298
  setPollActive(key, activePolls, deliveredFeedback, events, false);
1299
+ refreshIdleTimer();
1284
1300
  };
1285
1301
  const respond = async () => {
1286
1302
  if (responding || res.writableEnded) return;
@@ -1329,6 +1345,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1329
1345
  clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
1330
1346
  events.emit("ended", req.params.key);
1331
1347
  res.json({ status: "ended" });
1348
+ await shutdownIfNoLiveSessions();
1332
1349
  } catch (error) {
1333
1350
  next(error);
1334
1351
  }
@@ -1355,6 +1372,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1355
1372
  clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
1356
1373
  events.emit("ended", key);
1357
1374
  res.json({ status: "ended" });
1375
+ await shutdownIfNoLiveSessions();
1358
1376
  } catch (error) {
1359
1377
  next(error);
1360
1378
  }
@@ -1417,6 +1435,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
1417
1435
  connection: "keep-alive"
1418
1436
  });
1419
1437
  sseClients.add(res);
1438
+ refreshIdleTimer();
1420
1439
  const session = await store.findByKey(req.params.key);
1421
1440
  const sendReload = (key) => {
1422
1441
  if (key === req.params.key) {
@@ -1457,6 +1476,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
1457
1476
  events.off("reload", sendReload);
1458
1477
  events.off("agent-reply", sendAgentReply);
1459
1478
  events.off("agent-presence", sendPresence);
1479
+ refreshIdleTimer();
1460
1480
  });
1461
1481
  } catch (error) {
1462
1482
  next(error);
@@ -1502,6 +1522,10 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
1502
1522
  function shutdown() {
1503
1523
  if (shuttingDown) return;
1504
1524
  shuttingDown = true;
1525
+ if (idleTimer) {
1526
+ clearTimeout(idleTimer);
1527
+ idleTimer = null;
1528
+ }
1505
1529
  for (const res of sseClients) {
1506
1530
  try {
1507
1531
  res.write("event: chrome-reload\ndata: {}\n\n");
@@ -1520,6 +1544,35 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
1520
1544
  httpServer.closeAllConnections();
1521
1545
  }
1522
1546
  }
1547
+ let idleTimer = null;
1548
+ function refreshIdleTimer() {
1549
+ if (idleTimer) {
1550
+ clearTimeout(idleTimer);
1551
+ idleTimer = null;
1552
+ }
1553
+ if (shuttingDown || idleTimeoutMs == null) return;
1554
+ if (sseClients.size > 0 || activePolls.size > 0) return;
1555
+ idleTimer = setTimeout(() => {
1556
+ idleTimer = null;
1557
+ if (!shuttingDown && sseClients.size === 0 && activePolls.size === 0) {
1558
+ logEvent?.(`idle for ${idleTimeoutMs}ms with no connections, shutting down`);
1559
+ shutdown();
1560
+ }
1561
+ }, idleTimeoutMs);
1562
+ idleTimer.unref?.();
1563
+ }
1564
+ async function shutdownIfNoLiveSessions() {
1565
+ if (sseClients.size > 0 || activePolls.size > 0) return;
1566
+ try {
1567
+ const sessions = await store.listSessions();
1568
+ if (sessions.every((session) => session.status === "ended")) {
1569
+ logEvent?.("last open session ended with no live connections, shutting down");
1570
+ setImmediate(shutdown);
1571
+ }
1572
+ } catch {
1573
+ }
1574
+ }
1575
+ refreshIdleTimer();
1523
1576
  return {
1524
1577
  port: httpServer.address().port,
1525
1578
  close: async () => {
@@ -1835,9 +1888,9 @@ function normalizePagePath(path5) {
1835
1888
  }
1836
1889
 
1837
1890
  // src/cli.js
1838
- var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "server", "playbook", "design", "setup"]);
1891
+ var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup"]);
1839
1892
  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`.";
1840
- var VERSION = "0.1.16";
1893
+ var VERSION = "0.1.18";
1841
1894
  async function run(argv) {
1842
1895
  await ensureStateDir();
1843
1896
  const normalizedArgv = normalizeArgv(argv);
@@ -1865,6 +1918,7 @@ async function run(argv) {
1865
1918
  open: openCommand,
1866
1919
  poll: pollCommand,
1867
1920
  end: endCommand,
1921
+ stop: stopCommand,
1868
1922
  playbook: playbookCommand,
1869
1923
  design: designCommand,
1870
1924
  setup: setupCommand,
@@ -1930,9 +1984,10 @@ function createHomeOutput({ bin, sessions, includeSessions = true }) {
1930
1984
  "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",
1931
1985
  "Run `lavish-axi poll <html-file>` to wait for user feedback",
1932
1986
  "Run `lavish-axi end <html-file>` to end a session",
1987
+ "Run `lavish-axi stop` to shut down the background server (it also self-stops when idle or after the last session ends with nothing connected)",
1933
1988
  "Run `lavish-axi playbook <playbook_id>` for focused artifact guidance",
1934
1989
  DESIGN_SYSTEM_HINT,
1935
- "Use lavish-axi when the user asks for a visual artifact, HTML explainer, interactive prototype, review surface, technical plan, comparison, report, or browser-based feedback loop"
1990
+ "Use lavish-axi when the user asks for a visual artifact, HTML explainer, interactive prototype, review surface, product or technical plan, comparison, report, or browser-based feedback loop"
1936
1991
  ]
1937
1992
  };
1938
1993
  }
@@ -2031,6 +2086,35 @@ async function endCommand(args) {
2031
2086
  const response = await postJson(`${baseUrl}/api/end`, { file: absolute });
2032
2087
  return { session: { file: absolute, status: response.status || "ended" } };
2033
2088
  }
2089
+ async function stopCommand(args) {
2090
+ const port = Number(flagValue(args, "--port") || defaultPort());
2091
+ const baseUrl = `http://${LOOPBACK_HOST}:${port}`;
2092
+ return shutdownServerOnPort(port, { baseUrl, currentVersion: VERSION });
2093
+ }
2094
+ async function shutdownServerOnPort(port, {
2095
+ baseUrl = `http://${LOOPBACK_HOST}:${port}`,
2096
+ currentVersion = VERSION,
2097
+ fetchHealth: healthFetcher = fetchHealth,
2098
+ requestShutdown: shutdownRequester = requestShutdown,
2099
+ waitForPortFree: portFreeWaiter = waitForPortFree,
2100
+ killProcessOnPort: portKiller = killProcessOnPort,
2101
+ processMatchesLavish = processOnPortMatchesLavish
2102
+ } = {}) {
2103
+ const health = await healthFetcher(baseUrl);
2104
+ if (!health) {
2105
+ return { server: { status: "not-running", port } };
2106
+ }
2107
+ if (!await canControlServerOnPort(port, health, processMatchesLavish)) {
2108
+ return { server: { status: "not-lavish", port } };
2109
+ }
2110
+ await shutdownRequester(baseUrl);
2111
+ let freed = await portFreeWaiter(baseUrl, 3e3);
2112
+ if (!freed && shouldKillProcessOnPort(currentVersion, health)) {
2113
+ portKiller(port);
2114
+ freed = await portFreeWaiter(baseUrl, 3e3);
2115
+ }
2116
+ return { server: { status: freed ? "stopped" : "stopping", port } };
2117
+ }
2034
2118
  async function playbookCommand(args) {
2035
2119
  return createPlaybookOutput(args);
2036
2120
  }
@@ -2094,6 +2178,11 @@ async function ensureServer({ forceRestart = false } = {}) {
2094
2178
  return baseUrl;
2095
2179
  }
2096
2180
  if (existing) {
2181
+ if (!await canControlServerOnPort(port, existing, processOnPortMatchesLavish)) {
2182
+ throw new AxiError(`Port ${port} is occupied by a non-Lavish server`, "SERVER_ERROR", [
2183
+ `Stop the process using port ${port}, or set LAVISH_AXI_PORT to another port`
2184
+ ]);
2185
+ }
2097
2186
  await requestShutdown(baseUrl);
2098
2187
  const freed = await waitForPortFree(baseUrl, 2e3);
2099
2188
  if (!freed) {
@@ -2135,6 +2224,12 @@ function shouldKillProcessOnPort(currentVersion, healthBody) {
2135
2224
  if (healthBody.app !== "lavish-axi") return false;
2136
2225
  return healthBody.version !== currentVersion;
2137
2226
  }
2227
+ async function canControlServerOnPort(port, healthBody, processMatchesLavish) {
2228
+ if (!healthBody || typeof healthBody !== "object") return false;
2229
+ if (healthBody.app === "lavish-axi") return true;
2230
+ if (typeof healthBody.version === "string" && healthBody.version !== "") return false;
2231
+ return processMatchesLavish(port);
2232
+ }
2138
2233
  async function fetchHealth(baseUrl) {
2139
2234
  try {
2140
2235
  const response = await fetch(`${baseUrl}/health`);
@@ -2174,6 +2269,23 @@ function killProcessOnPort(port) {
2174
2269
  } catch {
2175
2270
  }
2176
2271
  }
2272
+ function processOnPortMatchesLavish(port) {
2273
+ try {
2274
+ const pids = spawnSync("lsof", ["-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" });
2275
+ if (pids.status !== 0) return false;
2276
+ for (const line of pids.stdout.split("\n")) {
2277
+ const pid = Number(line.trim());
2278
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
2279
+ const command = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
2280
+ if (command.status === 0 && /lavish-axi/.test(command.stdout)) {
2281
+ return true;
2282
+ }
2283
+ }
2284
+ } catch {
2285
+ return false;
2286
+ }
2287
+ return false;
2288
+ }
2177
2289
  async function startServer(port) {
2178
2290
  await ensureStateDir();
2179
2291
  const entry = resolveServerEntry();
@@ -2275,6 +2387,7 @@ Usage:
2275
2387
  lavish-axi <html-file>
2276
2388
  lavish-axi poll <html-file> [--agent-reply "..."]
2277
2389
  lavish-axi end <html-file>
2390
+ lavish-axi stop
2278
2391
  lavish-axi playbook [playbook_id]
2279
2392
  lavish-axi design
2280
2393
  lavish-axi setup hooks
@@ -2296,6 +2409,10 @@ This command long-polls indefinitely for queued user prompts, then returns them
2296
2409
  end: `Usage: lavish-axi end <html-file>
2297
2410
 
2298
2411
  End a Lavish Editor session.
2412
+ `,
2413
+ stop: `Usage: lavish-axi stop [--port <port>]
2414
+
2415
+ Shut down the background Lavish Editor server. The server also stops itself when no browser or poll has been connected for a while (LAVISH_AXI_IDLE_TIMEOUT_MS, default 30m) and immediately when the last session ends with nothing connected.
2299
2416
  `,
2300
2417
  playbook: `Usage: lavish-axi playbook [playbook_id]
2301
2418
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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",