home-hosted 0.7.19 → 0.7.21

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:
@@ -185,7 +191,9 @@ nobody reads is worse than a long file.
185
191
  commit*. A stale doc is a bug like stale code.
186
192
  - **UIs move together.** `uis/stock` is not the only client: a change to it — or to a shared contract
187
193
  it reads — lands in every other UI under `uis/`, and each altered UI bumps its `ui.json` (see
188
- [`.agentDocs/ARCHITECTURE.md`](.agentDocs/ARCHITECTURE.md)).
194
+ [`.agentDocs/ARCHITECTURE.md`](.agentDocs/ARCHITECTURE.md)). `src/shared/**` is a UI build input, so
195
+ a contract a UI *calls* at runtime ships in its bundle and needs the bump — the pre-commit hook only
196
+ inspects `uis/` paths and stays silent on that commit.
189
197
 
190
198
  ## Releasing
191
199
 
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
@@ -1077,7 +1077,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1077
1077
  logBufferLines: logBufferLinesSchema.optional()
1078
1078
  }).onUndeclaredKey("reject");
1079
1079
  editableFields = {
1080
- label: "string?",
1080
+ /**
1081
+ * `null` clears it, the same spelling `serverSchema` accepts and every display's `label ?? id`
1082
+ * fallback needs — an empty string satisfies neither. `serverPatchSchema` is what the editor
1083
+ * validates its payload against and what `PATCH /api/servers/:id` checks, so without this a
1084
+ * cleared Label was refused with "label must be a string (was null)" and could not be saved at all
1085
+ * (`1f58a5d` fixed `applyPatch` and the *control* patch schema, and cited that one in its message).
1086
+ */
1087
+ label: labelOrNullSchema.optional(),
1081
1088
  enabled: "boolean?",
1082
1089
  autostart: "boolean?",
1083
1090
  persistent: "boolean?",
@@ -7301,6 +7308,125 @@ function isProcessAlive(pid) {
7301
7308
  return error.code === "EPERM";
7302
7309
  }
7303
7310
  }
7311
+ /**
7312
+ * `/proc/<pid>/stat` field 22 is the start time in clock ticks since boot, which only
7313
+ * becomes an epoch by adding `btime` from `/proc/stat`. Field 3 is the state.
7314
+ *
7315
+ * The comm field is parenthesised and may itself hold spaces and parentheses, so the
7316
+ * fields are counted from after the **last** `)`. Counting from the first would shift
7317
+ * every offset for a process whose name contains a `)`, and this value decides whether a
7318
+ * live-looking pid is really the daemon.
7319
+ */
7320
+ function parseProcBirth(content, btimeSeconds, hz = LINUX_HZ) {
7321
+ const close = content.lastIndexOf(")");
7322
+ if (close < 0) return null;
7323
+ const fields = content.slice(close + 2).split(" ");
7324
+ const ticks = Number.parseInt(fields[19] ?? "", 10);
7325
+ if (!Number.isFinite(ticks) || ticks < 0) return null;
7326
+ return {
7327
+ startMs: btimeSeconds * 1e3 + ticks / hz * 1e3,
7328
+ zombie: fields[0] === "Z"
7329
+ };
7330
+ }
7331
+ /**
7332
+ * One `ps -o state=,lstart=` row, e.g. `S Wed Oct 7 19:43:26 2026` (macOS, other POSIX).
7333
+ * The leading weekday is stripped before parsing: `Date.parse` is not obliged to accept it
7334
+ * and it says nothing the date does not.
7335
+ */
7336
+ function parsePsBirth(output) {
7337
+ const line = output.trim().split("\n")[0]?.trim() ?? "";
7338
+ if (line.length === 0) return null;
7339
+ const [state = "", ...rest] = line.split(/\s+/);
7340
+ const startMs = Date.parse(rest.join(" ").replace(/^[A-Z]{3,9}\s+/i, ""));
7341
+ if (!Number.isFinite(startMs)) return null;
7342
+ return {
7343
+ startMs,
7344
+ zombie: state.startsWith("Z")
7345
+ };
7346
+ }
7347
+ /**
7348
+ * FILETIME ticks (100ns since **1601**, UTC) to a Unix epoch in ms — the shape
7349
+ * `DateTime.ToFileTimeUtc()` returns.
7350
+ *
7351
+ * The epoch matters: `DateTime.Ticks` counts from **0001**, not 1601, and the two differ by
7352
+ * 1600 years. Reading the wrong one put every Windows panel ~1600 years in the future, which
7353
+ * `processLiveness` then called `recycled` — a live panel that could never signal-stop itself.
7354
+ * The PowerShell call below therefore asks for `ToFileTimeUtc()` explicitly, and this is exported
7355
+ * so the conversion is pinned by a test.
7356
+ */
7357
+ function parseFiletime(ticks) {
7358
+ if (!Number.isFinite(ticks) || ticks <= 0) return null;
7359
+ return ticks / 1e4 - FILETIME_UNIX_OFFSET_MS;
7360
+ }
7361
+ /**
7362
+ * When the pid started, from the platform's own process table. `null` when the platform
7363
+ * cannot say — an unreadable `/proc`, no `ps`, or no PowerShell — which the caller must
7364
+ * read as "unknown", never as "gone".
7365
+ */
7366
+ async function processBirth(pid) {
7367
+ if (!Number.isInteger(pid) || pid <= 0) return null;
7368
+ if (process.platform === "linux") try {
7369
+ const [stat, system] = await Promise.all([fs.promises.readFile(`/proc/${pid}/stat`, "utf8"), fs.promises.readFile("/proc/stat", "utf8")]);
7370
+ const btime = /^btime (\d+)$/m.exec(system)?.[1];
7371
+ if (btime === void 0) return null;
7372
+ return parseProcBirth(stat, Number.parseInt(btime, 10));
7373
+ } catch {
7374
+ return null;
7375
+ }
7376
+ if (process.platform === "win32") try {
7377
+ const script = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToFileTimeUtc() }`;
7378
+ const { stdout } = await execFileAsync$2("powershell", [
7379
+ "-NoProfile",
7380
+ "-NonInteractive",
7381
+ "-Command",
7382
+ script
7383
+ ], {
7384
+ timeout: 2e4,
7385
+ windowsHide: true
7386
+ });
7387
+ const startMs = parseFiletime(Number.parseFloat(stdout.trim()));
7388
+ return startMs === null ? null : {
7389
+ startMs,
7390
+ zombie: false
7391
+ };
7392
+ } catch {
7393
+ return null;
7394
+ }
7395
+ try {
7396
+ const { stdout } = await execFileAsync$2("ps", [
7397
+ "-p",
7398
+ String(pid),
7399
+ "-o",
7400
+ "state=,lstart="
7401
+ ], { timeout: 3e3 });
7402
+ return parsePsBirth(stdout);
7403
+ } catch {
7404
+ return null;
7405
+ }
7406
+ }
7407
+ /**
7408
+ * A pid is **not** an identity. The OS hands a dead process's pid to an unrelated one later, and a
7409
+ * zombie — exited but not yet reaped by its parent — still answers signal 0. So `isProcessAlive`
7410
+ * alone can report a long-dead daemon as live, which is what let `up` say "already running" and
7411
+ * `status` say "running, but not answering" against a stale `run.json`, and what let `down` signal
7412
+ * whatever stranger had inherited the pid.
7413
+ *
7414
+ * `startedAt` settles it in one direction, with no threshold to guess at: the daemon writes its
7415
+ * record *during its own boot*, so its process is necessarily born **before** that stamp. A pid born
7416
+ * after it is not that process, whoever else it now is. The comparison is deliberately one-sided —
7417
+ * the other direction would have to tell a recycled pid from a wall clock that merely moved, and it
7418
+ * cannot, so a record is never called stale for being *older* than its process.
7419
+ *
7420
+ * `'unknown'` is not `'gone'`: a platform that cannot report a birth time must not have its live pid
7421
+ * dismissed, or a running panel's record would be cleared out from under it.
7422
+ */
7423
+ async function processLiveness(pid, startedAt, options = {}) {
7424
+ if (!isProcessAlive(pid)) return "gone";
7425
+ const birth = await (options.readBirth ?? processBirth)(pid);
7426
+ if (birth === null) return "unknown";
7427
+ if (birth.zombie) return "gone";
7428
+ return birth.startMs > startedAt + (options.toleranceMs ?? 2e3) ? "recycled" : "live";
7429
+ }
7304
7430
  function signalPid$1(pid, name) {
7305
7431
  try {
7306
7432
  process.kill(pid, name);
@@ -7384,9 +7510,11 @@ async function listPortHolders(port) {
7384
7510
  function parsePids(stdout) {
7385
7511
  return [...new Set(stdout.split(/\s+/).map((entry) => Number.parseInt(entry, 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
7386
7512
  }
7387
- var execFileAsync$2;
7513
+ var execFileAsync$2, LINUX_HZ, FILETIME_UNIX_OFFSET_MS;
7388
7514
  var init_port = __esmMin((() => {
7389
7515
  execFileAsync$2 = promisify(execFile);
7516
+ LINUX_HZ = 100;
7517
+ FILETIME_UNIX_OFFSET_MS = 0xa9730b66800;
7390
7518
  }));
7391
7519
  //#endregion
7392
7520
  //#region src/providers/nanny.ts
@@ -8215,6 +8343,7 @@ var init_proxy$1 = __esmMin((() => {
8215
8343
  init_types();
8216
8344
  init_nanny$2();
8217
8345
  init_port();
8346
+ init_proc();
8218
8347
  init_proxy$2();
8219
8348
  init_proxy_config();
8220
8349
  init_tls$1();
@@ -9610,7 +9739,9 @@ var init_proxy$1 = __esmMin((() => {
9610
9739
  const statePath = nannyStatePath(this.options.stateDir, PROXY_ID);
9611
9740
  const state = readNannyState(statePath);
9612
9741
  if (state !== null && state.serverId === PROXY_ID) {
9613
- const pids = [state.nannyPid, state.childPid].filter((pid) => pid !== null && pid > 0);
9742
+ const nanny = await nannyIsAlive(state, PROXY_ID);
9743
+ const child = state.childPid !== null && state.childPid > 0 && await processCarriesServerId(state.childPid, PROXY_ID);
9744
+ const pids = [...nanny ? [state.nannyPid] : [], ...nanny || child ? [state.childPid] : []].filter((pid) => pid !== null && pid > 0);
9614
9745
  for (const pid of pids) {
9615
9746
  if (!isProcessAlive(pid)) continue;
9616
9747
  try {
@@ -9621,10 +9752,12 @@ var init_proxy$1 = __esmMin((() => {
9621
9752
  }
9622
9753
  const deadline = Date.now() + (this.options.stopGraceMs ?? 1e4);
9623
9754
  while (Date.now() < deadline && pids.some((pid) => isProcessAlive(pid))) await new Promise((resolve) => setTimeout(resolve, 100));
9624
- for (const pid of pids.filter(isProcessAlive)) try {
9755
+ const survivors = pids.filter(isProcessAlive);
9756
+ for (const pid of survivors) try {
9625
9757
  process.kill(pid, "SIGKILL");
9626
9758
  } catch {}
9627
- fs.rmSync(statePath, { force: true });
9759
+ if (survivors.some(isProcessAlive)) logger.warn(`proxy: pid ${survivors.join(", ")} ignored SIGKILL — keeping ${path.basename(statePath)} so a later boot can still stop it`);
9760
+ else fs.rmSync(statePath, { force: true });
9628
9761
  }
9629
9762
  this.nanny = null;
9630
9763
  this.admin = null;
@@ -11126,12 +11259,16 @@ var init_layout = __esmMin((() => {
11126
11259
  //#region src/helpers/daemon.ts
11127
11260
  var daemon_exports = /* @__PURE__ */ __exportAll({
11128
11261
  clearRuntime: () => clearRuntime,
11262
+ isLiveLiveness: () => isLiveLiveness,
11129
11263
  isProcessAlive: () => isProcessAlive,
11130
11264
  newToken: () => newToken,
11131
11265
  probeRuntime: () => probeRuntime,
11132
11266
  readRuntime: () => readRuntime,
11133
11267
  requestControl: () => requestControl,
11134
11268
  requestShutdown: () => requestShutdown,
11269
+ runtimeIsAlive: () => runtimeIsAlive,
11270
+ runtimeIsGone: () => runtimeIsGone,
11271
+ runtimeLiveness: () => runtimeLiveness,
11135
11272
  runtimeSchema: () => runtimeSchema,
11136
11273
  writeRuntime: () => writeRuntime
11137
11274
  });
@@ -11152,6 +11289,51 @@ function clearRuntime() {
11152
11289
  function newToken() {
11153
11290
  return randomBytes(32).toString("base64url");
11154
11291
  }
11292
+ /**
11293
+ * What became of the daemon this record describes: the verdict `run.json`'s pid cannot give on
11294
+ * its own. See `processLiveness` in `providers/port.ts` for why the pid is not an identity.
11295
+ */
11296
+ async function runtimeLiveness(runtime) {
11297
+ return await processLiveness(runtime.pid, runtime.startedAt);
11298
+ }
11299
+ /**
11300
+ * Is the daemon this record describes still the one running?
11301
+ *
11302
+ * `readRuntime()` answers "does a record exist", never "is the panel behind it alive" — and a pid
11303
+ * alone cannot answer that either. The OS reuses pids, and a zombie still answers signal 0, so a
11304
+ * bare `isProcessAlive(runtime.pid)` reported a stale `run.json` as a live panel: `up` refused with
11305
+ * "already running", `status` said "running, but not answering", and `down` signalled whatever
11306
+ * unrelated process had since inherited the pid.
11307
+ *
11308
+ * Every caller must read this one predicate, because a caller that re-derives it slightly differently
11309
+ * is how a `down` comes to signal a stranger.
11310
+ */
11311
+ async function runtimeIsAlive(runtime) {
11312
+ return isLiveLiveness(await runtimeLiveness(runtime));
11313
+ }
11314
+ /**
11315
+ * Only `'live'` and `'unknown'` count as alive. `'unknown'` means the platform could not read a start
11316
+ * time, so the live pid is all there is — dismissing it would clear a record the running panel still
11317
+ * owns. `'recycled'` is emphatically **not** alive: the pid belongs to somebody else now.
11318
+ */
11319
+ function isLiveLiveness(liveness) {
11320
+ return liveness === "live" || liveness === "unknown";
11321
+ }
11322
+ /**
11323
+ * Is the record positively pointing at nothing at all — no process behind the pid?
11324
+ *
11325
+ * This is the **only** condition under which deleting `run.json` is safe, so it is deliberately
11326
+ * narrower than `!runtimeIsAlive`. The start-time check can be wrong in one direction: Linux derives
11327
+ * a process's birth epoch from `btime`, which the kernel computes from the wall clock
11328
+ * (`getboottime64`: "calls to settimeofday will affect the value returned"), so a forward clock step
11329
+ * — common when a laptop or a WSL host wakes from sleep — makes a *live* panel look recycled. Deleting
11330
+ * its record over that would orphan a running panel: still supervising servers, no longer stoppable by
11331
+ * `down`. A record that some process may still own is left alone and simply overwritten when a new
11332
+ * panel finishes booting.
11333
+ */
11334
+ async function runtimeIsGone(runtime) {
11335
+ return await runtimeLiveness(runtime) === "gone";
11336
+ }
11155
11337
  /** A degraded panel is still answering: a 503 must not read as "not running". */
11156
11338
  async function probeRuntime(runtime, timeoutMs = 2500) {
11157
11339
  const answer = await localCall(runtime, "/healthz", "GET", void 0, timeoutMs);
@@ -11792,28 +11974,24 @@ function signalPid(pid, signal, killGroup) {
11792
11974
  process.kill(pid, signal);
11793
11975
  } catch {}
11794
11976
  }
11795
- function alive(pid) {
11796
- try {
11797
- process.kill(pid, 0);
11798
- return true;
11799
- } catch {
11800
- return false;
11801
- }
11802
- }
11803
11977
  /**
11804
11978
  * The same shutdown a supervised child gets, for a process we adopted instead of
11805
11979
  * spawned: a detached successor is not our child, so it is signalled by pid (and
11806
11980
  * by process group when asked) and its exit is polled rather than awaited.
11981
+ *
11982
+ * Liveness comes from `isProcessAlive`, never a local signal-0 copy: a second definition returned
11983
+ * `false` for `EPERM`, so a process that was there but not ours to signal read as "exited" and
11984
+ * `'force-killed'` was returned for one still running. One predicate, one answer.
11807
11985
  */
11808
11986
  async function terminatePid(pid, options) {
11809
- if (!alive(pid)) return "exited";
11987
+ if (!isProcessAlive(pid)) return "exited";
11810
11988
  signalPid(pid, options.signal, options.killGroup);
11811
11989
  const deadline = Date.now() + Math.max(0, options.graceMs);
11812
- while (Date.now() < deadline && alive(pid)) await delay$1(50);
11813
- if (!alive(pid)) return "exited";
11990
+ while (Date.now() < deadline && isProcessAlive(pid)) await delay$1(50);
11991
+ if (!isProcessAlive(pid)) return "exited";
11814
11992
  signalPid(pid, "SIGKILL", options.killGroup);
11815
11993
  const hardDeadline = Date.now() + 2e3;
11816
- while (Date.now() < hardDeadline && alive(pid)) await delay$1(50);
11994
+ while (Date.now() < hardDeadline && isProcessAlive(pid)) await delay$1(50);
11817
11995
  return "force-killed";
11818
11996
  }
11819
11997
  function delay$1(ms) {
@@ -11837,6 +12015,7 @@ function waitForExit$1(child, timeoutMs) {
11837
12015
  var execFileAsync$1, WINDOWS_SHIM_EXTENSIONS;
11838
12016
  var init_process = __esmMin((() => {
11839
12017
  init_paths();
12018
+ init_port();
11840
12019
  execFileAsync$1 = promisify(execFile);
11841
12020
  WINDOWS_SHIM_EXTENSIONS = [
11842
12021
  ".cmd",
@@ -12653,6 +12832,7 @@ var init_supervisor = __esmMin((() => {
12653
12832
  nannyExit: null,
12654
12833
  starting: false,
12655
12834
  stopping: false,
12835
+ removed: false,
12656
12836
  bootstrapDone: !config.bootstrap,
12657
12837
  logs: new LogBuffer(config.logBufferLines),
12658
12838
  echoed: /* @__PURE__ */ new Set(),
@@ -12671,6 +12851,7 @@ var init_supervisor = __esmMin((() => {
12671
12851
  this.relay.unfollow(id);
12672
12852
  this.options.logFiles.clear(id);
12673
12853
  this.options.history.forget(id);
12854
+ entry.removed = true;
12674
12855
  this.stopEntry(entry).catch((error) => {
12675
12856
  logger.error(`could not stop the removed server ${id}`, error);
12676
12857
  });
@@ -12856,7 +13037,7 @@ var init_supervisor = __esmMin((() => {
12856
13037
  entry.responseMs = null;
12857
13038
  entry.exitCode = null;
12858
13039
  entry.exitSignal = null;
12859
- this.options.history.record(entry.config.id, {
13040
+ if (!entry.removed) this.options.history.record(entry.config.id, {
12860
13041
  type: "exit",
12861
13042
  detail,
12862
13043
  runtimeMs: ranForMs
@@ -13255,7 +13436,7 @@ var init_supervisor = __esmMin((() => {
13255
13436
  at: Date.now()
13256
13437
  });
13257
13438
  const ranForMs = nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
13258
- this.options.history.record(entry.config.id, {
13439
+ if (!entry.removed) this.options.history.record(entry.config.id, {
13259
13440
  type: "exit",
13260
13441
  detail,
13261
13442
  runtimeMs: ranForMs
@@ -13446,6 +13627,10 @@ var init_supervisor = __esmMin((() => {
13446
13627
  };
13447
13628
  }
13448
13629
  log(entry, stream, text) {
13630
+ if (entry.removed) {
13631
+ logger.debug(`[${entry.config.id}] ${text}`);
13632
+ return;
13633
+ }
13449
13634
  const line = {
13450
13635
  ts: Date.now(),
13451
13636
  stream,
@@ -15525,6 +15710,7 @@ var init_log_files = __esmMin((() => {
15525
15710
  return lines.slice(-tail);
15526
15711
  }
15527
15712
  clear(serverId) {
15713
+ this.pending.delete(serverId);
15528
15714
  for (const file of this.rotateTargets(serverId)) try {
15529
15715
  fs.rmSync(file, { force: true });
15530
15716
  } catch {}
@@ -16906,11 +17092,11 @@ function probeHostFor(bindHost) {
16906
17092
  async function runControlPlane(options) {
16907
17093
  ensureLayout();
16908
17094
  const existing = readRuntime();
16909
- if (existing !== null && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
17095
+ if (existing !== null && existing.pid !== process.pid && await runtimeIsAlive(existing)) {
16910
17096
  logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
16911
17097
  process.exit(1);
16912
17098
  }
16913
- if (existing !== null) clearRuntime();
17099
+ if (existing !== null && await runtimeIsGone(existing)) clearRuntime();
16914
17100
  const settings = new GlobalSettingsStore();
16915
17101
  settings.load();
16916
17102
  settings.writeJsonSchema();
@@ -17265,17 +17451,17 @@ async function runUp(flags, entry) {
17265
17451
  });
17266
17452
  return;
17267
17453
  }
17268
- const { clearRuntime, isProcessAlive, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17454
+ const { clearRuntime, readRuntime, runtimeIsAlive, runtimeIsGone } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17269
17455
  const { daemonLogPath, dataRoot, projectDir } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17270
17456
  const existing = readRuntime();
17271
- if (existing !== null && isProcessAlive(existing.pid)) {
17457
+ if (existing !== null && await runtimeIsAlive(existing)) {
17272
17458
  process.stdout.write(`${green("already running")} home-hosted ${existing.version} (pid ${existing.pid}) at ${existing.url}\n`);
17273
17459
  const note = versionMismatchNote(existing.version);
17274
17460
  if (note !== null) process.stdout.write(`${dim(note)}\n`);
17275
17461
  process.stdout.write(`${dim("stop it with `home-hosted down`")}\n`);
17276
17462
  return;
17277
17463
  }
17278
- if (existing !== null) clearRuntime();
17464
+ if (existing !== null && await runtimeIsGone(existing)) clearRuntime();
17279
17465
  fs.mkdirSync(path.dirname(daemonLogPath), { recursive: true });
17280
17466
  rotateLog(daemonLogPath);
17281
17467
  const log = fs.openSync(daemonLogPath, "a");
@@ -17383,13 +17569,14 @@ var down_exports = /* @__PURE__ */ __exportAll({
17383
17569
  });
17384
17570
  /** `down` stops the panel and everything it supervises. */
17385
17571
  async function runDown() {
17386
- const { clearRuntime, isProcessAlive, readRuntime, requestShutdown } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17572
+ const { clearRuntime, isLiveLiveness, readRuntime, requestShutdown, runtimeLiveness } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17387
17573
  const runtime = readRuntime();
17388
17574
  if (runtime === null) {
17389
17575
  process.stdout.write("home-hosted is not running\n");
17390
17576
  return;
17391
17577
  }
17392
- if (!isProcessAlive(runtime.pid)) {
17578
+ const liveness = await runtimeLiveness(runtime);
17579
+ if (liveness === "gone") {
17393
17580
  clearRuntime();
17394
17581
  process.stdout.write(`home-hosted is not running (removed a stale run.json from home-hosted ${runtime.version})\n`);
17395
17582
  return;
@@ -17400,8 +17587,21 @@ async function runDown() {
17400
17587
  const note = versionMismatchNote(runtime.version);
17401
17588
  if (note !== null) process.stdout.write(`${dim(note)}\n`);
17402
17589
  };
17403
- process.stdout.write(`stopping pid ${runtime.pid}…\n`);
17404
- if (!await requestShutdown(runtime)) signal(runtime.pid, "SIGTERM");
17590
+ let answered = false;
17591
+ if (isLiveLiveness(liveness)) {
17592
+ process.stdout.write(`stopping pid ${runtime.pid}…\n`);
17593
+ answered = await requestShutdown(runtime);
17594
+ if (!answered) signal(runtime.pid, "SIGTERM");
17595
+ } else {
17596
+ if (!await requestShutdown(runtime)) {
17597
+ process.stdout.write(`home-hosted is not running (the pid in run.json, ${runtime.pid}, belongs to another process — it was not signalled)\n`);
17598
+ return;
17599
+ }
17600
+ process.stdout.write(`stopped the panel on ${runtime.url} (its pid in run.json, ${runtime.pid}, was another process and was left alone)\n`);
17601
+ clearRuntime();
17602
+ await reportPersistent();
17603
+ return;
17604
+ }
17405
17605
  if (await waitForExit(runtime.pid, 2e4)) {
17406
17606
  clearRuntime();
17407
17607
  reportStopped(false);
@@ -17490,10 +17690,10 @@ var server_exports = /* @__PURE__ */ __exportAll({
17490
17690
  stopCommand: () => stopCommand
17491
17691
  });
17492
17692
  async function runServerAction(action, id, workspace) {
17493
- const { clearRuntime, isProcessAlive, readRuntime, requestControl } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17693
+ const { clearRuntime, readRuntime, requestControl, runtimeIsAlive, runtimeIsGone } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17494
17694
  const runtime = readRuntime();
17495
- if (runtime === null || !isProcessAlive(runtime.pid)) {
17496
- if (runtime !== null) clearRuntime();
17695
+ if (runtime === null || !await runtimeIsAlive(runtime)) {
17696
+ if (runtime !== null && await runtimeIsGone(runtime)) clearRuntime();
17497
17697
  fail("home-hosted is not running — start it with `home-hosted up`");
17498
17698
  }
17499
17699
  const query = workspace === void 0 || workspace.length === 0 ? "" : `?workspace=${encodeURIComponent(workspace)}`;
@@ -17590,7 +17790,7 @@ var status_exports = /* @__PURE__ */ __exportAll({
17590
17790
  statusCommand: () => statusCommand
17591
17791
  });
17592
17792
  async function runStatus(json) {
17593
- const { isProcessAlive, probeRuntime, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17793
+ const { isLiveLiveness, probeRuntime, readRuntime, runtimeLiveness } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17594
17794
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
17595
17795
  const { hhDir: dataRoot, workspaceDir, DEFAULT_WORKSPACE_ID, workspacesPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17596
17796
  const runtime = readRuntime();
@@ -17605,7 +17805,8 @@ async function runStatus(json) {
17605
17805
  process.exitCode = 1;
17606
17806
  return;
17607
17807
  }
17608
- const running = isProcessAlive(runtime.pid);
17808
+ const liveness = await runtimeLiveness(runtime);
17809
+ const running = isLiveLiveness(liveness);
17609
17810
  const probe = running ? await probeRuntime(runtime) : {
17610
17811
  reachable: false,
17611
17812
  degraded: false
@@ -17617,6 +17818,7 @@ async function runStatus(json) {
17617
17818
  const { token: _token, ...safe } = runtime;
17618
17819
  process.stdout.write(`${JSON.stringify({
17619
17820
  running,
17821
+ liveness,
17620
17822
  answering: probe.reachable,
17621
17823
  degraded: probe.degraded,
17622
17824
  ...safe,
@@ -17632,7 +17834,7 @@ async function runStatus(json) {
17632
17834
  }
17633
17835
  const uptime = formatDuration(Date.now() - runtime.startedAt);
17634
17836
  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")],
17837
+ ["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
17838
  ["pid", running ? `${runtime.pid} · up ${uptime}` : String(runtime.pid)],
17637
17839
  ["url", `${runtime.url} ${dim(`(${runtime.protocol})`)}`],
17638
17840
  ["version", runtime.version],