lavish-axi 0.1.72 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -236,7 +236,7 @@ pnpm link
236
236
  Lavish changes only the browser view, so saved, standalone, and exported artifacts still render plain Mermaid.
237
237
  - **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
238
  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.
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. 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
240
  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
241
  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
242
  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 +244,7 @@ pnpm link
244
244
  - **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set.
245
245
  - **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
246
  - **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.
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. 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
248
  - **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
249
  - **Browser opening** - Set `LAVISH_AXI_NO_OPEN=1`, equivalent to `--no-open`, to create or resume a session without launching a browser window.
250
250
 
@@ -289,7 +289,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
289
289
  | `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
290
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
291
291
  | `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. |
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 with UTC timestamps to `~/.lavish-axi/server.log` (or `LAVISH_AXI_STATE_DIR/server.log`) for startup, shutdown-cause, and crash diagnostics. |
293
293
 
294
294
  ## Development
295
295
 
@@ -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) outdatedDismissButton.onclick = () => setChromeOutdated(false);
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 os3 from "node:os";
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 TAILSCALE_BIND_RETRY_DELAYS_MS = [100, 250, 500];
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" && autoTailscale && typeof detect === "function" ? await reconcileTailscaleNetwork() : false;
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 {
@@ -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,8 +10653,8 @@ ${body}`
10596
10653
  const httpServers = [];
10597
10654
  const boundHosts = [];
10598
10655
  let boundPort = port;
10599
- for (const listenHost of listenHosts) {
10600
- const retryDelays = listenHost === tailscale?.ipv4 ? TAILSCALE_BIND_RETRY_DELAYS_MS : [];
10656
+ let lastBindError = null;
10657
+ async function bindListener(listenHost) {
10601
10658
  let retryIndex = 0;
10602
10659
  while (true) {
10603
10660
  try {
@@ -10606,26 +10663,63 @@ ${body}`
10606
10663
  if (boundPort === 0) boundPort = httpServer.address().port;
10607
10664
  httpServers.push(httpServer);
10608
10665
  boundHosts.push(listenHost);
10609
- break;
10666
+ return null;
10610
10667
  } catch (error) {
10611
- if (httpServers.length === 0) throw error;
10612
- if (retryIndex < retryDelays.length) {
10613
- await new Promise((resolve) => setTimeout(resolve, retryDelays[retryIndex]));
10668
+ if (retryIndex < BIND_RETRY_DELAYS_MS.length) {
10669
+ await new Promise((resolve) => setTimeout(resolve, BIND_RETRY_DELAYS_MS[retryIndex]));
10614
10670
  retryIndex += 1;
10615
10671
  continue;
10616
10672
  }
10617
- if (listenHost === tailscale?.ipv4) {
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;
10673
+ return error instanceof Error ? error : new Error(String(error));
10624
10674
  }
10625
10675
  }
10626
10676
  }
10677
+ for (const listenHost of listenHosts) {
10678
+ const error = await bindListener(listenHost);
10679
+ if (!error) continue;
10680
+ lastBindError = error;
10681
+ if (isAddressAbsentBindError(error)) absentRequestedHosts.push(listenHost);
10682
+ if (listenHost === tailscale?.ipv4) {
10683
+ networkWarning = "Tailscale binding failed; there is no phone access. Lavish remains available on loopback.";
10684
+ writeLog(`[lavish] WARNING: ${networkWarning} Address: ${listenHost}:${boundPort}.`);
10685
+ } else {
10686
+ logEvent?.(`failed to bind ${listenHost}:${boundPort}: ${error.message}`);
10687
+ }
10688
+ }
10689
+ let loopbackFallback = false;
10690
+ if (httpServers.length === 0 && !listenHosts.includes(LOOPBACK_HOST)) {
10691
+ const error = await bindListener(LOOPBACK_HOST);
10692
+ if (error) {
10693
+ lastBindError = error;
10694
+ } else {
10695
+ loopbackFallback = true;
10696
+ networkWarning = `Could not bind ${listenHosts.join(", ")}; Lavish fell back to loopback and is not reachable at that address.`;
10697
+ writeLog(`[lavish] WARNING: ${networkWarning} Address: ${listenHosts[0]}:${boundPort}.`);
10698
+ }
10699
+ }
10627
10700
  if (httpServers.length === 0) {
10628
- throw new Error("Lavish server failed to bind any address");
10701
+ throw new Error(
10702
+ `Lavish server failed to bind any address${lastBindError ? `: ${lastBindError.message}` : ""}`,
10703
+ lastBindError ? { cause: lastBindError } : void 0
10704
+ );
10705
+ }
10706
+ if (!boundHosts.includes(LOOPBACK_HOST) && !boundHosts.includes(listenHosts[0])) {
10707
+ const error = new Error(
10708
+ `Lavish server failed to bind a control-channel address${lastBindError ? `: ${lastBindError.message}` : ""}`,
10709
+ lastBindError ? { cause: lastBindError } : void 0
10710
+ );
10711
+ await Promise.all(
10712
+ httpServers.splice(0).map(
10713
+ (httpServer) => new Promise((resolve) => {
10714
+ httpServer.close(() => resolve(void 0));
10715
+ })
10716
+ )
10717
+ );
10718
+ boundHosts.length = 0;
10719
+ throw error;
10720
+ }
10721
+ if (loopbackFallback) {
10722
+ resolvedLinkHost = linkHostName ?? resolveLinkHost({ env, tailscale: null, fallbackHost: LOOPBACK_HOST });
10629
10723
  }
10630
10724
  tailscalePhoneReady = Boolean(tailscale?.ipv4 && boundHosts.includes(tailscale.ipv4));
10631
10725
  if (tailscale?.ipv4 && !tailscalePhoneReady) {
@@ -10641,9 +10735,10 @@ ${body}`
10641
10735
  }
10642
10736
  publicPort = httpServers[0].address().port;
10643
10737
  serverReady = true;
10644
- function shutdown(reloadKey = "", reason = "") {
10738
+ function shutdown(reloadKey = "", reason = "", cause = "requested") {
10645
10739
  if (shuttingDown) return;
10646
10740
  shuttingDown = true;
10741
+ writeLog(`[lavish] shutting down: ${cause}${reason ? ` (reason=${reason})` : ""}`);
10647
10742
  if (idleTimer) {
10648
10743
  clearTimeout(idleTimer);
10649
10744
  idleTimer = null;
@@ -10695,8 +10790,7 @@ ${body}`
10695
10790
  idleTimer = setTimeout(() => {
10696
10791
  idleTimer = null;
10697
10792
  if (!shuttingDown && liveEventClients.size === 0 && activePolls.size === 0) {
10698
- logEvent?.(`idle for ${idleTimeoutMs}ms with no connections, shutting down`);
10699
- shutdown();
10793
+ shutdown("", "", `idle-timeout after ${idleTimeoutMs}ms with no connections`);
10700
10794
  }
10701
10795
  }, idleTimeoutMs);
10702
10796
  idleTimer.unref?.();
@@ -10706,8 +10800,7 @@ ${body}`
10706
10800
  try {
10707
10801
  const sessions = await store.listSessions();
10708
10802
  if (sessions.every((session) => session.status === "ended")) {
10709
- logEvent?.("last open session ended with no live connections, shutting down");
10710
- setImmediate(shutdown);
10803
+ setImmediate(() => shutdown("", "", "last open session ended with no live connections"));
10711
10804
  }
10712
10805
  } catch {
10713
10806
  }
@@ -10759,7 +10852,7 @@ ${body}`
10759
10852
  hosts: boundHosts,
10760
10853
  addresses: httpServers.map((server) => server.address()),
10761
10854
  close: async () => {
10762
- shutdown();
10855
+ shutdown("", "", "close() called");
10763
10856
  await done;
10764
10857
  },
10765
10858
  done
@@ -10794,6 +10887,9 @@ function tailscaleNetworkKey(tailscale) {
10794
10887
  ${tailscale.ipv4}
10795
10888
  ${tailscale.magicDnsName}`;
10796
10889
  }
10890
+ function isAddressAbsentBindError(error) {
10891
+ return error instanceof Error && "code" in error && error.code === "EADDRNOTAVAIL";
10892
+ }
10797
10893
  function wantsHtml(req) {
10798
10894
  const accept = String(req.get("accept") || "");
10799
10895
  return accept.toLowerCase().includes("text/html");
@@ -11524,7 +11620,7 @@ var AGENT_REPLY_JSON_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ text: "
11524
11620
  var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_JSON_ENVELOPE_BYTES;
11525
11621
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
11526
11622
  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.72";
11623
+ var VERSION = "0.1.73";
11528
11624
  function detectInvokingAgent(env = process.env) {
11529
11625
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
11530
11626
  }
@@ -11618,7 +11714,7 @@ function telemetryCommandName(argv) {
11618
11714
  }
11619
11715
  function createHomeOutput({ bin, sessions, includeSessions = true, agent = "generic" }) {
11620
11716
  return {
11621
- bin: collapseHomeDirectory(bin, os3.homedir()),
11717
+ bin: collapseHomeDirectory(bin, os4.homedir()),
11622
11718
  description: DESCRIPTION,
11623
11719
  ...includeSessions ? {
11624
11720
  sessions: sessions.map((session) => ({
@@ -12304,7 +12400,7 @@ function generatedPasswordNote(password) {
12304
12400
  }
12305
12401
  async function stopCommand(args) {
12306
12402
  const port = Number(flagValue(args, "--port") || defaultPort());
12307
- const baseUrl = `http://${hostForUrl(clientHost())}:${port}`;
12403
+ const { baseUrl } = await findRunningServer(port);
12308
12404
  return shutdownServerOnPort(port, { baseUrl, currentVersion: VERSION });
12309
12405
  }
12310
12406
  async function shutdownServerOnPort(port, {
@@ -12537,7 +12633,7 @@ function sameResolvedPath(left, right) {
12537
12633
  return path8.resolve(left) === path8.resolve(right);
12538
12634
  }
12539
12635
  }
12540
- function resolveHookHomeDir(env = process.env, fallback = os3.homedir()) {
12636
+ function resolveHookHomeDir(env = process.env, fallback = os4.homedir()) {
12541
12637
  return env.HOME || fallback;
12542
12638
  }
12543
12639
  function resolveCopilotHookDir(env = process.env, homeDir = resolveHookHomeDir(env)) {
@@ -12647,10 +12743,42 @@ async function assertHtmlFile(file) {
12647
12743
  function isHtmlPath(file) {
12648
12744
  return file.toLowerCase().endsWith(".html") || file.toLowerCase().endsWith(".htm");
12649
12745
  }
12746
+ function serverBaseUrls(port) {
12747
+ const urls = [`http://${hostForUrl(clientHost())}:${port}`];
12748
+ const loopback = `http://${hostForUrl(LOOPBACK_HOST)}:${port}`;
12749
+ if (!urls.includes(loopback)) urls.push(loopback);
12750
+ return urls;
12751
+ }
12752
+ var HEALTH_PROBE_TIMEOUT_MS = 500;
12753
+ async function findRunningServer(port, { reconcileNetwork = false } = {}) {
12754
+ const candidates = serverBaseUrls(port);
12755
+ let foreign = null;
12756
+ for (const baseUrl of candidates) {
12757
+ const health = await probeHealth(baseUrl, { reconcileNetwork, timeoutMs: HEALTH_PROBE_TIMEOUT_MS });
12758
+ if (!health) continue;
12759
+ if (health.app === "lavish-axi") return { baseUrl, health };
12760
+ if (!foreign) foreign = { baseUrl, health };
12761
+ }
12762
+ return foreign ?? { baseUrl: candidates[0], health: null };
12763
+ }
12764
+ async function probeHealth(baseUrl, { reconcileNetwork, timeoutMs }) {
12765
+ const controller = new AbortController();
12766
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
12767
+ try {
12768
+ const health = await Promise.race([
12769
+ Promise.resolve().then(() => fetchHealth(baseUrl, { reconcileNetwork, timeoutMs, signal: controller.signal })).catch(() => null),
12770
+ new Promise((resolve) => {
12771
+ controller.signal.addEventListener("abort", () => resolve(null), { once: true });
12772
+ })
12773
+ ]);
12774
+ return health && typeof health === "object" ? health : null;
12775
+ } finally {
12776
+ clearTimeout(timer);
12777
+ }
12778
+ }
12650
12779
  async function ensureServer({ forceRestart = false, reloadKey = "" } = {}) {
12651
12780
  const port = defaultPort();
12652
- const baseUrl = `http://${hostForUrl(clientHost())}:${port}`;
12653
- const existing = await fetchHealth(baseUrl, { reconcileNetwork: true });
12781
+ const { baseUrl, health: existing } = await findRunningServer(port, { reconcileNetwork: true });
12654
12782
  if (existing && !shouldRestartServer(VERSION, existing, forceRestart)) {
12655
12783
  return baseUrl;
12656
12784
  }
@@ -12670,15 +12798,16 @@ async function ensureServer({ forceRestart = false, reloadKey = "" } = {}) {
12670
12798
  }
12671
12799
  }
12672
12800
  await startServer(port);
12673
- let networkRestarted = false;
12801
+ const replacedForNetwork = Boolean(existing) && existing.app === "lavish-axi" && existing.network_stale === true && !forceRestart && typeof existing.version === "string" && existing.version === VERSION;
12802
+ let networkRestarted = replacedForNetwork;
12674
12803
  let deadline = Date.now() + 5e3;
12675
12804
  while (Date.now() < deadline) {
12676
- const health = await fetchHealth(baseUrl, { reconcileNetwork: true });
12677
- if (health && !shouldRestartServer(VERSION, health)) return baseUrl;
12805
+ const { baseUrl: liveUrl, health } = await findRunningServer(port, { reconcileNetwork: true });
12806
+ if (health && !shouldRestartServer(VERSION, health)) return liveUrl;
12678
12807
  if (health?.network_stale === true && health.app === "lavish-axi") {
12679
- if (networkRestarted) return baseUrl;
12680
- await requestShutdown(baseUrl, { reloadKey, reason: "" });
12681
- if (!await waitForPortFree(baseUrl, 3e3)) break;
12808
+ if (networkRestarted) return liveUrl;
12809
+ await requestShutdown(liveUrl, { reloadKey, reason: "" });
12810
+ if (!await waitForPortFree(liveUrl, 3e3)) break;
12682
12811
  await startServer(port);
12683
12812
  networkRestarted = true;
12684
12813
  deadline = Date.now() + 5e3;
@@ -12724,10 +12853,11 @@ async function canControlServerOnPort(port, healthBody, processMatchesLavish) {
12724
12853
  if (typeof healthBody.version === "string" && healthBody.version !== "") return false;
12725
12854
  return processMatchesLavish(port);
12726
12855
  }
12727
- async function fetchHealth(baseUrl, { reconcileNetwork = false } = {}) {
12856
+ async function fetchHealth(baseUrl, { reconcileNetwork = false, timeoutMs, signal } = {}) {
12728
12857
  try {
12729
12858
  const suffix = reconcileNetwork ? "?reconcile_network=1" : "";
12730
- const response = await fetch(`${baseUrl}/health${suffix}`);
12859
+ const abortSignal = signal ?? (timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0);
12860
+ const response = await fetch(`${baseUrl}/health${suffix}`, abortSignal ? { signal: abortSignal } : {});
12731
12861
  if (!response.ok) return null;
12732
12862
  return await response.json();
12733
12863
  } catch {
@@ -12804,9 +12934,9 @@ async function startServer(port) {
12804
12934
  }
12805
12935
  }
12806
12936
  function resolveServerEntry() {
12807
- const binEntry = fileURLToPath4(new URL("../bin/lavish-axi.js", import.meta.url));
12808
- if (existsSync3(binEntry)) return binEntry;
12809
- return fileURLToPath4(import.meta.url);
12937
+ const sourceEntry = fileURLToPath4(new URL("../bin/lavish-axi-server.js", import.meta.url));
12938
+ if (existsSync3(sourceEntry)) return sourceEntry;
12939
+ return fileURLToPath4(new URL("./server.mjs", import.meta.url));
12810
12940
  }
12811
12941
  function createServerSpawnOptions(logFd = null) {
12812
12942
  const stdio = (
@@ -0,0 +1,59 @@
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
+ try {
55
+ await import("./cli.mjs");
56
+ } catch (error) {
57
+ console.error(error);
58
+ process.exit(1);
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.72",
3
+ "version": "0.1.73",
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",
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.72",
4
+ "version": "0.1.73",
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",