lavish-axi 0.1.77 → 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 +3 -1
- package/dist/chrome-client.js +3 -3
- package/dist/cli.mjs +576 -175
- package/package.json +1 -1
- package/plugin.json +1 -1
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
|
|
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
|
|
package/dist/chrome-client.js
CHANGED
|
@@ -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
|
|
3133
|
-
// restart and this chrome's one re-handshake landed in the same
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
9807
|
+
...lookupOptions,
|
|
9808
|
+
keepUnresolved: true
|
|
9710
9809
|
});
|
|
9711
9810
|
const activeTailscaleNetwork = tailscaleNetworkKey(tailscale);
|
|
9811
|
+
const serverStateId = stateId(stateFile2);
|
|
9712
9812
|
let tailscalePhoneReady = false;
|
|
9713
|
-
|
|
9714
|
-
|
|
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 (
|
|
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
|
|
9807
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
11028
|
-
|
|
11029
|
-
|
|
11030
|
-
|
|
11031
|
-
|
|
11032
|
-
|
|
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
|
-
|
|
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
|
|
11242
|
-
return error instanceof Error && "code" in error && error.code === "
|
|
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.
|
|
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
|
-
|
|
12856
|
-
processMatchesLavish =
|
|
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(
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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({
|
|
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
|
|
13203
|
-
|
|
13204
|
-
|
|
13205
|
-
|
|
13206
|
-
|
|
13207
|
-
|
|
13208
|
-
|
|
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
|
-
|
|
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
|
|
13214
|
-
|
|
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
|
|
13230
|
-
|
|
13231
|
-
|
|
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(
|
|
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
|
-
|
|
13244
|
-
|
|
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 {
|
|
13254
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
13652
|
+
return processMatchesLavish(baseUrl);
|
|
13303
13653
|
}
|
|
13304
|
-
|
|
13305
|
-
|
|
13306
|
-
|
|
13307
|
-
|
|
13308
|
-
const
|
|
13309
|
-
|
|
13310
|
-
|
|
13311
|
-
|
|
13312
|
-
|
|
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
|
|
13716
|
+
function lavishListenerPids(baseUrl) {
|
|
13717
|
+
const { hostname, port } = new URL(baseUrl);
|
|
13337
13718
|
try {
|
|
13338
|
-
const
|
|
13339
|
-
if (
|
|
13340
|
-
|
|
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)
|
|
13343
|
-
|
|
13344
|
-
|
|
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
|
|
13353
|
-
|
|
13354
|
-
|
|
13355
|
-
|
|
13356
|
-
|
|
13357
|
-
|
|
13358
|
-
|
|
13359
|
-
|
|
13360
|
-
|
|
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
|
|
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
|
|
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
|
|
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
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "lavish-axi",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.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",
|