home-hosted 0.6.2 → 0.6.4

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/dist/cli.js CHANGED
@@ -263,6 +263,43 @@ var init_io = __esmMin((() => {
263
263
  };
264
264
  }));
265
265
  //#endregion
266
+ //#region src/helpers/runtime.ts
267
+ /**
268
+ * How to run this CLI again in the same runtime. Under tsx that means passing the
269
+ * resolved loader too, because a spawned child's working directory is the project's,
270
+ * not the package's. Shared by `up` (which re-spawns itself detached) and by the
271
+ * panel (which spawns a persistent entry's nanny).
272
+ *
273
+ * Nothing here reads a state directory, which is why the CLI can import it before
274
+ * `--home`/`--project` have been applied.
275
+ */
276
+ function runtimeArgs() {
277
+ let resolved = null;
278
+ const resolveTsx = () => resolved ??= import.meta.resolve("tsx");
279
+ return process.execArgv.map((arg) => {
280
+ if (arg === "tsx") return resolveTsx();
281
+ if (arg.startsWith("--import=") && arg.slice(9) === "tsx") return `--import=${resolveTsx()}`;
282
+ return arg;
283
+ });
284
+ }
285
+ function nannyArgv(entry, id, specPath, statePath) {
286
+ return [
287
+ ...runtimeArgs(),
288
+ entry,
289
+ NANNY_COMMAND,
290
+ "--id",
291
+ id,
292
+ "--spec",
293
+ specPath,
294
+ "--state",
295
+ statePath
296
+ ];
297
+ }
298
+ var NANNY_COMMAND;
299
+ var init_runtime = __esmMin((() => {
300
+ NANNY_COMMAND = "__nanny";
301
+ }));
302
+ //#endregion
266
303
  //#region src/helpers/cookies.ts
267
304
  function parseCookies(header) {
268
305
  const cookies = {};
@@ -304,7 +341,7 @@ function parseBind(value) {
304
341
  const parsed = bindSchema(value);
305
342
  return parsed instanceof type.errors ? null : parsed;
306
343
  }
307
- var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
344
+ var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
308
345
  var init_contracts = __esmMin((() => {
309
346
  bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
310
347
  portSchema = type("1 <= number.integer <= 65535 | null");
@@ -368,6 +405,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
368
405
  label: "string?",
369
406
  enabled: "boolean = true",
370
407
  autostart: "boolean = false",
408
+ /**
409
+ * Handed to a nanny process that owns the pipes, so the entry keeps running when
410
+ * this panel stops, restarts or is killed. `down`/`stop-all` leave it alone and
411
+ * report it; only an explicit stop or restart stops it.
412
+ */
413
+ persistent: "boolean = false",
371
414
  command: "string >= 1",
372
415
  args: type("string[]").default(() => []),
373
416
  cwd: "string = \".\"",
@@ -562,6 +605,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
562
605
  label: "string?",
563
606
  enabled: "boolean?",
564
607
  autostart: "boolean?",
608
+ persistent: "boolean?",
565
609
  command: "string?",
566
610
  args: "string[]?",
567
611
  cwd: "string?",
@@ -587,6 +631,34 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
587
631
  ...editableFields,
588
632
  command: "string"
589
633
  }).onUndeclaredKey("reject");
634
+ nannySpecSchema = type({
635
+ serverId: "string",
636
+ command: "string",
637
+ args: "string[]",
638
+ cwd: "string",
639
+ env: "Record<string, string>",
640
+ /** Directory of the JSONL the nanny writes; `<dir>/<id>.log`, exactly as the panel names it. */
641
+ logDir: "string",
642
+ /** The same retention the panel applies, so the nanny rotates identically. */
643
+ logs: logsSchema,
644
+ /** How the nanny stops the child it owns. */
645
+ stop: stopSchema
646
+ }).onUndeclaredKey("reject");
647
+ nannyExitSchema = type({
648
+ code: "number | null",
649
+ signal: "string | null",
650
+ at: "number",
651
+ runtimeMs: "number"
652
+ }).onUndeclaredKey("reject");
653
+ nannyStateSchema = type({
654
+ "serverId": "string",
655
+ "nannyPid": "number",
656
+ "childPid": "number | null",
657
+ "startedAt": "number",
658
+ "logFile": "string",
659
+ "heartbeatAt": "number",
660
+ "lastExit?": nannyExitSchema
661
+ }).onUndeclaredKey("reject");
590
662
  settingsPatchSchema = type({
591
663
  control: controlPatchSchema.optional(),
592
664
  defaults: defaultsPatchSchema.optional(),
@@ -1744,6 +1816,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
1744
1816
  defaultConfigPath: () => defaultConfigPath,
1745
1817
  defaultHistoryPath: () => defaultHistoryPath,
1746
1818
  defaultLogsDir: () => defaultLogsDir,
1819
+ defaultNannyDir: () => defaultNannyDir,
1747
1820
  defaultSecretsPath: () => defaultSecretsPath,
1748
1821
  defaultTlsDir: () => defaultTlsDir,
1749
1822
  projectDir: () => projectDir,
@@ -1781,7 +1854,7 @@ function resolveUserPath(target, base = projectDir) {
1781
1854
  else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
1782
1855
  return path.isAbsolute(value) ? value : path.resolve(base, value);
1783
1856
  }
1784
- var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, runtimePath, daemonLogPath;
1857
+ var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, runtimePath, daemonLogPath;
1785
1858
  var init_paths = __esmMin((() => {
1786
1859
  dataRoot = resolveDataRoot();
1787
1860
  projectDir = resolveProjectDir();
@@ -1791,6 +1864,7 @@ var init_paths = __esmMin((() => {
1791
1864
  defaultLogsDir = path.join(dataRoot, ".logs");
1792
1865
  defaultHistoryPath = path.join(dataRoot, ".logs", "history.json");
1793
1866
  defaultTlsDir = path.join(dataRoot, ".tls");
1867
+ defaultNannyDir = path.join(dataRoot, ".state");
1794
1868
  runtimePath = path.join(dataRoot, "run.json");
1795
1869
  daemonLogPath = path.join(dataRoot, ".logs", "home-hosted.log");
1796
1870
  }));
@@ -4615,6 +4689,33 @@ function collectTree(rootPid, children) {
4615
4689
  return pids;
4616
4690
  }
4617
4691
  /**
4692
+ * The roots and every descendant of theirs, from one scan of the process table.
4693
+ *
4694
+ * Ownership has to mean the *tree*: a persistent entry runs under a nanny, and a
4695
+ * wrapper entry spawns the real server one generation down. Both keep the pid the
4696
+ * panel recorded, but the process holding the port is a descendant of it — and a
4697
+ * descendant classified as a stranger is one `kill`/`free-port` away from stopping
4698
+ * a server this panel is responsible for.
4699
+ *
4700
+ * A backend that cannot run answers with the roots alone, which is the old,
4701
+ * narrower behaviour rather than a wrong one.
4702
+ */
4703
+ async function processTreePids(roots) {
4704
+ const pids = new Set(roots);
4705
+ if (roots.length === 0) return pids;
4706
+ try {
4707
+ const rows = await readProcesses();
4708
+ const children = /* @__PURE__ */ new Map();
4709
+ for (const row of rows) {
4710
+ const siblings = children.get(row.ppid) ?? [];
4711
+ siblings.push(row.pid);
4712
+ children.set(row.ppid, siblings);
4713
+ }
4714
+ for (const root of roots) for (const pid of collectTree(root, children)) pids.add(pid);
4715
+ } catch {}
4716
+ return pids;
4717
+ }
4718
+ /**
4618
4719
  * Does this process carry the environment the supervisor gave the entry?
4619
4720
  *
4620
4721
  * A program that restarts itself — especially a plugin doing it — leaves behind a
@@ -4940,6 +5041,130 @@ var init_identity = __esmMin((() => {
4940
5041
  windowsFilter = /^\d+$/;
4941
5042
  }));
4942
5043
  //#endregion
5044
+ //#region src/providers/nanny.ts
5045
+ var nanny_exports$2 = /* @__PURE__ */ __exportAll({
5046
+ NANNY_HEARTBEAT_MS: () => NANNY_HEARTBEAT_MS,
5047
+ SPEC_SUFFIX: () => SPEC_SUFFIX,
5048
+ clearNannySpec: () => clearNannySpec,
5049
+ clearNannyState: () => clearNannyState,
5050
+ describeNannyExit: () => describeNannyExit,
5051
+ nannyEntryPoint: () => nannyEntryPoint,
5052
+ nannyIsAlive: () => nannyIsAlive,
5053
+ nannyLogFile: () => nannyLogFile,
5054
+ nannySpecPath: () => nannySpecPath,
5055
+ nannyStatePath: () => nannyStatePath,
5056
+ readNannyState: () => readNannyState,
5057
+ sweepNannySpecs: () => sweepNannySpecs,
5058
+ takeNannySpec: () => takeNannySpec,
5059
+ writeNannySpec: () => writeNannySpec,
5060
+ writeNannyState: () => writeNannyState
5061
+ });
5062
+ /**
5063
+ * The state directory is passed in rather than read from a global: the panel injects
5064
+ * `$HHOSTED_HOME/.state`, and a test injects its own temp directory.
5065
+ */
5066
+ function nannySpecPath(dir, id) {
5067
+ return path.join(dir, `${id}${SPEC_SUFFIX}`);
5068
+ }
5069
+ function nannyStatePath(dir, id) {
5070
+ return path.join(dir, `${id}.json`);
5071
+ }
5072
+ /** The file a nanny writes and the panel tails; one derivation, so they cannot drift. */
5073
+ function nannyLogFile(logDir, id) {
5074
+ return path.join(logDir, `${id}.log`);
5075
+ }
5076
+ /**
5077
+ * How to run this CLI again as a nanny. The panel was itself launched through this
5078
+ * entry point (`up` re-spawns itself with it), so `process.argv[1]` is the one path
5079
+ * that works under tsx, from `dist/cli.js` and through the published bin alike.
5080
+ */
5081
+ function nannyEntryPoint() {
5082
+ return process.argv[1] ?? "";
5083
+ }
5084
+ /** Written before the spawn, consumed by the nanny, never left behind. */
5085
+ function writeNannySpec(file, spec) {
5086
+ writeFileAtomic(file, JSON.stringify(spec), { mode: SPEC_MODE });
5087
+ }
5088
+ function clearNannySpec(file) {
5089
+ fs.rmSync(file, { force: true });
5090
+ }
5091
+ /**
5092
+ * Reads and unlinks in one step: the spec holds expanded secrets, and a spec left
5093
+ * behind — a nanny that died before reading it, a panel killed mid-spawn — is a
5094
+ * secret file with no owner. It is consumed even when it cannot be parsed.
5095
+ */
5096
+ function takeNannySpec(file) {
5097
+ const raw = fs.readFileSync(file, "utf8");
5098
+ fs.rmSync(file, { force: true });
5099
+ const parsed = nannySpecSchema(JSON.parse(raw));
5100
+ if (parsed instanceof type.errors) throw new Error(`invalid nanny spec: ${parsed.summary}`);
5101
+ return parsed;
5102
+ }
5103
+ /**
5104
+ * Nothing can be waiting on a spec before this panel has spawned a nanny, so every
5105
+ * one still on disk at boot belongs to a nanny that never read it.
5106
+ */
5107
+ function sweepNannySpecs(dir) {
5108
+ let swept = 0;
5109
+ for (const name of readSpecNames(dir)) {
5110
+ fs.rmSync(path.join(dir, name), { force: true });
5111
+ swept += 1;
5112
+ }
5113
+ return swept;
5114
+ }
5115
+ function readSpecNames(dir) {
5116
+ try {
5117
+ return fs.readdirSync(dir).filter((name) => name.endsWith(SPEC_SUFFIX));
5118
+ } catch {
5119
+ return [];
5120
+ }
5121
+ }
5122
+ /** A stale or foreign file reads as "no state", never as a throw. */
5123
+ function readNannyState(file) {
5124
+ try {
5125
+ const parsed = nannyStateSchema(JSON.parse(fs.readFileSync(file, "utf8")));
5126
+ return parsed instanceof type.errors ? null : parsed;
5127
+ } catch {
5128
+ return null;
5129
+ }
5130
+ }
5131
+ function writeNannyState(file, state) {
5132
+ writeFileAtomic(file, JSON.stringify(state));
5133
+ }
5134
+ function clearNannyState(file) {
5135
+ fs.rmSync(file, { force: true });
5136
+ }
5137
+ /** The same wording the supervisor uses for a child's exit, so history reads alike. */
5138
+ function describeNannyExit(exit) {
5139
+ return exit.signal !== null ? `signal ${exit.signal}` : `code ${exit.code}`;
5140
+ }
5141
+ /**
5142
+ * Is the nanny this state describes still ours? A live pid proves nothing on its
5143
+ * own — pids are reused. A fresh heartbeat means a process is still rewriting the
5144
+ * file; the environment marker (Linux, macOS) or the argv we spawned it with answers
5145
+ * everywhere else, Windows included.
5146
+ */
5147
+ async function nannyIsAlive(state, id) {
5148
+ if (state.nannyPid <= 0 || !isProcessAlive(state.nannyPid)) return false;
5149
+ if (Date.now() - state.heartbeatAt <= HEARTBEAT_STALE_MS) return true;
5150
+ if (await processCarriesServerId(state.nannyPid, id)) return true;
5151
+ const words = await processArgv(state.nannyPid);
5152
+ return words !== null && words.includes("__nanny") && words.some((word) => word.endsWith(`${id}.spec.json`));
5153
+ }
5154
+ var SPEC_MODE, SPEC_SUFFIX, NANNY_HEARTBEAT_MS, HEARTBEAT_STALE_MS;
5155
+ var init_nanny$2 = __esmMin((() => {
5156
+ init_atomic();
5157
+ init_runtime();
5158
+ init_identity();
5159
+ init_port();
5160
+ init_proc();
5161
+ init_contracts();
5162
+ SPEC_MODE = 384;
5163
+ SPEC_SUFFIX = ".spec.json";
5164
+ NANNY_HEARTBEAT_MS = 5e3;
5165
+ HEARTBEAT_STALE_MS = 3e4;
5166
+ }));
5167
+ //#endregion
4943
5168
  //#region src/providers/process.ts
4944
5169
  /**
4945
5170
  * Resolves a bare command through the entry's own directory and the project's
@@ -4963,14 +5188,14 @@ function resolveCwd(cwd, base = projectDir) {
4963
5188
  function needsShell(command) {
4964
5189
  return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
4965
5190
  }
4966
- function spawnManaged(spec) {
5191
+ function spawnManaged(spec, options = {}) {
4967
5192
  return spawn(spec.command, spec.args, {
4968
5193
  cwd: spec.cwd,
4969
5194
  env: {
4970
5195
  ...process.env,
4971
5196
  ...spec.env
4972
5197
  },
4973
- detached: true,
5198
+ detached: options.detached ?? true,
4974
5199
  stdio: [
4975
5200
  "ignore",
4976
5201
  "pipe",
@@ -5174,6 +5399,230 @@ var init_log_buffer = __esmMin((() => {
5174
5399
  };
5175
5400
  }));
5176
5401
  //#endregion
5402
+ //#region src/providers/log-tail.ts
5403
+ /**
5404
+ * Cheap structural guard rather than a full schema pass: this runs per line on a hot
5405
+ * path, and the writer is our own nanny (which serializes `LogLine` unchanged).
5406
+ */
5407
+ function parseLogLine(raw) {
5408
+ try {
5409
+ const value = JSON.parse(raw);
5410
+ if (typeof value.ts !== "number" || typeof value.text !== "string" || typeof value.stream !== "string") return null;
5411
+ if (!LOG_STREAMS.has(value.stream)) return null;
5412
+ return {
5413
+ ts: value.ts,
5414
+ stream: value.stream,
5415
+ text: value.text
5416
+ };
5417
+ } catch {
5418
+ return null;
5419
+ }
5420
+ }
5421
+ var MAX_BYTES_PER_READ, MAX_TAIL_BYTES, MAX_CARRY_CHARS, LOG_STREAMS, LogTailer;
5422
+ var init_log_tail = __esmMin((() => {
5423
+ MAX_BYTES_PER_READ = 262144;
5424
+ MAX_TAIL_BYTES = 262144;
5425
+ MAX_CARRY_CHARS = 1048576;
5426
+ LOG_STREAMS = /* @__PURE__ */ new Set([
5427
+ "stdout",
5428
+ "stderr",
5429
+ "system"
5430
+ ]);
5431
+ LogTailer = class {
5432
+ file;
5433
+ offset = 0;
5434
+ identity = null;
5435
+ carry = "";
5436
+ constructor(file) {
5437
+ this.file = file;
5438
+ }
5439
+ /** Bytes consumed so far; equal to the file size once it has been drained. */
5440
+ get position() {
5441
+ return this.offset;
5442
+ }
5443
+ /** Start from the beginning again — used when a fresh process takes the entry over. */
5444
+ reset() {
5445
+ this.offset = 0;
5446
+ this.identity = null;
5447
+ this.carry = "";
5448
+ }
5449
+ /**
5450
+ * The last `count` lines, with the read position moved to the end of the file in the
5451
+ * same operation.
5452
+ *
5453
+ * A panel attaching to a running entry wants both a backfill and a live stream, and
5454
+ * only one thing may own the offset. Reading the tail through the same tailer is what
5455
+ * keeps a line from being replayed as news or skipped as history — and it is why this
5456
+ * does not delegate to `LogFiles.readTail()`, which knows nothing of this position.
5457
+ */
5458
+ readTailLines(count) {
5459
+ let stats;
5460
+ try {
5461
+ stats = fs.statSync(this.file);
5462
+ } catch {
5463
+ return [];
5464
+ }
5465
+ const size = stats.size;
5466
+ this.carry = "";
5467
+ this.offset = size;
5468
+ this.identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
5469
+ if (size === 0 || count <= 0) return [];
5470
+ const start = Math.max(0, size - MAX_TAIL_BYTES);
5471
+ let handle;
5472
+ try {
5473
+ handle = fs.openSync(this.file, "r");
5474
+ } catch {
5475
+ return [];
5476
+ }
5477
+ try {
5478
+ const buffer = Buffer$1.alloc(size - start);
5479
+ const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
5480
+ const raw = buffer.subarray(0, bytes).toString("utf8").split("\n").filter((line) => line.trim().length > 0);
5481
+ return (start > 0 ? raw.slice(1) : raw).slice(-count).map(parseLogLine).filter((line) => line !== null);
5482
+ } catch {
5483
+ return [];
5484
+ } finally {
5485
+ fs.closeSync(handle);
5486
+ }
5487
+ }
5488
+ /**
5489
+ * Ignore what is already in the file and follow it from here.
5490
+ */
5491
+ skipToEnd() {
5492
+ this.carry = "";
5493
+ try {
5494
+ const stats = fs.statSync(this.file);
5495
+ this.offset = stats.size;
5496
+ this.identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
5497
+ } catch {
5498
+ this.offset = 0;
5499
+ this.identity = null;
5500
+ }
5501
+ }
5502
+ /** Every complete line written since the previous call. */
5503
+ read() {
5504
+ let stats;
5505
+ try {
5506
+ stats = fs.statSync(this.file);
5507
+ } catch {
5508
+ return [];
5509
+ }
5510
+ const identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
5511
+ if (this.identity !== null && (identity !== this.identity || stats.size < this.offset)) {
5512
+ this.carry = "";
5513
+ this.offset = 0;
5514
+ }
5515
+ this.identity = identity;
5516
+ if (stats.size <= this.offset) return [];
5517
+ const lines = [];
5518
+ let handle;
5519
+ try {
5520
+ handle = fs.openSync(this.file, "r");
5521
+ } catch {
5522
+ return [];
5523
+ }
5524
+ try {
5525
+ const buffer = Buffer$1.alloc(Math.min(MAX_BYTES_PER_READ, stats.size - this.offset));
5526
+ while (this.offset < stats.size) {
5527
+ const length = Math.min(buffer.length, stats.size - this.offset);
5528
+ const bytes = fs.readSync(handle, buffer, 0, length, this.offset);
5529
+ if (bytes <= 0) break;
5530
+ this.offset += bytes;
5531
+ this.consume(buffer.subarray(0, bytes).toString("utf8"), lines);
5532
+ if (bytes < buffer.length) break;
5533
+ }
5534
+ } catch {} finally {
5535
+ fs.closeSync(handle);
5536
+ }
5537
+ return lines;
5538
+ }
5539
+ consume(text, lines) {
5540
+ if (this.carry.length === 0) this.carry = text;
5541
+ else this.carry += text;
5542
+ const parts = this.carry.split("\n");
5543
+ this.carry = parts.pop() ?? "";
5544
+ for (const part of parts) {
5545
+ if (part.trim().length === 0) continue;
5546
+ const line = parseLogLine(part);
5547
+ if (line !== null) lines.push(line);
5548
+ }
5549
+ if (this.carry.length > MAX_CARRY_CHARS) this.carry = "";
5550
+ }
5551
+ };
5552
+ }));
5553
+ //#endregion
5554
+ //#region src/services/log-relay.ts
5555
+ var TAIL_INTERVAL_MS, LogRelay;
5556
+ var init_log_relay = __esmMin((() => {
5557
+ init_logger();
5558
+ init_log_tail();
5559
+ TAIL_INTERVAL_MS = 250;
5560
+ LogRelay = class {
5561
+ options;
5562
+ followed = /* @__PURE__ */ new Map();
5563
+ timer = null;
5564
+ closed = false;
5565
+ constructor(options) {
5566
+ this.options = options;
5567
+ }
5568
+ /**
5569
+ * Idempotent: following the same file again keeps the position already read.
5570
+ *
5571
+ * With `backfill`, the last lines already on disk are returned *and* the live position
5572
+ * is set past them by the same tailer, so the caller can fill a buffer without a line
5573
+ * being both history and news. Without it, the live stream simply starts at the end.
5574
+ */
5575
+ follow(serverId, file, options = {}) {
5576
+ if (this.closed) return [];
5577
+ const current = this.followed.get(serverId);
5578
+ if (current !== void 0 && current.file === file) return [];
5579
+ const tailer = new LogTailer(file);
5580
+ const backfill = options.backfill ?? 0;
5581
+ const history = backfill > 0 ? tailer.readTailLines(backfill) : [];
5582
+ if (backfill <= 0) tailer.skipToEnd();
5583
+ this.followed.set(serverId, {
5584
+ file,
5585
+ tailer
5586
+ });
5587
+ this.start();
5588
+ return history;
5589
+ }
5590
+ unfollow(serverId) {
5591
+ this.followed.delete(serverId);
5592
+ if (this.followed.size === 0) this.stopTimer();
5593
+ }
5594
+ followedIds() {
5595
+ return [...this.followed.keys()];
5596
+ }
5597
+ dispose() {
5598
+ this.closed = true;
5599
+ this.followed.clear();
5600
+ this.stopTimer();
5601
+ }
5602
+ start() {
5603
+ if (this.timer !== null) return;
5604
+ this.timer = setInterval(() => {
5605
+ try {
5606
+ this.poll();
5607
+ } catch (error) {
5608
+ logger.warn(`tail failed: ${error instanceof Error ? error.message : String(error)}`);
5609
+ }
5610
+ }, this.options.intervalMs ?? TAIL_INTERVAL_MS);
5611
+ this.timer.unref();
5612
+ }
5613
+ stopTimer() {
5614
+ if (this.timer !== null) clearInterval(this.timer);
5615
+ this.timer = null;
5616
+ }
5617
+ poll() {
5618
+ for (const [serverId, { tailer }] of this.followed) {
5619
+ const lines = tailer.read();
5620
+ if (lines.length > 0) this.options.onLines(serverId, lines);
5621
+ }
5622
+ }
5623
+ };
5624
+ }));
5625
+ //#endregion
5177
5626
  //#region src/services/supervisor.ts
5178
5627
  function delay(ms) {
5179
5628
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -5194,32 +5643,41 @@ function serverTemplateVars(config) {
5194
5643
  home: os.homedir()
5195
5644
  };
5196
5645
  }
5197
- var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, Supervisor;
5646
+ var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, Supervisor;
5198
5647
  var init_supervisor = __esmMin((() => {
5199
5648
  init_backoff();
5200
5649
  init_bind();
5201
5650
  init_env_file();
5202
5651
  init_logger();
5203
5652
  init_paths();
5653
+ init_runtime();
5204
5654
  init_template();
5205
5655
  init_health_check();
5206
5656
  init_identity();
5657
+ init_nanny$2();
5207
5658
  init_port();
5208
5659
  init_proc();
5209
5660
  init_process();
5210
5661
  init_dependencies();
5211
5662
  init_log_buffer();
5663
+ init_log_relay();
5212
5664
  HISTORY_WINDOW_MS = 864e5;
5213
5665
  HISTORY_CACHE_MS = 5e3;
5214
5666
  TICK_INTERVAL_MS = 1e3;
5215
5667
  PORT_STATE_INTERVAL_MS = 1e4;
5216
5668
  PORT_RELEASE_RECHECK_MS = 300;
5217
5669
  RESOURCE_SAMPLE_INTERVAL_MS = 5e3;
5670
+ PERSISTENT_BACKFILL_LINES = 200;
5218
5671
  Supervisor = class {
5219
5672
  store;
5220
5673
  hub;
5221
5674
  options;
5222
5675
  sampler = new ProcessSampler();
5676
+ /**
5677
+ * Live output of persistent entries. A normal entry's logs arrive down the pipe the
5678
+ * panel owns; a persistent one's are written by its nanny, so they are read back.
5679
+ */
5680
+ relay = new LogRelay({ onLines: (serverId, lines) => this.ingestExternalLines(serverId, lines) });
5223
5681
  entries = /* @__PURE__ */ new Map();
5224
5682
  tickTimer;
5225
5683
  disposed = false;
@@ -5228,6 +5686,8 @@ var init_supervisor = __esmMin((() => {
5228
5686
  this.store = store;
5229
5687
  this.hub = hub;
5230
5688
  this.options = options;
5689
+ const swept = sweepNannySpecs(this.options.nannyDir);
5690
+ if (swept > 0) logger.warn(`removed ${swept} unread nanny spec file(s) from ${this.options.nannyDir}`);
5231
5691
  this.sync();
5232
5692
  this.store.onChange(() => this.sync());
5233
5693
  this.tickTimer = setInterval(() => {
@@ -5245,12 +5705,38 @@ var init_supervisor = __esmMin((() => {
5245
5705
  return this.entries.get(id)?.logs.list(limit) ?? [];
5246
5706
  }
5247
5707
  async startAll(options = {}) {
5708
+ for (const entry of [...this.entries.values()]) {
5709
+ if (!entry.config.persistent || this.isActive(entry)) continue;
5710
+ if (entry.config.enabled) {
5711
+ await this.resumePersistent(entry).catch((error) => {
5712
+ logger.error(`could not reattach the persistent server ${entry.config.id}`, error);
5713
+ });
5714
+ continue;
5715
+ }
5716
+ try {
5717
+ if (await this.resumePersistent(entry)) await this.stop(entry.config.id);
5718
+ } catch (error) {
5719
+ logger.error(`could not stop the disabled persistent server ${entry.config.id}`, error);
5720
+ }
5721
+ }
5248
5722
  const targets = [...this.entries.values()].filter((entry) => !options.autostartOnly || entry.config.autostart).map((entry) => entry.config);
5249
5723
  for (const config of orderByDependencies(targets)) await this.start(config.id);
5250
5724
  }
5725
+ /**
5726
+ * Stops every entry this command owns. A persistent entry is deliberately not among
5727
+ * them: that is the whole point of the flag, and it is said out loud rather than
5728
+ * silently skipped, so "stopped everything" is never claimed about a running server.
5729
+ */
5251
5730
  async stopAll() {
5252
5731
  const targets = orderByDependencies([...this.entries.values()].map((entry) => entry.config)).reverse();
5253
- for (const config of targets) await this.stop(config.id);
5732
+ for (const config of targets) {
5733
+ const entry = this.entries.get(config.id);
5734
+ if (entry !== void 0 && entry.config.persistent) {
5735
+ if (this.isActive(entry)) this.log(entry, "system", "persistent: left running (stop it explicitly to end it)");
5736
+ continue;
5737
+ }
5738
+ await this.stop(config.id);
5739
+ }
5254
5740
  }
5255
5741
  async start(id, options = {}) {
5256
5742
  const entry = this.entries.get(id);
@@ -5284,6 +5770,7 @@ var init_supervisor = __esmMin((() => {
5284
5770
  entry.status = "starting";
5285
5771
  entry.health = entry.config.health.enabled ? "unknown" : "disabled";
5286
5772
  this.publishServer(entry);
5773
+ if (entry.config.persistent && await this.resumePersistent(entry)) return { ok: true };
5287
5774
  await this.runBootstrap(entry);
5288
5775
  if (entry.stopping) return {
5289
5776
  ok: false,
@@ -5396,17 +5883,32 @@ var init_supervisor = __esmMin((() => {
5396
5883
  * in an old banner from killing a recycled pid.
5397
5884
  */
5398
5885
  async portHolders(port) {
5399
- const supervised = this.supervisedPids();
5886
+ const supervised = await this.supervisedPids();
5400
5887
  const holders = await listPortHolders(port);
5401
5888
  return {
5402
5889
  ours: holders.filter((pid) => supervised.has(pid)),
5403
5890
  foreign: holders.filter((pid) => !supervised.has(pid))
5404
5891
  };
5405
5892
  }
5406
- /** Pids of the child processes this panel owns, plus itself. */
5407
- supervisedPids() {
5408
- const pids = /* @__PURE__ */ new Set([process.pid]);
5409
- for (const entry of this.entries.values()) if (entry.pid !== null) pids.add(entry.pid);
5893
+ /**
5894
+ * Pids of the processes this panel owns, plus itself — the whole *tree* of every
5895
+ * entry, not just the pid it recorded.
5896
+ *
5897
+ * Both kinds of indirection are covered by that: a persistent entry runs under a
5898
+ * nanny, and a wrapper entry spawns the real server one generation down. The process
5899
+ * holding the port is a descendant in both cases, and a descendant mistaken for a
5900
+ * stranger is one `kill`/`free-port` away from stopping a server we own.
5901
+ *
5902
+ * The panel's own tree is deliberately *not* walked: "ours" has to mean a process a
5903
+ * server owns, or anything the panel happened to spawn would be unkillable and
5904
+ * unattributable.
5905
+ */
5906
+ async supervisedPids() {
5907
+ const roots = [];
5908
+ for (const entry of this.entries.values()) if (entry.pid !== null) roots.push(entry.pid);
5909
+ else if (entry.child?.pid !== void 0) roots.push(entry.child.pid);
5910
+ const pids = await processTreePids(roots);
5911
+ pids.add(process.pid);
5410
5912
  return pids;
5411
5913
  }
5412
5914
  /** The port's own answer, with the same short recheck the preflight uses. */
@@ -5428,7 +5930,8 @@ var init_supervisor = __esmMin((() => {
5428
5930
  this.disposed = true;
5429
5931
  clearInterval(this.tickTimer);
5430
5932
  for (const entry of this.entries.values()) this.clearRetry(entry);
5431
- await Promise.all([...this.entries.values()].map((entry) => this.stopEntry(entry)));
5933
+ await Promise.all([...this.entries.values()].filter((entry) => !entry.config.persistent).map((entry) => this.stopEntry(entry)));
5934
+ this.relay.dispose();
5432
5935
  }
5433
5936
  /**
5434
5937
  * Starts whatever this server depends on and waits for it to accept
@@ -5497,20 +6000,38 @@ var init_supervisor = __esmMin((() => {
5497
6000
  }
5498
6001
  entry.status = "stopping";
5499
6002
  this.publishServer(entry);
6003
+ const nannyState = entry.config.persistent ? readNannyState(nannyStatePath(this.options.nannyDir, entry.config.id)) : null;
5500
6004
  if ((entry.child === null && entry.pid !== null ? await terminatePid(entry.pid, entry.config.stop) : await terminate(entry.child, entry.config.stop)) === "force-killed") this.log(entry, "system", "force-killed after grace period");
6005
+ if (nannyState?.childPid != null && !entry.config.stop.killGroup) await terminatePid(nannyState.childPid, {
6006
+ ...entry.config.stop,
6007
+ killGroup: false
6008
+ });
5501
6009
  const { port, stop } = entry.config;
5502
6010
  if (stop.killPortHolders && port !== null) {
5503
- const leftover = await killPortHolders(port, this.supervisedPids());
6011
+ const leftover = await killPortHolders(port, await this.supervisedPids());
5504
6012
  if (leftover.length > 0) this.log(entry, "system", `port ${port} was still held by pid ${leftover.join(", ")} — killed`);
5505
6013
  }
6014
+ const survivor = entry.config.persistent && entry.pid !== null && isProcessAlive(entry.pid) ? entry.pid : null;
6015
+ if (entry.config.persistent) {
6016
+ if (survivor === null) {
6017
+ clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
6018
+ clearNannySpec(nannySpecPath(this.options.nannyDir, entry.config.id));
6019
+ } else this.log(entry, "system", `pid ${survivor} is still alive after the stop — its state file is kept`);
6020
+ this.relay.unfollow(entry.config.id);
6021
+ }
5506
6022
  entry.stopping = false;
5507
6023
  entry.child = null;
5508
6024
  entry.pid = null;
5509
6025
  entry.adopted = false;
5510
- entry.status = "stopped";
5511
- this.log(entry, "system", "stopped");
6026
+ entry.nannyExit = null;
6027
+ entry.status = survivor === null ? "stopped" : "conflict";
6028
+ if (survivor !== null) entry.lastError = `pid ${survivor} survived the stop`;
6029
+ this.log(entry, "system", survivor === null ? "stopped" : entry.lastError);
5512
6030
  this.publishServer(entry);
5513
- return { ok: true };
6031
+ return survivor === null ? { ok: true } : {
6032
+ ok: false,
6033
+ error: entry.lastError
6034
+ };
5514
6035
  }
5515
6036
  createEntry(config) {
5516
6037
  return {
@@ -5533,6 +6054,7 @@ var init_supervisor = __esmMin((() => {
5533
6054
  lastOccupancyProbeAt: 0,
5534
6055
  probing: false,
5535
6056
  adopted: false,
6057
+ nannyExit: null,
5536
6058
  starting: false,
5537
6059
  stopping: false,
5538
6060
  bootstrapDone: !config.bootstrap,
@@ -5549,6 +6071,7 @@ var init_supervisor = __esmMin((() => {
5549
6071
  const config = wanted.get(id);
5550
6072
  if (!config) {
5551
6073
  this.entries.delete(id);
6074
+ this.relay.unfollow(id);
5552
6075
  this.stopEntry(entry).catch((error) => {
5553
6076
  logger.error(`could not stop the removed server ${id}`, error);
5554
6077
  });
@@ -5556,6 +6079,7 @@ var init_supervisor = __esmMin((() => {
5556
6079
  }
5557
6080
  const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
5558
6081
  entry.config = config;
6082
+ if (!config.persistent) this.relay.unfollow(id);
5559
6083
  if (bufferChanged) {
5560
6084
  const kept = entry.logs.list(config.logBufferLines);
5561
6085
  entry.logs = new LogBuffer(config.logBufferLines);
@@ -5701,6 +6225,11 @@ var init_supervisor = __esmMin((() => {
5701
6225
  * Takes over a detached successor: no spawn, no duplicate. The pid is supervised
5702
6226
  * from here on (liveness, health probe, resources, stop), while its output stays
5703
6227
  * wherever it was redirected.
6228
+ *
6229
+ * For a persistent entry that output is not a mystery: its nanny is still writing
6230
+ * the entry's log file, so the relay is started here too. Without this, adopting a
6231
+ * persistent entry whose state file was lost would show its history but never
6232
+ * another live line.
5704
6233
  */
5705
6234
  adoptEntry(entry, pid) {
5706
6235
  entry.adopted = true;
@@ -5711,13 +6240,17 @@ var init_supervisor = __esmMin((() => {
5711
6240
  entry.startedAt = Date.now();
5712
6241
  entry.lastError = null;
5713
6242
  this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
6243
+ if (entry.config.persistent) this.followPersistent(entry);
5714
6244
  this.publishServer(entry);
5715
6245
  return { ok: true };
5716
6246
  }
5717
6247
  /** An adopted successor disappeared: fall back to the normal lifecycle. */
5718
6248
  handleAdoptedExit(entry) {
5719
6249
  if (entry.pid !== null) this.sampler.forget(entry.pid);
5720
- const ranForMs = entry.startedAt === null ? 0 : Date.now() - entry.startedAt;
6250
+ if (entry.config.persistent) this.consumeNannyExit(entry);
6251
+ const ranForMs = entry.nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
6252
+ const detail = entry.nannyExit === null ? "adopted process exited" : describeNannyExit(entry.nannyExit);
6253
+ entry.nannyExit = null;
5721
6254
  entry.adopted = false;
5722
6255
  entry.pid = null;
5723
6256
  entry.resources = null;
@@ -5726,11 +6259,11 @@ var init_supervisor = __esmMin((() => {
5726
6259
  entry.exitSignal = null;
5727
6260
  this.options.history.record(entry.config.id, {
5728
6261
  type: "exit",
5729
- detail: "adopted process exited",
6262
+ detail,
5730
6263
  runtimeMs: ranForMs
5731
6264
  });
5732
6265
  this.log(entry, "system", `the adopted process is gone after ${Math.max(1, Math.round(ranForMs / 1e3))}s`);
5733
- this.afterExit(entry, "adopted process exited", ranForMs, false);
6266
+ this.afterExit(entry, detail, ranForMs, false);
5734
6267
  }
5735
6268
  async runBootstrap(entry) {
5736
6269
  const spec = entry.config.bootstrap;
@@ -5818,6 +6351,9 @@ var init_supervisor = __esmMin((() => {
5818
6351
  };
5819
6352
  }
5820
6353
  spawnEntry(entry) {
6354
+ return entry.config.persistent ? this.spawnNanny(entry) : this.spawnDirect(entry);
6355
+ }
6356
+ spawnDirect(entry) {
5821
6357
  const { command, args, env, cwd, loggedArgs } = this.resolveSpawn(entry);
5822
6358
  this.log(entry, "system", `start: ${command} ${loggedArgs.join(" ")}`);
5823
6359
  let child;
@@ -5829,46 +6365,198 @@ var init_supervisor = __esmMin((() => {
5829
6365
  env
5830
6366
  });
5831
6367
  } catch (error) {
5832
- entry.status = "crashed";
5833
- entry.lastError = error.message;
5834
- this.log(entry, "system", `spawn failed: ${entry.lastError}`);
5835
- this.publishServer(entry);
5836
- return {
5837
- ok: false,
5838
- error: entry.lastError
5839
- };
6368
+ return this.failSpawn(entry, error.message);
5840
6369
  }
6370
+ const stdout = new LineSplitter((stream, text) => this.log(entry, stream, text));
6371
+ const stderr = new LineSplitter((stream, text) => this.log(entry, stream, text));
6372
+ child.stdout?.on("data", (chunk) => stdout.push("stdout", chunk));
6373
+ child.stderr?.on("data", (chunk) => stderr.push("stderr", chunk));
6374
+ this.attachChild(entry, child, `${command} ${args.join(" ")}`.trim(), () => {
6375
+ stdout.flush("stdout");
6376
+ stderr.flush("stderr");
6377
+ });
6378
+ return { ok: true };
6379
+ }
6380
+ /**
6381
+ * Starts a persistent entry: the panel spawns a *nanny*, which spawns the entry and
6382
+ * owns its pipes. From here the nanny is the child this supervisor tracks — its exit
6383
+ * event drives the normal lifecycle (backoff, history, notifications) — while the
6384
+ * real exit cause is read back from the state file the nanny leaves behind.
6385
+ */
6386
+ spawnNanny(entry) {
6387
+ const { command, args, env, cwd, loggedArgs } = this.resolveSpawn(entry);
6388
+ this.log(entry, "system", `start (persistent): ${command} ${loggedArgs.join(" ")}`);
6389
+ const entryPoint = nannyEntryPoint();
6390
+ if (entryPoint.length === 0) return this.failSpawn(entry, "cannot locate this CLI to start a persistent entry");
6391
+ const id = entry.config.id;
6392
+ const specPath = nannySpecPath(this.options.nannyDir, id);
6393
+ const statePath = nannyStatePath(this.options.nannyDir, id);
6394
+ const spec = {
6395
+ serverId: id,
6396
+ command,
6397
+ args,
6398
+ cwd,
6399
+ env,
6400
+ logDir: this.options.logFiles.directory,
6401
+ logs: this.options.logFiles.config,
6402
+ stop: entry.config.stop
6403
+ };
6404
+ let child;
6405
+ try {
6406
+ writeNannySpec(specPath, spec);
6407
+ child = spawn(process.execPath, nannyArgv(entryPoint, id, specPath, statePath), {
6408
+ cwd,
6409
+ env: {
6410
+ ...process.env,
6411
+ ...env,
6412
+ HHOSTED_HOME: dataRoot,
6413
+ HHOSTED_PROJECT: projectDir
6414
+ },
6415
+ stdio: [
6416
+ "ignore",
6417
+ "ignore",
6418
+ "ignore"
6419
+ ],
6420
+ detached: true,
6421
+ windowsHide: true
6422
+ });
6423
+ } catch (error) {
6424
+ clearNannySpec(specPath);
6425
+ return this.failSpawn(entry, error.message);
6426
+ }
6427
+ child.unref();
6428
+ this.attachChild(entry, child, `nanny → ${command} ${args.join(" ")}`.trim());
6429
+ this.followPersistent(entry);
6430
+ return { ok: true };
6431
+ }
6432
+ failSpawn(entry, message) {
6433
+ entry.status = "crashed";
6434
+ entry.lastError = message;
6435
+ this.log(entry, "system", `spawn failed: ${message}`);
6436
+ this.publishServer(entry);
6437
+ return {
6438
+ ok: false,
6439
+ error: message
6440
+ };
6441
+ }
6442
+ /** The bookkeeping every spawn shares, plus the exit events that end it. */
6443
+ attachChild(entry, child, detail, flush) {
5841
6444
  entry.child = child;
5842
6445
  entry.pid = child.pid ?? null;
5843
6446
  entry.startedAt = Date.now();
5844
6447
  this.options.history.record(entry.config.id, {
5845
6448
  type: "start",
5846
- detail: `${command} ${args.join(" ")}`.trim()
6449
+ detail
5847
6450
  });
5848
6451
  entry.exitCode = null;
5849
6452
  entry.exitSignal = null;
6453
+ entry.nannyExit = null;
5850
6454
  entry.lastProbeAt = 0;
5851
6455
  entry.healthFailures = 0;
5852
6456
  entry.unhealthySince = null;
5853
6457
  this.publishServer(entry);
5854
- const stdout = new LineSplitter((stream, text) => this.log(entry, stream, text));
5855
- const stderr = new LineSplitter((stream, text) => this.log(entry, stream, text));
5856
- child.stdout?.on("data", (chunk) => stdout.push("stdout", chunk));
5857
- child.stderr?.on("data", (chunk) => stderr.push("stderr", chunk));
5858
6458
  child.once("error", (error) => {
5859
6459
  entry.lastError = error.message;
5860
6460
  this.log(entry, "system", `process error: ${entry.lastError}`);
5861
- stdout.flush("stdout");
5862
- stderr.flush("stderr");
6461
+ flush?.();
5863
6462
  this.handleExit(entry, child, null, null);
5864
6463
  });
5865
6464
  child.once("exit", (code, signal) => {
5866
- stdout.flush("stdout");
5867
- stderr.flush("stderr");
6465
+ flush?.();
5868
6466
  this.handleExit(entry, child, code, signal);
5869
6467
  });
5870
6468
  this.awaitReadiness(entry, child);
5871
- return { ok: true };
6469
+ }
6470
+ /**
6471
+ * Live output for a persistent entry, which arrives by reading its log file rather
6472
+ * than down a pipe. It is pushed into the same ring buffer and published as the same
6473
+ * SSE frame a normal entry's output is, so nothing downstream knows the difference.
6474
+ */
6475
+ ingestExternalLines(serverId, lines) {
6476
+ const entry = this.entries.get(serverId);
6477
+ if (entry === void 0 || !entry.config.persistent || lines.length === 0) return;
6478
+ for (const line of lines) entry.logs.push(line);
6479
+ this.hub.publish({
6480
+ type: "log",
6481
+ ts: lines[lines.length - 1].ts,
6482
+ serverId,
6483
+ lines
6484
+ });
6485
+ }
6486
+ /**
6487
+ * Follows a persistent entry's log file, filling the ring buffer from what is on disk.
6488
+ *
6489
+ * The backfill is read *through* the tailer, which is the only thing that may own the
6490
+ * offset: reading it from `LogFiles` first and seeking to EOF second would either
6491
+ * replay a line as news or skip one as history, depending on which order the two
6492
+ * syscalls happen to land in.
6493
+ */
6494
+ followPersistent(entry) {
6495
+ const id = entry.config.id;
6496
+ const backfill = entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
6497
+ const history = this.relay.follow(id, nannyLogFile(this.options.logFiles.directory, id), { backfill });
6498
+ if (history.length > 0) entry.logs.extend(history);
6499
+ }
6500
+ /**
6501
+ * Reattaches to a persistent entry that is still running, or reports how it ended
6502
+ * while nobody was watching. Never starts anything: that is `start()`'s job, and it
6503
+ * is what keeps this honest about "not starting entries on your own".
6504
+ */
6505
+ async resumePersistent(entry) {
6506
+ const id = entry.config.id;
6507
+ const state = readNannyState(nannyStatePath(this.options.nannyDir, id));
6508
+ if (state !== null && state.serverId === id && await nannyIsAlive(state, id)) {
6509
+ entry.adopted = true;
6510
+ entry.child = null;
6511
+ entry.pid = state.nannyPid;
6512
+ entry.startedAt = state.startedAt;
6513
+ entry.status = "running";
6514
+ entry.health = entry.config.health.enabled ? "unknown" : "disabled";
6515
+ entry.portState = entry.config.port === null ? "unknown" : "in-use";
6516
+ entry.lastError = null;
6517
+ entry.nannyExit = null;
6518
+ const child = state.childPid === null ? "" : `, server pid ${state.childPid}`;
6519
+ this.log(entry, "system", `persistent: still running (nanny pid ${state.nannyPid}${child}) — reattached`);
6520
+ this.followPersistent(entry);
6521
+ this.publishServer(entry);
6522
+ return true;
6523
+ }
6524
+ if (state === null) return false;
6525
+ this.consumeNannyExit(entry);
6526
+ const exit = entry.nannyExit;
6527
+ entry.nannyExit = null;
6528
+ if (exit === null) return false;
6529
+ const detail = exit.code === null && exit.signal === null ? "it could not start" : describeNannyExit(exit);
6530
+ const ranFor = `${Math.max(1, Math.round(exit.runtimeMs / 1e3))}s`;
6531
+ this.options.history.record(id, {
6532
+ type: "exit",
6533
+ detail: `while the panel was away: ${detail}`,
6534
+ runtimeMs: exit.runtimeMs
6535
+ });
6536
+ this.log(entry, "system", `persistent: exited while the panel was away with ${detail} after ${ranFor}`);
6537
+ if (exit.code !== 0 || exit.signal !== null) {
6538
+ entry.status = "crashed";
6539
+ entry.lastError = `exited while the panel was away with ${detail}`;
6540
+ this.options.history.record(id, {
6541
+ type: "crash",
6542
+ detail: entry.lastError,
6543
+ runtimeMs: exit.runtimeMs
6544
+ });
6545
+ this.notify(entry, "crash", entry.lastError);
6546
+ }
6547
+ this.publishServer(entry);
6548
+ return false;
6549
+ }
6550
+ /**
6551
+ * The nanny's last word, then the state file goes: a panel that has read how the
6552
+ * entry ended must not report it twice.
6553
+ */
6554
+ consumeNannyExit(entry) {
6555
+ const statePath = nannyStatePath(this.options.nannyDir, entry.config.id);
6556
+ const state = readNannyState(statePath);
6557
+ if (state?.lastExit !== void 0) entry.nannyExit = state.lastExit;
6558
+ clearNannyState(statePath);
6559
+ this.relay.unfollow(entry.config.id);
5872
6560
  }
5873
6561
  /** One probe using the configured mode (TCP or HTTP), with timing. */
5874
6562
  async probeEntryHealth(entry) {
@@ -5921,6 +6609,9 @@ var init_supervisor = __esmMin((() => {
5921
6609
  handleExit(entry, child, code, signal) {
5922
6610
  if (entry.child !== child) return;
5923
6611
  if (entry.pid !== null) this.sampler.forget(entry.pid);
6612
+ if (entry.config.persistent) this.consumeNannyExit(entry);
6613
+ const nannyExit = entry.nannyExit;
6614
+ entry.nannyExit = null;
5924
6615
  entry.child = null;
5925
6616
  entry.pid = null;
5926
6617
  entry.adopted = false;
@@ -5928,9 +6619,9 @@ var init_supervisor = __esmMin((() => {
5928
6619
  entry.responseMs = null;
5929
6620
  entry.exitCode = code;
5930
6621
  entry.exitSignal = signal;
5931
- const neverStarted = code === null && signal === null && entry.lastError !== null;
5932
- const detail = neverStarted ? entry.lastError : signal !== null ? `signal ${signal}` : `code ${code}`;
5933
- const ranForMs = entry.startedAt === null ? 0 : Date.now() - entry.startedAt;
6622
+ const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
6623
+ const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : signal !== null ? `signal ${signal}` : `code ${code}`;
6624
+ const ranForMs = nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
5934
6625
  this.options.history.record(entry.config.id, {
5935
6626
  type: "exit",
5936
6627
  detail,
@@ -7342,6 +8033,10 @@ var init_log_files = __esmMin((() => {
7342
8033
  get directory() {
7343
8034
  return this.dir;
7344
8035
  }
8036
+ /** The retention a nanny has to apply itself, on the same files. */
8037
+ get config() {
8038
+ return this.getConfig();
8039
+ }
7345
8040
  append(serverId, line) {
7346
8041
  if (this.closed || !this.getConfig().persist) return;
7347
8042
  const bucket = this.pending.get(serverId) ?? [];
@@ -7369,6 +8064,11 @@ var init_log_files = __esmMin((() => {
7369
8064
  const config = this.getConfig();
7370
8065
  const files = [];
7371
8066
  let sizeBytes = 0;
8067
+ if (!config.persist) return {
8068
+ enabled: false,
8069
+ sizeBytes,
8070
+ files
8071
+ };
7372
8072
  for (const file of this.rotateTargets(serverId)) try {
7373
8073
  const stats = fs.statSync(file);
7374
8074
  files.push({
@@ -7383,8 +8083,15 @@ var init_log_files = __esmMin((() => {
7383
8083
  files
7384
8084
  };
7385
8085
  }
7386
- /** Reads the last `tail` lines, newest file first, padding from one rotation back. */
8086
+ /**
8087
+ * Reads the last `tail` lines, newest file first, padding from one rotation back.
8088
+ *
8089
+ * With `persist` off there is nothing this panel is willing to serve, even though a
8090
+ * persistent entry's nanny still writes its file: that file is the log's transport —
8091
+ * there is no pipe to carry it — and `persist` decides what counts as history.
8092
+ */
7387
8093
  readTail(serverId, tail) {
8094
+ if (!this.getConfig().persist) return [];
7388
8095
  const sources = [this.currentPath(serverId), this.rotatedPath(serverId, 1)];
7389
8096
  const lines = [];
7390
8097
  for (const file of sources) {
@@ -8608,7 +9315,8 @@ async function runControlPlane(options) {
8608
9315
  history,
8609
9316
  logFiles,
8610
9317
  notifications,
8611
- hostMonitor
9318
+ hostMonitor,
9319
+ nannyDir: defaultNannyDir
8612
9320
  });
8613
9321
  /**
8614
9322
  * A config edited by hand — a text editor, a `git checkout`, a config-management
@@ -8790,20 +9498,6 @@ function toUpFlags(args) {
8790
9498
  printConfig: args.printConfig === true
8791
9499
  };
8792
9500
  }
8793
- /**
8794
- * How to run this CLI again in the same runtime. Under tsx that means passing the
8795
- * resolved loader too, because the daemon's working directory is the project's,
8796
- * not the package's.
8797
- */
8798
- function runtimeArgs() {
8799
- let resolved = null;
8800
- const resolveTsx = () => resolved ??= import.meta.resolve("tsx");
8801
- return process.execArgv.map((arg) => {
8802
- if (arg === "tsx") return resolveTsx();
8803
- if (arg.startsWith("--import=") && arg.slice(9) === "tsx") return `--import=${resolveTsx()}`;
8804
- return arg;
8805
- });
8806
- }
8807
9501
  /** One rotation is enough for a console log. */
8808
9502
  function rotateLog(file) {
8809
9503
  try {
@@ -8905,6 +9599,7 @@ var DEFAULT_PORT$1, LOG_ROTATE_BYTES, upArgs;
8905
9599
  var init_up = __esmMin((() => {
8906
9600
  init_args();
8907
9601
  init_io();
9602
+ init_runtime();
8908
9603
  DEFAULT_PORT$1 = 3999;
8909
9604
  LOG_ROTATE_BYTES = 5242880;
8910
9605
  upArgs = {
@@ -8965,6 +9660,7 @@ async function runDown() {
8965
9660
  if (await waitForExit(runtime.pid, 2e4)) {
8966
9661
  clearRuntime();
8967
9662
  process.stdout.write(`${green("stopped")}\n`);
9663
+ await reportPersistent();
8968
9664
  return;
8969
9665
  }
8970
9666
  process.stdout.write(`${dim("it did not stop in time — forcing")}\n`);
@@ -8972,6 +9668,32 @@ async function runDown() {
8972
9668
  await waitForExit(runtime.pid, 5e3);
8973
9669
  clearRuntime();
8974
9670
  process.stdout.write(`${green("stopped")} (forced)\n`);
9671
+ await reportPersistent();
9672
+ }
9673
+ /**
9674
+ * `down` stops what this panel supervises — a persistent entry is the one thing it
9675
+ * deliberately does not, so the silence about it has to be broken here. The state
9676
+ * files are the only record that survives the panel, so they answer it.
9677
+ */
9678
+ async function reportPersistent() {
9679
+ const { readNannyState, nannyIsAlive, SPEC_SUFFIX } = await Promise.resolve().then(() => (init_nanny$2(), nanny_exports$2));
9680
+ const { defaultNannyDir } = await Promise.resolve().then(() => (init_paths(), paths_exports));
9681
+ const { readdirSync } = await import("node:fs");
9682
+ const path = await import("node:path");
9683
+ let files = [];
9684
+ try {
9685
+ files = readdirSync(defaultNannyDir).filter((name) => name.endsWith(".json") && !name.endsWith(SPEC_SUFFIX));
9686
+ } catch {
9687
+ return;
9688
+ }
9689
+ const running = [];
9690
+ for (const file of files) {
9691
+ const state = readNannyState(path.join(defaultNannyDir, file));
9692
+ if (state !== null && await nannyIsAlive(state, state.serverId)) running.push(state.serverId);
9693
+ }
9694
+ if (running.length === 0) return;
9695
+ process.stdout.write(`${dim(`${running.length} persistent server(s) left running: ${running.join(", ")}`)}\n`);
9696
+ process.stdout.write(`${dim("stop one from the panel, or restart it here to reattach")}\n`);
8975
9697
  }
8976
9698
  async function waitForExit(pid, timeoutMs) {
8977
9699
  const { isProcessAlive } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
@@ -10000,9 +10722,150 @@ var init_ui_revert = __esmMin((() => {
10000
10722
  });
10001
10723
  }));
10002
10724
  //#endregion
10725
+ //#region src/services/nanny.ts
10726
+ var nanny_exports$1 = /* @__PURE__ */ __exportAll({ runNanny: () => runNanny });
10727
+ /**
10728
+ * The nanny of one persistent entry: it spawns the child, owns its pipes, writes its
10729
+ * output to the entry's JSONL log and mirrors the child's exit.
10730
+ *
10731
+ * This is the whole reason a persistent entry survives the panel. The pipes belong to
10732
+ * a process that is not the panel, so a panel stop, restart or SIGKILL cannot break
10733
+ * them; the log keeps being written; and the exit code the panel would have collected
10734
+ * as a parent is left in the state file for whoever comes back.
10735
+ *
10736
+ * It deliberately does *not* restart anything: retries, backoff and health stay the
10737
+ * panel's business, so there is only ever one supervisor making that decision.
10738
+ */
10739
+ async function runNanny(spec, statePath) {
10740
+ const id = spec.serverId;
10741
+ const startedAt = Date.now();
10742
+ const logFile = nannyLogFile(spec.logDir, id);
10743
+ const logFiles = new LogFiles(spec.logDir, () => ({
10744
+ ...spec.logs,
10745
+ persist: true
10746
+ }));
10747
+ let childPid = null;
10748
+ let lastExit = null;
10749
+ let exitCode = 0;
10750
+ const persistState = () => {
10751
+ const state = {
10752
+ serverId: id,
10753
+ nannyPid: process.pid,
10754
+ childPid,
10755
+ startedAt,
10756
+ logFile,
10757
+ heartbeatAt: Date.now()
10758
+ };
10759
+ if (lastExit !== null) state.lastExit = lastExit;
10760
+ writeNannyState(statePath, state);
10761
+ };
10762
+ const emit = (stream, text) => {
10763
+ logFiles.append(id, {
10764
+ ts: Date.now(),
10765
+ stream,
10766
+ text
10767
+ });
10768
+ };
10769
+ const child = spawnManaged({
10770
+ command: spec.command,
10771
+ args: spec.args,
10772
+ cwd: spec.cwd,
10773
+ env: spec.env
10774
+ }, { detached: false });
10775
+ childPid = child.pid ?? null;
10776
+ persistState();
10777
+ emit("system", `persistent: nanny pid ${process.pid} runs this entry`);
10778
+ const stdout = new LineSplitter((stream, text) => emit(stream, text));
10779
+ const stderr = new LineSplitter((stream, text) => emit(stream, text));
10780
+ child.stdout?.on("data", (chunk) => stdout.push("stdout", chunk));
10781
+ child.stderr?.on("data", (chunk) => stderr.push("stderr", chunk));
10782
+ const heartbeat = setInterval(persistState, NANNY_HEARTBEAT_MS);
10783
+ heartbeat.unref();
10784
+ let stopping = false;
10785
+ const onSignal = (signal) => {
10786
+ if (stopping) return;
10787
+ stopping = true;
10788
+ emit("system", `${signal} — stopping this entry (grace ${spec.stop.graceMs}ms)`);
10789
+ terminate(child, {
10790
+ signal: spec.stop.signal,
10791
+ killGroup: false,
10792
+ graceMs: spec.stop.graceMs
10793
+ });
10794
+ };
10795
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
10796
+ process.on("SIGINT", () => onSignal("SIGINT"));
10797
+ await new Promise((resolve) => {
10798
+ let settled = false;
10799
+ const finish = (code, signal, runtimeMs, note) => {
10800
+ if (settled) return;
10801
+ settled = true;
10802
+ clearInterval(heartbeat);
10803
+ stdout.flush("stdout");
10804
+ stderr.flush("stderr");
10805
+ lastExit = {
10806
+ code,
10807
+ signal,
10808
+ at: Date.now(),
10809
+ runtimeMs
10810
+ };
10811
+ emit("system", note ?? (signal !== null ? `persistent: exited with signal ${signal}` : `persistent: exited with code ${code}`));
10812
+ persistState();
10813
+ logFiles.dispose();
10814
+ exitCode = code ?? (signal === null ? 0 : 1);
10815
+ resolve();
10816
+ };
10817
+ child.once("exit", (code, signal) => finish(code, signal, Date.now() - startedAt));
10818
+ child.once("error", (error) => {
10819
+ finish(null, null, 0, `persistent: could not start the entry: ${error.message}`);
10820
+ });
10821
+ });
10822
+ process.exit(exitCode);
10823
+ }
10824
+ var init_nanny$1 = __esmMin((() => {
10825
+ init_nanny$2();
10826
+ init_process();
10827
+ init_log_buffer();
10828
+ init_log_files();
10829
+ }));
10830
+ //#endregion
10831
+ //#region src/cli/nanny.ts
10832
+ var nanny_exports = /* @__PURE__ */ __exportAll({ nannyCommand: () => nannyCommand });
10833
+ var nannyCommand;
10834
+ var init_nanny = __esmMin((() => {
10835
+ init_runtime();
10836
+ nannyCommand = defineCommand({
10837
+ meta: {
10838
+ name: NANNY_COMMAND,
10839
+ description: "internal: run one persistent entry"
10840
+ },
10841
+ args: {
10842
+ id: {
10843
+ type: "string",
10844
+ required: true
10845
+ },
10846
+ spec: {
10847
+ type: "string",
10848
+ required: true
10849
+ },
10850
+ state: {
10851
+ type: "string",
10852
+ required: true
10853
+ }
10854
+ },
10855
+ run: async ({ args }) => {
10856
+ const { takeNannySpec } = await Promise.resolve().then(() => (init_nanny$2(), nanny_exports$2));
10857
+ const { runNanny } = await Promise.resolve().then(() => (init_nanny$1(), nanny_exports$1));
10858
+ const spec = takeNannySpec(args.spec);
10859
+ if (spec.serverId !== args.id) throw new Error(`this spec runs "${spec.serverId}", not "${args.id}"`);
10860
+ await runNanny(spec, args.state);
10861
+ }
10862
+ });
10863
+ }));
10864
+ //#endregion
10003
10865
  //#region src/cli.ts
10004
10866
  init_args();
10005
10867
  init_io();
10868
+ init_runtime();
10006
10869
  /**
10007
10870
  * The command line, and nothing else. Two things happen before citty is asked
10008
10871
  * anything: `--home`/`--project` are peeled off and applied (because
@@ -10012,9 +10875,10 @@ init_io();
10012
10875
  * own `#src` imports are safe: by the time one is imported, the directories are
10013
10876
  * already in the environment.
10014
10877
  *
10015
- * The only static imports here are node builtins, citty, and the two path-free
10016
- * local modules the pre-pass needs; every command (and so every state-reading
10017
- * module) is a dynamic import behind `subCommands`.
10878
+ * The only static imports here are node builtins, citty, and the path-free local
10879
+ * modules the pre-pass needs (`helpers/runtime.ts` only names the hidden nanny
10880
+ * command); every command (and so every state-reading module) is a dynamic import
10881
+ * behind `subCommands`.
10018
10882
  */
10019
10883
  var CLI_ENTRY = fileURLToPath(import.meta.url);
10020
10884
  /**
@@ -10252,6 +11116,15 @@ async function main() {
10252
11116
  const dirFlags = extractDirFlags(process.argv.slice(2));
10253
11117
  if (dirFlags.error !== void 0) fail(dirFlags.error);
10254
11118
  applyDirFlags(dirFlags);
11119
+ if (dirFlags.rest[0] === "__nanny") {
11120
+ const { nannyCommand } = await Promise.resolve().then(() => (init_nanny(), nanny_exports));
11121
+ try {
11122
+ await runCommand(nannyCommand, { rawArgs: dirFlags.rest });
11123
+ } catch (error) {
11124
+ fail(error instanceof Error ? error.message : String(error));
11125
+ }
11126
+ return;
11127
+ }
10255
11128
  const invocation = resolveInvocation(dirFlags.rest, commandNames);
10256
11129
  if (invocation.kind === "help") {
10257
11130
  process.stdout.write(invocation.command === void 0 ? USAGE : commandHelp(invocation.command));