lavish-axi 0.1.76 → 0.1.78

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
@@ -249,7 +249,8 @@ pnpm link
249
249
  - **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set.
250
250
  - **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.
251
251
  - **Server port** - Set `LAVISH_AXI_PORT` to choose the server port; it defaults to `4387`.
252
- - **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.
252
+ - **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, 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 concrete bind address, and the server listens on loopback as well so every local CLI finds it whatever its own `LAVISH_AXI_HOST` says. An address that cannot be bound - the tailnet address while another process holds it, while Tailscale restarts, or an explicit hostname that temporarily does not resolve - is reported as `network_warning` in `lavish-axi <file>` output and logged to `server.log`; session links point at loopback meanwhile unless `LAVISH_AXI_LINK_HOST` overrides them, and the server keeps retrying that address in the background and immediately on the next CLI invocation. Once it binds, the warning clears and new session links use it again, with no restart. If no listener can bind, startup fails rather than claiming a reachable server. `LAVISH_AXI_LINK_HOST` controls the link host when automatic Tailscale binding is disabled.
253
+ - **One server per port** - Agents on one machine can run with different `LAVISH_AXI_HOST` settings, or none, and still share one server: before starting one, the CLI looks for a running server on its configured host, loopback, and every other local address. A running server that was never asked to listen on the address a CLI's `LAVISH_AXI_HOST` names is replaced once by a server that listens on that address plus every address the old one served, keeping all sessions. Upgrade and network-change restarts keep every address the old server served in the same way, except an address that is no longer on this machine. Switching to an address the running server already serves, including loopback, reuses it, so run `lavish-axi stop` first to drop an address. Another server of the same installation (the same state directory) found on the same port at a different address is shut down through its own shutdown endpoint, after the kept server is made to serve that address too, so review links already handed out there keep working. A server belonging to another state directory that reports its installation identity is never used, replaced, or stopped: when it holds loopback or the configured address, the CLI fails with an error naming its state directory, so pick another `LAVISH_AXI_PORT`. Older servers that cannot report an identity are treated as belonging to this installation only at those control addresses, for upgrade compatibility.
253
254
  - **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.
254
255
  - **Browser opening** - Set `LAVISH_AXI_NO_OPEN=1`, equivalent to `--no-open`, to create or resume a session without launching a browser window.
255
256
 
@@ -297,6 +298,7 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
297
298
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
298
299
  | `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
299
300
  | `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. |
301
+ | `lavish-axi server` | `--also-listen <host>` | Also listen on this concrete address (repeatable). The CLI passes it when it replaces a running server, so the replacement keeps every address the old one served; see One server per port. |
300
302
 
301
303
  ## Development
302
304
 
@@ -3129,9 +3129,9 @@ async function replaceArtifactFrame({ recoveryRetry = false } = {}) {
3129
3129
  return false;
3130
3130
  };
3131
3131
  // Keep whatever is on screen, then try again later. A begin-load can fail for reasons that
3132
- // clear on their own - the shared server is mid-restart, or its handoff map was reset by that
3133
- // restart and this chrome's one re-handshake landed in the same outage window. Giving up here
3134
- // is what leaves the review permanently unloaded.
3132
+ // clear on their own - the shared server is mid-restart, or a handoff no begin-load had yet
3133
+ // persisted was lost with that restart and this chrome's one re-handshake landed in the same
3134
+ // outage window. Giving up here is what leaves the review permanently unloaded.
3135
3135
  const recoverLater = () => {
3136
3136
  preservePreviousLoad();
3137
3137
  if (requestSequence !== artifactLoadRequestSequence || ended) return false;
package/dist/cli.mjs CHANGED
@@ -14,10 +14,12 @@ import {
14
14
  mkdirSync as mkdirSync2,
15
15
  openSync,
16
16
  readFileSync as readFileSync2,
17
- realpathSync,
17
+ realpathSync as realpathSync2,
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 { get as httpGet2 } from "node:http";
22
+ import { isIP as isIP4 } from "node:net";
21
23
  import os4 from "node:os";
22
24
  import path8 from "node:path";
23
25
  import { fileURLToPath as fileURLToPath4 } from "node:url";
@@ -4400,8 +4402,13 @@ function parseJson(text) {
4400
4402
  }
4401
4403
  }
4402
4404
 
4405
+ // src/local-address.js
4406
+ import os2 from "node:os";
4407
+
4403
4408
  // src/paths.js
4409
+ import { createHash } from "node:crypto";
4404
4410
  import { lookup as dnsLookup } from "node:dns/promises";
4411
+ import { realpathSync } from "node:fs";
4405
4412
  import { mkdir } from "node:fs/promises";
4406
4413
  import { isIP } from "node:net";
4407
4414
  import os from "node:os";
@@ -4424,15 +4431,16 @@ function isWildcardHost(host) {
4424
4431
  function bindHost(env = process.env) {
4425
4432
  return env.LAVISH_AXI_HOST?.trim() || LOOPBACK_HOST;
4426
4433
  }
4427
- function resolveListenHosts({ host, env = process.env, tailscale = null } = {}) {
4434
+ function resolveListenHosts({ host, env = process.env, tailscale = null, extraHosts = [] } = {}) {
4428
4435
  const envHost = env.LAVISH_AXI_HOST?.trim() || "";
4429
4436
  const autoTailscale = !envHost;
4430
4437
  const requested = host || bindHost(env);
4431
4438
  const primary = isWildcardHost(requested) ? LOOPBACK_HOST : requested || LOOPBACK_HOST;
4432
- const hosts = [primary];
4439
+ const hosts = [primary, LOOPBACK_HOST];
4433
4440
  if (autoTailscale && tailscale?.ipv4 && tailscale.ipv4 !== primary && !isWildcardHost(tailscale.ipv4)) {
4434
4441
  hosts.push(tailscale.ipv4);
4435
4442
  }
4443
+ hosts.push(...extraHosts);
4436
4444
  return sanitizeListenHosts(hosts);
4437
4445
  }
4438
4446
  function sanitizeListenHosts(hosts) {
@@ -4444,12 +4452,17 @@ function sanitizeListenHosts(hosts) {
4444
4452
  }
4445
4453
  return out.length ? out : [LOOPBACK_HOST];
4446
4454
  }
4447
- async function resolveConcreteListenHosts(hosts, { lookup = dnsLookup } = {}) {
4455
+ async function resolveConcreteListenHosts(hosts, { lookup = dnsLookup, keepUnresolved = false } = {}) {
4448
4456
  const resolved = [];
4449
4457
  for (const host of hosts) {
4450
- const addresses = await lookup(host, { all: true, verbatim: true });
4458
+ const addresses = await lookup(host, { all: true, verbatim: true }).catch((error) => {
4459
+ if (keepUnresolved) return [];
4460
+ throw error;
4461
+ });
4451
4462
  if (!Array.isArray(addresses) || addresses.length === 0) {
4452
- throw new Error(`Listen host did not resolve: ${host}`);
4463
+ if (!keepUnresolved) throw new Error(`Listen host did not resolve: ${host}`);
4464
+ if (!resolved.includes(host)) resolved.push(host);
4465
+ continue;
4453
4466
  }
4454
4467
  if (addresses.some(({ address: address2 }) => isWildcardHost(address2))) {
4455
4468
  throw new Error(`Listen host resolves to an all-interfaces address: ${host}`);
@@ -4482,6 +4495,17 @@ function stateDir() {
4482
4495
  function stateFile() {
4483
4496
  return path2.join(stateDir(), "state.json");
4484
4497
  }
4498
+ function stateId(file = stateFile()) {
4499
+ const directory = path2.dirname(path2.resolve(file));
4500
+ let canonicalDirectory;
4501
+ try {
4502
+ canonicalDirectory = realpathSync(directory);
4503
+ } catch (error) {
4504
+ if (error?.code !== "ENOENT") throw error;
4505
+ canonicalDirectory = directory;
4506
+ }
4507
+ return createHash("sha256").update(path2.join(canonicalDirectory, path2.basename(file))).digest("hex").slice(0, 16);
4508
+ }
4485
4509
  function serverLogFile() {
4486
4510
  return path2.join(stateDir(), "server.log");
4487
4511
  }
@@ -4492,6 +4516,29 @@ function defaultPort() {
4492
4516
  return Number(process.env.LAVISH_AXI_PORT || 4387);
4493
4517
  }
4494
4518
 
4519
+ // src/local-address.js
4520
+ function localInterfaceAddresses(interfaces = safeNetworkInterfaces()) {
4521
+ const addresses = [];
4522
+ for (const entries of Object.values(interfaces || {})) {
4523
+ for (const entry of entries || []) {
4524
+ const address = typeof entry?.address === "string" ? entry.address : "";
4525
+ if (!address || isWildcardHost(address)) continue;
4526
+ const family = entry.family === 4 ? "IPv4" : entry.family === 6 ? "IPv6" : entry.family;
4527
+ if (family !== "IPv4" && family !== "IPv6") continue;
4528
+ if (family === "IPv6" && /^fe[89ab]/i.test(address)) continue;
4529
+ if (!addresses.includes(address)) addresses.push(address);
4530
+ }
4531
+ }
4532
+ return addresses;
4533
+ }
4534
+ function safeNetworkInterfaces() {
4535
+ try {
4536
+ return os2.networkInterfaces();
4537
+ } catch {
4538
+ return {};
4539
+ }
4540
+ }
4541
+
4495
4542
  // src/plugin.js
4496
4543
  import { randomUUID } from "node:crypto";
4497
4544
  import {
@@ -4508,7 +4555,7 @@ import {
4508
4555
  writeFileSync
4509
4556
  } from "node:fs";
4510
4557
  import { createRequire } from "node:module";
4511
- import os2 from "node:os";
4558
+ import os3 from "node:os";
4512
4559
  import path3 from "node:path";
4513
4560
  import { fileURLToPath as fileURLToPath2 } from "node:url";
4514
4561
  var crossSpawn = createRequire(import.meta.url)("cross-spawn");
@@ -4549,7 +4596,7 @@ function computeVsCodePluginLocationsUpdate(settings, pluginRoot, pluginName) {
4549
4596
  updated["chat.pluginLocations"] = locations;
4550
4597
  return [updated, changed];
4551
4598
  }
4552
- function resolveVsCodeSettingsFile(env = process.env, homeDir = os2.homedir(), platform = process.platform) {
4599
+ function resolveVsCodeSettingsFile(env = process.env, homeDir = os3.homedir(), platform = process.platform) {
4553
4600
  const platformPath = platform === "win32" ? path3.win32 : path3.posix;
4554
4601
  if (platform === "win32") {
4555
4602
  return platformPath.join(
@@ -4569,7 +4616,7 @@ function resolveVsCodeSettingsFile(env = process.env, homeDir = os2.homedir(), p
4569
4616
  "settings.json"
4570
4617
  );
4571
4618
  }
4572
- function resolveCursorLocalPluginsDir(homeDir = os2.homedir()) {
4619
+ function resolveCursorLocalPluginsDir(homeDir = os3.homedir()) {
4573
4620
  return path3.join(homeDir, ".cursor", "plugins", "local");
4574
4621
  }
4575
4622
  function writeTextFileAtomically(file, content, operations = {}) {
@@ -4704,7 +4751,7 @@ import crypto6 from "node:crypto";
4704
4751
  import { EventEmitter } from "node:events";
4705
4752
  import { existsSync as existsSync2 } from "node:fs";
4706
4753
  import { readFile as readFile5, realpath as realpath3 } from "node:fs/promises";
4707
- import { createServer } from "node:http";
4754
+ import { createServer, get as httpGet } from "node:http";
4708
4755
  import { isIP as isIP3 } from "node:net";
4709
4756
  import { homedir } from "node:os";
4710
4757
  import path7 from "node:path";
@@ -8126,22 +8173,6 @@ function serializeChatSync(session) {
8126
8173
  };
8127
8174
  }
8128
8175
 
8129
- // src/local-address.js
8130
- import os3 from "node:os";
8131
- function isLocalAddressPresent(host) {
8132
- if (typeof host !== "string" || host === "") return false;
8133
- try {
8134
- for (const entries of Object.values(os3.networkInterfaces() || {})) {
8135
- for (const entry of entries || []) {
8136
- if (entry?.address === host) return true;
8137
- }
8138
- }
8139
- } catch {
8140
- return false;
8141
- }
8142
- return false;
8143
- }
8144
-
8145
8176
  // src/server-log.js
8146
8177
  function formatServerLogLine(line, now = /* @__PURE__ */ new Date()) {
8147
8178
  return `${now.toISOString()} ${line}`;
@@ -8298,6 +8329,21 @@ var SessionStore = class {
8298
8329
  this.artifactLoads = /* @__PURE__ */ new Map();
8299
8330
  this.chromeLoadContexts = /* @__PURE__ */ new Map();
8300
8331
  }
8332
+ // The one reader of the active load. It exists because the load outlives the process that
8333
+ // issued it: an upgrade restart replaces the server under a reviewer who never asked for it,
8334
+ // and a load this store merely forgot is not a load that ended. Whether a token is still
8335
+ // current is decided by `beginArtifactLoad` alone - nothing else may retire one.
8336
+ #activeArtifactLoad(session) {
8337
+ const cached = this.artifactLoads.get(session.key);
8338
+ if (cached) return cached;
8339
+ const restored = restoreArtifactLoad(session.artifact_load);
8340
+ if (!restored) return void 0;
8341
+ this.artifactLoads.set(session.key, restored);
8342
+ if (!this.chromeLoadContexts.has(session.key)) {
8343
+ this.chromeLoadContexts.set(session.key, restored.handoffToken);
8344
+ }
8345
+ return restored;
8346
+ }
8301
8347
  async listSessions() {
8302
8348
  return this.runExclusive(async () => {
8303
8349
  const state = await this.readState();
@@ -8338,6 +8384,9 @@ var SessionStore = class {
8338
8384
  // must never silently drop unresolved warnings the user has not triaged yet.
8339
8385
  layout_warnings: normalizeStoredWarnings(existing.layout_warnings),
8340
8386
  artifact_revision: normalizeRevision(existing.artifact_revision),
8387
+ // The reviewer's open tab is holding this token, so reopening the artifact must not retire
8388
+ // it: only a newer `beginArtifactLoad` retires a load.
8389
+ artifact_load: normalizeStoredArtifactLoad(existing.artifact_load),
8341
8390
  artifact_failures: Array.isArray(existing.artifact_failures) ? existing.artifact_failures : [],
8342
8391
  // Carried across a reopen on purpose: this list is what keeps a just-delivered
8343
8392
  // attachment out of the sweeper's reach, and re-opening the artifact during the
@@ -8492,7 +8541,7 @@ var SessionStore = class {
8492
8541
  }
8493
8542
  const chromeLoadToken = crypto4.randomBytes(24).toString("base64url");
8494
8543
  this.chromeLoadContexts.set(key, chromeLoadToken);
8495
- const activeLoad = this.artifactLoads.get(key);
8544
+ const activeLoad = this.#activeArtifactLoad(session);
8496
8545
  return {
8497
8546
  session,
8498
8547
  chrome_load_token: chromeLoadToken,
@@ -8514,8 +8563,8 @@ var SessionStore = class {
8514
8563
  const parsedRequestSequence = Number(requestSequence);
8515
8564
  const normalizedRequestSequence = Number.isSafeInteger(parsedRequestSequence) && parsedRequestSequence > 0 ? parsedRequestSequence : 0;
8516
8565
  const normalizedHandoffToken = String(handoffToken || "");
8566
+ const activeLoad = this.#activeArtifactLoad(session);
8517
8567
  const activeHandoffToken = this.chromeLoadContexts.get(key) || "";
8518
- const activeLoad = this.artifactLoads.get(key);
8519
8568
  const staleResult = (status) => ({
8520
8569
  session,
8521
8570
  stale: status,
@@ -8540,14 +8589,16 @@ var SessionStore = class {
8540
8589
  }
8541
8590
  const artifactRevision = normalizeRevision(session.artifact_revision) + 1;
8542
8591
  const artifactLoadToken = crypto4.randomBytes(24).toString("base64url");
8543
- this.artifactLoads.set(key, {
8592
+ const load = {
8544
8593
  artifactRevision,
8545
8594
  artifactLoadToken,
8546
8595
  lastPassSequence: 0,
8547
8596
  requestId: normalizedRequestId,
8548
8597
  requestSequence: normalizedRequestSequence,
8549
8598
  handoffToken: normalizedHandoffToken
8550
- });
8599
+ };
8600
+ this.artifactLoads.set(key, load);
8601
+ session.artifact_load = serializeArtifactLoad(load);
8551
8602
  session.artifact_revision = artifactRevision;
8552
8603
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
8553
8604
  await this.writeState(state);
@@ -8561,7 +8612,7 @@ var SessionStore = class {
8561
8612
  if (!session) {
8562
8613
  return null;
8563
8614
  }
8564
- const load = this.artifactLoads.get(key);
8615
+ const load = this.#activeArtifactLoad(session);
8565
8616
  const revision = parseRevisionValue(artifactRevision);
8566
8617
  const valid = Boolean(
8567
8618
  load && String(artifactLoadToken || "") && String(artifactLoadToken) === load.artifactLoadToken && revision === load.artifactRevision
@@ -8588,7 +8639,7 @@ var SessionStore = class {
8588
8639
  return null;
8589
8640
  }
8590
8641
  const revision = normalizeRevision(session.artifact_revision);
8591
- const load = this.artifactLoads.get(key);
8642
+ const load = this.#activeArtifactLoad(session);
8592
8643
  const artifactLoadToken = String(payload?.artifact_load_token || payload?.artifactLoadToken || "");
8593
8644
  const reportedRevision = parseDiagnosticRevision(payload);
8594
8645
  const passSequence = parsePassSequence(payload);
@@ -8621,6 +8672,7 @@ var SessionStore = class {
8621
8672
  return { session, changed: false, warnings: serializeLayoutWarnings(warnings) };
8622
8673
  }
8623
8674
  session.layout_warnings = warnings;
8675
+ session.artifact_load = serializeArtifactLoad(load);
8624
8676
  session.updated_at = at;
8625
8677
  await this.writeState(state);
8626
8678
  return { session, changed: true, warnings: serializeLayoutWarnings(warnings) };
@@ -8677,7 +8729,7 @@ var SessionStore = class {
8677
8729
  if (!session) {
8678
8730
  return null;
8679
8731
  }
8680
- const load = this.artifactLoads.get(key);
8732
+ const load = this.#activeArtifactLoad(session);
8681
8733
  const artifactLoadToken = String(payload?.artifact_load_token || payload?.artifactLoadToken || "");
8682
8734
  const reportedRevision = parseDiagnosticRevision(payload);
8683
8735
  if (!load || artifactLoadToken !== load.artifactLoadToken || !reportedRevision.present || reportedRevision.value !== load.artifactRevision) {
@@ -8991,6 +9043,47 @@ function planLayoutWarningPrompt(warnings, prompt, revision) {
8991
9043
  }
8992
9044
  return { warningIds, expectedRevision, conflicts, queueIds, hadKnownWarning };
8993
9045
  }
9046
+ function serializeArtifactLoad(load) {
9047
+ return {
9048
+ artifact_load_token: load.artifactLoadToken,
9049
+ artifact_revision: load.artifactRevision,
9050
+ last_pass_sequence: load.lastPassSequence,
9051
+ request_id: load.requestId,
9052
+ request_sequence: load.requestSequence,
9053
+ handoff_token: load.handoffToken
9054
+ };
9055
+ }
9056
+ var STORED_ARTIFACT_LOAD_FIELDS = [
9057
+ "artifact_load_token",
9058
+ "artifact_revision",
9059
+ "last_pass_sequence",
9060
+ "request_id",
9061
+ "request_sequence",
9062
+ "handoff_token"
9063
+ ];
9064
+ function restoreArtifactLoad(stored) {
9065
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) return null;
9066
+ if (STORED_ARTIFACT_LOAD_FIELDS.some((field) => !Object.hasOwn(stored, field))) return null;
9067
+ const artifactLoadToken = stored.artifact_load_token;
9068
+ const handoffToken = stored.handoff_token;
9069
+ const requestId = stored.request_id;
9070
+ if (typeof artifactLoadToken !== "string" || !artifactLoadToken) return null;
9071
+ if (typeof handoffToken !== "string" || !handoffToken) return null;
9072
+ if (typeof requestId !== "string") return null;
9073
+ const artifactRevision = parseSequenceValue(stored.artifact_revision);
9074
+ if (artifactRevision === 0) return null;
9075
+ const lastPassSequence = parseSequenceValue(stored.last_pass_sequence);
9076
+ const requestSequence = parseSequenceValue(stored.request_sequence);
9077
+ if (artifactRevision === null || lastPassSequence === null || requestSequence === null) return null;
9078
+ return { artifactRevision, artifactLoadToken, lastPassSequence, requestId, requestSequence, handoffToken };
9079
+ }
9080
+ function normalizeStoredArtifactLoad(stored) {
9081
+ const restored = restoreArtifactLoad(stored);
9082
+ return restored ? serializeArtifactLoad(restored) : null;
9083
+ }
9084
+ function parseSequenceValue(value) {
9085
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
9086
+ }
8994
9087
  function normalizeRevision(value) {
8995
9088
  const number = Number(value);
8996
9089
  return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 0;
@@ -9584,6 +9677,8 @@ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
9584
9677
  var WHITEBOARD_CHANNEL_TOKEN_TTL_MS = 5 * 6e4;
9585
9678
  var NETWORK_RECONCILE_CACHE_MS = 1e3;
9586
9679
  var BIND_RETRY_DELAYS_MS = [100, 250, 500];
9680
+ var BIND_RECOVERY_DELAYS_MS = [1e3, 2e3, 5e3, 1e4, 3e4];
9681
+ var LOOPBACK_OWNER_PROBE_TIMEOUT_MS = 500;
9587
9682
  var WEBSOCKET_CLOSE_GRACE_MS = 250;
9588
9683
  var LIVE_EVENT_HEARTBEAT_MS = 3e4;
9589
9684
  var BROWSER_DISCONNECT_GRACE_MS = 1e4;
@@ -9694,6 +9789,8 @@ async function serve({
9694
9789
  allowedHosts,
9695
9790
  detectTailscale: detectTailscaleFn,
9696
9791
  lookupHost,
9792
+ extraListenHosts = [],
9793
+ bindRecoveryDelaysMs = BIND_RECOVERY_DELAYS_MS,
9697
9794
  whiteboardAssetsDir = defaultWhiteboardAssetsDir()
9698
9795
  } = {}) {
9699
9796
  const { WebSocket, WebSocketServer } = await import("ws");
@@ -9703,16 +9800,22 @@ async function serve({
9703
9800
  const detect = detectTailscaleFn === void 0 ? detectTailscale : detectTailscaleFn;
9704
9801
  const tailscale = !hosts?.length && autoTailscale && typeof detect === "function" ? await detect() : null;
9705
9802
  const requestedListenHosts = sanitizeListenHosts(
9706
- hosts?.length ? hosts : resolveListenHosts({ host, env, tailscale })
9803
+ hosts?.length ? [...hosts, ...extraListenHosts] : resolveListenHosts({ host, env, tailscale, extraHosts: extraListenHosts })
9707
9804
  );
9805
+ const lookupOptions = lookupHost ? { lookup: lookupHost } : {};
9708
9806
  const listenHosts = await resolveConcreteListenHosts(requestedListenHosts, {
9709
- ...lookupHost ? { lookup: lookupHost } : {}
9807
+ ...lookupOptions,
9808
+ keepUnresolved: true
9710
9809
  });
9711
9810
  const activeTailscaleNetwork = tailscaleNetworkKey(tailscale);
9811
+ const serverStateId = stateId(stateFile2);
9712
9812
  let tailscalePhoneReady = false;
9713
- const absentRequestedHosts = [];
9714
- let networkWarning = typeof tailscale?.warning === "string" ? tailscale.warning : "";
9813
+ let tailscaleDetectionWarning = typeof tailscale?.warning === "string" ? tailscale.warning : "";
9814
+ const pendingBinds = /* @__PURE__ */ new Map();
9715
9815
  let resolvedLinkHost = linkHostName ?? resolveLinkHost({ env, tailscale, fallbackHost: host });
9816
+ let idleTimer = null;
9817
+ let attachmentSweepTimer = null;
9818
+ let bindRecoveryTimer = null;
9716
9819
  const app = express();
9717
9820
  const store = new SessionStore(stateFile2);
9718
9821
  const events = new EventEmitter();
@@ -9730,7 +9833,7 @@ async function serve({
9730
9833
  const writeLog = typeof log === "function" ? log : (line) => process.stderr.write(`${serverStdioIsTimestamped() ? line : formatServerLogLine(line)}
9731
9834
  `);
9732
9835
  const logEvent = verbose ? (line) => writeLog(`[lavish] ${line}`) : null;
9733
- if (networkWarning) writeLog(`[lavish] WARNING: ${networkWarning}`);
9836
+ if (tailscaleDetectionWarning) writeLog(`[lavish] WARNING: ${tailscaleDetectionWarning}`);
9734
9837
  let publicPort = port;
9735
9838
  let serverReady = false;
9736
9839
  let networkReconcileCheckedAt = 0;
@@ -9803,11 +9906,29 @@ async function serve({
9803
9906
  client.sendEvent("agent-presence", presenceEventData(key, presence, activePolls, deliveredFeedback));
9804
9907
  if (session?.status === "ended") client.sendEvent("ended", { ended_by: session.ended_by || null });
9805
9908
  }
9806
- function requestedBindIsRecoverable() {
9807
- return absentRequestedHosts.some((listenHost) => isLocalAddressPresent(listenHost));
9909
+ function networkWarningField() {
9910
+ const networkWarning = currentNetworkWarning();
9911
+ return networkWarning ? { network_warning: networkWarning } : {};
9912
+ }
9913
+ function describeBindFailure(listenHost, error) {
9914
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
9915
+ const why = code === "EADDRINUSE" ? "EADDRINUSE: another process is already listening there" : code === "EADDRNOTAVAIL" ? "EADDRNOTAVAIL: that address is not on this machine right now" : code === "ENOTFOUND" || code === "EAI_AGAIN" ? `${code}: that name does not resolve right now` : code || (error instanceof Error ? error.message : String(error));
9916
+ const address = `${hostForUrl(listenHost)}:${publicPort || port}`;
9917
+ const reachable = `Lavish remains available on ${boundHosts.map((bound2) => hostForUrl(bound2)).join(", ") || "loopback"}`;
9918
+ if (listenHost === tailscale?.ipv4) {
9919
+ return `Tailscale binding failed for ${address} (${why}); there is no phone access and tailnet review links do not load. ${reachable} and keeps retrying the tailnet address in the background.`;
9920
+ }
9921
+ const fellBack = boundHosts.length > 0 && boundHosts.every((bound2) => bound2 === LOOPBACK_HOST);
9922
+ return `Could not bind ${address} (${why}); ${fellBack ? "Lavish fell back to loopback and is" : "Lavish is"} not reachable at that address. ${reachable} and keeps retrying ${hostForUrl(listenHost)} in the background.`;
9923
+ }
9924
+ function currentNetworkWarning() {
9925
+ return [
9926
+ tailscaleDetectionWarning,
9927
+ ...[...pendingBinds].map(([listenHost, error]) => describeBindFailure(listenHost, error))
9928
+ ].filter(Boolean).join(" ");
9808
9929
  }
9809
9930
  async function reconcileNetwork() {
9810
- if (requestedBindIsRecoverable()) return true;
9931
+ await retryPendingBinds();
9811
9932
  if (!(autoTailscale && typeof detect === "function")) return false;
9812
9933
  return reconcileTailscaleNetwork();
9813
9934
  }
@@ -9819,7 +9940,9 @@ async function serve({
9819
9940
  const detectedTailscale = await detect();
9820
9941
  const detectedNetwork = tailscaleNetworkKey(detectedTailscale);
9821
9942
  const stale = detectedNetwork !== activeTailscaleNetwork;
9822
- if (stale) networkWarning = typeof detectedTailscale?.warning === "string" ? detectedTailscale.warning : "";
9943
+ if (stale) {
9944
+ tailscaleDetectionWarning = typeof detectedTailscale?.warning === "string" ? detectedTailscale.warning : "";
9945
+ }
9823
9946
  return stale;
9824
9947
  } catch {
9825
9948
  return false;
@@ -9957,10 +10080,20 @@ async function serve({
9957
10080
  return;
9958
10081
  }
9959
10082
  const networkStale = req.query.reconcile_network === "1" ? await reconcileNetwork() : false;
10083
+ const networkWarning = currentNetworkWarning();
9960
10084
  res.json({
9961
10085
  ok: true,
9962
10086
  app: "lavish-axi",
9963
10087
  version,
10088
+ state_id: serverStateId,
10089
+ state_dir: path7.dirname(stateFile2),
10090
+ // Where this process is listening and every address it was asked to serve (bound or still
10091
+ // retrying). The CLI uses both to find one daemon per port whatever host it was configured
10092
+ // with, and to tell a same-port daemon at another address apart from this one.
10093
+ hosts: [...boundHosts],
10094
+ // Configured names sit beside the addresses they resolved to, so a CLI that resolves the same
10095
+ // name finds it served whichever form it compares.
10096
+ requested_hosts: [.../* @__PURE__ */ new Set([...listenHosts, ...requestedListenHosts])],
9964
10097
  ...networkStale ? { network_stale: true } : {},
9965
10098
  ...networkWarning ? { network_warning: networkWarning } : {},
9966
10099
  listeners: [...activePolls].map(([key, holder]) => ({
@@ -9993,7 +10126,7 @@ async function serve({
9993
10126
  file,
9994
10127
  url: sessionUrl,
9995
10128
  status: "user-ended",
9996
- ...networkWarning ? { network_warning: networkWarning } : {}
10129
+ ...networkWarningField()
9997
10130
  });
9998
10131
  return;
9999
10132
  }
@@ -10010,7 +10143,7 @@ async function serve({
10010
10143
  file,
10011
10144
  url,
10012
10145
  status: "opened",
10013
- ...networkWarning ? { network_warning: networkWarning } : {}
10146
+ ...networkWarningField()
10014
10147
  });
10015
10148
  } catch (error) {
10016
10149
  next(error);
@@ -10998,20 +11131,37 @@ ${body}`
10998
11131
  const boundHosts = [];
10999
11132
  let boundPort = port;
11000
11133
  let lastBindError = null;
11134
+ async function listenOnce(listenHost) {
11135
+ const address = isIP3(listenHost) ? listenHost : (await resolveConcreteListenHosts([listenHost], lookupOptions))[0];
11136
+ if (address !== listenHost) {
11137
+ if (!listenHosts.includes(address)) listenHosts.push(address);
11138
+ if (boundHosts.includes(address)) {
11139
+ boundHosts.push(listenHost);
11140
+ return;
11141
+ }
11142
+ }
11143
+ const httpServer = await listenHttp(app, boundPort, address, (error) => {
11144
+ writeLog(`[lavish] HTTP server error: ${error instanceof Error ? error.message : String(error)}`);
11145
+ });
11146
+ if (shuttingDown) {
11147
+ httpServer.close();
11148
+ throw new Error("Lavish server is shutting down");
11149
+ }
11150
+ httpServer.on("upgrade", handleEventUpgrade);
11151
+ if (boundPort === 0) boundPort = httpServer.address().port;
11152
+ if (!publicPort) publicPort = boundPort;
11153
+ httpServers.push(httpServer);
11154
+ boundHosts.push(listenHost);
11155
+ if (address !== listenHost) boundHosts.push(address);
11156
+ }
11001
11157
  async function bindListener(listenHost) {
11002
11158
  let retryIndex = 0;
11003
11159
  while (true) {
11004
11160
  try {
11005
- const httpServer = await listenHttp(app, boundPort, listenHost, (error) => {
11006
- writeLog(`[lavish] HTTP server error: ${error instanceof Error ? error.message : String(error)}`);
11007
- });
11008
- httpServer.on("upgrade", handleEventUpgrade);
11009
- if (boundPort === 0) boundPort = httpServer.address().port;
11010
- httpServers.push(httpServer);
11011
- boundHosts.push(listenHost);
11161
+ await listenOnce(listenHost);
11012
11162
  return null;
11013
11163
  } catch (error) {
11014
- if (retryIndex < BIND_RETRY_DELAYS_MS.length) {
11164
+ if (!shuttingDown && retryIndex < BIND_RETRY_DELAYS_MS.length) {
11015
11165
  await new Promise((resolve) => setTimeout(resolve, BIND_RETRY_DELAYS_MS[retryIndex]));
11016
11166
  retryIndex += 1;
11017
11167
  continue;
@@ -11020,28 +11170,80 @@ ${body}`
11020
11170
  }
11021
11171
  }
11022
11172
  }
11023
- for (const listenHost of listenHosts) {
11173
+ function applyNetworkState() {
11174
+ tailscalePhoneReady = Boolean(tailscale?.ipv4 && boundHosts.includes(tailscale.ipv4));
11175
+ if (linkHostName != null) {
11176
+ resolvedLinkHost = linkHostName;
11177
+ } else if (tailscalePhoneReady) {
11178
+ resolvedLinkHost = resolveLinkHost({ env, tailscale, fallbackHost: host });
11179
+ } else if (boundHosts.includes(listenHosts[0])) {
11180
+ resolvedLinkHost = resolveLinkHost({ env, tailscale: null, fallbackHost: host });
11181
+ } else {
11182
+ resolvedLinkHost = resolveLinkHost({ env, tailscale: null, fallbackHost: LOOPBACK_HOST });
11183
+ }
11184
+ const servesHost = (candidate) => candidate !== tailscale?.ipv4 || tailscalePhoneReady;
11185
+ const nextAllowedHostnames = buildAllowedHostnames({
11186
+ host: requestedListenHosts[0],
11187
+ hosts: [...requestedListenHosts.filter(servesHost), ...listenHosts.filter(servesHost), ...boundHosts],
11188
+ linkHost: resolvedLinkHost,
11189
+ allowedHosts: extraHosts
11190
+ });
11191
+ allowedHostnames.clear();
11192
+ for (const allowedHostname of nextAllowedHostnames) allowedHostnames.add(allowedHostname);
11193
+ }
11194
+ let bindRecoveryInFlight = null;
11195
+ async function retryPendingBinds() {
11196
+ if (pendingBinds.size === 0 || shuttingDown) return;
11197
+ if (!bindRecoveryInFlight) {
11198
+ bindRecoveryInFlight = (async () => {
11199
+ let bound2 = false;
11200
+ for (const listenHost of [...pendingBinds.keys()]) {
11201
+ if (shuttingDown) return;
11202
+ try {
11203
+ await listenOnce(listenHost);
11204
+ pendingBinds.delete(listenHost);
11205
+ bound2 = true;
11206
+ writeLog(`[lavish] now listening on ${hostForUrl(listenHost)}:${boundPort} after an earlier bind failure.`);
11207
+ } catch (error) {
11208
+ if (!shuttingDown) pendingBinds.set(listenHost, error instanceof Error ? error : new Error(String(error)));
11209
+ }
11210
+ }
11211
+ if (bound2) applyNetworkState();
11212
+ })().finally(() => {
11213
+ bindRecoveryInFlight = null;
11214
+ });
11215
+ }
11216
+ await bindRecoveryInFlight;
11217
+ }
11218
+ let bindRecoveryAttempt = 0;
11219
+ function scheduleBindRecovery() {
11220
+ if (shuttingDown || pendingBinds.size === 0 || bindRecoveryTimer || !bindRecoveryDelaysMs.length) return;
11221
+ const delay2 = bindRecoveryDelaysMs[Math.min(bindRecoveryAttempt, bindRecoveryDelaysMs.length - 1)];
11222
+ bindRecoveryAttempt += 1;
11223
+ bindRecoveryTimer = setTimeout(() => {
11224
+ bindRecoveryTimer = null;
11225
+ retryPendingBinds().catch(() => {
11226
+ }).finally(() => scheduleBindRecovery());
11227
+ }, delay2);
11228
+ bindRecoveryTimer.unref?.();
11229
+ }
11230
+ const bindOrder = listenHosts.includes(LOOPBACK_HOST) ? [LOOPBACK_HOST, ...listenHosts.filter((listenHost) => listenHost !== LOOPBACK_HOST)] : listenHosts;
11231
+ for (const listenHost of bindOrder) {
11024
11232
  const error = await bindListener(listenHost);
11025
11233
  if (!error) continue;
11026
11234
  lastBindError = error;
11027
- if (isAddressAbsentBindError(error)) absentRequestedHosts.push(listenHost);
11028
- if (listenHost === tailscale?.ipv4) {
11029
- networkWarning = "Tailscale binding failed; there is no phone access. Lavish remains available on loopback.";
11030
- writeLog(`[lavish] WARNING: ${networkWarning} Address: ${listenHost}:${boundPort}.`);
11031
- } else {
11032
- logEvent?.(`failed to bind ${listenHost}:${boundPort}: ${error.message}`);
11235
+ if (listenHost === LOOPBACK_HOST && isAddressInUseBindError(error)) {
11236
+ const owner = await probeLavishHealth(LOOPBACK_HOST, boundPort);
11237
+ throw new Error(
11238
+ owner ? `Another Lavish server (version ${owner.version || "unknown"}) already owns port ${boundPort} on ${LOOPBACK_HOST}; not starting a second one.` : `Loopback ${LOOPBACK_HOST}:${boundPort} is already in use; not starting a Lavish server on another address.`,
11239
+ { cause: error }
11240
+ );
11033
11241
  }
11242
+ pendingBinds.set(listenHost, error);
11034
11243
  }
11035
- let loopbackFallback = false;
11036
11244
  if (httpServers.length === 0 && !listenHosts.includes(LOOPBACK_HOST)) {
11037
11245
  const error = await bindListener(LOOPBACK_HOST);
11038
- if (error) {
11039
- lastBindError = error;
11040
- } else {
11041
- loopbackFallback = true;
11042
- networkWarning = `Could not bind ${listenHosts.join(", ")}; Lavish fell back to loopback and is not reachable at that address.`;
11043
- writeLog(`[lavish] WARNING: ${networkWarning} Address: ${listenHosts[0]}:${boundPort}.`);
11044
- }
11246
+ if (error) lastBindError = error;
11045
11247
  }
11046
11248
  if (httpServers.length === 0) {
11047
11249
  throw new Error(
@@ -11064,23 +11266,13 @@ ${body}`
11064
11266
  boundHosts.length = 0;
11065
11267
  throw error;
11066
11268
  }
11067
- if (loopbackFallback) {
11068
- resolvedLinkHost = linkHostName ?? resolveLinkHost({ env, tailscale: null, fallbackHost: LOOPBACK_HOST });
11069
- }
11070
- tailscalePhoneReady = Boolean(tailscale?.ipv4 && boundHosts.includes(tailscale.ipv4));
11071
- if (tailscale?.ipv4 && !tailscalePhoneReady) {
11072
- resolvedLinkHost = linkHostName ?? resolveLinkHost({ env, tailscale: null, fallbackHost: host });
11073
- const fallbackAllowedHostnames = buildAllowedHostnames({
11074
- host: requestedListenHosts[0],
11075
- hosts: [...requestedListenHosts.filter((requestedHost) => requestedHost !== tailscale.ipv4), ...boundHosts],
11076
- linkHost: resolvedLinkHost,
11077
- allowedHosts: extraHosts
11078
- });
11079
- allowedHostnames.clear();
11080
- for (const allowedHostname of fallbackAllowedHostnames) allowedHostnames.add(allowedHostname);
11081
- }
11269
+ applyNetworkState();
11082
11270
  publicPort = httpServers[0].address().port;
11271
+ for (const [listenHost, error] of pendingBinds) {
11272
+ writeLog(`[lavish] WARNING: ${describeBindFailure(listenHost, error)}`);
11273
+ }
11083
11274
  serverReady = true;
11275
+ scheduleBindRecovery();
11084
11276
  function shutdown(reloadKey = "", reason = "", cause = "requested") {
11085
11277
  if (shuttingDown) return;
11086
11278
  shuttingDown = true;
@@ -11093,6 +11285,10 @@ ${body}`
11093
11285
  clearInterval(attachmentSweepTimer);
11094
11286
  attachmentSweepTimer = null;
11095
11287
  }
11288
+ if (bindRecoveryTimer) {
11289
+ clearTimeout(bindRecoveryTimer);
11290
+ bindRecoveryTimer = null;
11291
+ }
11096
11292
  const shutdownData = { reason };
11097
11293
  for (const [client, clientKey] of liveEventClients) {
11098
11294
  try {
@@ -11125,7 +11321,6 @@ ${body}`
11125
11321
  }
11126
11322
  }
11127
11323
  }
11128
- let idleTimer = null;
11129
11324
  function refreshIdleTimer() {
11130
11325
  if (idleTimer) {
11131
11326
  clearTimeout(idleTimer);
@@ -11165,7 +11360,6 @@ ${body}`
11165
11360
  return outstandingRepairBatches.has(key) ? BATCH_RELOAD_DEBOUNCE_MS : RELOAD_DEBOUNCE_MS;
11166
11361
  }
11167
11362
  const attachmentSweepEnabled = attachmentConfig.ttlMs != null || attachmentConfig.maxDiskBytes != null;
11168
- let attachmentSweepTimer = null;
11169
11363
  async function sweepAttachmentsNow() {
11170
11364
  try {
11171
11365
  const result = await store.runExclusive(async () => {
@@ -11238,8 +11432,36 @@ function tailscaleNetworkKey(tailscale) {
11238
11432
  ${tailscale.ipv4}
11239
11433
  ${tailscale.magicDnsName}`;
11240
11434
  }
11241
- function isAddressAbsentBindError(error) {
11242
- return error instanceof Error && "code" in error && error.code === "EADDRNOTAVAIL";
11435
+ function isAddressInUseBindError(error) {
11436
+ return error instanceof Error && "code" in error && error.code === "EADDRINUSE";
11437
+ }
11438
+ function probeLavishHealth(host, port) {
11439
+ return new Promise((resolve) => {
11440
+ const request = httpGet({ host, port, path: "/health", agent: false }, (response) => {
11441
+ let body = "";
11442
+ response.setEncoding("utf8");
11443
+ response.on("data", (chunk) => {
11444
+ body += chunk;
11445
+ if (body.length > 64 * 1024) request.destroy();
11446
+ });
11447
+ response.on("end", () => {
11448
+ request.destroy();
11449
+ try {
11450
+ const parsed = JSON.parse(body);
11451
+ resolve(parsed && typeof parsed === "object" && parsed.app === "lavish-axi" ? parsed : null);
11452
+ } catch {
11453
+ resolve(null);
11454
+ }
11455
+ });
11456
+ response.on("error", () => resolve(null));
11457
+ });
11458
+ request.setTimeout(LOOPBACK_OWNER_PROBE_TIMEOUT_MS, () => {
11459
+ request.destroy();
11460
+ resolve(null);
11461
+ });
11462
+ request.on("error", () => resolve(null));
11463
+ request.on("close", () => resolve(null));
11464
+ });
11243
11465
  }
11244
11466
  function wantsHtml(req) {
11245
11467
  const accept = String(req.get("accept") || "");
@@ -11995,7 +12217,7 @@ var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_J
11995
12217
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
11996
12218
  var POLL_STATE_HEADER = "lavish-poll-state";
11997
12219
  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.";
11998
- var VERSION = "0.1.76";
12220
+ var VERSION = "0.1.78";
11999
12221
  function detectInvokingAgent(env = process.env) {
12000
12222
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
12001
12223
  }
@@ -12191,11 +12413,12 @@ function createOpenOutput({
12191
12413
  networkWarning = void 0
12192
12414
  }) {
12193
12415
  const selfPaintPrefix = selfPaintWarning ? `First fix the unpainted page surface flagged in self_paint_warning and save - Lavish live-reloads the artifact automatically, so you do not need to re-run \`lavish-axi ${file}\`. ` : "";
12416
+ const networkPrefix = networkWarning ? "Lavish could not serve every address it was asked to (see network_warning): when you next message the user, tell them, because review links at that address will not load until it clears. " : "";
12194
12417
  return {
12195
12418
  session: { file, url, status },
12196
12419
  ...networkWarning ? { network_warning: networkWarning } : {},
12197
12420
  ...selfPaintWarning ? { self_paint_warning: selfPaintWarning } : {},
12198
- next_step: `${selfPaintPrefix}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show a concise response in Lavish Editor and wait for more feedback. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_NEXT_POINTER} If the user ends the session, stop polling and do not reopen it by re-running \`lavish-axi ${file}\` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`
12421
+ next_step: `${networkPrefix}${selfPaintPrefix}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback, ends the session, or leaves every review window disconnected past the reconnect grace period, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show a concise response in Lavish Editor and wait for more feedback. ${POLL_AGENT_REPLY_RULE} ${POLL_AGENT_REPLY_NEXT_POINTER} If the user ends the session, stop polling and do not reopen it by re-running \`lavish-axi ${file}\` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`
12199
12422
  };
12200
12423
  }
12201
12424
  function createUserEndedOpenOutput({ file, url, networkWarning = void 0 }) {
@@ -12843,7 +13066,9 @@ function generatedPasswordNote(password) {
12843
13066
  }
12844
13067
  async function stopCommand(args) {
12845
13068
  const port = Number(flagValue(args, "--port") || defaultPort());
12846
- const { baseUrl } = await findRunningServer(port);
13069
+ const { baseUrl, duplicates, foreign } = await findRunningServer(port);
13070
+ if (foreign) throw otherInstallationError(port, foreign);
13071
+ await stopDuplicateServers(duplicates, { reason: "stop" });
12847
13072
  return shutdownServerOnPort(port, { baseUrl, currentVersion: VERSION });
12848
13073
  }
12849
13074
  async function shutdownServerOnPort(port, {
@@ -12852,20 +13077,20 @@ async function shutdownServerOnPort(port, {
12852
13077
  fetchHealth: healthFetcher = fetchHealth,
12853
13078
  requestShutdown: shutdownRequester = requestShutdown,
12854
13079
  waitForPortFree: portFreeWaiter = waitForPortFree,
12855
- killProcessOnPort: portKiller = killProcessOnPort,
12856
- processMatchesLavish = processOnPortMatchesLavish
13080
+ killServerProcess = killLavishListener,
13081
+ processMatchesLavish = listenerMatchesLavish
12857
13082
  } = {}) {
12858
13083
  const health = await healthFetcher(baseUrl);
12859
13084
  if (!health) {
12860
13085
  return { server: { status: "not-running", port } };
12861
13086
  }
12862
- if (!await canControlServerOnPort(port, health, processMatchesLavish)) {
13087
+ if (!await canControlServerOnPort(baseUrl, health, processMatchesLavish)) {
12863
13088
  return { server: { status: "not-lavish", port } };
12864
13089
  }
12865
13090
  await shutdownRequester(baseUrl, { reason: "stop" });
12866
13091
  let freed = await portFreeWaiter(baseUrl, 3e3);
12867
13092
  if (!freed && shouldKillProcessOnPort(currentVersion, health)) {
12868
- portKiller(port);
13093
+ if (!killServerProcess(baseUrl)) throw unidentifiedListenerError(baseUrl);
12869
13094
  freed = await portFreeWaiter(baseUrl, 3e3);
12870
13095
  }
12871
13096
  return { server: { status: freed ? "stopped" : "stopping", port } };
@@ -12956,7 +13181,7 @@ function registerVsCodePlugin(pluginRoot, pluginName) {
12956
13181
  const [updated, changed] = computeVsCodePluginLocationsUpdate(settings, pluginRoot, pluginName);
12957
13182
  if (!changed) return { client: "vscode", status: "current", detail: collapseHome(settingsFile) };
12958
13183
  try {
12959
- const writeTarget = hasSettingsFile ? realpathSync(settingsFile) : settingsFile;
13184
+ const writeTarget = hasSettingsFile ? realpathSync2(settingsFile) : settingsFile;
12960
13185
  writeTextFileAtomically(writeTarget, `${JSON.stringify(updated, null, 2)}
12961
13186
  `);
12962
13187
  } catch (error) {
@@ -13071,7 +13296,7 @@ function copilotPluginSourcePath(record) {
13071
13296
  }
13072
13297
  function sameResolvedPath(left, right) {
13073
13298
  try {
13074
- return realpathSync(left) === realpathSync(right);
13299
+ return realpathSync2(left) === realpathSync2(right);
13075
13300
  } catch {
13076
13301
  return path8.resolve(left) === path8.resolve(right);
13077
13302
  }
@@ -13163,7 +13388,13 @@ function deepEqual(a, b) {
13163
13388
  async function serverCommand(args) {
13164
13389
  const port = Number(flagValue(args, "--port") || defaultPort());
13165
13390
  const debug = args.includes("--verbose") || process.env.LAVISH_AXI_DEBUG === "1";
13166
- const server = await serve({ port, stateFile: stateFile(), version: VERSION, debug });
13391
+ const server = await serve({
13392
+ port,
13393
+ stateFile: stateFile(),
13394
+ version: VERSION,
13395
+ debug,
13396
+ extraListenHosts: flagValues(args, "--also-listen")
13397
+ });
13167
13398
  await server.done;
13168
13399
  return "";
13169
13400
  }
@@ -13191,82 +13422,201 @@ async function assertHtmlFile(file) {
13191
13422
  function isHtmlPath(file) {
13192
13423
  return file.toLowerCase().endsWith(".html") || file.toLowerCase().endsWith(".htm");
13193
13424
  }
13194
- function serverBaseUrls(port) {
13195
- const urls = [`http://${hostForUrl(clientHost())}:${port}`];
13196
- const loopback = `http://${hostForUrl(LOOPBACK_HOST)}:${port}`;
13197
- if (!urls.includes(loopback)) urls.push(loopback);
13198
- return urls;
13199
- }
13200
13425
  var HEALTH_PROBE_TIMEOUT_MS = 500;
13426
+ var DEFAULT_HEALTH_TIMEOUT_MS = 2e3;
13427
+ var MAX_HEALTH_BODY_BYTES = 256 * 1024;
13428
+ var HEALTH_RECONCILE_TIMEOUT_MS = 3e3;
13429
+ function serverCandidateHosts() {
13430
+ const hosts = [clientHost(), LOOPBACK_HOST];
13431
+ for (const address of localInterfaceAddresses()) {
13432
+ if (!hosts.includes(address)) hosts.push(address);
13433
+ }
13434
+ return hosts;
13435
+ }
13436
+ function serverBaseUrl(host, port) {
13437
+ return `http://${hostForUrl(host)}:${port}`;
13438
+ }
13439
+ function isOwnedServer(entry, controlHosts) {
13440
+ if (entry.health.app !== "lavish-axi") return false;
13441
+ if (typeof entry.health.state_id === "string") return entry.health.state_id === stateId();
13442
+ return controlHosts.has(entry.host);
13443
+ }
13444
+ function otherInstallationError(port, foreign) {
13445
+ const dir = typeof foreign.health.state_dir === "string" ? foreign.health.state_dir : "an unknown state directory";
13446
+ return new AxiError(
13447
+ `Port ${port} is served by another Lavish installation at ${foreign.host} (state directory ${dir})`,
13448
+ "SERVER_ERROR",
13449
+ [`Set LAVISH_AXI_PORT to another port, or set LAVISH_AXI_STATE_DIR to ${dir} to use that installation`]
13450
+ );
13451
+ }
13201
13452
  async function findRunningServer(port, { reconcileNetwork = false } = {}) {
13202
- const candidates = serverBaseUrls(port);
13203
- let foreign = null;
13204
- for (const baseUrl of candidates) {
13205
- const health = await probeHealth(baseUrl, { reconcileNetwork, timeoutMs: HEALTH_PROBE_TIMEOUT_MS });
13206
- if (!health) continue;
13207
- if (health.app === "lavish-axi") return { baseUrl, health };
13208
- if (!foreign) foreign = { baseUrl, health };
13453
+ const controlHosts = /* @__PURE__ */ new Set([clientHost(), LOOPBACK_HOST]);
13454
+ const probed = await Promise.all(
13455
+ serverCandidateHosts().map(async (host) => {
13456
+ const baseUrl = serverBaseUrl(host, port);
13457
+ const health2 = await probeHealth(baseUrl, { reconcileNetwork: false, timeoutMs: HEALTH_PROBE_TIMEOUT_MS });
13458
+ return health2 ? { host, baseUrl, health: health2 } : null;
13459
+ })
13460
+ );
13461
+ const found = probed.filter((entry) => entry !== null);
13462
+ const owned = found.filter((entry) => isOwnedServer(entry, controlHosts));
13463
+ const foreign = found.find(
13464
+ (entry) => controlHosts.has(entry.host) && entry.health.app === "lavish-axi" && !isOwnedServer(entry, controlHosts)
13465
+ ) ?? null;
13466
+ const chosen = owned[0];
13467
+ if (!chosen) {
13468
+ const other = found.find((entry) => controlHosts.has(entry.host));
13469
+ return other ? { baseUrl: other.baseUrl, health: other.health, duplicates: [], foreign } : { baseUrl: serverBaseUrl(clientHost(), port), health: null, duplicates: [], foreign };
13470
+ }
13471
+ let health = chosen.health;
13472
+ if (reconcileNetwork) {
13473
+ health = await probeHealth(chosen.baseUrl, { reconcileNetwork: true, timeoutMs: HEALTH_RECONCILE_TIMEOUT_MS }) ?? health;
13474
+ }
13475
+ return { baseUrl: chosen.baseUrl, health, duplicates: ownedDuplicates(owned, chosen), foreign };
13476
+ }
13477
+ function ownedDuplicates(owned, chosen) {
13478
+ const covered = /* @__PURE__ */ new Set([chosen.host, ...Array.isArray(chosen.health.hosts) ? chosen.health.hosts : []]);
13479
+ const duplicates = [];
13480
+ for (const entry of owned) {
13481
+ if (entry === chosen || covered.has(entry.host)) continue;
13482
+ duplicates.push(entry);
13483
+ for (const host of Array.isArray(entry.health.hosts) ? entry.health.hosts : [entry.host]) covered.add(host);
13484
+ }
13485
+ return duplicates;
13486
+ }
13487
+ async function stopDuplicateServers(duplicates, { reloadKey = "", reason = "" } = {}) {
13488
+ for (const duplicate of duplicates) {
13489
+ await requestShutdown(duplicate.baseUrl, {
13490
+ reloadKey,
13491
+ reason: reason || serverReplacementReason(VERSION, duplicate.health)
13492
+ });
13493
+ await waitForPortFree(duplicate.baseUrl, 3e3);
13209
13494
  }
13210
- return foreign ?? { baseUrl: candidates[0], health: null };
13495
+ }
13496
+ async function adoptServer(baseUrl, duplicates, reloadKey) {
13497
+ if (duplicates.length === 0) return baseUrl;
13498
+ await stopDuplicateServers(duplicates, { reloadKey });
13499
+ await probeHealth(baseUrl, { reconcileNetwork: true, timeoutMs: HEALTH_RECONCILE_TIMEOUT_MS });
13500
+ return baseUrl;
13501
+ }
13502
+ async function requiredServerHosts(env = process.env) {
13503
+ const envHost = env.LAVISH_AXI_HOST?.trim();
13504
+ if (!envHost || isWildcardHost(envHost)) return [];
13505
+ return resolveConcreteListenHosts([clientHost(env)], { keepUnresolved: true });
13506
+ }
13507
+ function missingServerHosts(healthBody, requiredHosts) {
13508
+ if (!healthBody || !Array.isArray(healthBody.requested_hosts)) return [];
13509
+ return requiredHosts.filter((host) => !healthBody.requested_hosts.includes(host));
13510
+ }
13511
+ function inheritedListenHosts(healthBodies, requiredHosts, inherited = [], localAddresses = localInterfaceAddresses()) {
13512
+ const hosts = [...inherited];
13513
+ for (const health of healthBodies) {
13514
+ if (!health || !Array.isArray(health.requested_hosts)) continue;
13515
+ for (const host of health.requested_hosts) {
13516
+ if (host === LOOPBACK_HOST || requiredHosts.includes(host)) continue;
13517
+ if (isIP4(host) && !localAddresses.includes(host)) continue;
13518
+ if (!hosts.includes(host)) hosts.push(host);
13519
+ }
13520
+ }
13521
+ return hosts;
13522
+ }
13523
+ function hostsToServe(requiredHosts, duplicates) {
13524
+ return inheritedListenHosts(
13525
+ duplicates.map((duplicate) => duplicate.health),
13526
+ [],
13527
+ requiredHosts
13528
+ );
13211
13529
  }
13212
13530
  async function probeHealth(baseUrl, { reconcileNetwork, timeoutMs }) {
13213
- const controller = new AbortController();
13214
- const timer = setTimeout(() => controller.abort(), timeoutMs);
13215
- try {
13216
- const health = await Promise.race([
13217
- Promise.resolve().then(() => fetchHealth(baseUrl, { reconcileNetwork, timeoutMs, signal: controller.signal })).catch(() => null),
13218
- new Promise((resolve) => {
13219
- controller.signal.addEventListener("abort", () => resolve(null), { once: true });
13220
- })
13221
- ]);
13222
- return health && typeof health === "object" ? health : null;
13223
- } finally {
13224
- clearTimeout(timer);
13225
- }
13531
+ const health = await fetchHealth(baseUrl, { reconcileNetwork, timeoutMs });
13532
+ return health && typeof health === "object" ? health : null;
13226
13533
  }
13227
13534
  async function ensureServer({ forceRestart = false, reloadKey = "" } = {}) {
13228
13535
  const port = defaultPort();
13229
- const { baseUrl, health: existing } = await findRunningServer(port, { reconcileNetwork: true });
13230
- if (existing && !shouldRestartServer(VERSION, existing, forceRestart)) {
13231
- return baseUrl;
13232
- }
13536
+ const requiredHosts = await requiredServerHosts();
13537
+ const { baseUrl, health: existing, duplicates, foreign } = await findRunningServer(port, { reconcileNetwork: true });
13538
+ if (foreign) throw otherInstallationError(port, foreign);
13539
+ const missingHosts = existing?.app === "lavish-axi" ? missingServerHosts(existing, hostsToServe(requiredHosts, duplicates)) : [];
13540
+ if (existing && !shouldRestartServer(VERSION, existing, forceRestart) && missingHosts.length === 0) {
13541
+ return adoptServer(baseUrl, duplicates, reloadKey);
13542
+ }
13543
+ let alsoListen = inheritedListenHosts([existing, ...duplicates.map((duplicate) => duplicate.health)], requiredHosts);
13544
+ let unidentified = "";
13233
13545
  if (existing) {
13234
- if (!await canControlServerOnPort(port, existing, processOnPortMatchesLavish)) {
13546
+ if (!await canControlServerOnPort(baseUrl, existing, listenerMatchesLavish)) {
13235
13547
  throw new AxiError(`Port ${port} is occupied by a non-Lavish server`, "SERVER_ERROR", [
13236
13548
  `Stop the process using port ${port}, or set LAVISH_AXI_PORT to another port`
13237
13549
  ]);
13238
13550
  }
13551
+ await stopDuplicateServers(duplicates, { reloadKey });
13239
13552
  await requestShutdown(baseUrl, { reloadKey, reason: serverReplacementReason(VERSION, existing, forceRestart) });
13240
13553
  const freed = await waitForPortFree(baseUrl, 2e3);
13241
13554
  if (!freed) {
13242
13555
  if (shouldKillProcessOnPort(VERSION, existing)) {
13243
- killProcessOnPort(port);
13244
- await waitForPortFree(baseUrl, 3e3);
13556
+ if (killLavishListener(baseUrl)) await waitForPortFree(baseUrl, 3e3);
13557
+ else unidentified = baseUrl;
13245
13558
  }
13246
13559
  }
13247
13560
  }
13248
- await startServer(port);
13561
+ await startServer(port, { alsoListen });
13249
13562
  const replacedForNetwork = Boolean(existing) && existing.app === "lavish-axi" && existing.network_stale === true && !forceRestart && typeof existing.version === "string" && existing.version === VERSION;
13250
13563
  let networkRestarted = replacedForNetwork;
13564
+ let raceRestarted = false;
13251
13565
  let deadline = Date.now() + 5e3;
13252
13566
  while (Date.now() < deadline) {
13253
- const { baseUrl: liveUrl, health } = await findRunningServer(port, { reconcileNetwork: true });
13254
- if (health && !shouldRestartServer(VERSION, health)) return liveUrl;
13567
+ const {
13568
+ baseUrl: liveUrl,
13569
+ health,
13570
+ duplicates: liveDuplicates,
13571
+ foreign: liveForeign
13572
+ } = await findRunningServer(port, { reconcileNetwork: true });
13573
+ if (liveForeign) throw otherInstallationError(port, liveForeign);
13574
+ const liveMissing = health?.app === "lavish-axi" ? missingServerHosts(health, hostsToServe(requiredHosts, liveDuplicates)) : [];
13575
+ if (health && !shouldRestartServer(VERSION, health)) {
13576
+ if (liveMissing.length === 0) return adoptServer(liveUrl, liveDuplicates, reloadKey);
13577
+ if (raceRestarted) throw missingHostsError(port, liveMissing);
13578
+ }
13579
+ if (health?.app === "lavish-axi" && health.network_stale !== true && !raceRestarted) {
13580
+ raceRestarted = true;
13581
+ alsoListen = inheritedListenHosts(
13582
+ [health, ...liveDuplicates.map((duplicate) => duplicate.health)],
13583
+ requiredHosts,
13584
+ alsoListen
13585
+ );
13586
+ await stopDuplicateServers(liveDuplicates, { reloadKey });
13587
+ await requestShutdown(liveUrl, { reloadKey, reason: serverReplacementReason(VERSION, health) });
13588
+ if (!await waitForPortFree(liveUrl, 3e3)) break;
13589
+ await startServer(port, { alsoListen });
13590
+ deadline = Date.now() + 5e3;
13591
+ continue;
13592
+ }
13255
13593
  if (health?.network_stale === true && health.app === "lavish-axi") {
13256
- if (networkRestarted) return liveUrl;
13594
+ if (networkRestarted) {
13595
+ if (liveMissing.length > 0) throw missingHostsError(port, liveMissing);
13596
+ return adoptServer(liveUrl, liveDuplicates, reloadKey);
13597
+ }
13598
+ alsoListen = inheritedListenHosts([health], requiredHosts, alsoListen);
13257
13599
  await requestShutdown(liveUrl, { reloadKey, reason: "" });
13258
13600
  if (!await waitForPortFree(liveUrl, 3e3)) break;
13259
- await startServer(port);
13601
+ await startServer(port, { alsoListen });
13260
13602
  networkRestarted = true;
13261
13603
  deadline = Date.now() + 5e3;
13262
13604
  continue;
13263
13605
  }
13264
13606
  await delay(100);
13265
13607
  }
13608
+ if (unidentified) throw unidentifiedListenerError(unidentified);
13266
13609
  throw new AxiError("Lavish Editor server did not start", "SERVER_ERROR", [
13267
13610
  `Run \`lavish-axi server --port ${port}\` to inspect server startup`
13268
13611
  ]);
13269
13612
  }
13613
+ function missingHostsError(port, missingHosts) {
13614
+ return new AxiError(
13615
+ `The Lavish server on port ${port} does not serve ${missingHosts.join(", ")}, and replacing it did not stick`,
13616
+ "SERVER_ERROR",
13617
+ ["Run `lavish-axi stop`, then retry"]
13618
+ );
13619
+ }
13270
13620
  function shouldRestartServer(currentVersion, healthBody, forceRestart = false) {
13271
13621
  if (!healthBody || typeof healthBody !== "object") return false;
13272
13622
  if (forceRestart && healthBody.app === "lavish-axi") return true;
@@ -13295,22 +13645,52 @@ function shouldKillProcessOnPort(currentVersion, healthBody) {
13295
13645
  if (healthBody.app !== "lavish-axi") return false;
13296
13646
  return healthBody.version !== currentVersion;
13297
13647
  }
13298
- async function canControlServerOnPort(port, healthBody, processMatchesLavish) {
13648
+ async function canControlServerOnPort(baseUrl, healthBody, processMatchesLavish) {
13299
13649
  if (!healthBody || typeof healthBody !== "object") return false;
13300
13650
  if (healthBody.app === "lavish-axi") return true;
13301
13651
  if (typeof healthBody.version === "string" && healthBody.version !== "") return false;
13302
- return processMatchesLavish(port);
13652
+ return processMatchesLavish(baseUrl);
13303
13653
  }
13304
- async function fetchHealth(baseUrl, { reconcileNetwork = false, timeoutMs, signal } = {}) {
13305
- try {
13306
- const suffix = reconcileNetwork ? "?reconcile_network=1" : "";
13307
- const abortSignal = signal ?? (timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0);
13308
- const response = await fetch(`${baseUrl}/health${suffix}`, abortSignal ? { signal: abortSignal } : {});
13309
- if (!response.ok) return null;
13310
- return await response.json();
13311
- } catch {
13312
- return null;
13313
- }
13654
+ function fetchHealth(baseUrl, { reconcileNetwork = false, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS } = {}) {
13655
+ return new Promise((resolve) => {
13656
+ let settled = false;
13657
+ let request = null;
13658
+ const finish = (value) => {
13659
+ if (settled) return;
13660
+ settled = true;
13661
+ clearTimeout(timer);
13662
+ request?.destroy();
13663
+ resolve(value);
13664
+ };
13665
+ const abort = () => finish(null);
13666
+ const timer = setTimeout(abort, timeoutMs);
13667
+ try {
13668
+ const url = new URL(`${baseUrl}/health${reconcileNetwork ? "?reconcile_network=1" : ""}`);
13669
+ request = httpGet2(url, { agent: false }, (response) => {
13670
+ let body = "";
13671
+ response.setEncoding("utf8");
13672
+ response.on("data", (chunk) => {
13673
+ body += chunk;
13674
+ if (body.length > MAX_HEALTH_BODY_BYTES) finish(null);
13675
+ });
13676
+ response.on("end", () => {
13677
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
13678
+ finish(null);
13679
+ return;
13680
+ }
13681
+ try {
13682
+ finish(JSON.parse(body));
13683
+ } catch {
13684
+ finish(null);
13685
+ }
13686
+ });
13687
+ response.on("error", abort);
13688
+ });
13689
+ request.on("error", abort);
13690
+ } catch {
13691
+ abort();
13692
+ }
13693
+ });
13314
13694
  }
13315
13695
  async function requestShutdown(baseUrl, { reloadKey = "", reason = "" } = {}) {
13316
13696
  const body = {};
@@ -13333,40 +13713,45 @@ async function waitForPortFree(baseUrl, timeoutMs) {
13333
13713
  }
13334
13714
  return false;
13335
13715
  }
13336
- function killProcessOnPort(port) {
13716
+ function lavishListenerPids(baseUrl) {
13717
+ const { hostname, port } = new URL(baseUrl);
13337
13718
  try {
13338
- const result = spawnSync("lsof", ["-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" });
13339
- if (result.status !== 0) return;
13340
- for (const line of result.stdout.split("\n")) {
13719
+ const listing = spawnSync("lsof", ["-t", `-iTCP@${hostname}:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" });
13720
+ if (listing.status !== 0) return [];
13721
+ const pids = [];
13722
+ for (const line of listing.stdout.split("\n")) {
13341
13723
  const pid = Number(line.trim());
13342
- if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
13343
- try {
13344
- process.kill(pid, "SIGTERM");
13345
- } catch {
13346
- }
13347
- }
13724
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
13725
+ const command = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
13726
+ if (command.status === 0 && /lavish-axi/.test(command.stdout)) pids.push(pid);
13348
13727
  }
13728
+ return pids;
13349
13729
  } catch {
13730
+ return [];
13350
13731
  }
13351
13732
  }
13352
- function processOnPortMatchesLavish(port) {
13353
- try {
13354
- const pids = spawnSync("lsof", ["-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" });
13355
- if (pids.status !== 0) return false;
13356
- for (const line of pids.stdout.split("\n")) {
13357
- const pid = Number(line.trim());
13358
- if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
13359
- const command = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
13360
- if (command.status === 0 && /lavish-axi/.test(command.stdout)) {
13361
- return true;
13362
- }
13733
+ function listenerMatchesLavish(baseUrl) {
13734
+ return lavishListenerPids(baseUrl).length > 0;
13735
+ }
13736
+ function killLavishListener(baseUrl) {
13737
+ const pids = lavishListenerPids(baseUrl);
13738
+ for (const pid of pids) {
13739
+ try {
13740
+ process.kill(pid, "SIGTERM");
13741
+ } catch {
13363
13742
  }
13364
- } catch {
13365
- return false;
13366
13743
  }
13367
- return false;
13744
+ return pids.length > 0;
13745
+ }
13746
+ function unidentifiedListenerError(baseUrl) {
13747
+ const { host } = new URL(baseUrl);
13748
+ return new AxiError(
13749
+ `The server at ${host} did not shut down, and no Lavish process listening there could be identified`,
13750
+ "SERVER_ERROR",
13751
+ [`Stop the process listening on ${host} yourself, or set LAVISH_AXI_PORT to another port`]
13752
+ );
13368
13753
  }
13369
- async function startServer(port) {
13754
+ async function startServer(port, { alsoListen = [] } = {}) {
13370
13755
  await ensureStateDir();
13371
13756
  const entry = resolveServerEntry();
13372
13757
  let logFd = null;
@@ -13375,7 +13760,9 @@ async function startServer(port) {
13375
13760
  } catch {
13376
13761
  }
13377
13762
  try {
13378
- const child = spawn(process.execPath, [entry, "server", "--port", String(port)], createServerSpawnOptions(logFd));
13763
+ const args = [entry, "server", "--port", String(port)];
13764
+ for (const host of alsoListen) args.push("--also-listen", host);
13765
+ const child = spawn(process.execPath, args, createServerSpawnOptions(logFd));
13379
13766
  child.unref();
13380
13767
  } finally {
13381
13768
  if (logFd !== null) closeSync(logFd);
@@ -13497,6 +13884,20 @@ function flagValue(args, flag) {
13497
13884
  }
13498
13885
  return null;
13499
13886
  }
13887
+ function flagValues(args, flag) {
13888
+ const values = [];
13889
+ for (let i = 0; i < args.length; i += 1) {
13890
+ const arg = args[i];
13891
+ if (arg === "--") break;
13892
+ if (arg === flag && args[i + 1]) {
13893
+ values.push(args[i + 1]);
13894
+ i += 1;
13895
+ } else if (arg.startsWith(`${flag}=`) && arg.length > flag.length + 1) {
13896
+ values.push(arg.slice(flag.length + 1));
13897
+ }
13898
+ }
13899
+ return values;
13900
+ }
13500
13901
  function inspectValueFlag(args, flag) {
13501
13902
  for (let i = 0; i < args.length; i += 1) {
13502
13903
  const arg = args[i];
@@ -13700,11 +14101,11 @@ plugin: register the installed lavish-axi package as an Agent Plugin (agent-plug
13700
14101
 
13701
14102
  Both actions are explicit opt-in, idempotent, and repair a stale path after a reinstall.
13702
14103
  `,
13703
- server: `Usage: lavish-axi server [--port 4387] [--verbose]
14104
+ server: `Usage: lavish-axi server [--port 4387] [--verbose] [--also-listen <host>...]
13704
14105
 
13705
14106
  Run the local Lavish Editor server. Pass --verbose (or set LAVISH_AXI_DEBUG=1) to log session and watcher events to stderr. Detached server output is appended to ~/.lavish-axi/server.log, or LAVISH_AXI_STATE_DIR/server.log when set, for startup and crash diagnostics.
13706
14107
 
13707
- By default Lavish binds to 127.0.0.1 and, when Tailscale is running, this machine's Tailscale IPv4. Any explicit LAVISH_AXI_HOST overrides automatic Tailscale binding; wildcard values such as 0.0.0.0 or :: are restricted to loopback. An explicit non-wildcard LAVISH_AXI_HOST sets one bind address; binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to anything that can reach it, so only do so on a trusted network. With automatic binding enabled, a successfully bound Tailscale listener uses its MagicDNS name in generated session links; otherwise LAVISH_AXI_LINK_HOST can set the link hostname. See README's Allowed hosts section for Host allowlisting and LAVISH_AXI_ALLOWED_HOSTS. LAVISH_AXI_NO_OPEN=1 (or --no-open) suppresses the local browser launch.
14108
+ By default Lavish binds to 127.0.0.1 and, when Tailscale is running, this machine's Tailscale IPv4. Any explicit LAVISH_AXI_HOST overrides automatic Tailscale binding; wildcard values such as 0.0.0.0 or :: are restricted to loopback. An explicit non-wildcard LAVISH_AXI_HOST sets the bind address, and the server also listens on 127.0.0.1 so every local CLI finds it; --also-listen adds further concrete addresses (the CLI passes it when it replaces a server, to keep every address the old one served). An address that cannot be bound is retried in the background and reported as network_warning. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to anything that can reach it, so only do so on a trusted network. With automatic binding enabled, a successfully bound Tailscale listener uses its MagicDNS name in generated session links; otherwise LAVISH_AXI_LINK_HOST can set the link hostname. See README's Allowed hosts section for Host allowlisting and LAVISH_AXI_ALLOWED_HOSTS. LAVISH_AXI_NO_OPEN=1 (or --no-open) suppresses the local browser launch.
13708
14109
  `
13709
14110
  };
13710
14111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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.76",
4
+ "version": "0.1.78",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lavish
3
- description: Turn complex or visual agent responses into rich, reviewable HTML artifacts the user can annotate and send feedback on, using the lavish-axi CLI. Use when about to give a plan, comparison, diagram, table, code diff, report, or anything easier to grasp visually than as prose.
3
+ description: Turn complex or visual agent responses into rich, reviewable HTML artifacts (HTML files) the user can annotate and send feedback on, using the lavish-axi CLI. Use when about to give a plan, comparison, diagram, table, code diff, report, or anything easier to grasp visually than as prose.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: Kun Chen (kunchenguid)
@@ -29,5 +29,5 @@ If lavish-axi output shows a follow-up command starting with `lavish-axi`, run i
29
29
 
30
30
  $ARGUMENTS
31
31
 
32
- If the request above is non-empty, the user invoked `/lavish` explicitly - fetch the current CLI guidance, then build that artifact.
32
+ If the request above is non-empty, the user invoked `/lavish` explicitly - fetch the current CLI guidance, then build that artifact as an HTML file.
33
33
  If it is empty, infer what to visualize from the conversation.