@alphacoder-v0/pi-loops 0.22.8 → 0.24.1

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +114 -78
  4. package/README.zh-CN.md +62 -41
  5. package/docs/cli.md +352 -91
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +404 -61
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +41 -28
  17. package/docs/web-ui-parity.md +702 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/browser-open.ts +169 -0
  32. package/src/cli-entry.mjs +12 -3
  33. package/src/cli.ts +960 -143
  34. package/src/config.ts +53 -5
  35. package/src/conversation-rows.ts +469 -0
  36. package/src/danger.ts +227 -6
  37. package/src/engine.ts +1240 -0
  38. package/src/goal.ts +71 -0
  39. package/src/held-read.ts +27 -0
  40. package/src/hooks.ts +49 -18
  41. package/src/host-control-channel.ts +455 -40
  42. package/src/host-control.ts +602 -111
  43. package/src/host-entry.mjs +5 -3
  44. package/src/host-runtime.ts +455 -42
  45. package/src/host.ts +659 -123
  46. package/src/inbox-card.ts +278 -0
  47. package/src/inbox.ts +139 -62
  48. package/src/job-edit.ts +7 -1
  49. package/src/job-health.ts +14 -2
  50. package/src/job-lines.ts +201 -13
  51. package/src/job-owner.ts +74 -13
  52. package/src/job-tune.ts +110 -0
  53. package/src/lease.ts +537 -0
  54. package/src/lock.ts +7 -2
  55. package/src/mcp-pool.ts +66 -14
  56. package/src/mcp.ts +1 -1
  57. package/src/open-file.ts +54 -0
  58. package/src/page-children.ts +1507 -0
  59. package/src/page-devices.ts +192 -0
  60. package/src/page-server.ts +3150 -0
  61. package/src/path-precheck.ts +215 -0
  62. package/src/paths.ts +24 -0
  63. package/src/pi-floor.ts +10 -0
  64. package/src/pi-loops.ts +1370 -473
  65. package/src/pi-resolver.d.mts +1 -0
  66. package/src/pi-resolver.mjs +110 -10
  67. package/src/presence.ts +12 -63
  68. package/src/protocol.ts +10 -1
  69. package/src/recipe.ts +43 -1
  70. package/src/requests.ts +354 -0
  71. package/src/rule-lines.ts +68 -0
  72. package/src/run-card.ts +105 -0
  73. package/src/runner.ts +18 -1
  74. package/src/schedule.ts +1 -2
  75. package/src/scheduler.ts +595 -245
  76. package/src/sdk-runner.ts +321 -27
  77. package/src/seen.ts +278 -0
  78. package/src/session-head.ts +21 -0
  79. package/src/share.ts +12 -2
  80. package/src/snapshot.ts +3 -2
  81. package/src/store.ts +81 -8
  82. package/src/tools.ts +196 -65
  83. package/src/trigger-runtime.ts +164 -127
  84. package/src/triggers.ts +39 -7
  85. package/src/trust.ts +46 -2
  86. package/src/ui-prefs.ts +22 -7
  87. package/src/watch-plan.ts +174 -0
  88. package/src/web.d.mts +3 -0
  89. package/src/web.mjs +2261 -2340
  90. package/src/window-executor.ts +459 -0
  91. package/src/window-guard.ts +158 -0
  92. package/docs/agents/issue-tracker.md +0 -34
@@ -1,116 +1,23 @@
1
1
  /**
2
- * The headless host: a `node` process running `src/host.ts` that keeps the clock (loops,
3
- * trigger checks, MCP pushes) while no interactive pi is open on this machine. The last
4
- * interactive pi to quit starts it; the first to open takes the clock back and the host exits.
5
- * `host.json` under the loops dir names the running host; `host.log` is its output.
2
+ * The always-on host: a `node` process running `src/host.ts` that runs every loop, rule check and
3
+ * MCP push for one loops directory. A pi window starts it when there is automation to run
4
+ * (`ensureHost`), and it never yields to one. `host-service.json` names the running host;
5
+ * `host.log` is its output.
6
6
  */
7
7
  import { execFileSync, spawn } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
- import { pidAlive, writeFileAtomic } from "./lock.ts";
12
- import type { PresenceEntry } from "./presence.ts";
13
- import { stamp } from "./schedule.ts";
11
+ import { pidAlive, withFileLockSync, writeFileAtomic } from "./lock.ts";
12
+ import { procStart } from "./lease.ts";
13
+ import { compareVersions } from "./pi-floor.ts";
14
+ import { askHost, type StopReason } from "./host-control-channel.ts";
15
+ import { formatLocal, stamp } from "./schedule.ts";
16
+ import { previewRedacted } from "./redact.ts";
17
+ import { readUiPrefs } from "./ui-prefs.ts";
14
18
 
15
- export interface HostRecord {
16
- pid: number;
17
- host: string;
18
- startedAt: string;
19
- node: string;
20
- /** The host entry as it appears in the process's argv, for an exact command-line match. */
21
- entry?: string;
22
- }
23
-
24
- export const HOST_FILE = "host.json";
25
19
  export const HOST_LOG = "host.log";
26
20
 
27
- export function readHost(dir: string): HostRecord | undefined {
28
- try {
29
- const rec = JSON.parse(fs.readFileSync(path.join(dir, HOST_FILE), "utf8")) as HostRecord;
30
- return rec && typeof rec.pid === "number" ? rec : undefined;
31
- } catch {
32
- return undefined;
33
- }
34
- }
35
-
36
- /**
37
- * Is `pid` really our host? A record can outlive its process (SIGKILL, power loss) and the pid
38
- * be recycled, so besides liveness we require the record to postdate the last boot and, where
39
- * /proc exists, the command line to be the host entry.
40
- */
41
- export function hostProcessMatches(rec: HostRecord): boolean {
42
- if (!pidAlive(rec.pid)) return false;
43
- const booted = Date.now() - os.uptime() * 1000;
44
- if (Number.isFinite(booted) && Date.parse(rec.startedAt) < booted - 60_000) return false;
45
- const isOurs = (argv: string[]) => (rec.entry ? argv.includes(rec.entry) : argv.some((a) => a.endsWith("host.ts")));
46
- try {
47
- return isOurs(fs.readFileSync(`/proc/${rec.pid}/cmdline`, "utf8").split("\0"));
48
- } catch {
49
- /* no /proc (macOS): ask ps */
50
- }
51
- try {
52
- const command = execFileSync("ps", ["-o", "command=", "-p", String(rec.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
53
- return isOurs(command.trim().split(/\s+/));
54
- } catch {
55
- return true; // liveness + boot time is all we can check
56
- }
57
- }
58
-
59
- /** The host process on this machine, if it is alive. A stale record is removed. */
60
- export function liveHost(dir: string): HostRecord | undefined {
61
- const rec = readHost(dir);
62
- if (!rec) return undefined;
63
- if (rec.host !== os.hostname()) return undefined;
64
- if (hostProcessMatches(rec)) return rec;
65
- fs.rmSync(path.join(dir, HOST_FILE), { force: true });
66
- return undefined;
67
- }
68
-
69
- /** A record whose process is gone means the host crashed (it removes the record on a clean exit). */
70
- export function crashedHost(dir: string): HostRecord | undefined {
71
- const rec = readHost(dir);
72
- if (!rec || rec.host !== os.hostname()) return undefined;
73
- if (hostProcessMatches(rec)) return undefined;
74
- fs.rmSync(path.join(dir, HOST_FILE), { force: true });
75
- return rec;
76
- }
77
-
78
- export function writeHostRecord(dir: string, rec: HostRecord): void {
79
- writeFileAtomic(path.join(dir, HOST_FILE), `${JSON.stringify(rec, null, 2)}\n`);
80
- }
81
-
82
- export function clearHostRecord(dir: string, pid?: number): void {
83
- const rec = readHost(dir);
84
- if (pid !== undefined && rec && rec.pid !== pid) return;
85
- fs.rmSync(path.join(dir, HOST_FILE), { force: true });
86
- }
87
-
88
- /** SIGTERM the host (it stops its scheduler and exits); returns the pid or undefined when none ran. */
89
- export function stopHost(dir: string): number | undefined {
90
- const rec = liveHost(dir);
91
- if (!rec) return undefined;
92
- try {
93
- process.kill(rec.pid, "SIGTERM");
94
- } catch {
95
- /* already gone */
96
- }
97
- fs.rmSync(path.join(dir, HOST_FILE), { force: true });
98
- return rec.pid;
99
- }
100
-
101
- /**
102
- * Hand the clock to a headless host when the last interactive pi on this machine quits and
103
- * there is something for it to keep running. Pure, so the decision is testable.
104
- */
105
- export function shouldHandOff(input: { auto: boolean; presence: PresenceEntry[]; selfPid: number; selfInstance?: string; hostName: string; enabledLoops: number; enabledRules: number; pushServers: number; hostAlive: boolean }): { handOff: boolean; reason: string } {
106
- if (!input.auto) return { handOff: false, reason: "[host] auto = false" };
107
- if (input.hostAlive) return { handOff: false, reason: "a host is already running" };
108
- const others = input.presence.filter((e) => e.host === input.hostName && e.kind !== "host" && !(e.pid === input.selfPid && (!input.selfInstance || e.instance === input.selfInstance)));
109
- if (others.length) return { handOff: false, reason: `${others.length} other interactive pi still open` };
110
- if (!input.enabledLoops && !input.enabledRules && !input.pushServers) return { handOff: false, reason: "nothing to keep running" };
111
- return { handOff: true, reason: `${input.enabledLoops} loop(s), ${input.enabledRules} rule(s), ${input.pushServers} push source(s)` };
112
- }
113
-
114
21
  /**
115
22
  * MCP servers the host can do something with: a push either injects (inbox) or is evaluated
116
23
  * against rules — a tool-only server with no rules is nothing to stay up for.
@@ -151,25 +58,89 @@ export interface SpawnHostOptions {
151
58
  dir: string;
152
59
  packageDir: string;
153
60
  piPackage?: string;
61
+ /** Who starts it, said by `pi-loops status`: its environment is theirs. Default: this process. */
62
+ startedBy?: string;
154
63
  /** The handing-off pi's model and thinking level: the host's defaults for unpinned work. */
155
64
  model?: string;
156
65
  thinking?: string;
157
66
  node?: string;
158
67
  nodeVersion?: string;
68
+ /** A person started it by name: with `[host] auto = false` it stays until stopped, not only while a window is open. */
69
+ asked?: boolean;
70
+ }
71
+
72
+ /** An executable `node` in one of `pathEnv`'s directories, for a pi that is not running under node itself. */
73
+ export function nodeOnPath(pathEnv: string = process.env.PATH ?? "", isExecutable: (file: string) => boolean = (file) => {
74
+ try {
75
+ fs.accessSync(file, fs.constants.X_OK);
76
+ return fs.statSync(file).isFile();
77
+ } catch {
78
+ return false; // not there, or not ours to run
79
+ }
80
+ }, cwd: string = process.cwd()): string | undefined {
81
+ // Absolute entries only: a relative one (`.`, `node_modules/.bin`) resolves against the project pi
82
+ // was opened in, and would run a binary from an untrusted checkout. So would an absolute one that
83
+ // `bun run`, `npx` or direnv put there: anything inside that project, or under a node_modules.
84
+ // The project is the repository around cwd (its nearest `.git` below home), compared by real path so
85
+ // a symlink into it counts. Not when pi was opened in the home directory or at the root: that is
86
+ // no project, and node itself (nvm, fnm, a local prefix) lives under home.
87
+ const real = (p: string): string => {
88
+ try {
89
+ return fs.realpathSync(p);
90
+ } catch {
91
+ return path.resolve(p); // not there: compared as written
92
+ }
93
+ };
94
+ const project = projectRoot(real(cwd), real(os.homedir()));
95
+ const projects = (d: string) => d.split(path.sep).includes("node_modules") || (project !== undefined && (d === project || d.startsWith(project + path.sep)));
96
+ return pathEnv.split(path.delimiter).filter((d) => d && path.isAbsolute(d) && !projects(real(d))).map((d) => path.join(d, "node")).find(isExecutable);
97
+ }
98
+
99
+ /** The repository `dir` is in (its nearest ancestor with `.git`, below home), else `dir`; none for home or the root. */
100
+ function projectRoot(dir: string, home: string): string | undefined {
101
+ if (dir === home || dir === path.parse(dir).root) return undefined;
102
+ for (let at = dir; at !== home && at !== path.parse(at).root; at = path.dirname(at)) {
103
+ if (fs.existsSync(path.join(at, ".git"))) return at;
104
+ }
105
+ return dir;
106
+ }
107
+
108
+ /**
109
+ * The command line that runs the host: node (the one on PATH under bun), pi's module resolver, the
110
+ * entry, and the environment it needs. `spawnHost` starts it detached; `pi-loops start --foreground`
111
+ * runs it in the terminal, for a service manager the person set up.
112
+ */
113
+ export function hostCommand(opts: SpawnHostOptions): { node: string; args: string[]; env: NodeJS.ProcessEnv } {
114
+ let node = opts.node ?? process.execPath;
115
+ let nodeVersion = opts.nodeVersion ?? process.version;
116
+ // Under bun the running binary is not node; the host needs node, so the one on PATH runs it.
117
+ if ((process.versions as any).bun && !opts.node) {
118
+ const onPath = nodeOnPath();
119
+ if (!onPath) throw new Error("pi-loops needs node in the background, and pi is running under bun with no node on PATH");
120
+ // The file that is probed is the file that is started: a shim or symlink is resolved once.
121
+ const found = fs.realpathSync(onPath);
122
+ const version = execFileSync(found, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000, cwd: os.homedir() }).trim();
123
+ if (!/^v\d+\./.test(version)) throw new Error(`pi-loops needs node in the background, and ${onPath} does not answer like node (${previewRedacted(version, 40)})`);
124
+ node = found;
125
+ nodeVersion = version;
126
+ }
127
+ return {
128
+ node,
129
+ args: hostSpawnArgs(nodeVersion, path.join(opts.packageDir, "src", "register-pi.mjs"), path.join(opts.packageDir, "src", "host-entry.mjs")),
130
+ env: { ...process.env, PI_LOOPS_STARTED_BY: opts.startedBy ?? `pid ${process.pid}`, PI_LOOPS_DIR: path.resolve(opts.dir), ...(opts.piPackage ? { PI_LOOPS_PI_PACKAGE: opts.piPackage } : {}), ...(opts.model ? { PI_LOOPS_HOST_MODEL: opts.model } : {}), ...(opts.thinking ? { PI_LOOPS_HOST_THINKING: opts.thinking } : {}), ...(opts.asked ? { PI_LOOPS_HOST_ASKED: "1" } : {}) },
131
+ };
159
132
  }
160
133
 
161
134
  /** Start the host detached, logging to `host.log`; returns its pid. */
162
135
  export function spawnHost(opts: SpawnHostOptions): number {
163
- if ((process.versions as any).bun) throw new Error("the headless host needs node (pi is running under bun)");
164
- const node = opts.node ?? process.execPath;
165
- const args = hostSpawnArgs(opts.nodeVersion ?? process.version, path.join(opts.packageDir, "src", "register-pi.mjs"), path.join(opts.packageDir, "src", "host-entry.mjs"));
136
+ const { node, args, env } = hostCommand(opts);
166
137
  fs.mkdirSync(opts.dir, { recursive: true });
167
138
  const log = fs.openSync(path.join(opts.dir, HOST_LOG), "a");
168
139
  const child = spawn(node, args, {
169
140
  detached: true,
170
141
  stdio: ["ignore", log, log],
171
142
  cwd: os.homedir(),
172
- env: { ...process.env, PI_LOOPS_DIR: opts.dir, ...(opts.piPackage ? { PI_LOOPS_PI_PACKAGE: opts.piPackage } : {}), ...(opts.model ? { PI_LOOPS_HOST_MODEL: opts.model } : {}), ...(opts.thinking ? { PI_LOOPS_HOST_THINKING: opts.thinking } : {}) },
143
+ env,
173
144
  });
174
145
  fs.closeSync(log);
175
146
  // A spawn failure surfaces on the next tick; without a listener it would crash the quitting pi.
@@ -181,7 +152,7 @@ export function spawnHost(opts: SpawnHostOptions): number {
181
152
  }
182
153
  });
183
154
  child.unref();
184
- if (!child.pid) throw new Error("could not start the headless host");
155
+ if (!child.pid) throw new Error("could not start pi-loops");
185
156
  // The host writes host.json itself, under a lock, once it knows no other host is running.
186
157
  return child.pid;
187
158
  }
@@ -195,9 +166,529 @@ export function spawnHost(opts: SpawnHostOptions): number {
195
166
  export async function waitForHost(dir: string, pid: number, timeoutMs = 3000): Promise<boolean> {
196
167
  const deadline = Date.now() + timeoutMs;
197
168
  while (Date.now() < deadline) {
198
- if (readHost(dir)?.pid === pid) return true;
169
+ if (readHostService(dir)?.pid === pid) return true;
199
170
  if (!pidAlive(pid)) return false;
200
171
  await new Promise((r) => setTimeout(r, 100));
201
172
  }
202
- return readHost(dir)?.pid === pid;
173
+ return readHostService(dir)?.pid === pid;
174
+ }
175
+
176
+ export const HOST_SERVICE_FILE = "host-service.json";
177
+ export const HOST_SERVICE_LOCK = "host-service.lock";
178
+ export const HOST_SPAWNS_FILE = "host-spawns.json";
179
+
180
+ export interface HostServiceRecord {
181
+ v: 1;
182
+ pid: number;
183
+ /** OS process start time (src/lease.ts procStart), against pid recycling. */
184
+ procStart?: string;
185
+ startedAt: string;
186
+ /** PI_LOOPS_VERSION of the running host. */
187
+ version: string;
188
+ piVersion?: string;
189
+ /** argv entry, for the command-line match hostProcessMatches already does. */
190
+ entry: string;
191
+ node: string;
192
+ /** The loops directory this host serves. */
193
+ dir: string;
194
+ socket: string;
195
+ /** The browser page's address, without its token, once its port is bound. */
196
+ url?: string;
197
+ /** The page's token (`web-token`): `pi-loops status --url` prints the address with it. */
198
+ token: string;
199
+ /** Why the page is not served yet: an older pi-loops page holds the port, or another program does. */
200
+ pageWaiting?: { reason: "older-page" | "other-program"; since: string };
201
+ /** Written by the host on every tick: its loop is turning, whatever its socket says (see decideEviction). */
202
+ tickAt?: string;
203
+ /** "foreground": `pi-loops start --foreground`, under a service manager the person set up. */
204
+ managed: "spawned" | "foreground";
205
+ }
206
+
207
+ export function readHostService(dir: string): HostServiceRecord | undefined {
208
+ try {
209
+ const value: unknown = JSON.parse(fs.readFileSync(path.join(dir, HOST_SERVICE_FILE), "utf8"));
210
+ if (!value || typeof value !== "object") return undefined;
211
+ const rec = value as HostServiceRecord;
212
+ return rec.v === 1 && typeof rec.pid === "number" ? rec : undefined;
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ function commandMatches(pid: number, entry: string): boolean {
219
+ const isOurs = (argv: string[]) => argv.includes(entry);
220
+ try {
221
+ return isOurs(fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0"));
222
+ } catch {
223
+ /* no /proc (macOS): ask ps */
224
+ }
225
+ try {
226
+ // ps joins argv with spaces, so an install path with a space in it cannot be split back out:
227
+ // look for the entry as a whole word of the line instead.
228
+ const command = ` ${execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()} `;
229
+ return command.includes(` ${entry} `);
230
+ } catch {
231
+ return true; // liveness is all we can check
232
+ }
233
+ }
234
+
235
+ export function hostServiceAlive(rec: HostServiceRecord): boolean {
236
+ if (!pidAlive(rec.pid)) return false;
237
+ // A start time that cannot be read now (a `ps` that failed to start) is unknown, not a different
238
+ // process: as `holderAlive` takes it, with its fallback. Read as a mismatch, one failed `ps` had a
239
+ // window remove a healthy host's record and count a crash; trusted alone, a pid reused since a
240
+ // reboot could be sent SIGKILL. A record written before this boot names some other process.
241
+ const started = rec.procStart === undefined ? undefined : procStart(rec.pid);
242
+ if (started !== undefined && started !== rec.procStart) return false;
243
+ if (started === undefined && Date.parse(rec.startedAt) < Date.now() - os.uptime() * 1000 - 60_000) return false;
244
+ return commandMatches(rec.pid, rec.entry);
245
+ }
246
+
247
+ export function claimHostService(dir: string, rec: HostServiceRecord, alive: (r: HostServiceRecord) => boolean = hostServiceAlive): { claimed: true } | { claimed: false; other: HostServiceRecord } {
248
+ return withFileLockSync(path.join(dir, HOST_SERVICE_LOCK), () => {
249
+ const other = readHostService(dir);
250
+ if (other && other.pid !== rec.pid && alive(other)) return { claimed: false, other };
251
+ // A record naming a dead host is one that never got to its clean exit: counted here when the new
252
+ // host is the first to find it (a window that found it first removed it and counted it).
253
+ if (other && other.pid !== rec.pid) recordCrash(dir, `pi-loops (pid ${other.pid}) went away without a clean exit; see ${HOST_LOG}`);
254
+ const file = path.join(dir, HOST_SERVICE_FILE);
255
+ writeFileAtomic(file, `${JSON.stringify(rec, null, 2)}\n`, 0o600);
256
+ fs.chmodSync(file, 0o600);
257
+ return { claimed: true };
258
+ });
259
+ }
260
+
261
+ export function removeHostService(dir: string, pid: number): boolean {
262
+ return withFileLockSync(path.join(dir, HOST_SERVICE_LOCK), () => {
263
+ if (readHostService(dir)?.pid !== pid) return false;
264
+ fs.rmSync(path.join(dir, HOST_SERVICE_FILE), { force: true });
265
+ return true;
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Add what the host learns after its claim (the page's URL, why it waits for its port, the heartbeat)
271
+ * to its own record, and only to its own. A key given as `undefined` is removed.
272
+ */
273
+ export function updateHostService(dir: string, pid: number, patch: Partial<Pick<HostServiceRecord, "url" | "tickAt" | "pageWaiting">>): boolean {
274
+ return withFileLockSync(path.join(dir, HOST_SERVICE_LOCK), () => {
275
+ const rec = readHostService(dir);
276
+ if (rec?.pid !== pid) return false;
277
+ const file = path.join(dir, HOST_SERVICE_FILE);
278
+ writeFileAtomic(file, `${JSON.stringify({ ...rec, ...patch }, null, 2)}\n`, 0o600);
279
+ fs.chmodSync(file, 0o600);
280
+ return true;
281
+ });
282
+ }
283
+
284
+ export function hostServiceNames(dir: string, pid: number, startedAt: string): boolean {
285
+ const rec = readHostService(dir);
286
+ return rec?.pid === pid && rec.startedAt === startedAt;
287
+ }
288
+
289
+ export type SpawnOutcome = "up" | "died" | "no-node" | "evicted" | "stopped";
290
+ export interface HostSpawnLog {
291
+ attempts: Array<{ at: string; by: number; outcome?: SpawnOutcome }>;
292
+ notBefore?: string;
293
+ lastError?: string;
294
+ /**
295
+ * When a host that had started went away without a clean exit: it crashed (and said so first), a
296
+ * window found its record naming a dead pid, or a window evicted it. The crash budget: three within
297
+ * `CRASH_BUDGET.windowMs` pause the host (`pauseEndsAt`), instead of a restart every tick that
298
+ * aborts every run in flight each time.
299
+ */
300
+ crashes?: string[];
301
+ /** Set when the crash budget ran out. The pause ends on its own (`pauseEndsAt`), or when a person starts the host (`clearCrashes`). */
302
+ pausedAt?: string;
303
+ /** Pauses in a row, each twice as long as the one before: a host that crashes again as soon as it is back waits longer. */
304
+ pauses?: number;
305
+ /** The last pid a window evicted, so several windows evicting it count one crash. */
306
+ evictedPid?: number;
307
+ /** A newer pi-loops asked the host to make way: older clients do not start their own while it lives. */
308
+ upgradedBy?: { pid: number; version: string; procStart?: string };
309
+ }
310
+
311
+ /**
312
+ * Crashes inside this window that pause the host. Sized to the respawn cadence: a pi window restarts
313
+ * a dead host on its 30 s tick, so three back-to-back crashes take about 90 s, and a 60 s window
314
+ * could never catch them.
315
+ */
316
+ export const CRASH_BUDGET = { count: 3, windowMs: 5 * 60_000 };
317
+
318
+ /**
319
+ * How long a crash-pause lasts: half an hour, doubled for each pause in a row, a day at most. A pause
320
+ * that lasted until a person came back stopped every project's loops for one bad one, overnight; a
321
+ * pause that ends lets them run again between the crashes.
322
+ */
323
+ export const CRASH_PAUSE = { firstMs: 30 * 60_000, maxMs: 24 * 60 * 60_000 };
324
+
325
+ /** When the pause in `log` ends, if it is paused. */
326
+ export function pauseEndsAt(log: Pick<HostSpawnLog, "pausedAt" | "pauses">): number | undefined {
327
+ if (!log.pausedAt) return undefined;
328
+ const n = Math.max(1, log.pauses ?? 1);
329
+ return Date.parse(log.pausedAt) + Math.min(CRASH_PAUSE.firstMs * 2 ** (n - 1), CRASH_PAUSE.maxMs);
330
+ }
331
+
332
+ /** What a crash-pause is, in the words every surface uses for it. */
333
+ export function crashPauseText(log: Pick<HostSpawnLog, "pausedAt" | "pauses">): string {
334
+ const ends = pauseEndsAt(log);
335
+ return `pi-loops stopped unexpectedly ${CRASH_BUDGET.count} times within ${CRASH_BUDGET.windowMs / 60_000} minutes — see ${HOST_LOG}; it tries again at ${ends === undefined ? "?" : formatLocal(ends)}, or /pi-loops start resumes it now`;
336
+ }
337
+
338
+ /** Whether the host is paused at `now` by its crash budget. */
339
+ export function crashPaused(log: Pick<HostSpawnLog, "pausedAt" | "pauses">, now: number): boolean {
340
+ const ends = pauseEndsAt(log);
341
+ return ends !== undefined && now < ends;
342
+ }
343
+
344
+ /** How many crashes count against the budget at `now`; the host is paused when that reaches the count. */
345
+ export function recentCrashes(log: HostSpawnLog, now: number): number {
346
+ return (log.crashes ?? []).filter((at) => now - Date.parse(at) < CRASH_BUDGET.windowMs).length;
347
+ }
348
+
349
+ /** Record a host going away uncleanly, with why. Synchronous and locked: the crashing host calls it on its way out. */
350
+ export function recordCrash(dir: string, reason: string, now = Date.now()): HostSpawnLog {
351
+ return withFileLockSync(path.join(dir, "host-spawns.lock"), () => {
352
+ const log = readSpawnLog(dir);
353
+ log.crashes = [...(log.crashes ?? []), stamp(now)].slice(-10);
354
+ log.lastError = reason;
355
+ const ended = pauseEndsAt(log);
356
+ if (recentCrashes(log, now) >= CRASH_BUDGET.count && (ended === undefined || now >= ended)) {
357
+ // In a row: the last pause ended within a day. Later than that, this is a new run of crashes.
358
+ log.pauses = ended !== undefined && now - ended < CRASH_PAUSE.maxMs ? (log.pauses ?? 1) + 1 : 1;
359
+ log.pausedAt = stamp(now);
360
+ }
361
+ writeFileAtomic(path.join(dir, HOST_SPAWNS_FILE), `${JSON.stringify(log, null, 2)}\n`);
362
+ return log;
363
+ });
364
+ }
365
+
366
+ /** True for the first caller to evict `pid`: the eviction is counted once, however many windows made it. */
367
+ function claimEviction(dir: string, pid: number): boolean {
368
+ return withFileLockSync(path.join(dir, "host-spawns.lock"), () => {
369
+ const log = readSpawnLog(dir);
370
+ if (log.evictedPid === pid) return false;
371
+ log.evictedPid = pid;
372
+ writeFileAtomic(path.join(dir, HOST_SPAWNS_FILE), `${JSON.stringify(log, null, 2)}\n`);
373
+ return true;
374
+ });
375
+ }
376
+
377
+ /** A person started the host by name: the crash budget and any upgrade claim start over. */
378
+ export function clearCrashes(dir: string): void {
379
+ withFileLockSync(path.join(dir, "host-spawns.lock"), () => {
380
+ const log = readSpawnLog(dir);
381
+ if (!log.crashes?.length && !log.upgradedBy && !log.pausedAt) return;
382
+ delete log.crashes;
383
+ delete log.pausedAt;
384
+ delete log.pauses;
385
+ delete log.upgradedBy;
386
+ writeFileAtomic(path.join(dir, HOST_SPAWNS_FILE), `${JSON.stringify(log, null, 2)}\n`);
387
+ });
388
+ }
389
+
390
+ /** Record which newer pi-loops asked the host to make way (see `HostSpawnLog.upgradedBy`). */
391
+ export function recordUpgrader(dir: string, upgrader: { pid: number; version: string; procStart?: string } | undefined): void {
392
+ withFileLockSync(path.join(dir, "host-spawns.lock"), () => {
393
+ const log = readSpawnLog(dir);
394
+ if (upgrader) log.upgradedBy = upgrader;
395
+ else if (!log.upgradedBy) return;
396
+ else delete log.upgradedBy;
397
+ writeFileAtomic(path.join(dir, HOST_SPAWNS_FILE), `${JSON.stringify(log, null, 2)}\n`);
398
+ });
399
+ }
400
+
401
+ export function readSpawnLog(dir: string): HostSpawnLog {
402
+ try {
403
+ const value: unknown = JSON.parse(fs.readFileSync(path.join(dir, HOST_SPAWNS_FILE), "utf8"));
404
+ const log = value as HostSpawnLog;
405
+ return log && Array.isArray(log.attempts) ? log : { attempts: [] };
406
+ } catch {
407
+ return { attempts: [] };
408
+ }
409
+ }
410
+
411
+ export function recordSpawn(dir: string, event: { kind: "attempt"; by: number } | { kind: "outcome"; by: number; outcome: SpawnOutcome; error?: string }, now = Date.now()): HostSpawnLog {
412
+ return withFileLockSync(path.join(dir, "host-spawns.lock"), () => {
413
+ const log = readSpawnLog(dir);
414
+ if (event.kind === "attempt") log.attempts.push({ at: stamp(now), by: event.by });
415
+ else {
416
+ let attempt = [...log.attempts].reverse().find((a) => a.by === event.by && a.outcome === undefined);
417
+ if (!attempt) {
418
+ attempt = { at: stamp(now), by: event.by };
419
+ log.attempts.push(attempt);
420
+ }
421
+ attempt.outcome = event.outcome;
422
+ if (event.error !== undefined) log.lastError = event.error;
423
+ let failures = 0;
424
+ for (const a of [...log.attempts].reverse()) {
425
+ if (a.outcome === "evicted") continue;
426
+ if (a.outcome === "died" || a.outcome === "no-node") failures++;
427
+ else break;
428
+ }
429
+ if (!failures) {
430
+ delete log.notBefore;
431
+ if (event.outcome === "up") delete log.lastError;
432
+ } else log.notBefore = stamp(now + (failures === 1 ? 60_000 : failures === 2 ? 300_000 : 1_800_000));
433
+ }
434
+ log.attempts = log.attempts.slice(-20);
435
+ writeFileAtomic(path.join(dir, HOST_SPAWNS_FILE), `${JSON.stringify(log, null, 2)}\n`);
436
+ return log;
437
+ });
438
+ }
439
+
440
+ export interface EnsureInput {
441
+ /** `closedSessionJobs`: enabled plain jobs of conversations no window holds, which the host fires. */
442
+ automation: { loops: number; rules: number; pushServers: number; closedSessionJobs?: number; closedMailboxes?: number };
443
+ auto: boolean;
444
+ stopped: boolean;
445
+ envOff: boolean;
446
+ now: number;
447
+ spawns: HostSpawnLog;
448
+ host?: { version: string; piVersion?: string };
449
+ myVersion: string;
450
+ myPiVersion?: string;
451
+ /** A person asked for the host by name (`/cron host start`, `pi-loops host start`): start it even with nothing to run, `[host] auto = false` or a backoff pending. */
452
+ asked?: boolean;
453
+ /** `pi-loops` opening the browser page: the page is work to start for, and nothing more (no `asked`, so `[host] auto = false` still ends it with the last window). */
454
+ page?: true;
455
+ /** The newer pi-loops that last asked a host to make way, and whether its process is alive. */
456
+ upgrader?: { pid: number; version: string; alive: boolean };
457
+ }
458
+
459
+ export type EnsureAction = { kind: "none"; reason: string } | { kind: "spawn" } | { kind: "upgrade" };
460
+
461
+ export function decideEnsure(input: EnsureInput): EnsureAction {
462
+ if (input.envOff) return { kind: "none", reason: "PI_LOOPS_HOST=off" };
463
+ if (input.host) {
464
+ const cmp = compareVersions(input.myVersion, input.host.version);
465
+ if (cmp > 0 || (cmp === 0 && input.myPiVersion !== undefined && input.host.piVersion !== undefined && compareVersions(input.myPiVersion, input.host.piVersion) > 0)) return { kind: "upgrade" };
466
+ return { kind: "none", reason: "running" };
467
+ }
468
+ const work = input.automation;
469
+ if (!input.asked && !input.page && !work.loops && !work.rules && !work.pushServers && !work.closedSessionJobs && !work.closedMailboxes) return { kind: "none", reason: "nothing to run" };
470
+ // The newer client that asked the old host to make way starts its own; an older one starting the
471
+ // old code in the gap is how two versions traded the host back and forth, aborting runs each time.
472
+ if (input.upgrader?.alive && compareVersions(input.myVersion, input.upgrader.version) < 0) return { kind: "none", reason: `a newer pi-loops (pid ${input.upgrader.pid}) is already running` };
473
+ if (!input.asked && crashPaused(input.spawns, input.now)) return { kind: "none", reason: `paused: ${crashPauseText(input.spawns)}` };
474
+ // `[host] auto = false` does not keep a window from starting it: it makes the process leave once
475
+ // no window is open (`leavesWithWindows`), as 0.22.8 ran loops only while a pi was open.
476
+ if (input.stopped) return { kind: "none", reason: "stopped (/pi-loops start resumes it)" };
477
+ if (!input.asked && !input.page && input.spawns.notBefore && input.now < Date.parse(input.spawns.notBefore)) return { kind: "none", reason: `backing off until ${input.spawns.notBefore}` };
478
+ if (input.spawns.attempts.some((a) => a.outcome === undefined && input.now - Date.parse(a.at) < 10_000 && input.now >= Date.parse(a.at))) return { kind: "none", reason: "a start is in progress" };
479
+ return { kind: "spawn" };
480
+ }
481
+
482
+ /** How long a window-started process waits for the window that started it to announce itself before it may leave for want of one. */
483
+ export const WINDOWS_GRACE_MS = 60_000;
484
+
485
+ /**
486
+ * Pure. Whether the background process leaves because no pi window is open: `[host] auto = false`
487
+ * means what it meant in 0.22.8, loops run only while a window is open. The first window starts the
488
+ * process (as `auto = true` does) and the last one closing ends it; nothing runs with none open. One a
489
+ * service manager runs (`pi-loops start --foreground`) is the person's to stop and stays. `windows`
490
+ * is the live pi windows this machine's presence lists; `upForMs` gives the window that started the
491
+ * process its first tick to be listed.
492
+ */
493
+ export function leavesWithWindows(input: { auto: boolean; managed: HostServiceRecord["managed"]; windows: number; upForMs: number; asked?: boolean }): boolean {
494
+ return !input.auto && !input.asked && input.managed !== "foreground" && input.windows === 0 && input.upForMs >= WINDOWS_GRACE_MS;
495
+ }
496
+
497
+ /**
498
+ * Pure. The windows `[host] auto = false` waits for: terminal windows that hold a lease, the page's
499
+ * pis, and tabs on the page. A page pi still starting, or one a terminal took its conversation from,
500
+ * holds no lease, so the pool lists it; one that holds a lease is counted once.
501
+ */
502
+ export function openWindows(input: { terminalPids: Iterable<number>; pageChildPids: Iterable<number>; tabs: number }): number {
503
+ return new Set([...input.terminalPids, ...input.pageChildPids]).size + input.tabs;
504
+ }
505
+
506
+ /**
507
+ * Whether three unanswered probes mean a wedged host. Only with a second signal: the host writes a
508
+ * heartbeat into its record every tick, so a socket that does not answer while the heartbeat is
509
+ * fresh is a host that runs with a broken control channel (a removed socket, a temp-dir cleaner) —
510
+ * killing that one only to start another with the same broken channel looped every ~100 s.
511
+ */
512
+ export function decideEviction(input: { failures: number; probeOk: boolean; fromTick: boolean; pidMatches: boolean; heartbeatFresh?: boolean }): { failures: number; evict: boolean; channelBroken?: boolean } {
513
+ if (input.probeOk) return { failures: 0, evict: false };
514
+ if (!input.fromTick) return { failures: input.failures, evict: false };
515
+ const failures = input.failures + 1;
516
+ if (failures < 3 || !input.pidMatches) return { failures, evict: false };
517
+ if (input.heartbeatFresh) return { failures, evict: false, channelBroken: true };
518
+ return { failures: 0, evict: true };
519
+ }
520
+
521
+ /** A heartbeat younger than three ticks is a host whose loop still turns. */
522
+ export function heartbeatFresh(rec: Pick<HostServiceRecord, "tickAt">, now: number, tickMs = 30_000): boolean {
523
+ return !!rec.tickAt && now - Date.parse(rec.tickAt) < 3 * tickMs;
524
+ }
525
+
526
+ export async function evictHost(dir: string, pid: number, opts: { graceMs?: number; kill?: (pid: number, signal: NodeJS.Signals) => void; alive?: (pid: number) => boolean } = {}): Promise<void> {
527
+ const kill = opts.kill ?? ((target, signal) => process.kill(target, signal));
528
+ const alive = opts.alive ?? pidAlive;
529
+ try {
530
+ kill(pid, "SIGTERM");
531
+ } catch (err: any) {
532
+ if (err?.code !== "ESRCH") throw err;
533
+ /* ESRCH means it exited before the signal arrived. */
534
+ }
535
+ const deadline = Date.now() + (opts.graceMs ?? 10_000);
536
+ while (alive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 100));
537
+ if (alive(pid)) {
538
+ try {
539
+ kill(pid, "SIGKILL");
540
+ } catch (err: any) {
541
+ if (err?.code !== "ESRCH") throw err;
542
+ /* ESRCH means it exited before the signal arrived. */
543
+ }
544
+ }
545
+ removeHostService(dir, pid);
546
+ // Every window counts its own unanswered probes, so several evict one wedged process together:
547
+ // the first to get here counts the crash, once per pid. Who removed the record says nothing, since
548
+ // a process that still turns removes its own on SIGTERM.
549
+ if (!claimEviction(dir, pid)) return;
550
+ recordSpawn(dir, { kind: "outcome", by: process.pid, outcome: "evicted" });
551
+ recordCrash(dir, `pi-loops (pid ${pid}) stopped answering and its heartbeat stopped; it was replaced`);
552
+ }
553
+
554
+ /**
555
+ * How long a restart or an upgrade lets a turn in the browser page finish before the process leaves
556
+ * anyway (`waitForPageTurns`). A plain stop and a signal do not wait.
557
+ */
558
+ export const PAGE_TURN_WAIT_MS = 120_000;
559
+
560
+ /**
561
+ * How long the side that asked for a restart or an upgrade waits for the old process to go: the
562
+ * page's turns, then the shutdown, which gives up on its own after 10 s, and room to spare. A
563
+ * restart that gives up sends SIGKILL, so this must outlast `PAGE_TURN_WAIT_MS`.
564
+ */
565
+ export const LEAVE_WAIT_MS = PAGE_TURN_WAIT_MS + 60_000;
566
+
567
+ /** What `pi-loops restart` and `/pi-loops restart` say before they wait. */
568
+ export const RESTART_WAIT_LINE = `waiting up to ${PAGE_TURN_WAIT_MS / 60_000} min for a turn in the browser page to finish…`;
569
+
570
+ /** Whether a stop waits for the page's turns: a restart and an upgrade do, a person's plain stop does not. */
571
+ export function stopWaitsForPage(reason: StopReason): boolean {
572
+ return reason === "restart" || reason === "upgrade";
573
+ }
574
+
575
+ /**
576
+ * Wait until no conversation in the page is busy (a turn or a compaction), at most `boundMs`. Says
577
+ * once that it waits and once if it gives up; "idle" when nothing was busy or everything finished.
578
+ */
579
+ export async function waitForPageTurns(opts: { busy: () => number; log: (line: string) => void; boundMs?: number; pollMs?: number; now?: () => number; sleep?: (ms: number) => Promise<void> }): Promise<"idle" | "gave up"> {
580
+ const boundMs = opts.boundMs ?? PAGE_TURN_WAIT_MS;
581
+ const pollMs = opts.pollMs ?? 500;
582
+ const now = opts.now ?? Date.now;
583
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
584
+ let busy = opts.busy();
585
+ if (!busy) return "idle";
586
+ opts.log(`waiting up to ${Math.round(boundMs / 1000)} s for ${busy === 1 ? "a turn" : `${busy} turns`} in the browser page to finish`);
587
+ const deadline = now() + boundMs;
588
+ while (now() < deadline) {
589
+ await sleep(Math.min(pollMs, Math.max(0, deadline - now())));
590
+ busy = opts.busy();
591
+ if (!busy) return "idle";
592
+ }
593
+ opts.log(`gave up waiting: ${busy === 1 ? "a turn" : `${busy} turns`} in the browser page still running after ${Math.round(boundMs / 1000)} s; leaving`);
594
+ return "gave up";
595
+ }
596
+
597
+ export interface EnsureHostOptions extends SpawnHostOptions {
598
+ automation: EnsureInput["automation"];
599
+ auto: boolean;
600
+ stopped: boolean;
601
+ myVersion: string;
602
+ myPiVersion?: string;
603
+ asked?: boolean;
604
+ page?: true;
605
+ /** How long an upgrade waits for the old process to leave; `LEAVE_WAIT_MS` unless a test says. */
606
+ leaveWaitMs?: number;
607
+ /**
608
+ * Whether a start waits for the process to record itself (up to 10 s). Every caller waits but a
609
+ * window that is quitting: it has nothing to do with the answer, and waiting made quitting slow.
610
+ * Not waiting leaves the spawn log's attempt open, which the next window reads as a start in
611
+ * progress for 10 s, and `up` undefined.
612
+ */
613
+ wait?: boolean;
614
+ }
615
+
616
+ /**
617
+ * Make sure the host is running when there is automation for it: start it, ask an older one to make
618
+ * way for this version, or leave it alone (src/host-control.ts decideEnsure decides which). Never
619
+ * throws: a host that cannot start is a result, recorded in the spawn log with its backoff.
620
+ */
621
+ export async function ensureHost(opts: EnsureHostOptions): Promise<{ action: EnsureAction; pid?: number; up?: boolean; error?: string }> {
622
+ let rec = readHostService(opts.dir);
623
+ // A clean exit removes the record, so one naming a dead process is a host that crashed, was killed
624
+ // (SIGKILL, OOM) or lost power: counted against the crash budget once, then cleared.
625
+ if (rec && !hostServiceAlive(rec)) {
626
+ // Only the caller that removes it counts it: several windows find the same dead record at once.
627
+ if (removeHostService(opts.dir, rec.pid)) recordCrash(opts.dir, `pi-loops (pid ${rec.pid}) went away without a clean exit; see ${HOST_LOG}`);
628
+ rec = undefined;
629
+ }
630
+ const spawns = readSpawnLog(opts.dir);
631
+ const action = decideEnsure({
632
+ automation: opts.automation,
633
+ auto: opts.auto,
634
+ stopped: opts.stopped,
635
+ envOff: process.env.PI_LOOPS_HOST?.toLowerCase() === "off",
636
+ now: Date.now(),
637
+ spawns,
638
+ host: rec ? { version: rec.version, piVersion: rec.piVersion } : undefined,
639
+ myVersion: opts.myVersion,
640
+ myPiVersion: opts.myPiVersion,
641
+ asked: opts.asked,
642
+ page: opts.page,
643
+ upgrader: spawns.upgradedBy ? { ...spawns.upgradedBy, alive: spawns.upgradedBy.pid === process.pid || (pidAlive(spawns.upgradedBy.pid) && (spawns.upgradedBy.procStart === undefined || procStart(spawns.upgradedBy.pid) === spawns.upgradedBy.procStart)) } : undefined,
644
+ });
645
+ if (action.kind === "upgrade") {
646
+ recordUpgrader(opts.dir, { pid: process.pid, version: opts.myVersion, procStart: procStart(process.pid) });
647
+ await askHost(opts.dir, { op: "upgrade" });
648
+ // Wait for it to go and start this version at once: the gap until the next tick is where an
649
+ // older window used to start the old code again. It may first let a turn in the page finish.
650
+ const old = rec!.pid;
651
+ const deadline = Date.now() + (opts.leaveWaitMs ?? LEAVE_WAIT_MS);
652
+ while (Date.now() < deadline) {
653
+ const now = readHostService(opts.dir);
654
+ if (!now || now.pid !== old || !hostServiceAlive(now)) break;
655
+ await new Promise((r) => setTimeout(r, 100));
656
+ }
657
+ const still = readHostService(opts.dir);
658
+ if (still && hostServiceAlive(still)) return { action };
659
+ const started = await startHost(opts, action);
660
+ // This version's host is up: the claim that held older clients back has done its job. Only then:
661
+ // the one that answers can still be the old host, slow to leave, and the claim must outlive it.
662
+ const now = readHostService(opts.dir);
663
+ if (started.up && now && compareVersions(now.version, opts.myVersion) >= 0) recordUpgrader(opts.dir, undefined);
664
+ return started;
665
+ }
666
+ if (action.kind !== "spawn") return { action };
667
+ if (opts.asked) clearCrashes(opts.dir);
668
+ return startHost(opts, action);
669
+ }
670
+
671
+ /** Start the host and, unless `opts.wait` is false, wait for it to record itself. */
672
+ async function startHost(opts: EnsureHostOptions, action: EnsureAction): Promise<{ action: EnsureAction; pid?: number; up?: boolean; error?: string }> {
673
+ recordSpawn(opts.dir, { kind: "attempt", by: process.pid });
674
+ let pid: number;
675
+ try {
676
+ pid = spawnHost(opts);
677
+ } catch (err: any) {
678
+ const error = err?.message ?? String(err);
679
+ recordSpawn(opts.dir, { kind: "outcome", by: process.pid, outcome: "no-node", error });
680
+ return { action, error };
681
+ }
682
+ if (opts.wait === false) return { action, pid };
683
+ const up = await waitForHost(opts.dir, pid, 10_000);
684
+ // A second host that lost the claim exits 0 at once: it is not a failed start, the winner is up.
685
+ const winner = readHostService(opts.dir);
686
+ const ok = up || (!!winner && hostServiceAlive(winner));
687
+ // One that found the stop mark a person set meanwhile left as asked (src/host.ts): not a failure to back off from.
688
+ if (!ok && readUiPrefs(opts.dir).host === "stopped") {
689
+ recordSpawn(opts.dir, { kind: "outcome", by: process.pid, outcome: "stopped" });
690
+ return { action, pid, up: false, error: "pi-loops was stopped by hand while it started" };
691
+ }
692
+ recordSpawn(opts.dir, { kind: "outcome", by: process.pid, outcome: ok ? "up" : "died", error: ok ? undefined : `pi-loops (pid ${pid}) exited during startup; see ${HOST_LOG}` });
693
+ return { action, pid, up: ok };
203
694
  }