lavish-axi 0.1.17 → 0.1.19
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 +26 -1
- package/dist/cli.mjs +139 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,10 @@ Just tell your agent:
|
|
|
51
51
|
Use `npx lavish-axi` to write a product or technical plan for what we discussed.
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
That works with zero setup - Lavish is an AXI, so any capable agent can run the CLI directly.
|
|
55
|
+
|
|
56
|
+
To make your agent reach for Lavish on its own (without you naming it every time), install the agent hooks as instructed below.
|
|
57
|
+
|
|
54
58
|
## Install
|
|
55
59
|
|
|
56
60
|
**npm**
|
|
@@ -69,6 +73,23 @@ pnpm run build
|
|
|
69
73
|
pnpm link
|
|
70
74
|
```
|
|
71
75
|
|
|
76
|
+
## Set Up Agent Hooks (recommended)
|
|
77
|
+
|
|
78
|
+
Lavish does not install agent hooks automatically, which means your agent would not know to use it in a fresh session.
|
|
79
|
+
|
|
80
|
+
Run this once to opt in:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
lavish-axi setup hooks
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This installs a `SessionStart` hook for **Claude Code**, **Codex**, and **OpenCode** that feeds Lavish's ambient context (open sessions, visualization playbooks, and usage guidance) into your agent at the start of each session.
|
|
87
|
+
With the hook installed, your agent learns to turn complex responses into rich, reviewable HTML artifacts proactively - no need to mention `lavish-axi` by name.
|
|
88
|
+
|
|
89
|
+
**Restart your agent session after running this** so the new hook takes effect.
|
|
90
|
+
|
|
91
|
+
Prefer not to install hooks? Lavish still works fully as a plain CLI - just tell your agent to `npx lavish-axi <file.html>` as shown in the Quick Start.
|
|
92
|
+
|
|
72
93
|
## How It Works
|
|
73
94
|
|
|
74
95
|
```
|
|
@@ -98,9 +119,11 @@ pnpm link
|
|
|
98
119
|
- **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. Choose a design system in priority order: follow a user-requested look first, match the current project's design system or conventions next, and otherwise run `lavish-axi design` for a copy-pasteable Tailwind CSS v4 + DaisyUI v5 CDN fallback.
|
|
99
120
|
- **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.
|
|
100
121
|
- **Live reload** - Lavish watches the HTML artifact file by default and preserves the artifact iframe scroll position across reloads. 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">`.
|
|
101
|
-
- **Feedback controls** -
|
|
122
|
+
- **Feedback controls** - Native form controls (radios, checkboxes, inputs, selects, buttons, labels, contenteditable) are interactive automatically, so they do not need `data-lavish-action`; wire their handlers to `window.lavish.queuePrompt()` or `window.lavish.sendQueuedPrompts()` to send feedback. Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them.
|
|
102
123
|
- **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
124
|
- **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
|
|
125
|
+
- **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.
|
|
126
|
+
Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
|
|
104
127
|
- **Local-first state** - Session state stays under `.lavish-axi/` in the workspace.
|
|
105
128
|
|
|
106
129
|
## CLI Reference
|
|
@@ -111,6 +134,7 @@ pnpm link
|
|
|
111
134
|
| `lavish-axi <html-file>` | Open or resume a Lavish Editor session. |
|
|
112
135
|
| `lavish-axi poll <html-file>` | Long-poll until the user sends feedback or ends the session. |
|
|
113
136
|
| `lavish-axi end <html-file>` | End a session. |
|
|
137
|
+
| `lavish-axi stop` | Shut down the background server. |
|
|
114
138
|
| `lavish-axi playbook [id]` | List focused artifact guidance or show one playbook. |
|
|
115
139
|
| `lavish-axi design` | Show CDN snippet + DaisyUI component reference (opt-in). |
|
|
116
140
|
| `lavish-axi setup hooks` | Install or repair optional SessionStart hooks for Claude Code, Codex, and OpenCode; restart the agent session afterward. |
|
|
@@ -125,6 +149,7 @@ Known playbook IDs: `diagram`, `table`, `comparison`, `plan`, `diff`, `input`, `
|
|
|
125
149
|
| `lavish-axi <html-file>` | `--no-open` | Ensure the server/session exists without opening another browser window. |
|
|
126
150
|
| `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat before polling again. |
|
|
127
151
|
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it. |
|
|
152
|
+
| `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
|
|
128
153
|
| `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
154
|
|
|
130
155
|
## Development
|
package/dist/cli.mjs
CHANGED
|
@@ -642,7 +642,9 @@ var PLAYBOOKS = [
|
|
|
642
642
|
"Show queued or selected state clearly so the user trusts what will be sent back."
|
|
643
643
|
],
|
|
644
644
|
design_rules: [
|
|
645
|
-
"
|
|
645
|
+
"Native form controls - radios, checkboxes, text inputs, selects, textareas, buttons, options, labels, and contenteditable regions - are interactive automatically: clicks toggle, focus, and type instead of annotating, so they do not need data-lavish-action. Build choice and option UIs from these whenever you can.",
|
|
646
|
+
"To turn a native control into agent feedback, wire its own change, click, or submit handler to window.lavish.queuePrompt() (for example a radio group's onchange, or a submit button that reads the selected values) - no marker required.",
|
|
647
|
+
"Put data-lavish-action only on custom (non-native) elements that should act like a feedback control - typically a styled div or span you made clickable - so Lavish does not annotate it and shows a pointer cursor instead.",
|
|
646
648
|
"Call window.lavish.queuePrompt(prompt, options) from the control's click, change, or submit handler to queue a precise request for the agent.",
|
|
647
649
|
"Pass options such as tag, text, selector, target, or data when they help the agent understand exactly what the user chose.",
|
|
648
650
|
"Call window.lavish.sendQueuedPrompts() when the control should immediately send the queued feedback instead of waiting for the user to press Send to Agent.",
|
|
@@ -656,7 +658,8 @@ var PLAYBOOKS = [
|
|
|
656
658
|
],
|
|
657
659
|
lavish_notes: [
|
|
658
660
|
"Lavish is strongest when the artifact becomes a focused review surface and not just a static page.",
|
|
659
|
-
`A
|
|
661
|
+
`A native choice looks like \`<label><input type="radio" name="plan" onchange="window.lavish.queuePrompt('Use the Pro plan', { tag: 'choice', text: 'Pro' })"> Pro</label>\` - it just works, no marker needed.`,
|
|
662
|
+
`A custom (non-native) control needs the marker: \`<div role="button" tabindex="0" data-lavish-action onclick="window.lavish.queuePrompt('Choose option A', { tag: 'choice', text: 'Option A' })">Choose option A</div>\`.`,
|
|
660
663
|
"Use window.lavish.queuePrompt for user intent, not internal analytics or UI-only state changes.",
|
|
661
664
|
"End input paths with an obvious way for the user to send feedback back to the agent."
|
|
662
665
|
]
|
|
@@ -779,7 +782,7 @@ function createArtifactSdk() {
|
|
|
779
782
|
const text = selection.toString().trim().replace(/\s+/g, " ");
|
|
780
783
|
if (range.collapsed || !text) return null;
|
|
781
784
|
const ancestor = closestElement(range.commonAncestorContainer);
|
|
782
|
-
if (isLavishUi(ancestor) || isLavishAction(ancestor)) return null;
|
|
785
|
+
if (isLavishUi(ancestor) || isLavishAction(ancestor) || isInteractiveControl(ancestor)) return null;
|
|
783
786
|
const commonAncestorSelector = selector(ancestor);
|
|
784
787
|
const target = {
|
|
785
788
|
type: "text-range",
|
|
@@ -805,6 +808,9 @@ function createArtifactSdk() {
|
|
|
805
808
|
function isLavishAction(el) {
|
|
806
809
|
return !!(el && el.closest && el.closest("[data-lavish-action]"));
|
|
807
810
|
}
|
|
811
|
+
function isInteractiveControl(el) {
|
|
812
|
+
return !!(el && el.closest && el.closest("button,input,select,textarea,option,optgroup,label,[contenteditable]:not([contenteditable='false'])"));
|
|
813
|
+
}
|
|
808
814
|
function highlightElement(el) {
|
|
809
815
|
if (!el) return;
|
|
810
816
|
el.style.outline = "var(--lavish-annotate-outline,2px solid #f4c95d)";
|
|
@@ -837,7 +843,7 @@ function createArtifactSdk() {
|
|
|
837
843
|
if (annotationMode && !style) {
|
|
838
844
|
style = document.createElement("style");
|
|
839
845
|
style.id = "lavish-cursor-style";
|
|
840
|
-
style.textContent = ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}";
|
|
846
|
+
style.textContent = ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}input,textarea,[contenteditable]:not([contenteditable='false']){cursor:text!important}button,select,label,option,input[type='button'],input[type='submit'],input[type='reset'],input[type='checkbox'],input[type='radio'],input[type='file'],input[type='color'],input[type='range'],input[type='image']{cursor:pointer!important}";
|
|
841
847
|
document.head.appendChild(style);
|
|
842
848
|
}
|
|
843
849
|
if (!annotationMode && style) style.remove();
|
|
@@ -971,7 +977,8 @@ function createArtifactSdk() {
|
|
|
971
977
|
document.addEventListener(
|
|
972
978
|
"mouseover",
|
|
973
979
|
(event) => {
|
|
974
|
-
if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target))
|
|
980
|
+
if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
|
|
981
|
+
return;
|
|
975
982
|
if (event.target === selected) return;
|
|
976
983
|
if (hovered && hovered !== selected) clearHighlight(hovered);
|
|
977
984
|
hovered = event.target;
|
|
@@ -992,7 +999,8 @@ function createArtifactSdk() {
|
|
|
992
999
|
document.addEventListener(
|
|
993
1000
|
"mouseup",
|
|
994
1001
|
(event) => {
|
|
995
|
-
if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target))
|
|
1002
|
+
if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
|
|
1003
|
+
return;
|
|
996
1004
|
const c = textSelectionContext(document.getSelection());
|
|
997
1005
|
if (!c) return;
|
|
998
1006
|
ignoreNextClick = true;
|
|
@@ -1003,7 +1011,8 @@ function createArtifactSdk() {
|
|
|
1003
1011
|
document.addEventListener(
|
|
1004
1012
|
"click",
|
|
1005
1013
|
(event) => {
|
|
1006
|
-
if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target))
|
|
1014
|
+
if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
|
|
1015
|
+
return;
|
|
1007
1016
|
event.preventDefault();
|
|
1008
1017
|
event.stopPropagation();
|
|
1009
1018
|
if (ignoreNextClick) {
|
|
@@ -1195,7 +1204,24 @@ var designAssetUrls = {
|
|
|
1195
1204
|
type: "application/javascript"
|
|
1196
1205
|
}
|
|
1197
1206
|
};
|
|
1198
|
-
|
|
1207
|
+
var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
1208
|
+
function resolveIdleTimeoutMs(env = process.env) {
|
|
1209
|
+
const raw = env.LAVISH_AXI_IDLE_TIMEOUT_MS?.trim();
|
|
1210
|
+
if (raw === void 0 || raw === "") return DEFAULT_IDLE_TIMEOUT_MS;
|
|
1211
|
+
if (raw === "0" || raw.toLowerCase() === "off") return null;
|
|
1212
|
+
const value = Number(raw);
|
|
1213
|
+
if (!Number.isFinite(value) || value <= 0) return DEFAULT_IDLE_TIMEOUT_MS;
|
|
1214
|
+
return value;
|
|
1215
|
+
}
|
|
1216
|
+
async function serve({
|
|
1217
|
+
port,
|
|
1218
|
+
stateFile: stateFile2,
|
|
1219
|
+
version = "",
|
|
1220
|
+
debug = false,
|
|
1221
|
+
log = null,
|
|
1222
|
+
pollHeartbeatMs = 15e3,
|
|
1223
|
+
idleTimeoutMs = resolveIdleTimeoutMs()
|
|
1224
|
+
}) {
|
|
1199
1225
|
const app = express();
|
|
1200
1226
|
const store = new SessionStore(stateFile2);
|
|
1201
1227
|
const events = new EventEmitter();
|
|
@@ -1267,6 +1293,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
|
|
|
1267
1293
|
heartbeat.unref?.();
|
|
1268
1294
|
}
|
|
1269
1295
|
setPollActive(key, activePolls, deliveredFeedback, events, true);
|
|
1296
|
+
refreshIdleTimer();
|
|
1270
1297
|
const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
|
|
1271
1298
|
let cleaned = false;
|
|
1272
1299
|
let responding = false;
|
|
@@ -1278,6 +1305,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
|
|
|
1278
1305
|
events.off("feedback", onFeedback);
|
|
1279
1306
|
events.off("ended", onFeedback);
|
|
1280
1307
|
setPollActive(key, activePolls, deliveredFeedback, events, false);
|
|
1308
|
+
refreshIdleTimer();
|
|
1281
1309
|
};
|
|
1282
1310
|
const respond = async () => {
|
|
1283
1311
|
if (responding || res.writableEnded) return;
|
|
@@ -1326,6 +1354,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
|
|
|
1326
1354
|
clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
|
|
1327
1355
|
events.emit("ended", req.params.key);
|
|
1328
1356
|
res.json({ status: "ended" });
|
|
1357
|
+
await shutdownIfNoLiveSessions();
|
|
1329
1358
|
} catch (error) {
|
|
1330
1359
|
next(error);
|
|
1331
1360
|
}
|
|
@@ -1352,6 +1381,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
|
|
|
1352
1381
|
clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
|
|
1353
1382
|
events.emit("ended", key);
|
|
1354
1383
|
res.json({ status: "ended" });
|
|
1384
|
+
await shutdownIfNoLiveSessions();
|
|
1355
1385
|
} catch (error) {
|
|
1356
1386
|
next(error);
|
|
1357
1387
|
}
|
|
@@ -1414,6 +1444,7 @@ async function serve({ port, stateFile: stateFile2, version = "", debug = false,
|
|
|
1414
1444
|
connection: "keep-alive"
|
|
1415
1445
|
});
|
|
1416
1446
|
sseClients.add(res);
|
|
1447
|
+
refreshIdleTimer();
|
|
1417
1448
|
const session = await store.findByKey(req.params.key);
|
|
1418
1449
|
const sendReload = (key) => {
|
|
1419
1450
|
if (key === req.params.key) {
|
|
@@ -1454,6 +1485,7 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
|
|
|
1454
1485
|
events.off("reload", sendReload);
|
|
1455
1486
|
events.off("agent-reply", sendAgentReply);
|
|
1456
1487
|
events.off("agent-presence", sendPresence);
|
|
1488
|
+
refreshIdleTimer();
|
|
1457
1489
|
});
|
|
1458
1490
|
} catch (error) {
|
|
1459
1491
|
next(error);
|
|
@@ -1499,6 +1531,10 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
|
|
|
1499
1531
|
function shutdown() {
|
|
1500
1532
|
if (shuttingDown) return;
|
|
1501
1533
|
shuttingDown = true;
|
|
1534
|
+
if (idleTimer) {
|
|
1535
|
+
clearTimeout(idleTimer);
|
|
1536
|
+
idleTimer = null;
|
|
1537
|
+
}
|
|
1502
1538
|
for (const res of sseClients) {
|
|
1503
1539
|
try {
|
|
1504
1540
|
res.write("event: chrome-reload\ndata: {}\n\n");
|
|
@@ -1517,6 +1553,35 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
|
|
|
1517
1553
|
httpServer.closeAllConnections();
|
|
1518
1554
|
}
|
|
1519
1555
|
}
|
|
1556
|
+
let idleTimer = null;
|
|
1557
|
+
function refreshIdleTimer() {
|
|
1558
|
+
if (idleTimer) {
|
|
1559
|
+
clearTimeout(idleTimer);
|
|
1560
|
+
idleTimer = null;
|
|
1561
|
+
}
|
|
1562
|
+
if (shuttingDown || idleTimeoutMs == null) return;
|
|
1563
|
+
if (sseClients.size > 0 || activePolls.size > 0) return;
|
|
1564
|
+
idleTimer = setTimeout(() => {
|
|
1565
|
+
idleTimer = null;
|
|
1566
|
+
if (!shuttingDown && sseClients.size === 0 && activePolls.size === 0) {
|
|
1567
|
+
logEvent?.(`idle for ${idleTimeoutMs}ms with no connections, shutting down`);
|
|
1568
|
+
shutdown();
|
|
1569
|
+
}
|
|
1570
|
+
}, idleTimeoutMs);
|
|
1571
|
+
idleTimer.unref?.();
|
|
1572
|
+
}
|
|
1573
|
+
async function shutdownIfNoLiveSessions() {
|
|
1574
|
+
if (sseClients.size > 0 || activePolls.size > 0) return;
|
|
1575
|
+
try {
|
|
1576
|
+
const sessions = await store.listSessions();
|
|
1577
|
+
if (sessions.every((session) => session.status === "ended")) {
|
|
1578
|
+
logEvent?.("last open session ended with no live connections, shutting down");
|
|
1579
|
+
setImmediate(shutdown);
|
|
1580
|
+
}
|
|
1581
|
+
} catch {
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
refreshIdleTimer();
|
|
1520
1585
|
return {
|
|
1521
1586
|
port: httpServer.address().port,
|
|
1522
1587
|
close: async () => {
|
|
@@ -1832,9 +1897,9 @@ function normalizePagePath(path5) {
|
|
|
1832
1897
|
}
|
|
1833
1898
|
|
|
1834
1899
|
// src/cli.js
|
|
1835
|
-
var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "server", "playbook", "design", "setup"]);
|
|
1900
|
+
var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup"]);
|
|
1836
1901
|
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`.";
|
|
1837
|
-
var VERSION = "0.1.
|
|
1902
|
+
var VERSION = "0.1.19";
|
|
1838
1903
|
async function run(argv) {
|
|
1839
1904
|
await ensureStateDir();
|
|
1840
1905
|
const normalizedArgv = normalizeArgv(argv);
|
|
@@ -1862,6 +1927,7 @@ async function run(argv) {
|
|
|
1862
1927
|
open: openCommand,
|
|
1863
1928
|
poll: pollCommand,
|
|
1864
1929
|
end: endCommand,
|
|
1930
|
+
stop: stopCommand,
|
|
1865
1931
|
playbook: playbookCommand,
|
|
1866
1932
|
design: designCommand,
|
|
1867
1933
|
setup: setupCommand,
|
|
@@ -1927,6 +1993,7 @@ function createHomeOutput({ bin, sessions, includeSessions = true }) {
|
|
|
1927
1993
|
"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",
|
|
1928
1994
|
"Run `lavish-axi poll <html-file>` to wait for user feedback",
|
|
1929
1995
|
"Run `lavish-axi end <html-file>` to end a session",
|
|
1996
|
+
"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)",
|
|
1930
1997
|
"Run `lavish-axi playbook <playbook_id>` for focused artifact guidance",
|
|
1931
1998
|
DESIGN_SYSTEM_HINT,
|
|
1932
1999
|
"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"
|
|
@@ -2028,6 +2095,35 @@ async function endCommand(args) {
|
|
|
2028
2095
|
const response = await postJson(`${baseUrl}/api/end`, { file: absolute });
|
|
2029
2096
|
return { session: { file: absolute, status: response.status || "ended" } };
|
|
2030
2097
|
}
|
|
2098
|
+
async function stopCommand(args) {
|
|
2099
|
+
const port = Number(flagValue(args, "--port") || defaultPort());
|
|
2100
|
+
const baseUrl = `http://${LOOPBACK_HOST}:${port}`;
|
|
2101
|
+
return shutdownServerOnPort(port, { baseUrl, currentVersion: VERSION });
|
|
2102
|
+
}
|
|
2103
|
+
async function shutdownServerOnPort(port, {
|
|
2104
|
+
baseUrl = `http://${LOOPBACK_HOST}:${port}`,
|
|
2105
|
+
currentVersion = VERSION,
|
|
2106
|
+
fetchHealth: healthFetcher = fetchHealth,
|
|
2107
|
+
requestShutdown: shutdownRequester = requestShutdown,
|
|
2108
|
+
waitForPortFree: portFreeWaiter = waitForPortFree,
|
|
2109
|
+
killProcessOnPort: portKiller = killProcessOnPort,
|
|
2110
|
+
processMatchesLavish = processOnPortMatchesLavish
|
|
2111
|
+
} = {}) {
|
|
2112
|
+
const health = await healthFetcher(baseUrl);
|
|
2113
|
+
if (!health) {
|
|
2114
|
+
return { server: { status: "not-running", port } };
|
|
2115
|
+
}
|
|
2116
|
+
if (!await canControlServerOnPort(port, health, processMatchesLavish)) {
|
|
2117
|
+
return { server: { status: "not-lavish", port } };
|
|
2118
|
+
}
|
|
2119
|
+
await shutdownRequester(baseUrl);
|
|
2120
|
+
let freed = await portFreeWaiter(baseUrl, 3e3);
|
|
2121
|
+
if (!freed && shouldKillProcessOnPort(currentVersion, health)) {
|
|
2122
|
+
portKiller(port);
|
|
2123
|
+
freed = await portFreeWaiter(baseUrl, 3e3);
|
|
2124
|
+
}
|
|
2125
|
+
return { server: { status: freed ? "stopped" : "stopping", port } };
|
|
2126
|
+
}
|
|
2031
2127
|
async function playbookCommand(args) {
|
|
2032
2128
|
return createPlaybookOutput(args);
|
|
2033
2129
|
}
|
|
@@ -2091,6 +2187,11 @@ async function ensureServer({ forceRestart = false } = {}) {
|
|
|
2091
2187
|
return baseUrl;
|
|
2092
2188
|
}
|
|
2093
2189
|
if (existing) {
|
|
2190
|
+
if (!await canControlServerOnPort(port, existing, processOnPortMatchesLavish)) {
|
|
2191
|
+
throw new AxiError(`Port ${port} is occupied by a non-Lavish server`, "SERVER_ERROR", [
|
|
2192
|
+
`Stop the process using port ${port}, or set LAVISH_AXI_PORT to another port`
|
|
2193
|
+
]);
|
|
2194
|
+
}
|
|
2094
2195
|
await requestShutdown(baseUrl);
|
|
2095
2196
|
const freed = await waitForPortFree(baseUrl, 2e3);
|
|
2096
2197
|
if (!freed) {
|
|
@@ -2132,6 +2233,12 @@ function shouldKillProcessOnPort(currentVersion, healthBody) {
|
|
|
2132
2233
|
if (healthBody.app !== "lavish-axi") return false;
|
|
2133
2234
|
return healthBody.version !== currentVersion;
|
|
2134
2235
|
}
|
|
2236
|
+
async function canControlServerOnPort(port, healthBody, processMatchesLavish) {
|
|
2237
|
+
if (!healthBody || typeof healthBody !== "object") return false;
|
|
2238
|
+
if (healthBody.app === "lavish-axi") return true;
|
|
2239
|
+
if (typeof healthBody.version === "string" && healthBody.version !== "") return false;
|
|
2240
|
+
return processMatchesLavish(port);
|
|
2241
|
+
}
|
|
2135
2242
|
async function fetchHealth(baseUrl) {
|
|
2136
2243
|
try {
|
|
2137
2244
|
const response = await fetch(`${baseUrl}/health`);
|
|
@@ -2171,6 +2278,23 @@ function killProcessOnPort(port) {
|
|
|
2171
2278
|
} catch {
|
|
2172
2279
|
}
|
|
2173
2280
|
}
|
|
2281
|
+
function processOnPortMatchesLavish(port) {
|
|
2282
|
+
try {
|
|
2283
|
+
const pids = spawnSync("lsof", ["-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
2284
|
+
if (pids.status !== 0) return false;
|
|
2285
|
+
for (const line of pids.stdout.split("\n")) {
|
|
2286
|
+
const pid = Number(line.trim());
|
|
2287
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
|
|
2288
|
+
const command = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
|
|
2289
|
+
if (command.status === 0 && /lavish-axi/.test(command.stdout)) {
|
|
2290
|
+
return true;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
} catch {
|
|
2294
|
+
return false;
|
|
2295
|
+
}
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2174
2298
|
async function startServer(port) {
|
|
2175
2299
|
await ensureStateDir();
|
|
2176
2300
|
const entry = resolveServerEntry();
|
|
@@ -2272,6 +2396,7 @@ Usage:
|
|
|
2272
2396
|
lavish-axi <html-file>
|
|
2273
2397
|
lavish-axi poll <html-file> [--agent-reply "..."]
|
|
2274
2398
|
lavish-axi end <html-file>
|
|
2399
|
+
lavish-axi stop
|
|
2275
2400
|
lavish-axi playbook [playbook_id]
|
|
2276
2401
|
lavish-axi design
|
|
2277
2402
|
lavish-axi setup hooks
|
|
@@ -2293,6 +2418,10 @@ This command long-polls indefinitely for queued user prompts, then returns them
|
|
|
2293
2418
|
end: `Usage: lavish-axi end <html-file>
|
|
2294
2419
|
|
|
2295
2420
|
End a Lavish Editor session.
|
|
2421
|
+
`,
|
|
2422
|
+
stop: `Usage: lavish-axi stop [--port <port>]
|
|
2423
|
+
|
|
2424
|
+
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.
|
|
2296
2425
|
`,
|
|
2297
2426
|
playbook: `Usage: lavish-axi playbook [playbook_id]
|
|
2298
2427
|
|