home-hosted 0.7.19 → 0.7.20

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/AGENTS.md CHANGED
@@ -121,6 +121,12 @@ nobody reads is worse than a long file.
121
121
  never recreated.
122
122
  - **A port is only ever freed by re-listing its listeners.** `free-port` never trusts a pid quoted in
123
123
  a message, and refuses any listener in `supervisedPids()` instead of killing it.
124
+ - **A live pid is not the running panel.** Pids are recycled and a zombie still answers signal 0, so
125
+ `up`/`status`/`down` decide with `runtimeIsAlive`/`runtimeLiveness` (pid **plus** `run.json`'s own
126
+ `startedAt`), never `isProcessAlive` alone — otherwise `up` refuses against a dead panel, `status`
127
+ reports "running, but not answering", and `down` signals an unrelated process. Only `'gone'`
128
+ (nothing behind the pid) deletes a record; `'recycled'` blocks a **signal** but still allows the
129
+ token-authenticated `/_hh/shutdown`, because the birth time is wall-clock-derived.
124
130
  - **Only `kill` ever touches a process the panel could not identify** — which is why it logs the pids
125
131
  it stopped. `follow` adopts this entry's own successor, `reclaim` replaces it. Ownership is the
126
132
  entry's whole **tree**, and the panel's own tree is excluded. Full matching rules:
package/README.md CHANGED
@@ -341,7 +341,7 @@ restarts itself), and how hand-edits are validated: [SERVERS.md](./docs/SERVERS.
341
341
  | `home-hosted up` | start the panel detached, and keep it alive in the background |
342
342
  | `home-hosted down` | stop it cleanly — supervised processes included, persistent entries left running |
343
343
  | `home-hosted restart` | `down`, then `up` — or `restart <id>` to restart one server, leaving the panel up |
344
- | `home-hosted status` | pid, URL, health, uptime, state and log paths — the panel's console *and* the per-server directory, naming the `<id>.log` convention once logs exist (`--json` for scripts; stopped answers `{ running: false, initialised }`, where `initialised: false` means nothing was ever set up here) |
344
+ | `home-hosted status` | pid, URL, health, uptime, state and log paths — the panel's console *and* the per-server directory, naming the `<id>.log` convention once logs exist (`--json` for scripts; stopped answers `{ running: false, initialised }`, where `initialised: false` means nothing was ever set up here, plus `liveness` — `gone`, or `recycled` when the OS has handed the recorded pid to another process and the record is stale) |
345
345
  | `home-hosted start <id>` | start one server in the default workspace — and anything it `dependsOn` (`--workspace <id>`) |
346
346
  | `home-hosted stop <id>` | stop one server, nothing else (`--workspace <id>`) |
347
347
  | `home-hosted restart <id>` | restart one server, nothing else (`--workspace <id>`) — the same as the panel's per-server Restart |
package/dist/cli.js CHANGED
@@ -7301,6 +7301,125 @@ function isProcessAlive(pid) {
7301
7301
  return error.code === "EPERM";
7302
7302
  }
7303
7303
  }
7304
+ /**
7305
+ * `/proc/<pid>/stat` field 22 is the start time in clock ticks since boot, which only
7306
+ * becomes an epoch by adding `btime` from `/proc/stat`. Field 3 is the state.
7307
+ *
7308
+ * The comm field is parenthesised and may itself hold spaces and parentheses, so the
7309
+ * fields are counted from after the **last** `)`. Counting from the first would shift
7310
+ * every offset for a process whose name contains a `)`, and this value decides whether a
7311
+ * live-looking pid is really the daemon.
7312
+ */
7313
+ function parseProcBirth(content, btimeSeconds, hz = LINUX_HZ) {
7314
+ const close = content.lastIndexOf(")");
7315
+ if (close < 0) return null;
7316
+ const fields = content.slice(close + 2).split(" ");
7317
+ const ticks = Number.parseInt(fields[19] ?? "", 10);
7318
+ if (!Number.isFinite(ticks) || ticks < 0) return null;
7319
+ return {
7320
+ startMs: btimeSeconds * 1e3 + ticks / hz * 1e3,
7321
+ zombie: fields[0] === "Z"
7322
+ };
7323
+ }
7324
+ /**
7325
+ * One `ps -o state=,lstart=` row, e.g. `S Wed Oct 7 19:43:26 2026` (macOS, other POSIX).
7326
+ * The leading weekday is stripped before parsing: `Date.parse` is not obliged to accept it
7327
+ * and it says nothing the date does not.
7328
+ */
7329
+ function parsePsBirth(output) {
7330
+ const line = output.trim().split("\n")[0]?.trim() ?? "";
7331
+ if (line.length === 0) return null;
7332
+ const [state = "", ...rest] = line.split(/\s+/);
7333
+ const startMs = Date.parse(rest.join(" ").replace(/^[A-Z]{3,9}\s+/i, ""));
7334
+ if (!Number.isFinite(startMs)) return null;
7335
+ return {
7336
+ startMs,
7337
+ zombie: state.startsWith("Z")
7338
+ };
7339
+ }
7340
+ /**
7341
+ * FILETIME ticks (100ns since **1601**, UTC) to a Unix epoch in ms — the shape
7342
+ * `DateTime.ToFileTimeUtc()` returns.
7343
+ *
7344
+ * The epoch matters: `DateTime.Ticks` counts from **0001**, not 1601, and the two differ by
7345
+ * 1600 years. Reading the wrong one put every Windows panel ~1600 years in the future, which
7346
+ * `processLiveness` then called `recycled` — a live panel that could never signal-stop itself.
7347
+ * The PowerShell call below therefore asks for `ToFileTimeUtc()` explicitly, and this is exported
7348
+ * so the conversion is pinned by a test.
7349
+ */
7350
+ function parseFiletime(ticks) {
7351
+ if (!Number.isFinite(ticks) || ticks <= 0) return null;
7352
+ return ticks / 1e4 - FILETIME_UNIX_OFFSET_MS;
7353
+ }
7354
+ /**
7355
+ * When the pid started, from the platform's own process table. `null` when the platform
7356
+ * cannot say — an unreadable `/proc`, no `ps`, or no PowerShell — which the caller must
7357
+ * read as "unknown", never as "gone".
7358
+ */
7359
+ async function processBirth(pid) {
7360
+ if (!Number.isInteger(pid) || pid <= 0) return null;
7361
+ if (process.platform === "linux") try {
7362
+ const [stat, system] = await Promise.all([fs.promises.readFile(`/proc/${pid}/stat`, "utf8"), fs.promises.readFile("/proc/stat", "utf8")]);
7363
+ const btime = /^btime (\d+)$/m.exec(system)?.[1];
7364
+ if (btime === void 0) return null;
7365
+ return parseProcBirth(stat, Number.parseInt(btime, 10));
7366
+ } catch {
7367
+ return null;
7368
+ }
7369
+ if (process.platform === "win32") try {
7370
+ const script = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToFileTimeUtc() }`;
7371
+ const { stdout } = await execFileAsync$2("powershell", [
7372
+ "-NoProfile",
7373
+ "-NonInteractive",
7374
+ "-Command",
7375
+ script
7376
+ ], {
7377
+ timeout: 2e4,
7378
+ windowsHide: true
7379
+ });
7380
+ const startMs = parseFiletime(Number.parseFloat(stdout.trim()));
7381
+ return startMs === null ? null : {
7382
+ startMs,
7383
+ zombie: false
7384
+ };
7385
+ } catch {
7386
+ return null;
7387
+ }
7388
+ try {
7389
+ const { stdout } = await execFileAsync$2("ps", [
7390
+ "-p",
7391
+ String(pid),
7392
+ "-o",
7393
+ "state=,lstart="
7394
+ ], { timeout: 3e3 });
7395
+ return parsePsBirth(stdout);
7396
+ } catch {
7397
+ return null;
7398
+ }
7399
+ }
7400
+ /**
7401
+ * A pid is **not** an identity. The OS hands a dead process's pid to an unrelated one later, and a
7402
+ * zombie — exited but not yet reaped by its parent — still answers signal 0. So `isProcessAlive`
7403
+ * alone can report a long-dead daemon as live, which is what let `up` say "already running" and
7404
+ * `status` say "running, but not answering" against a stale `run.json`, and what let `down` signal
7405
+ * whatever stranger had inherited the pid.
7406
+ *
7407
+ * `startedAt` settles it in one direction, with no threshold to guess at: the daemon writes its
7408
+ * record *during its own boot*, so its process is necessarily born **before** that stamp. A pid born
7409
+ * after it is not that process, whoever else it now is. The comparison is deliberately one-sided —
7410
+ * the other direction would have to tell a recycled pid from a wall clock that merely moved, and it
7411
+ * cannot, so a record is never called stale for being *older* than its process.
7412
+ *
7413
+ * `'unknown'` is not `'gone'`: a platform that cannot report a birth time must not have its live pid
7414
+ * dismissed, or a running panel's record would be cleared out from under it.
7415
+ */
7416
+ async function processLiveness(pid, startedAt, options = {}) {
7417
+ if (!isProcessAlive(pid)) return "gone";
7418
+ const birth = await (options.readBirth ?? processBirth)(pid);
7419
+ if (birth === null) return "unknown";
7420
+ if (birth.zombie) return "gone";
7421
+ return birth.startMs > startedAt + (options.toleranceMs ?? 2e3) ? "recycled" : "live";
7422
+ }
7304
7423
  function signalPid$1(pid, name) {
7305
7424
  try {
7306
7425
  process.kill(pid, name);
@@ -7384,9 +7503,11 @@ async function listPortHolders(port) {
7384
7503
  function parsePids(stdout) {
7385
7504
  return [...new Set(stdout.split(/\s+/).map((entry) => Number.parseInt(entry, 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
7386
7505
  }
7387
- var execFileAsync$2;
7506
+ var execFileAsync$2, LINUX_HZ, FILETIME_UNIX_OFFSET_MS;
7388
7507
  var init_port = __esmMin((() => {
7389
7508
  execFileAsync$2 = promisify(execFile);
7509
+ LINUX_HZ = 100;
7510
+ FILETIME_UNIX_OFFSET_MS = 0xa9730b66800;
7390
7511
  }));
7391
7512
  //#endregion
7392
7513
  //#region src/providers/nanny.ts
@@ -11126,12 +11247,16 @@ var init_layout = __esmMin((() => {
11126
11247
  //#region src/helpers/daemon.ts
11127
11248
  var daemon_exports = /* @__PURE__ */ __exportAll({
11128
11249
  clearRuntime: () => clearRuntime,
11250
+ isLiveLiveness: () => isLiveLiveness,
11129
11251
  isProcessAlive: () => isProcessAlive,
11130
11252
  newToken: () => newToken,
11131
11253
  probeRuntime: () => probeRuntime,
11132
11254
  readRuntime: () => readRuntime,
11133
11255
  requestControl: () => requestControl,
11134
11256
  requestShutdown: () => requestShutdown,
11257
+ runtimeIsAlive: () => runtimeIsAlive,
11258
+ runtimeIsGone: () => runtimeIsGone,
11259
+ runtimeLiveness: () => runtimeLiveness,
11135
11260
  runtimeSchema: () => runtimeSchema,
11136
11261
  writeRuntime: () => writeRuntime
11137
11262
  });
@@ -11152,6 +11277,51 @@ function clearRuntime() {
11152
11277
  function newToken() {
11153
11278
  return randomBytes(32).toString("base64url");
11154
11279
  }
11280
+ /**
11281
+ * What became of the daemon this record describes: the verdict `run.json`'s pid cannot give on
11282
+ * its own. See `processLiveness` in `providers/port.ts` for why the pid is not an identity.
11283
+ */
11284
+ async function runtimeLiveness(runtime) {
11285
+ return await processLiveness(runtime.pid, runtime.startedAt);
11286
+ }
11287
+ /**
11288
+ * Is the daemon this record describes still the one running?
11289
+ *
11290
+ * `readRuntime()` answers "does a record exist", never "is the panel behind it alive" — and a pid
11291
+ * alone cannot answer that either. The OS reuses pids, and a zombie still answers signal 0, so a
11292
+ * bare `isProcessAlive(runtime.pid)` reported a stale `run.json` as a live panel: `up` refused with
11293
+ * "already running", `status` said "running, but not answering", and `down` signalled whatever
11294
+ * unrelated process had since inherited the pid.
11295
+ *
11296
+ * Every caller must read this one predicate, because a caller that re-derives it slightly differently
11297
+ * is how a `down` comes to signal a stranger.
11298
+ */
11299
+ async function runtimeIsAlive(runtime) {
11300
+ return isLiveLiveness(await runtimeLiveness(runtime));
11301
+ }
11302
+ /**
11303
+ * Only `'live'` and `'unknown'` count as alive. `'unknown'` means the platform could not read a start
11304
+ * time, so the live pid is all there is — dismissing it would clear a record the running panel still
11305
+ * owns. `'recycled'` is emphatically **not** alive: the pid belongs to somebody else now.
11306
+ */
11307
+ function isLiveLiveness(liveness) {
11308
+ return liveness === "live" || liveness === "unknown";
11309
+ }
11310
+ /**
11311
+ * Is the record positively pointing at nothing at all — no process behind the pid?
11312
+ *
11313
+ * This is the **only** condition under which deleting `run.json` is safe, so it is deliberately
11314
+ * narrower than `!runtimeIsAlive`. The start-time check can be wrong in one direction: Linux derives
11315
+ * a process's birth epoch from `btime`, which the kernel computes from the wall clock
11316
+ * (`getboottime64`: "calls to settimeofday will affect the value returned"), so a forward clock step
11317
+ * — common when a laptop or a WSL host wakes from sleep — makes a *live* panel look recycled. Deleting
11318
+ * its record over that would orphan a running panel: still supervising servers, no longer stoppable by
11319
+ * `down`. A record that some process may still own is left alone and simply overwritten when a new
11320
+ * panel finishes booting.
11321
+ */
11322
+ async function runtimeIsGone(runtime) {
11323
+ return await runtimeLiveness(runtime) === "gone";
11324
+ }
11155
11325
  /** A degraded panel is still answering: a 503 must not read as "not running". */
11156
11326
  async function probeRuntime(runtime, timeoutMs = 2500) {
11157
11327
  const answer = await localCall(runtime, "/healthz", "GET", void 0, timeoutMs);
@@ -16906,11 +17076,11 @@ function probeHostFor(bindHost) {
16906
17076
  async function runControlPlane(options) {
16907
17077
  ensureLayout();
16908
17078
  const existing = readRuntime();
16909
- if (existing !== null && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
17079
+ if (existing !== null && existing.pid !== process.pid && await runtimeIsAlive(existing)) {
16910
17080
  logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
16911
17081
  process.exit(1);
16912
17082
  }
16913
- if (existing !== null) clearRuntime();
17083
+ if (existing !== null && await runtimeIsGone(existing)) clearRuntime();
16914
17084
  const settings = new GlobalSettingsStore();
16915
17085
  settings.load();
16916
17086
  settings.writeJsonSchema();
@@ -17265,17 +17435,17 @@ async function runUp(flags, entry) {
17265
17435
  });
17266
17436
  return;
17267
17437
  }
17268
- const { clearRuntime, isProcessAlive, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17438
+ const { clearRuntime, readRuntime, runtimeIsAlive, runtimeIsGone } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17269
17439
  const { daemonLogPath, dataRoot, projectDir } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17270
17440
  const existing = readRuntime();
17271
- if (existing !== null && isProcessAlive(existing.pid)) {
17441
+ if (existing !== null && await runtimeIsAlive(existing)) {
17272
17442
  process.stdout.write(`${green("already running")} home-hosted ${existing.version} (pid ${existing.pid}) at ${existing.url}\n`);
17273
17443
  const note = versionMismatchNote(existing.version);
17274
17444
  if (note !== null) process.stdout.write(`${dim(note)}\n`);
17275
17445
  process.stdout.write(`${dim("stop it with `home-hosted down`")}\n`);
17276
17446
  return;
17277
17447
  }
17278
- if (existing !== null) clearRuntime();
17448
+ if (existing !== null && await runtimeIsGone(existing)) clearRuntime();
17279
17449
  fs.mkdirSync(path.dirname(daemonLogPath), { recursive: true });
17280
17450
  rotateLog(daemonLogPath);
17281
17451
  const log = fs.openSync(daemonLogPath, "a");
@@ -17383,13 +17553,14 @@ var down_exports = /* @__PURE__ */ __exportAll({
17383
17553
  });
17384
17554
  /** `down` stops the panel and everything it supervises. */
17385
17555
  async function runDown() {
17386
- const { clearRuntime, isProcessAlive, readRuntime, requestShutdown } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17556
+ const { clearRuntime, isLiveLiveness, readRuntime, requestShutdown, runtimeLiveness } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17387
17557
  const runtime = readRuntime();
17388
17558
  if (runtime === null) {
17389
17559
  process.stdout.write("home-hosted is not running\n");
17390
17560
  return;
17391
17561
  }
17392
- if (!isProcessAlive(runtime.pid)) {
17562
+ const liveness = await runtimeLiveness(runtime);
17563
+ if (liveness === "gone") {
17393
17564
  clearRuntime();
17394
17565
  process.stdout.write(`home-hosted is not running (removed a stale run.json from home-hosted ${runtime.version})\n`);
17395
17566
  return;
@@ -17400,8 +17571,21 @@ async function runDown() {
17400
17571
  const note = versionMismatchNote(runtime.version);
17401
17572
  if (note !== null) process.stdout.write(`${dim(note)}\n`);
17402
17573
  };
17403
- process.stdout.write(`stopping pid ${runtime.pid}…\n`);
17404
- if (!await requestShutdown(runtime)) signal(runtime.pid, "SIGTERM");
17574
+ let answered = false;
17575
+ if (isLiveLiveness(liveness)) {
17576
+ process.stdout.write(`stopping pid ${runtime.pid}…\n`);
17577
+ answered = await requestShutdown(runtime);
17578
+ if (!answered) signal(runtime.pid, "SIGTERM");
17579
+ } else {
17580
+ if (!await requestShutdown(runtime)) {
17581
+ process.stdout.write(`home-hosted is not running (the pid in run.json, ${runtime.pid}, belongs to another process — it was not signalled)\n`);
17582
+ return;
17583
+ }
17584
+ process.stdout.write(`stopped the panel on ${runtime.url} (its pid in run.json, ${runtime.pid}, was another process and was left alone)\n`);
17585
+ clearRuntime();
17586
+ await reportPersistent();
17587
+ return;
17588
+ }
17405
17589
  if (await waitForExit(runtime.pid, 2e4)) {
17406
17590
  clearRuntime();
17407
17591
  reportStopped(false);
@@ -17490,10 +17674,10 @@ var server_exports = /* @__PURE__ */ __exportAll({
17490
17674
  stopCommand: () => stopCommand
17491
17675
  });
17492
17676
  async function runServerAction(action, id, workspace) {
17493
- const { clearRuntime, isProcessAlive, readRuntime, requestControl } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17677
+ const { clearRuntime, readRuntime, requestControl, runtimeIsAlive, runtimeIsGone } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17494
17678
  const runtime = readRuntime();
17495
- if (runtime === null || !isProcessAlive(runtime.pid)) {
17496
- if (runtime !== null) clearRuntime();
17679
+ if (runtime === null || !await runtimeIsAlive(runtime)) {
17680
+ if (runtime !== null && await runtimeIsGone(runtime)) clearRuntime();
17497
17681
  fail("home-hosted is not running — start it with `home-hosted up`");
17498
17682
  }
17499
17683
  const query = workspace === void 0 || workspace.length === 0 ? "" : `?workspace=${encodeURIComponent(workspace)}`;
@@ -17590,7 +17774,7 @@ var status_exports = /* @__PURE__ */ __exportAll({
17590
17774
  statusCommand: () => statusCommand
17591
17775
  });
17592
17776
  async function runStatus(json) {
17593
- const { isProcessAlive, probeRuntime, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17777
+ const { isLiveLiveness, probeRuntime, readRuntime, runtimeLiveness } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17594
17778
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
17595
17779
  const { hhDir: dataRoot, workspaceDir, DEFAULT_WORKSPACE_ID, workspacesPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17596
17780
  const runtime = readRuntime();
@@ -17605,7 +17789,8 @@ async function runStatus(json) {
17605
17789
  process.exitCode = 1;
17606
17790
  return;
17607
17791
  }
17608
- const running = isProcessAlive(runtime.pid);
17792
+ const liveness = await runtimeLiveness(runtime);
17793
+ const running = isLiveLiveness(liveness);
17609
17794
  const probe = running ? await probeRuntime(runtime) : {
17610
17795
  reachable: false,
17611
17796
  degraded: false
@@ -17617,6 +17802,7 @@ async function runStatus(json) {
17617
17802
  const { token: _token, ...safe } = runtime;
17618
17803
  process.stdout.write(`${JSON.stringify({
17619
17804
  running,
17805
+ liveness,
17620
17806
  answering: probe.reachable,
17621
17807
  degraded: probe.degraded,
17622
17808
  ...safe,
@@ -17632,7 +17818,7 @@ async function runStatus(json) {
17632
17818
  }
17633
17819
  const uptime = formatDuration(Date.now() - runtime.startedAt);
17634
17820
  const rows = [
17635
- ["status", !running ? paint("31", "stale (the process is gone)") : probe.degraded ? paint("33", "running — a server needs attention") : probe.reachable ? green("running") : paint("33", "running, but not answering")],
17821
+ ["status", !running ? liveness === "recycled" ? paint("31", "stale (the pid belongs to another process now)") : paint("31", "stale (the process is gone)") : probe.degraded ? paint("33", "running — a server needs attention") : probe.reachable ? green("running") : paint("33", "running, but not answering")],
17636
17822
  ["pid", running ? `${runtime.pid} · up ${uptime}` : String(runtime.pid)],
17637
17823
  ["url", `${runtime.url} ${dim(`(${runtime.protocol})`)}`],
17638
17824
  ["version", runtime.version],