lavish-axi 0.1.72 → 0.1.74
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 +4 -3
- package/dist/chrome-client.js +39 -1
- package/dist/cli.mjs +236 -52
- package/dist/server.mjs +68 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -206,6 +206,7 @@ pnpm link
|
|
|
206
206
|
In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it.
|
|
207
207
|
Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control.
|
|
208
208
|
- **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 keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting.
|
|
209
|
+
In a Herdr-managed pane, set `LAVISH_AXI_HERDR_CHIME=1` to show a Herdr request notification and play its chime once an indefinite poll enters the waiting state. Immediate queued feedback and timed debug polls do not trigger a notification. The integration is off unless both `LAVISH_AXI_HERDR_CHIME` and Herdr's `HERDR_ENV` session marker equal `1`; leaving either unset or using any other value opts out. Notification delivery runs without blocking feedback: if Herdr is unavailable, rejects the notification, stalls, or fails, the poll continues unchanged.
|
|
209
210
|
Closing the last review tab while a poll is active starts a 10-second reconnect grace period. A reload or quick reconnect keeps an active poll waiting; if every review stays disconnected, the poll returns `browser_disconnected` without ending the resumable session, and the agent asks whether to reopen or end it instead of acting uninvited.
|
|
210
211
|
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.
|
|
211
212
|
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.
|
|
@@ -236,7 +237,7 @@ pnpm link
|
|
|
236
237
|
Lavish changes only the browser view, so saved, standalone, and exported artifacts still render plain Mermaid.
|
|
237
238
|
- **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.
|
|
238
239
|
Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
|
|
239
|
-
- **Server upgrades** - One background server serves every session, so upgrading `lavish-axi` while reviews are open makes the next `lavish-axi <html-file>` replace that server. Only the review page for the artifact being opened reloads itself once the replacement answers - and not even that one while you have unsent annotation text open, which gets the same banner instead so the reload is yours to make. Every other open review page keeps working and shows a banner reading "Lavish was updated. This page is running the previous version.", with a Check and reload button and a Dismiss button, so no page you are reading reloads on its own. After `lavish-axi stop` those pages say Lavish was stopped and to reload after you start it again, and a restart that only picks up a local build says that rather than claiming an update.
|
|
240
|
+
- **Server upgrades** - One background server serves every session, so upgrading `lavish-axi` while reviews are open makes the next `lavish-axi <html-file>` replace that server. Only the review page for the artifact being opened reloads itself once the replacement answers - and not even that one while you have unsent annotation text open, which gets the same banner instead so the reload is yours to make. Every other open review page keeps working and shows a banner reading "Lavish was updated. This page is running the previous version.", with a Check and reload button and a Dismiss button, so no page you are reading reloads on its own. After `lavish-axi stop` those pages say Lavish was stopped and to reload after you start it again, and a restart that only picks up a local build says that rather than claiming an update. An open review page whose server simply goes away - idle self-shutdown, a crash, a machine that slept - shows that same banner once reconnecting has failed for a few seconds, rather than retrying a dead server silently while looking fine; it clears itself if the server comes back, and dismissing it keeps it dismissed until then.
|
|
240
241
|
A page still using the legacy event stream reloads once when it reconnects to the replacement server, moving itself onto the current transport without retaining an HTTP connection. If unsent annotation text is open, it shows the neutral server-restarted banner instead and waits for you to reload.
|
|
241
242
|
Every **Check and reload** control asks the server whether it is running before it navigates; while nothing answers, the page stays where it is and says so, and a check that gets no answer at all says that instead of guessing.
|
|
242
243
|
In-flight `lavish-axi poll` commands end with an interrupted-poll error and are safe to re-run; queued feedback is never lost, and annotation text you have typed but not queued yet survives the reload as described under **Live reload**.
|
|
@@ -244,7 +245,7 @@ pnpm link
|
|
|
244
245
|
- **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set.
|
|
245
246
|
- **Diagnostic viewports** - `LAVISH_AXI_DIAGNOSTIC_VIEWPORTS` sets which viewport classes the layout-issue inbox tracks (`mobile`, `compact`, `desktop`; comma-separated, default all). Warnings whose class leaves the set are marked obsolete with an explicit reason instead of silently reading as fixed.
|
|
246
247
|
- **Server port** - Set `LAVISH_AXI_PORT` to choose the server port; it defaults to `4387`.
|
|
247
|
-
- **Network binding** - With Tailscale running, the review server automatically listens only on loopback (`127.0.0.1`) and this machine's Tailscale IPv4 address - never on `0.0.0.0` or every interface. The generated session link uses the machine's MagicDNS name, so it is the phone-ready URL to open from another device on the same tailnet. When Tailscale is absent or down, Lavish silently falls back to loopback-only and prints no phone URL. If Tailscale is running but MagicDNS is unavailable or its address cannot be bound after brief retries, Lavish visibly warns that phone access is unavailable and remains loopback-only. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to devices that can reach it, so the tailnet should be trusted. Any explicit `LAVISH_AXI_HOST` overrides automatic Tailscale binding; wildcard values are restricted to loopback, while a non-wildcard value selects that one concrete bind address. `LAVISH_AXI_LINK_HOST` controls the link host when automatic Tailscale binding is disabled.
|
|
248
|
+
- **Network binding** - With Tailscale running, the review server automatically listens only on loopback (`127.0.0.1`) and this machine's Tailscale IPv4 address - never on `0.0.0.0` or every interface. The generated session link uses the machine's MagicDNS name, so it is the phone-ready URL to open from another device on the same tailnet. When Tailscale is absent or down, Lavish silently falls back to loopback-only and prints no phone URL. If Tailscale is running but MagicDNS is unavailable or its address cannot be bound after brief retries, Lavish visibly warns that phone access is unavailable and remains loopback-only. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to devices that can reach it, so the tailnet should be trusted. Any explicit `LAVISH_AXI_HOST` overrides automatic Tailscale binding; wildcard values are restricted to loopback, while a non-wildcard value selects that one concrete bind address. Each address is retried briefly, and if none can be bound Lavish falls back to loopback, warns that it is not reachable at the requested address, and points session links at loopback unless `LAVISH_AXI_LINK_HOST` explicitly overrides them - a locally reachable server beats no server at all. When an unavailable requested address returns, the next CLI invocation restarts the fallback server once to restore that listener. `LAVISH_AXI_LINK_HOST` controls the link host when automatic Tailscale binding is disabled.
|
|
248
249
|
- **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: loopback names plus the concrete Tailscale IPv4 address and MagicDNS name when the Tailscale listener is successfully bound. If you configure a reverse proxy or another intentional hostname, list it in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated). Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add the public hostname there and have the proxy send it together with `X-Forwarded-Proto`. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely, only when the server sits behind your own authentication or proxy. Mutating routes also reject a present foreign `Origin` or `Referer` (`403`); header-less CLI control requests remain allowed where supported.
|
|
249
250
|
- **Browser opening** - Set `LAVISH_AXI_NO_OPEN=1`, equivalent to `--no-open`, to create or resume a session without launching a browser window.
|
|
250
251
|
|
|
@@ -289,7 +290,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
|
|
|
289
290
|
| `lavish-axi poll` | `--agent-reply-file <path>` | When a longer reply is genuinely necessary, read Markdown from a file (`-` for stdin) so newlines survive quoting. Use blank-line paragraphs, `- ` / `1. ` lists, and `## ` headings so the Conversation panel renders it scannably. The Markdown subset is under Feedback controls. Cannot be combined with `--agent-reply`. |
|
|
290
291
|
| `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
|
|
291
292
|
| `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
|
|
292
|
-
| `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.
|
|
293
|
+
| `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 with UTC timestamps to `~/.lavish-axi/server.log` (or `LAVISH_AXI_STATE_DIR/server.log`) for startup, shutdown-cause, and crash diagnostics. |
|
|
293
294
|
|
|
294
295
|
## Development
|
|
295
296
|
|
package/dist/chrome-client.js
CHANGED
|
@@ -198,6 +198,16 @@ const layoutGateMaxHoldMs =
|
|
|
198
198
|
let chromeOutdatedReason = "";
|
|
199
199
|
let chromeOutdatedGeneration = 0;
|
|
200
200
|
let outdatedReloadInFlight = false;
|
|
201
|
+
// The live-event socket reconnects forever on a 5s cap. Silence there is indistinguishable from a
|
|
202
|
+
// healthy idle stream, so a page whose server has gone away keeps rendering its last state and
|
|
203
|
+
// tells the user nothing until they reload into a connection error. Past this many consecutive
|
|
204
|
+
// failures the banner says so, with the health-probed reload the banner already offers.
|
|
205
|
+
const LIVE_EVENT_UNREACHABLE_FAILURES = 5;
|
|
206
|
+
let liveEventFailures = 0;
|
|
207
|
+
// Only a banner this path raised may be hidden by this path: a `chrome-outdated` event means the
|
|
208
|
+
// server was replaced, which a reconnect does not disprove.
|
|
209
|
+
let unreachableBannerOwned = false;
|
|
210
|
+
let unreachableDismissed = false;
|
|
201
211
|
/** @type {{ selector: string, revision: number } | null} */
|
|
202
212
|
let unrestorableDraftMiss = null;
|
|
203
213
|
let retiredDrafts = loadRetiredDrafts();
|
|
@@ -3935,7 +3945,17 @@ endButton.onclick = () => {
|
|
|
3935
3945
|
};
|
|
3936
3946
|
handoffTakeoverButton.onclick = () => location.reload();
|
|
3937
3947
|
if (outdatedReloadButton) outdatedReloadButton.onclick = () => reloadChromeForOutdatedBanner();
|
|
3938
|
-
if (outdatedDismissButton)
|
|
3948
|
+
if (outdatedDismissButton) {
|
|
3949
|
+
outdatedDismissButton.onclick = () => {
|
|
3950
|
+
// A dismissed unreachable banner stays dismissed until the stream actually recovers; without
|
|
3951
|
+
// this the next failed reconnect puts it straight back on screen.
|
|
3952
|
+
if (unreachableBannerOwned) {
|
|
3953
|
+
unreachableBannerOwned = false;
|
|
3954
|
+
unreachableDismissed = true;
|
|
3955
|
+
}
|
|
3956
|
+
setChromeOutdated(false);
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3939
3959
|
document.addEventListener("mousedown", (event) => {
|
|
3940
3960
|
const target = /** @type {Node} */ (event.target);
|
|
3941
3961
|
if (!moreMenu.hidden && !moreWrap.contains(target)) setMenuOpen(moreButton, moreMenu, false);
|
|
@@ -3998,6 +4018,12 @@ function connectLiveEvents() {
|
|
|
3998
4018
|
const socket = new WebSocket(protocol + "//" + location.host + "/events/" + encodeURIComponent(key));
|
|
3999
4019
|
socket.addEventListener("open", () => {
|
|
4000
4020
|
eventReconnectDelayMs = 500;
|
|
4021
|
+
liveEventFailures = 0;
|
|
4022
|
+
unreachableDismissed = false;
|
|
4023
|
+
if (unreachableBannerOwned) {
|
|
4024
|
+
unreachableBannerOwned = false;
|
|
4025
|
+
setChromeOutdated(false);
|
|
4026
|
+
}
|
|
4001
4027
|
refreshLayoutWarnings();
|
|
4002
4028
|
});
|
|
4003
4029
|
socket.addEventListener("message", (message) => {
|
|
@@ -4009,11 +4035,23 @@ function connectLiveEvents() {
|
|
|
4009
4035
|
}
|
|
4010
4036
|
});
|
|
4011
4037
|
socket.addEventListener("close", () => {
|
|
4038
|
+
liveEventFailures += 1;
|
|
4039
|
+
noteLiveEventsUnreachable();
|
|
4012
4040
|
setTimeout(connectLiveEvents, eventReconnectDelayMs);
|
|
4013
4041
|
eventReconnectDelayMs = Math.min(eventReconnectDelayMs * 2, 5000);
|
|
4014
4042
|
});
|
|
4015
4043
|
}
|
|
4016
4044
|
|
|
4045
|
+
// Raise the existing banner once the stream has been down long enough to mean it, and never
|
|
4046
|
+
// against an ended session or a banner someone else owns. Reconnecting retires it.
|
|
4047
|
+
function noteLiveEventsUnreachable() {
|
|
4048
|
+
if (liveEventFailures < LIVE_EVENT_UNREACHABLE_FAILURES) return;
|
|
4049
|
+
if (ended || unreachableDismissed || unreachableBannerOwned) return;
|
|
4050
|
+
if (outdatedBanner && !outdatedBanner.hidden) return;
|
|
4051
|
+
unreachableBannerOwned = true;
|
|
4052
|
+
setChromeOutdated(true, "");
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4017
4055
|
events.set("reload", () => {
|
|
4018
4056
|
resetFrame().then((reloaded) => {
|
|
4019
4057
|
if (reloaded) refreshWhiteboardSource();
|
package/dist/cli.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
writeFileSync as writeFileSync2
|
|
19
19
|
} from "node:fs";
|
|
20
20
|
import { access, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
|
|
21
|
-
import
|
|
21
|
+
import os4 from "node:os";
|
|
22
22
|
import path8 from "node:path";
|
|
23
23
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
24
24
|
import { AxiError, installSessionStartHooks, RESERVED_COMMANDS, runAxiCli } from "axi-sdk-js";
|
|
@@ -7928,6 +7928,31 @@ function serializeChatSync(session) {
|
|
|
7928
7928
|
};
|
|
7929
7929
|
}
|
|
7930
7930
|
|
|
7931
|
+
// src/local-address.js
|
|
7932
|
+
import os3 from "node:os";
|
|
7933
|
+
function isLocalAddressPresent(host) {
|
|
7934
|
+
if (typeof host !== "string" || host === "") return false;
|
|
7935
|
+
try {
|
|
7936
|
+
for (const entries of Object.values(os3.networkInterfaces() || {})) {
|
|
7937
|
+
for (const entry of entries || []) {
|
|
7938
|
+
if (entry?.address === host) return true;
|
|
7939
|
+
}
|
|
7940
|
+
}
|
|
7941
|
+
} catch {
|
|
7942
|
+
return false;
|
|
7943
|
+
}
|
|
7944
|
+
return false;
|
|
7945
|
+
}
|
|
7946
|
+
|
|
7947
|
+
// src/server-log.js
|
|
7948
|
+
function formatServerLogLine(line, now = /* @__PURE__ */ new Date()) {
|
|
7949
|
+
return `${now.toISOString()} ${line}`;
|
|
7950
|
+
}
|
|
7951
|
+
var STDIO_STAMPED = /* @__PURE__ */ Symbol.for("lavish-axi.stdio-timestamped");
|
|
7952
|
+
function serverStdioIsTimestamped() {
|
|
7953
|
+
return Boolean(globalThis[STDIO_STAMPED]);
|
|
7954
|
+
}
|
|
7955
|
+
|
|
7931
7956
|
// src/html-transform.js
|
|
7932
7957
|
function injectLavishSdk(html, key, artifactRevision, artifactLoadToken = "") {
|
|
7933
7958
|
const revisionNumber = Number(artifactRevision);
|
|
@@ -9360,8 +9385,9 @@ var designAssetUrls = {
|
|
|
9360
9385
|
var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
9361
9386
|
var WHITEBOARD_CHANNEL_TOKEN_TTL_MS = 5 * 6e4;
|
|
9362
9387
|
var NETWORK_RECONCILE_CACHE_MS = 1e3;
|
|
9363
|
-
var
|
|
9388
|
+
var BIND_RETRY_DELAYS_MS = [100, 250, 500];
|
|
9364
9389
|
var WEBSOCKET_CLOSE_GRACE_MS = 250;
|
|
9390
|
+
var LIVE_EVENT_HEARTBEAT_MS = 3e4;
|
|
9365
9391
|
var BROWSER_DISCONNECT_GRACE_MS = 1e4;
|
|
9366
9392
|
var ARTIFACT_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads";
|
|
9367
9393
|
var ATTACHMENT_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
@@ -9460,6 +9486,7 @@ async function serve({
|
|
|
9460
9486
|
debug = false,
|
|
9461
9487
|
log = null,
|
|
9462
9488
|
pollHeartbeatMs = 15e3,
|
|
9489
|
+
liveEventHeartbeatMs = LIVE_EVENT_HEARTBEAT_MS,
|
|
9463
9490
|
browserDisconnectGraceMs = BROWSER_DISCONNECT_GRACE_MS,
|
|
9464
9491
|
idleTimeoutMs = resolveIdleTimeoutMs(),
|
|
9465
9492
|
host = bindHost(env),
|
|
@@ -9484,6 +9511,7 @@ async function serve({
|
|
|
9484
9511
|
});
|
|
9485
9512
|
const activeTailscaleNetwork = tailscaleNetworkKey(tailscale);
|
|
9486
9513
|
let tailscalePhoneReady = false;
|
|
9514
|
+
const absentRequestedHosts = [];
|
|
9487
9515
|
let networkWarning = typeof tailscale?.warning === "string" ? tailscale.warning : "";
|
|
9488
9516
|
let resolvedLinkHost = linkHostName ?? resolveLinkHost({ env, tailscale, fallbackHost: host });
|
|
9489
9517
|
const app = express();
|
|
@@ -9498,7 +9526,7 @@ async function serve({
|
|
|
9498
9526
|
const outstandingRepairBatches = /* @__PURE__ */ new Set();
|
|
9499
9527
|
const diagnosticViewportClasses = resolveDiagnosticViewportClasses();
|
|
9500
9528
|
const verbose = debug || env.LAVISH_AXI_DEBUG === "1";
|
|
9501
|
-
const writeLog = typeof log === "function" ? log : (line) => process.stderr.write(`${line}
|
|
9529
|
+
const writeLog = typeof log === "function" ? log : (line) => process.stderr.write(`${serverStdioIsTimestamped() ? line : formatServerLogLine(line)}
|
|
9502
9530
|
`);
|
|
9503
9531
|
const logEvent = verbose ? (line) => writeLog(`[lavish] ${line}`) : null;
|
|
9504
9532
|
if (networkWarning) writeLog(`[lavish] WARNING: ${networkWarning}`);
|
|
@@ -9566,6 +9594,14 @@ async function serve({
|
|
|
9566
9594
|
client.sendEvent("agent-presence", { state: computePresence(key, activePolls, deliveredFeedback) });
|
|
9567
9595
|
if (session?.status === "ended") client.sendEvent("ended", { ended_by: session.ended_by || null });
|
|
9568
9596
|
}
|
|
9597
|
+
function requestedBindIsRecoverable() {
|
|
9598
|
+
return absentRequestedHosts.some((listenHost) => isLocalAddressPresent(listenHost));
|
|
9599
|
+
}
|
|
9600
|
+
async function reconcileNetwork() {
|
|
9601
|
+
if (requestedBindIsRecoverable()) return true;
|
|
9602
|
+
if (!(autoTailscale && typeof detect === "function")) return false;
|
|
9603
|
+
return reconcileTailscaleNetwork();
|
|
9604
|
+
}
|
|
9569
9605
|
async function reconcileTailscaleNetwork() {
|
|
9570
9606
|
if (Date.now() - networkReconcileCheckedAt < NETWORK_RECONCILE_CACHE_MS) return cachedNetworkStale;
|
|
9571
9607
|
if (networkReconcilePromise) return networkReconcilePromise;
|
|
@@ -9712,7 +9748,7 @@ async function serve({
|
|
|
9712
9748
|
res.status(503).json({ ok: false, app: "lavish-axi", version });
|
|
9713
9749
|
return;
|
|
9714
9750
|
}
|
|
9715
|
-
const networkStale = req.query.reconcile_network === "1"
|
|
9751
|
+
const networkStale = req.query.reconcile_network === "1" ? await reconcileNetwork() : false;
|
|
9716
9752
|
res.json({
|
|
9717
9753
|
ok: true,
|
|
9718
9754
|
app: "lavish-axi",
|
|
@@ -9729,7 +9765,7 @@ async function serve({
|
|
|
9729
9765
|
const reloadKey = String(req.body?.reload_key || "");
|
|
9730
9766
|
const reason = SHUTDOWN_REASONS.has(String(req.body?.reason || "")) ? String(req.body.reason) : "";
|
|
9731
9767
|
res.json({ status: "shutting-down" });
|
|
9732
|
-
setImmediate(() => shutdown(reloadKey, reason));
|
|
9768
|
+
setImmediate(() => shutdown(reloadKey, reason, "shutdown-request"));
|
|
9733
9769
|
});
|
|
9734
9770
|
app.post("/api/sessions", async (req, res, next) => {
|
|
9735
9771
|
try {
|
|
@@ -9808,7 +9844,7 @@ async function serve({
|
|
|
9808
9844
|
const streamHeartbeat = timeoutMs === null;
|
|
9809
9845
|
let heartbeat = null;
|
|
9810
9846
|
if (streamHeartbeat) {
|
|
9811
|
-
res.status(200).type("application/json");
|
|
9847
|
+
res.status(200).type("application/json").set("Lavish-Poll-State", "listening");
|
|
9812
9848
|
res.write(" ");
|
|
9813
9849
|
heartbeat = setInterval(() => {
|
|
9814
9850
|
if (!res.writableEnded) res.write(" ");
|
|
@@ -10585,6 +10621,27 @@ ${body}`
|
|
|
10585
10621
|
};
|
|
10586
10622
|
webSocket.on("error", () => {
|
|
10587
10623
|
});
|
|
10624
|
+
if (liveEventHeartbeatMs != null && liveEventHeartbeatMs > 0) {
|
|
10625
|
+
let awaitingPong = false;
|
|
10626
|
+
const heartbeat = setInterval(() => {
|
|
10627
|
+
if (awaitingPong) {
|
|
10628
|
+
logEvent?.(`event WebSocket heartbeat missed session=${key}, terminating`);
|
|
10629
|
+
webSocket.terminate();
|
|
10630
|
+
return;
|
|
10631
|
+
}
|
|
10632
|
+
awaitingPong = true;
|
|
10633
|
+
try {
|
|
10634
|
+
webSocket.ping();
|
|
10635
|
+
} catch {
|
|
10636
|
+
webSocket.terminate();
|
|
10637
|
+
}
|
|
10638
|
+
}, liveEventHeartbeatMs);
|
|
10639
|
+
heartbeat.unref?.();
|
|
10640
|
+
webSocket.on("pong", () => {
|
|
10641
|
+
awaitingPong = false;
|
|
10642
|
+
});
|
|
10643
|
+
webSocket.once("close", () => clearInterval(heartbeat));
|
|
10644
|
+
}
|
|
10588
10645
|
const cleanup = attachLiveEventClient(client, key, (remove) => webSocket.once("close", remove));
|
|
10589
10646
|
sendInitialLiveEventState(client, key, cleanup).catch((error) => {
|
|
10590
10647
|
client.close(1011, "Failed to initialize live events");
|
|
@@ -10596,36 +10653,75 @@ ${body}`
|
|
|
10596
10653
|
const httpServers = [];
|
|
10597
10654
|
const boundHosts = [];
|
|
10598
10655
|
let boundPort = port;
|
|
10599
|
-
|
|
10600
|
-
|
|
10656
|
+
let lastBindError = null;
|
|
10657
|
+
async function bindListener(listenHost) {
|
|
10601
10658
|
let retryIndex = 0;
|
|
10602
10659
|
while (true) {
|
|
10603
10660
|
try {
|
|
10604
|
-
const httpServer = await listenHttp(app, boundPort, listenHost)
|
|
10661
|
+
const httpServer = await listenHttp(app, boundPort, listenHost, (error) => {
|
|
10662
|
+
writeLog(`[lavish] HTTP server error: ${error instanceof Error ? error.message : String(error)}`);
|
|
10663
|
+
});
|
|
10605
10664
|
httpServer.on("upgrade", handleEventUpgrade);
|
|
10606
10665
|
if (boundPort === 0) boundPort = httpServer.address().port;
|
|
10607
10666
|
httpServers.push(httpServer);
|
|
10608
10667
|
boundHosts.push(listenHost);
|
|
10609
|
-
|
|
10668
|
+
return null;
|
|
10610
10669
|
} catch (error) {
|
|
10611
|
-
if (
|
|
10612
|
-
|
|
10613
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelays[retryIndex]));
|
|
10670
|
+
if (retryIndex < BIND_RETRY_DELAYS_MS.length) {
|
|
10671
|
+
await new Promise((resolve) => setTimeout(resolve, BIND_RETRY_DELAYS_MS[retryIndex]));
|
|
10614
10672
|
retryIndex += 1;
|
|
10615
10673
|
continue;
|
|
10616
10674
|
}
|
|
10617
|
-
|
|
10618
|
-
networkWarning = "Tailscale binding failed; there is no phone access. Lavish remains available on loopback.";
|
|
10619
|
-
writeLog(`[lavish] WARNING: ${networkWarning} Address: ${listenHost}:${boundPort}.`);
|
|
10620
|
-
} else {
|
|
10621
|
-
logEvent?.(`failed to bind ${listenHost}:${boundPort}: ${error instanceof Error ? error.message : error}`);
|
|
10622
|
-
}
|
|
10623
|
-
break;
|
|
10675
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
10624
10676
|
}
|
|
10625
10677
|
}
|
|
10626
10678
|
}
|
|
10679
|
+
for (const listenHost of listenHosts) {
|
|
10680
|
+
const error = await bindListener(listenHost);
|
|
10681
|
+
if (!error) continue;
|
|
10682
|
+
lastBindError = error;
|
|
10683
|
+
if (isAddressAbsentBindError(error)) absentRequestedHosts.push(listenHost);
|
|
10684
|
+
if (listenHost === tailscale?.ipv4) {
|
|
10685
|
+
networkWarning = "Tailscale binding failed; there is no phone access. Lavish remains available on loopback.";
|
|
10686
|
+
writeLog(`[lavish] WARNING: ${networkWarning} Address: ${listenHost}:${boundPort}.`);
|
|
10687
|
+
} else {
|
|
10688
|
+
logEvent?.(`failed to bind ${listenHost}:${boundPort}: ${error.message}`);
|
|
10689
|
+
}
|
|
10690
|
+
}
|
|
10691
|
+
let loopbackFallback = false;
|
|
10692
|
+
if (httpServers.length === 0 && !listenHosts.includes(LOOPBACK_HOST)) {
|
|
10693
|
+
const error = await bindListener(LOOPBACK_HOST);
|
|
10694
|
+
if (error) {
|
|
10695
|
+
lastBindError = error;
|
|
10696
|
+
} else {
|
|
10697
|
+
loopbackFallback = true;
|
|
10698
|
+
networkWarning = `Could not bind ${listenHosts.join(", ")}; Lavish fell back to loopback and is not reachable at that address.`;
|
|
10699
|
+
writeLog(`[lavish] WARNING: ${networkWarning} Address: ${listenHosts[0]}:${boundPort}.`);
|
|
10700
|
+
}
|
|
10701
|
+
}
|
|
10627
10702
|
if (httpServers.length === 0) {
|
|
10628
|
-
throw new Error(
|
|
10703
|
+
throw new Error(
|
|
10704
|
+
`Lavish server failed to bind any address${lastBindError ? `: ${lastBindError.message}` : ""}`,
|
|
10705
|
+
lastBindError ? { cause: lastBindError } : void 0
|
|
10706
|
+
);
|
|
10707
|
+
}
|
|
10708
|
+
if (!boundHosts.includes(LOOPBACK_HOST) && !boundHosts.includes(listenHosts[0])) {
|
|
10709
|
+
const error = new Error(
|
|
10710
|
+
`Lavish server failed to bind a control-channel address${lastBindError ? `: ${lastBindError.message}` : ""}`,
|
|
10711
|
+
lastBindError ? { cause: lastBindError } : void 0
|
|
10712
|
+
);
|
|
10713
|
+
await Promise.all(
|
|
10714
|
+
httpServers.splice(0).map(
|
|
10715
|
+
(httpServer) => new Promise((resolve) => {
|
|
10716
|
+
httpServer.close(() => resolve(void 0));
|
|
10717
|
+
})
|
|
10718
|
+
)
|
|
10719
|
+
);
|
|
10720
|
+
boundHosts.length = 0;
|
|
10721
|
+
throw error;
|
|
10722
|
+
}
|
|
10723
|
+
if (loopbackFallback) {
|
|
10724
|
+
resolvedLinkHost = linkHostName ?? resolveLinkHost({ env, tailscale: null, fallbackHost: LOOPBACK_HOST });
|
|
10629
10725
|
}
|
|
10630
10726
|
tailscalePhoneReady = Boolean(tailscale?.ipv4 && boundHosts.includes(tailscale.ipv4));
|
|
10631
10727
|
if (tailscale?.ipv4 && !tailscalePhoneReady) {
|
|
@@ -10641,9 +10737,10 @@ ${body}`
|
|
|
10641
10737
|
}
|
|
10642
10738
|
publicPort = httpServers[0].address().port;
|
|
10643
10739
|
serverReady = true;
|
|
10644
|
-
function shutdown(reloadKey = "", reason = "") {
|
|
10740
|
+
function shutdown(reloadKey = "", reason = "", cause = "requested") {
|
|
10645
10741
|
if (shuttingDown) return;
|
|
10646
10742
|
shuttingDown = true;
|
|
10743
|
+
writeLog(`[lavish] shutting down: ${cause}${reason ? ` (reason=${reason})` : ""}`);
|
|
10647
10744
|
if (idleTimer) {
|
|
10648
10745
|
clearTimeout(idleTimer);
|
|
10649
10746
|
idleTimer = null;
|
|
@@ -10695,8 +10792,7 @@ ${body}`
|
|
|
10695
10792
|
idleTimer = setTimeout(() => {
|
|
10696
10793
|
idleTimer = null;
|
|
10697
10794
|
if (!shuttingDown && liveEventClients.size === 0 && activePolls.size === 0) {
|
|
10698
|
-
|
|
10699
|
-
shutdown();
|
|
10795
|
+
shutdown("", "", `idle-timeout after ${idleTimeoutMs}ms with no connections`);
|
|
10700
10796
|
}
|
|
10701
10797
|
}, idleTimeoutMs);
|
|
10702
10798
|
idleTimer.unref?.();
|
|
@@ -10706,8 +10802,7 @@ ${body}`
|
|
|
10706
10802
|
try {
|
|
10707
10803
|
const sessions = await store.listSessions();
|
|
10708
10804
|
if (sessions.every((session) => session.status === "ended")) {
|
|
10709
|
-
|
|
10710
|
-
setImmediate(shutdown);
|
|
10805
|
+
setImmediate(() => shutdown("", "", "last open session ended with no live connections"));
|
|
10711
10806
|
}
|
|
10712
10807
|
} catch {
|
|
10713
10808
|
}
|
|
@@ -10759,21 +10854,26 @@ ${body}`
|
|
|
10759
10854
|
hosts: boundHosts,
|
|
10760
10855
|
addresses: httpServers.map((server) => server.address()),
|
|
10761
10856
|
close: async () => {
|
|
10762
|
-
shutdown();
|
|
10857
|
+
shutdown("", "", "close() called");
|
|
10763
10858
|
await done;
|
|
10764
10859
|
},
|
|
10765
10860
|
done
|
|
10766
10861
|
};
|
|
10767
10862
|
}
|
|
10768
|
-
function listenHttp(app, port, host) {
|
|
10863
|
+
function listenHttp(app, port, host, onRuntimeError) {
|
|
10769
10864
|
return new Promise((resolve, reject) => {
|
|
10770
10865
|
const server = createServer(app);
|
|
10866
|
+
let listening = false;
|
|
10771
10867
|
const onError = (error) => {
|
|
10772
|
-
|
|
10773
|
-
|
|
10868
|
+
if (!listening) {
|
|
10869
|
+
server.off("listening", onListening);
|
|
10870
|
+
reject(error);
|
|
10871
|
+
return;
|
|
10872
|
+
}
|
|
10873
|
+
onRuntimeError?.(error);
|
|
10774
10874
|
};
|
|
10775
10875
|
const onListening = () => {
|
|
10776
|
-
|
|
10876
|
+
listening = true;
|
|
10777
10877
|
const address = server.address();
|
|
10778
10878
|
if (address && typeof address === "object" && isWildcardHost(address.address)) {
|
|
10779
10879
|
server.close(() => reject(new Error(`Refusing all-interfaces listener at ${address.address}`)));
|
|
@@ -10781,7 +10881,7 @@ function listenHttp(app, port, host) {
|
|
|
10781
10881
|
}
|
|
10782
10882
|
resolve(server);
|
|
10783
10883
|
};
|
|
10784
|
-
server.
|
|
10884
|
+
server.on("error", onError);
|
|
10785
10885
|
server.once("listening", onListening);
|
|
10786
10886
|
server.listen({ port, host });
|
|
10787
10887
|
});
|
|
@@ -10794,6 +10894,9 @@ function tailscaleNetworkKey(tailscale) {
|
|
|
10794
10894
|
${tailscale.ipv4}
|
|
10795
10895
|
${tailscale.magicDnsName}`;
|
|
10796
10896
|
}
|
|
10897
|
+
function isAddressAbsentBindError(error) {
|
|
10898
|
+
return error instanceof Error && "code" in error && error.code === "EADDRNOTAVAIL";
|
|
10899
|
+
}
|
|
10797
10900
|
function wantsHtml(req) {
|
|
10798
10901
|
const accept = String(req.get("accept") || "");
|
|
10799
10902
|
return accept.toLowerCase().includes("text/html");
|
|
@@ -11523,14 +11626,57 @@ var AGENT_REPLY_JSON_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
|
11523
11626
|
var AGENT_REPLY_JSON_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ text: "" }));
|
|
11524
11627
|
var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_JSON_ENVELOPE_BYTES;
|
|
11525
11628
|
var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
|
|
11629
|
+
var POLL_STATE_HEADER = "lavish-poll-state";
|
|
11526
11630
|
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.";
|
|
11527
|
-
var VERSION = "0.1.
|
|
11631
|
+
var VERSION = "0.1.74";
|
|
11528
11632
|
function detectInvokingAgent(env = process.env) {
|
|
11529
11633
|
return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
|
|
11530
11634
|
}
|
|
11531
11635
|
function shouldNarratePollWaitTicks({ isTTY }) {
|
|
11532
11636
|
return Boolean(isTTY);
|
|
11533
11637
|
}
|
|
11638
|
+
function herdrPollChimeEnabled(env = process.env) {
|
|
11639
|
+
return env.HERDR_ENV === "1" && env.LAVISH_AXI_HERDR_CHIME === "1";
|
|
11640
|
+
}
|
|
11641
|
+
function notifyHerdrPollReady({ env = process.env, runner = spawn } = {}) {
|
|
11642
|
+
if (!herdrPollChimeEnabled(env)) return false;
|
|
11643
|
+
try {
|
|
11644
|
+
const child = runner(
|
|
11645
|
+
"herdr",
|
|
11646
|
+
[
|
|
11647
|
+
"notification",
|
|
11648
|
+
"show",
|
|
11649
|
+
"Lavish review ready",
|
|
11650
|
+
"--body",
|
|
11651
|
+
"The artifact is open and Lavish is polling for your feedback.",
|
|
11652
|
+
"--sound",
|
|
11653
|
+
"request"
|
|
11654
|
+
],
|
|
11655
|
+
{ stdio: "ignore" }
|
|
11656
|
+
);
|
|
11657
|
+
const kill = () => {
|
|
11658
|
+
try {
|
|
11659
|
+
child.kill("SIGKILL");
|
|
11660
|
+
} catch {
|
|
11661
|
+
return;
|
|
11662
|
+
}
|
|
11663
|
+
};
|
|
11664
|
+
const timer = setTimeout(kill, 2e3);
|
|
11665
|
+
timer.unref();
|
|
11666
|
+
const cleanup = () => {
|
|
11667
|
+
clearTimeout(timer);
|
|
11668
|
+
process.off("exit", kill);
|
|
11669
|
+
};
|
|
11670
|
+
child.on("error", () => {
|
|
11671
|
+
});
|
|
11672
|
+
child.once("close", cleanup);
|
|
11673
|
+
process.once("exit", kill);
|
|
11674
|
+
child.unref();
|
|
11675
|
+
return true;
|
|
11676
|
+
} catch {
|
|
11677
|
+
return false;
|
|
11678
|
+
}
|
|
11679
|
+
}
|
|
11534
11680
|
function pollExecutionGuidance({ agent = "generic" } = {}) {
|
|
11535
11681
|
const sharedGuidance = POLL_WAKE_PATH_RULES.join(" ");
|
|
11536
11682
|
const agentGuidance = agent === "codex" ? ` ${CODEX_POLL_WAKE_PATH_GUIDANCE}` : "";
|
|
@@ -11618,7 +11764,7 @@ function telemetryCommandName(argv) {
|
|
|
11618
11764
|
}
|
|
11619
11765
|
function createHomeOutput({ bin, sessions, includeSessions = true, agent = "generic" }) {
|
|
11620
11766
|
return {
|
|
11621
|
-
bin: collapseHomeDirectory(bin,
|
|
11767
|
+
bin: collapseHomeDirectory(bin, os4.homedir()),
|
|
11622
11768
|
description: DESCRIPTION,
|
|
11623
11769
|
...includeSessions ? {
|
|
11624
11770
|
sessions: sessions.map((session) => ({
|
|
@@ -11770,7 +11916,10 @@ ${pollInterruptedText(absolute)}
|
|
|
11770
11916
|
try {
|
|
11771
11917
|
const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}`, {
|
|
11772
11918
|
retries: 3,
|
|
11773
|
-
retryDelayMs: 500
|
|
11919
|
+
retryDelayMs: 500,
|
|
11920
|
+
onResponse: (pollResponse) => {
|
|
11921
|
+
if (pollResponse.headers.get(POLL_STATE_HEADER) === "listening") notifyHerdrPollReady();
|
|
11922
|
+
}
|
|
11774
11923
|
});
|
|
11775
11924
|
return createPollOutput({ file: absolute, response, agent: detectInvokingAgent(process.env) });
|
|
11776
11925
|
} finally {
|
|
@@ -12304,7 +12453,7 @@ function generatedPasswordNote(password) {
|
|
|
12304
12453
|
}
|
|
12305
12454
|
async function stopCommand(args) {
|
|
12306
12455
|
const port = Number(flagValue(args, "--port") || defaultPort());
|
|
12307
|
-
const baseUrl =
|
|
12456
|
+
const { baseUrl } = await findRunningServer(port);
|
|
12308
12457
|
return shutdownServerOnPort(port, { baseUrl, currentVersion: VERSION });
|
|
12309
12458
|
}
|
|
12310
12459
|
async function shutdownServerOnPort(port, {
|
|
@@ -12537,7 +12686,7 @@ function sameResolvedPath(left, right) {
|
|
|
12537
12686
|
return path8.resolve(left) === path8.resolve(right);
|
|
12538
12687
|
}
|
|
12539
12688
|
}
|
|
12540
|
-
function resolveHookHomeDir(env = process.env, fallback =
|
|
12689
|
+
function resolveHookHomeDir(env = process.env, fallback = os4.homedir()) {
|
|
12541
12690
|
return env.HOME || fallback;
|
|
12542
12691
|
}
|
|
12543
12692
|
function resolveCopilotHookDir(env = process.env, homeDir = resolveHookHomeDir(env)) {
|
|
@@ -12647,10 +12796,42 @@ async function assertHtmlFile(file) {
|
|
|
12647
12796
|
function isHtmlPath(file) {
|
|
12648
12797
|
return file.toLowerCase().endsWith(".html") || file.toLowerCase().endsWith(".htm");
|
|
12649
12798
|
}
|
|
12799
|
+
function serverBaseUrls(port) {
|
|
12800
|
+
const urls = [`http://${hostForUrl(clientHost())}:${port}`];
|
|
12801
|
+
const loopback = `http://${hostForUrl(LOOPBACK_HOST)}:${port}`;
|
|
12802
|
+
if (!urls.includes(loopback)) urls.push(loopback);
|
|
12803
|
+
return urls;
|
|
12804
|
+
}
|
|
12805
|
+
var HEALTH_PROBE_TIMEOUT_MS = 500;
|
|
12806
|
+
async function findRunningServer(port, { reconcileNetwork = false } = {}) {
|
|
12807
|
+
const candidates = serverBaseUrls(port);
|
|
12808
|
+
let foreign = null;
|
|
12809
|
+
for (const baseUrl of candidates) {
|
|
12810
|
+
const health = await probeHealth(baseUrl, { reconcileNetwork, timeoutMs: HEALTH_PROBE_TIMEOUT_MS });
|
|
12811
|
+
if (!health) continue;
|
|
12812
|
+
if (health.app === "lavish-axi") return { baseUrl, health };
|
|
12813
|
+
if (!foreign) foreign = { baseUrl, health };
|
|
12814
|
+
}
|
|
12815
|
+
return foreign ?? { baseUrl: candidates[0], health: null };
|
|
12816
|
+
}
|
|
12817
|
+
async function probeHealth(baseUrl, { reconcileNetwork, timeoutMs }) {
|
|
12818
|
+
const controller = new AbortController();
|
|
12819
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
12820
|
+
try {
|
|
12821
|
+
const health = await Promise.race([
|
|
12822
|
+
Promise.resolve().then(() => fetchHealth(baseUrl, { reconcileNetwork, timeoutMs, signal: controller.signal })).catch(() => null),
|
|
12823
|
+
new Promise((resolve) => {
|
|
12824
|
+
controller.signal.addEventListener("abort", () => resolve(null), { once: true });
|
|
12825
|
+
})
|
|
12826
|
+
]);
|
|
12827
|
+
return health && typeof health === "object" ? health : null;
|
|
12828
|
+
} finally {
|
|
12829
|
+
clearTimeout(timer);
|
|
12830
|
+
}
|
|
12831
|
+
}
|
|
12650
12832
|
async function ensureServer({ forceRestart = false, reloadKey = "" } = {}) {
|
|
12651
12833
|
const port = defaultPort();
|
|
12652
|
-
const baseUrl =
|
|
12653
|
-
const existing = await fetchHealth(baseUrl, { reconcileNetwork: true });
|
|
12834
|
+
const { baseUrl, health: existing } = await findRunningServer(port, { reconcileNetwork: true });
|
|
12654
12835
|
if (existing && !shouldRestartServer(VERSION, existing, forceRestart)) {
|
|
12655
12836
|
return baseUrl;
|
|
12656
12837
|
}
|
|
@@ -12670,15 +12851,16 @@ async function ensureServer({ forceRestart = false, reloadKey = "" } = {}) {
|
|
|
12670
12851
|
}
|
|
12671
12852
|
}
|
|
12672
12853
|
await startServer(port);
|
|
12673
|
-
|
|
12854
|
+
const replacedForNetwork = Boolean(existing) && existing.app === "lavish-axi" && existing.network_stale === true && !forceRestart && typeof existing.version === "string" && existing.version === VERSION;
|
|
12855
|
+
let networkRestarted = replacedForNetwork;
|
|
12674
12856
|
let deadline = Date.now() + 5e3;
|
|
12675
12857
|
while (Date.now() < deadline) {
|
|
12676
|
-
const health = await
|
|
12677
|
-
if (health && !shouldRestartServer(VERSION, health)) return
|
|
12858
|
+
const { baseUrl: liveUrl, health } = await findRunningServer(port, { reconcileNetwork: true });
|
|
12859
|
+
if (health && !shouldRestartServer(VERSION, health)) return liveUrl;
|
|
12678
12860
|
if (health?.network_stale === true && health.app === "lavish-axi") {
|
|
12679
|
-
if (networkRestarted) return
|
|
12680
|
-
await requestShutdown(
|
|
12681
|
-
if (!await waitForPortFree(
|
|
12861
|
+
if (networkRestarted) return liveUrl;
|
|
12862
|
+
await requestShutdown(liveUrl, { reloadKey, reason: "" });
|
|
12863
|
+
if (!await waitForPortFree(liveUrl, 3e3)) break;
|
|
12682
12864
|
await startServer(port);
|
|
12683
12865
|
networkRestarted = true;
|
|
12684
12866
|
deadline = Date.now() + 5e3;
|
|
@@ -12724,10 +12906,11 @@ async function canControlServerOnPort(port, healthBody, processMatchesLavish) {
|
|
|
12724
12906
|
if (typeof healthBody.version === "string" && healthBody.version !== "") return false;
|
|
12725
12907
|
return processMatchesLavish(port);
|
|
12726
12908
|
}
|
|
12727
|
-
async function fetchHealth(baseUrl, { reconcileNetwork = false } = {}) {
|
|
12909
|
+
async function fetchHealth(baseUrl, { reconcileNetwork = false, timeoutMs, signal } = {}) {
|
|
12728
12910
|
try {
|
|
12729
12911
|
const suffix = reconcileNetwork ? "?reconcile_network=1" : "";
|
|
12730
|
-
const
|
|
12912
|
+
const abortSignal = signal ?? (timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0);
|
|
12913
|
+
const response = await fetch(`${baseUrl}/health${suffix}`, abortSignal ? { signal: abortSignal } : {});
|
|
12731
12914
|
if (!response.ok) return null;
|
|
12732
12915
|
return await response.json();
|
|
12733
12916
|
} catch {
|
|
@@ -12804,9 +12987,9 @@ async function startServer(port) {
|
|
|
12804
12987
|
}
|
|
12805
12988
|
}
|
|
12806
12989
|
function resolveServerEntry() {
|
|
12807
|
-
const
|
|
12808
|
-
if (existsSync3(
|
|
12809
|
-
return fileURLToPath4(import.meta.url);
|
|
12990
|
+
const sourceEntry = fileURLToPath4(new URL("../bin/lavish-axi-server.js", import.meta.url));
|
|
12991
|
+
if (existsSync3(sourceEntry)) return sourceEntry;
|
|
12992
|
+
return fileURLToPath4(new URL("./server.mjs", import.meta.url));
|
|
12810
12993
|
}
|
|
12811
12994
|
function createServerSpawnOptions(logFd = null) {
|
|
12812
12995
|
const stdio = (
|
|
@@ -12819,7 +13002,7 @@ function createServerSpawnOptions(logFd = null) {
|
|
|
12819
13002
|
env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
|
|
12820
13003
|
};
|
|
12821
13004
|
}
|
|
12822
|
-
async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) {
|
|
13005
|
+
async function fetchJson(url, { retries = 0, retryDelayMs = 250, onResponse = null } = {}) {
|
|
12823
13006
|
let response;
|
|
12824
13007
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
12825
13008
|
try {
|
|
@@ -12835,6 +13018,7 @@ async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) {
|
|
|
12835
13018
|
if (!response.ok) {
|
|
12836
13019
|
throw new AxiError(`Lavish Editor request failed: ${response.status}`, "SERVER_ERROR");
|
|
12837
13020
|
}
|
|
13021
|
+
onResponse?.(response);
|
|
12838
13022
|
try {
|
|
12839
13023
|
return await response.json();
|
|
12840
13024
|
} catch {
|
|
@@ -13039,7 +13223,7 @@ Open or resume a Lavish Editor review session for an HTML artifact. Use --no-ope
|
|
|
13039
13223
|
`,
|
|
13040
13224
|
poll: `Usage: lavish-axi poll <html-file> [--agent-reply "..."] [--agent-reply-file <path>]
|
|
13041
13225
|
|
|
13042
|
-
This command long-polls indefinitely for queued user prompts. It stays silent while it waits - that is normal, never kill it. Browser-detected layout issues do NOT return this poll: they are filed passively in the user's Layout issues inbox and arrive as an ordinary tag "layout-warnings" prompt only after the user selects them and queues the fixes. Warning lifecycle: an issue stays unresolved and counted while queued, becomes recurring if a newer artifact revision still shows it, and is resolved only after a newer artifact load plus a complete diagnostic pass at the same viewport no longer detects it. A failed or incomplete pass preserves it as unverified rather than clearing it. The only response that arrives without user action is artifact_failures - a fatal failure that made the review surface itself unusable. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display a concise response in Lavish Editor before waiting again. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine --agent-reply with --agent-reply-file.
|
|
13226
|
+
This command long-polls indefinitely for queued user prompts. It stays silent while it waits - that is normal, never kill it. Browser-detected layout issues do NOT return this poll: they are filed passively in the user's Layout issues inbox and arrive as an ordinary tag "layout-warnings" prompt only after the user selects them and queues the fixes. Warning lifecycle: an issue stays unresolved and counted while queued, becomes recurring if a newer artifact revision still shows it, and is resolved only after a newer artifact load plus a complete diagnostic pass at the same viewport no longer detects it. A failed or incomplete pass preserves it as unverified rather than clearing it. The only response that arrives without user action is artifact_failures - a fatal failure that made the review surface itself unusable. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. In a Herdr-managed pane, set LAVISH_AXI_HERDR_CHIME=1 to request attention when the poll has entered its waiting state; unset it or use any other value to keep the current silent behavior. Notification failures never interrupt the poll. ${pollExecutionGuidance({ agent })} Use --agent-reply after applying prior feedback to display a concise response in Lavish Editor before waiting again. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_HELP_POINTER} Do not combine --agent-reply with --agent-reply-file.
|
|
13043
13227
|
|
|
13044
13228
|
Examples:
|
|
13045
13229
|
lavish-axi poll report.html --agent-reply "Renamed the payment step."
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server-log.js
|
|
4
|
+
function formatServerLogLine(line, now = /* @__PURE__ */ new Date()) {
|
|
5
|
+
return `${now.toISOString()} ${line}`;
|
|
6
|
+
}
|
|
7
|
+
var STDIO_STAMPED = /* @__PURE__ */ Symbol.for("lavish-axi.stdio-timestamped");
|
|
8
|
+
function serverStdioIsTimestamped() {
|
|
9
|
+
return Boolean(globalThis[STDIO_STAMPED]);
|
|
10
|
+
}
|
|
11
|
+
function createTimestampedWrite(write, now = () => /* @__PURE__ */ new Date()) {
|
|
12
|
+
let atLineStart = true;
|
|
13
|
+
return function timestampedWrite(chunk, encoding, callback) {
|
|
14
|
+
let enc = encoding;
|
|
15
|
+
let cb = callback;
|
|
16
|
+
if (typeof encoding === "function") {
|
|
17
|
+
cb = encoding;
|
|
18
|
+
enc = void 0;
|
|
19
|
+
}
|
|
20
|
+
const str = chunkToString(chunk, enc);
|
|
21
|
+
let out = "";
|
|
22
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
23
|
+
if (atLineStart) {
|
|
24
|
+
out += formatServerLogLine("", now());
|
|
25
|
+
atLineStart = false;
|
|
26
|
+
}
|
|
27
|
+
const ch = str[i];
|
|
28
|
+
out += ch;
|
|
29
|
+
if (ch === "\n") atLineStart = true;
|
|
30
|
+
}
|
|
31
|
+
return write(out, enc, cb);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function installServerStdioTimestamps() {
|
|
35
|
+
if (serverStdioIsTimestamped()) return;
|
|
36
|
+
globalThis[STDIO_STAMPED] = true;
|
|
37
|
+
process.stdout.write = createTimestampedWrite(process.stdout.write.bind(process.stdout));
|
|
38
|
+
process.stderr.write = createTimestampedWrite(process.stderr.write.bind(process.stderr));
|
|
39
|
+
}
|
|
40
|
+
function chunkToString(chunk, encoding) {
|
|
41
|
+
if (typeof chunk === "string") return chunk;
|
|
42
|
+
if (Buffer.isBuffer(chunk)) {
|
|
43
|
+
const bufferEncoding = (
|
|
44
|
+
/** @type {BufferEncoding} */
|
|
45
|
+
typeof encoding === "string" ? encoding : "utf8"
|
|
46
|
+
);
|
|
47
|
+
return chunk.toString(bufferEncoding);
|
|
48
|
+
}
|
|
49
|
+
return String(chunk);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// bin/lavish-axi-server.js
|
|
53
|
+
installServerStdioTimestamps();
|
|
54
|
+
var fatalExitScheduled = false;
|
|
55
|
+
function handleFatalServerError(kind, error) {
|
|
56
|
+
const detail = error instanceof Error ? error.stack || error.message : String(error);
|
|
57
|
+
console.error(`[lavish] ${kind}: ${detail}`);
|
|
58
|
+
if (fatalExitScheduled) return;
|
|
59
|
+
fatalExitScheduled = true;
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
setImmediate(() => process.exit(1));
|
|
62
|
+
}
|
|
63
|
+
process.on("uncaughtException", (error) => handleFatalServerError("uncaught exception", error));
|
|
64
|
+
try {
|
|
65
|
+
await import("./cli.mjs");
|
|
66
|
+
} catch (error) {
|
|
67
|
+
handleFatalServerError("server startup failed", error);
|
|
68
|
+
}
|
package/package.json
CHANGED
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.
|
|
4
|
+
"version": "0.1.74",
|
|
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",
|