@alphacoder-v0/pi-loops 0.22.7 → 0.24.0

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 (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  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 +37 -26
  17. package/docs/web-ui-parity.md +691 -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/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
package/src/cli.ts CHANGED
@@ -8,26 +8,35 @@
8
8
  * pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]
9
9
  * pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
10
10
  */
11
- import { spawn, spawnSync } from "node:child_process";
11
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
12
+ import { plural } from "./job-lines.ts";
12
13
  import * as fs from "node:fs";
14
+ import * as net from "node:net";
13
15
  import * as os from "node:os";
14
16
  import * as path from "node:path";
15
17
  import * as readline from "node:readline";
16
- import { fileURLToPath } from "node:url";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
19
  import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
18
20
  import { applyImport, automationOf, exportSession, defaultExportPath, importSession, inspectArchive, offerToEnable, parseActivateMode } from "./archive.ts";
19
- import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
21
+ import { API_KEY_REFUSAL, CONTROL_TIMEOUT_MS, COPY_KEY_MS, askHost, hostSocketPath, openConversations, renderHostSnapshot, requestLines, requestQueues, ringHost } from "./host-control-channel.ts";
22
+ import { heldTurnFor, stopRow } from "./engine.ts";
23
+ import { LeaseStore, procStart, sessionFileName } from "./lease.ts";
24
+ import { RequestStore } from "./requests.ts";
20
25
  import { FINDING_FIELDS, Inbox, type InboxEntry, inProject, resolveInboxRef } from "./inbox.ts";
21
- import { sameProject } from "./presence.ts";
26
+ import { cardLine, cardOrigin, inboxCard } from "./inbox-card.ts";
27
+ import { sameProject } from "./paths.ts";
22
28
  import { redact } from "./redact.ts";
23
- import { liveHost, stopHost } from "./host-control.ts";
29
+ import { crashPaused, crashPauseText, ensureHost, HOST_LOG, hostCommand, hostServiceAlive, LEAVE_WAIT_MS, readHostService, RESTART_WAIT_LINE, readSpawnLog, removeHostService, type EnsureHostOptions, type HostServiceRecord } from "./host-control.ts";
30
+ import { loadConfig, pageBindPort } from "./config.ts";
31
+ import { writeUiPref } from "./ui-prefs.ts";
24
32
  import { type UiPrefs, clearUiPref, readUiPrefs } from "./ui-prefs.ts";
25
- import { JobStore, defaultLoopsDir } from "./store.ts";
26
- import { automationBadge, previewText, readSessionHead } from "./session-head.ts";
33
+ import { JobStore, defaultLoopsDir, homeLoopsDir } from "./store.ts";
34
+ import { automationBadge, previewText, readSessionHead, shortSessionId } from "./session-head.ts";
27
35
  import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
28
36
  import { TriggerStore } from "./triggers.ts";
29
37
  import { PI_LOOPS_VERSION } from "./version.ts";
30
- import { stamp } from "./schedule.ts";
38
+ import { formatLocal, stamp } from "./schedule.ts";
39
+ import { canonicalDir } from "./trust.ts";
31
40
 
32
41
  /**
33
42
  * Which front end `pi-loops` opens when you do not say. A browser is the better window when one is
@@ -44,79 +53,96 @@ export function resolveUiMode(input: { web: boolean; tui: boolean; interactiveTt
44
53
  return input.remoteTty ? "terminal" : "web";
45
54
  }
46
55
 
56
+ /** pi's flags that run it without drawing in a terminal: one answer, a protocol, or a listing. */
57
+ const PI_NO_TERMINAL_FLAGS = new Set(["-p", "--print", "--mode", "--help", "-h", "--version", "-v", "--list-models", "--export"]);
58
+
59
+ /**
60
+ * The line `pi-loops` says, and exits 1 on, when it would start pi in a terminal that is not there:
61
+ * no `--web` or `--tui`, stdin or stdout not a terminal, and nothing in pi's arguments that runs
62
+ * without one. Started anyway, pi had nowhere to draw and exited with nothing said.
63
+ */
64
+ export function noTerminalRefusal(input: { web: boolean; tui: boolean; interactiveTty: boolean; piArgs: readonly string[] }): string | undefined {
65
+ if (input.web || input.tui || input.interactiveTty) return undefined;
66
+ if (input.piArgs.some((a) => PI_NO_TERMINAL_FLAGS.has(a.split("=")[0]))) return undefined;
67
+ return `pi-loops: there is no terminal here to open pi in; run pi-loops --web to open the page, or pi-loops -- -p "<prompt>" for one answer`;
68
+ }
69
+
47
70
  /** ssh and mosh set at least one of these; opening a browser on the far end helps nobody. */
48
71
  export function isRemoteTty(env: NodeJS.ProcessEnv = process.env): boolean {
49
72
  return ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "MOSH_CONNECTION"].some((k) => !!env[k]);
50
73
  }
51
74
 
52
- /** Flags `pi-loops` reads for itself when launching; everything else is pi's. */
75
+ /**
76
+ * Flags `pi-loops` reads for itself when launching; everything else is pi's. `port`, `host`,
77
+ * `allow-host` and `no-auth` are claimed only to be refused: the page outlives the command now, so
78
+ * its address lives in config.toml.
79
+ */
53
80
  const LAUNCH_FLAGS = new Set(["web", "tui", "port", "host", "allow-host", "open", "no-open", "no-auth", "loops-dir", "help"]);
54
81
  /** The ones that take the next argument as their value. */
55
82
  const LAUNCH_FLAGS_WITH_VALUE = new Set(["port", "host", "allow-host", "loops-dir"]);
56
83
 
57
84
  /**
58
85
  * Split `pi-loops <flags> <rest>` into ours and pi's. Unknown flags go to pi on purpose: the point
59
- * of this command is that you can type what you would have typed after `pi`.
86
+ * of this command is that you can type what you would have typed after `pi`. With `--`, everything
87
+ * after it is pi's, and a flag before it that is not ours is `misplaced`: it used to be dropped
88
+ * without a word, which lost a `--session` and opened a new conversation instead.
60
89
  */
61
- export function splitLaunchArgs(argv: string[]): { ours: string[]; pi: string[] } {
90
+ export function splitLaunchArgs(argv: string[]): { ours: string[]; pi: string[]; misplaced: string[] } {
62
91
  const dashdash = argv.indexOf("--");
63
- if (dashdash !== -1) return { ours: argv.slice(0, dashdash), pi: argv.slice(dashdash + 1) };
92
+ const before = dashdash === -1 ? argv : argv.slice(0, dashdash);
64
93
  const ours: string[] = [];
65
- const pi: string[] = [];
66
- for (let i = 0; i < argv.length; i++) {
67
- const arg = argv[i];
94
+ const rest: string[] = [];
95
+ for (let i = 0; i < before.length; i++) {
96
+ const arg = before[i];
68
97
  const name = arg.startsWith("--") ? arg.slice(2).split("=")[0] : undefined;
69
98
  if (name && LAUNCH_FLAGS.has(name)) {
70
99
  ours.push(arg);
71
- // `--port 4173` takes a value; `--web` does not.
72
- if (!arg.includes("=") && LAUNCH_FLAGS_WITH_VALUE.has(name) && argv[i + 1] && !argv[i + 1].startsWith("-")) ours.push(argv[++i]);
100
+ // `--loops-dir <dir>` takes a value; `--web` does not.
101
+ if (!arg.includes("=") && LAUNCH_FLAGS_WITH_VALUE.has(name) && before[i + 1] && !before[i + 1].startsWith("-")) ours.push(before[++i]);
73
102
  continue;
74
103
  }
75
- pi.push(arg);
104
+ rest.push(arg);
76
105
  }
77
- return { ours, pi };
106
+ if (dashdash === -1) return { ours, pi: rest, misplaced: [] };
107
+ return { ours, pi: argv.slice(dashdash + 1), misplaced: rest };
78
108
  }
79
109
 
80
- export const CLI_USAGE = [
81
- "pi-loops [--web | --tui] [--port <n>] [--no-auth] [<pi flags>]",
82
- " Start a session. On a local terminal that means the browser UI; over ssh, and anywhere",
83
- " without a terminal, it means pi itself. --web and --tui say which, and anything this",
84
- " command does not recognise is passed to pi (pi-loops --model anthropic/claude-opus-5).",
85
- " --no-auth drops the token: the browser UI is then open to anything on this machine.",
86
- " --host <addr> binds somewhere other than loopback, so a phone can reach it; the terminal",
87
- " prints a six-digit pairing code for that device. --allow-host <name,...> accepts a proxy,",
88
- " and a name resolved by public DNS puts the token back in charge of keeping strangers out.",
110
+ /** A command typed wrong: the entry point prints the message and that command's usage, and exits 2. */
111
+ export class UsageError extends Error {}
112
+
113
+ const CLI_USAGE = [
114
+ "pi-loops [--web | --tui] [--no-open] [--loops-dir <dir>] [<pi flags>] [-- <pi flags>]",
115
+ " Open a conversation: the browser page on a local terminal, pi itself over ssh (pi-loops web and pi-loops tui say which).",
89
116
  "",
90
117
  "pi-loops upgrade [--check]",
91
- " Install the newest release from the repository this copy came from. --check only looks.",
118
+ " Install the newest release from the repository this copy came from.",
92
119
  "",
93
120
  "pi-loops install-launcher [--dir <dir>]",
94
- " Put a `pi-loops` launcher on your PATH, so this command works from anywhere.",
121
+ " Put a `pi-loops` launcher on your PATH.",
95
122
  "",
96
123
  "pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]",
97
124
  " Bundle a session and its automation into a .pisession archive.",
98
- " Default session: the newest one for --cwd (default: the current directory).",
99
125
  "",
100
126
  "pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]",
101
- " Restore an archive into --cwd (default: the current directory).",
102
- " --activate-triggers: off never asks, on activates, ask (the default) prompts after the import.",
127
+ " Restore an archive into a project.",
103
128
  "",
104
129
  "pi-loops sessions [--all] [--limit <n>]",
105
- " List sessions, newest first: short id, when it started, its automation, what was first said in it.",
130
+ " List sessions, newest first.",
106
131
  "",
107
132
  "pi-loops inspect <file>",
108
133
  " Show what an archive contains without writing anything.",
109
134
  "",
110
- "pi-loops host status | abort <run-id|trace-id> | stop",
111
- " Look in on the background host that keeps the clock while no pi is open, or interrupt it.",
135
+ "pi-loops start | stop | restart | status [--url] | abort <run-id|trace-id|session-id>",
136
+ " Run, stop or check pi-loops in the background, or stop one run or turn.",
137
+ "",
138
+ "pi-loops pair",
139
+ " Print a one-use code that signs another device in to the page.",
112
140
  "",
113
141
  "pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level report|propose|act] [--overwrite]",
114
- " The packaged recipes; what one installs; copy its playbooks into a project (.agents/skills/<name>/).",
115
- " The setup script and the jobs need a pi open in the project: /recipe add <name> finds the copies and finishes.",
142
+ " List, show or install the packaged recipes.",
116
143
  "",
117
144
  "pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason <text>] [--json]",
118
- " The triage inbox with no pi open: what /inbox lists, and the two things it does to a finding.",
119
- " --json prints the shape docs/downstream.md fixes for programs.",
145
+ " List, claim or dismiss findings with no pi open.",
120
146
  ].join("\n");
121
147
 
122
148
  /**
@@ -184,9 +210,19 @@ export function pickSession(sessions: SessionFile[], opts: { id?: string; cwd: s
184
210
  if (byPrefix.length > 1) throw new Error(`session id ${opts.id} is ambiguous (${byPrefix.length} matches)`);
185
211
  throw new Error(`no session with id ${opts.id}`);
186
212
  }
213
+ // The nearest directory's newest session: this directory's own, else the closest one above or below
214
+ // it, which finds the project's session from a subdirectory without reaching past it to an
215
+ // unrelated parent (a `/tmp` session, say) that happens to be newer.
216
+ const cwd = canonicalDir(opts.cwd);
217
+ const distance = (dir: string): number => {
218
+ const d = canonicalDir(dir);
219
+ const rel = path.relative(d, cwd) || path.relative(cwd, d);
220
+ return rel ? rel.split(path.sep).length : 0;
221
+ };
187
222
  const here = sessions.filter((s) => sameProject(s.cwd, opts.cwd));
188
223
  if (!here.length) throw new Error(`no sessions recorded for ${opts.cwd}; pass --session <id>`);
189
- return here[0];
224
+ // Sorting is stable, so within one distance the newest (the order `sessions` came in) stays first.
225
+ return [...here].sort((a, b) => distance(a.cwd) - distance(b.cwd))[0];
190
226
  }
191
227
 
192
228
  interface Parsed {
@@ -238,19 +274,25 @@ function runChild(command: string, args: string[], env?: NodeJS.ProcessEnv): Pro
238
274
  process.stderr.write(`pi-loops: could not start ${command}: ${err?.message ?? err}\n`);
239
275
  resolve(1);
240
276
  });
241
- // A signal that reaches us reaches this child too: the launcher and what it starts — pi in a
242
- // terminal, or `web.mjs` — share the terminal's process group, so Ctrl-C arrives at both. The
243
- // browser front end starts its own pi *outside* that group on purpose (see `src/web.mjs`), so
244
- // that pi is ended by web.mjs asking it to quit rather than by the group's SIGINT, and gets to
245
- // hand the clock to a headless host on the way out. Either way, wait for the child to finish
246
- // rather than exiting first and leaving a session with no terminal attached to it.
277
+ // A signal that reaches us reaches this child too: pi in a terminal shares the terminal's
278
+ // process group, so Ctrl-C arrives at both. Wait for it to finish rather than exiting first and
279
+ // leaving a session with no terminal attached to it. The browser page's pis are not started
280
+ // here: they are children of pi-loops in the background.
247
281
  child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 0)));
248
282
  });
249
283
  }
250
284
 
251
285
  export async function runCli(argv: string[], out: (line: string) => void = console.log): Promise<number> {
252
- if (cliRoute(argv) === "launch") return launch(argv, out);
286
+ const route = cliRoute(argv);
287
+ if (route === "launch") return launch(argv, out);
288
+ if (route === "help") {
289
+ out(usageFor(argv[0]));
290
+ return 0;
291
+ }
253
292
  const { command, positional, flags } = parseCliArgs(argv);
293
+ // `host` is the 0.22.8 spelling of the service verbs; `stop help` asks how stop works.
294
+ const [verb, words] = command === "host" ? [positional[0] ?? "status", positional.slice(1)] : [command, positional];
295
+ if (!(SERVICE_VERBS.has(verb) && words.includes("help"))) refuseUnread(verb, words, flags);
254
296
  const str = (name: string): string | undefined => {
255
297
  const v = flags.get(name);
256
298
  return typeof v === "string" ? v : undefined;
@@ -270,7 +312,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
270
312
  // negative offset or a fraction, and change what is listed without ever saying so.
271
313
  const limitFlag = flags.get("limit");
272
314
  if (limitFlag !== undefined && (typeof limitFlag !== "string" || !/^\d+$/.test(limitFlag) || Number(limitFlag) < 1))
273
- throw new Error(`--limit takes a whole number of sessions ≥ 1; got ${limitFlag === true ? "nothing" : JSON.stringify(limitFlag)}`);
315
+ throw new UsageError(`--limit takes a whole number of sessions ≥ 1; got ${limitFlag === true ? "nothing" : JSON.stringify(limitFlag)}`);
274
316
  const limit = limitFlag === undefined ? 20 : Number(limitFlag);
275
317
  // `pickSession` refuses an unknown id, and there was no way to discover one.
276
318
  const sessions = listSessions(path.join(agentDir, "sessions"));
@@ -293,7 +335,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
293
335
  if (command === "inspect") {
294
336
  // What is in this archive, without writing anything.
295
337
  const file = positional[0];
296
- if (!file) throw new Error("inspect needs an archive path");
338
+ if (!file) throw new UsageError("inspect needs an archive path");
297
339
  const info = inspectArchive(path.resolve(file));
298
340
  out(`${plain(info.schema)} exported ${plain(info.createdAt)} from ${plain(info.sourceCwd)}`);
299
341
  // The counts come out of the same manifest and are only numbers if it says so.
@@ -324,7 +366,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
324
366
 
325
367
  if (command === "import") {
326
368
  const file = positional[0];
327
- if (!file) throw new Error("import needs an archive path");
369
+ if (!file) throw new UsageError("import needs an archive path");
328
370
  const mode = parseActivateMode(str("activate-triggers"));
329
371
  const jobStore = new JobStore(loopsDir);
330
372
  const triggerStore = new TriggerStore(loopsDir);
@@ -348,55 +390,18 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
348
390
  if (process.stdin.isTTY) out(detail); // what the dialog shows above the question
349
391
  return askYesNo(question);
350
392
  } : undefined);
351
- if (enabled.jobs || enabled.rules) out(`enabled ${enabled.jobs} cron job(s) and ${enabled.rules} trigger rule(s)`);
393
+ if (enabled.jobs || enabled.rules) out(`enabled ${enabled.jobs} ${enabled.jobs === 1 ? "loop" : "loops"} and ${enabled.rules} ${enabled.rules === 1 ? "rule" : "rules"}`);
352
394
  else if (!summary.automationEnabled && (summary.jobs.length || summary.rules.length)) out("automation is disabled; enable it with /cron enable <id> or re-import with --activate-triggers=on");
353
395
  return 0;
354
396
  }
355
397
 
356
- if (command === "host") {
357
- const sub = positional[0] ?? "status";
358
- if (sub === "status") {
359
- const res = await askHost(loopsDir, { op: "status" });
360
- if (!res) {
361
- // No answer is not the same as no host: one wedged on a hung MCP read still holds the
362
- // record. Fall back to it, so the user gets a pid and a log to look at.
363
- const rec = liveHost(loopsDir);
364
- if (rec) {
365
- out(`background host pid ${rec.pid} on ${rec.host} is not answering (started ${rec.startedAt})`);
366
- out(` log: ${path.join(loopsDir, "host.log")}`);
367
- out(` stop it with: pi-loops host stop`);
368
- return 1;
369
- }
370
- out("no background host is running (it runs only while no pi is open)");
371
- return 1;
372
- }
373
- if (!res.ok) throw new Error(res.error);
374
- out("background host");
375
- for (const line of renderHostSnapshot(res.snapshot!)) out(line);
376
- return 0;
377
- }
378
- if (sub === "abort") {
379
- const ref = positional[1];
380
- if (!ref) throw new Error("host abort needs a run id or a trigger trace id");
381
- // A run id is `run-<32 hex>`; anything else is treated as a trigger trace.
382
- const res = await askHost(loopsDir, ref.startsWith("run-") ? { op: "abort", runId: ref } : { op: "abort", traceId: ref });
383
- if (!res) throw new Error("no background host is running");
384
- if (!res.ok) throw new Error(res.error);
385
- out(res.aborted ? `aborted ${ref}` : `nothing running with id ${ref}`);
386
- return res.aborted ? 0 : 1;
387
- }
388
- if (sub === "stop") {
389
- const res = await askHost(loopsDir, { op: "stop" });
390
- if (res?.ok) {
391
- out("asked the background host to stop");
392
- return 0;
393
- }
394
- // It did not answer; SIGTERM the recorded pid, which is what `/cron host stop` does.
395
- const pid = stopHost(loopsDir);
396
- out(pid ? `background host (pid ${pid}) was not answering; sent SIGTERM` : "no background host is running");
397
- return pid ? 0 : 1;
398
- }
399
- throw new Error(`unknown host command ${JSON.stringify(sub)}`);
398
+ // 0.22.8 shipped `pi-loops host status|abort|stop`: they stay spellings of the same commands.
399
+ if (SERVICE_VERBS.has(command) || command === "host") {
400
+ const code = await serviceCommand(verb, words, flags, loopsDir, out);
401
+ // In 0.22.8 this spelling stopped the running process and recorded nothing; a script written then
402
+ // should learn that it now pauses everything.
403
+ if (command === "host" && verb === "stop" && code === 0 && !flags.has("help") && !words.includes("help")) out("pi-loops host stop: stopped; nothing runs until `pi-loops start` (every loop, every rule, and the plain jobs of closed conversations)");
404
+ return code;
400
405
  }
401
406
 
402
407
  const asked = command && command !== "help" && command !== "--help";
@@ -411,12 +416,17 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
411
416
  const spec = upgradeSpec(packageRoot(), manifest().name ?? "", agentDir, "<newer release>", configuredNpmSource(packageRoot(), manifest().name ?? ""));
412
417
  out(`if you expected it, the copy you are running may be older than the command: ${spec ? `pi install ${spec}` : checkoutUpgradeHint()}`);
413
418
  out("");
419
+ out("pi-loops --help lists the commands");
420
+ return 2;
414
421
  }
415
422
  out(CLI_USAGE);
416
- return asked ? 2 : 0;
423
+ return 0;
417
424
  }
418
425
 
419
- const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "inbox", "web", "upgrade", "install-launcher", "help"]);
426
+ /** What runs loops while no pi is open, started, stopped and looked at by name. */
427
+ const SERVICE_VERBS = new Set(["start", "stop", "restart", "status", "abort", "pair"]);
428
+
429
+ const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "start", "stop", "restart", "status", "abort", "pair", "host", "recipe", "inbox", "web", "upgrade", "install-launcher", "help"]);
420
430
 
421
431
  /**
422
432
  * `pi-loops inbox`: `/inbox list|claim|dismiss` for a shell with no pi open, and — with `--json` —
@@ -449,7 +459,12 @@ async function inboxCommandBody(positional: string[], opts: { all: boolean; json
449
459
  return 0;
450
460
  }
451
461
  if (!entries.length) out(opts.all ? "inbox: nothing new on this machine" : `inbox: nothing new in ${cwd} (--all lists every project)`);
452
- for (const e of entries) out(findingLine(e));
462
+ const names = new Map(new JobStore(loopsDir).load().map((j) => [j.id, j.name]));
463
+ const ctx = { loopName: (id: string) => names.get(id) };
464
+ for (const e of entries) {
465
+ const card = inboxCard(e, ctx);
466
+ if (card) out(plain(cardLine(card, `${e.id} `, ` (${path.basename(e.cwd) || e.cwd || "—"}, ${cardOrigin(card)}, ${formatLocal(Date.parse(e.created_at))})`)));
467
+ }
453
468
  return 0;
454
469
  }
455
470
  if (sub === "claim" || sub === "dismiss") {
@@ -462,8 +477,12 @@ async function inboxCommandBody(positional: string[], opts: { all: boolean; json
462
477
  // digits only pick the wording, since "no entry matching '1'" reads like a missing finding.
463
478
  const entry = resolveInboxRef(inbox.listNew(), ref);
464
479
  if (!entry) return fail(/^\d+$/.test(ref.trim()) ? `inbox ${sub} takes a finding id (inb-…) or a prefix of one, not a number` : `no new inbox entry matching '${ref}'`);
465
- const updated = await inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? "cli" : undefined, opts.reason);
466
- if (!updated) return fail(`inbox entry ${entry.id} is gone`);
480
+ // Resolved outside the lock; `decide` checks `new` again under it, so a claim racing this one
481
+ // (another shell, a terminal, the page) wins or loses as a whole.
482
+ const decided = await inbox.decide(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? { by: "cli" } : { reason: opts.reason });
483
+ if ("missing" in decided) return fail(`inbox entry ${entry.id} is gone`);
484
+ if ("already" in decided) return fail(`inbox entry ${entry.id} is already ${decided.already}`);
485
+ const updated = decided.ok;
467
486
  out(opts.json ? JSON.stringify({ finding: findingJson(updated) }) : `${updated.status} ${updated.id}`);
468
487
  return 0;
469
488
  }
@@ -482,12 +501,6 @@ function findingJson(e: InboxEntry): Record<string, unknown> {
482
501
  return out;
483
502
  }
484
503
 
485
- /** A person's line: the full id (it is what `claim` takes), the finding, where and when it came from. */
486
- function findingLine(e: InboxEntry): string {
487
- const mark = `${e.kind === "checkpoint" ? "⚑ " : ""}${e.verified ? "✓ " : ""}`;
488
- return `${e.id} ${mark}${plain(redact(e.text))} (${path.basename(e.cwd) || e.cwd || "—"}, ${e.source}, ${e.created_at})`;
489
- }
490
-
491
504
  /**
492
505
  * `pi-loops recipe`: the deterministic part of `/recipe`, for a shell with no pi open. `add` copies
493
506
  * the playbooks, lists the directory in `.git/info/exclude` and writes the record — and stops there:
@@ -503,7 +516,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
503
516
  for (const r of packaged) {
504
517
  const rec = installed.get(r.manifest.name);
505
518
  const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
506
- out(`${r.manifest.name.padEnd(17)} ${(rec ? `installed: ${rec.level}, ${n} job(s)` : "—").padEnd(28)} ${r.manifest.summary}`);
519
+ out(`${r.manifest.name.padEnd(17)} ${rec ? `[installed: ${rec.level}, ${plural(n, "loop")}] ` : ""}${r.manifest.line ?? r.manifest.summary}`);
507
520
  }
508
521
  for (const rec of installed.values()) if (!packaged.some((r) => r.manifest.name === rec.recipe)) out(`${rec.recipe.padEnd(17)} installed from ${rec.source} (${rec.level})`);
509
522
  if (!packaged.length && !installed.size) out("no recipes packaged, none installed here");
@@ -511,7 +524,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
511
524
  }
512
525
  if (sub === "show" || sub === "add") {
513
526
  const ref = positional[1];
514
- if (!ref) throw new Error(`recipe ${sub} needs a recipe name or a directory`);
527
+ if (!ref) throw new UsageError(`recipe ${sub} needs a recipe name or a directory`);
515
528
  const resolved = resolveRecipeRef(ref, { cwd });
516
529
  const recipe = loadRecipe(resolved.dir);
517
530
  const m = recipe.manifest;
@@ -535,7 +548,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
535
548
  const record = installFiles(recipe, cwd, level, { overwrite: opts.overwrite, source: resolved.source });
536
549
  const excluded = ensureExcluded(cwd, rel);
537
550
  if (m.needsTracker && fs.existsSync(path.join(cwd, TRACKER_FILE))) ensureExcluded(cwd, TRACKER_FILE, "file");
538
- out(`copied ${record.files.length} file(s) → ${plan.targetDir}/ (level ${level}${opts.level ? "" : ", the lowest; --level to choose"})`);
551
+ out(`copied ${plural(record.files.length, "file")} → ${plan.targetDir}/ (level ${level}${opts.level ? "" : ", the lowest; --level to choose"})`);
539
552
  for (const f of plan.changed) out(` ${f}: ${opts.overwrite ? "overwritten" : "kept as edited (--overwrite replaces it)"}`);
540
553
  out(excluded === "no-git" ? "not a git repository: nothing to exclude" : `.git/info/exclude: ${excluded === "added" ? "added" : "already listed"}`);
541
554
  if (m.needsTracker && !fs.existsSync(path.join(cwd, TRACKER_FILE))) out(`${m.name} reads docs/agents/issue-tracker.md and this project has none: /recipe add ${m.name} in pi writes it with you first`);
@@ -751,17 +764,70 @@ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise
751
764
  *
752
765
  * A bare word is never passed on, though: `pi-loops exprot` is a typo, and turning it into an
753
766
  * argument for pi would start a session and hide the mistake, which is the worst of both.
767
+ *
768
+ * `-h` or `--help` anywhere before a `--` asks how a subcommand works, and asking must not run it:
769
+ * `install-launcher --help` wrote the launcher and `upgrade --help` ran the upgrade. After `--`
770
+ * they are pi's.
754
771
  */
755
- export function cliRoute(argv: string[]): "launch" | "subcommand" {
772
+ export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" {
756
773
  const first = argv[0];
757
774
  if (first === undefined) return "launch";
758
- if (first === "--help" || first === "-h" || first === "help") return "subcommand";
775
+ if (first === "--help" || first === "-h" || first === "help") return "help";
759
776
  if (first.startsWith("-")) return "launch";
777
+ const dashdash = argv.indexOf("--");
778
+ const before = dashdash === -1 ? argv.slice(1) : argv.slice(1, dashdash);
779
+ if (Object.hasOwn(USAGE_SECTIONS, first) && before.some((a) => a === "-h" || a === "--help")) return "help";
760
780
  // Known or not, a word goes down the subcommand path — which is where the unknown-command
761
781
  // message lives, and `web` and `tui` are handled there as launch words.
762
782
  return "subcommand";
763
783
  }
764
784
 
785
+ /** Each subcommand, and the line that starts its section of `CLI_USAGE`. */
786
+ const USAGE_SECTIONS: Record<string, string> = {
787
+ web: "pi-loops [--web", tui: "pi-loops [--web", upgrade: "pi-loops upgrade", "install-launcher": "pi-loops install-launcher",
788
+ export: "pi-loops export", import: "pi-loops import", sessions: "pi-loops sessions", inspect: "pi-loops inspect",
789
+ start: "pi-loops start | stop", stop: "pi-loops start | stop", restart: "pi-loops start | stop", status: "pi-loops start | stop",
790
+ abort: "pi-loops start | stop", host: "pi-loops start | stop", pair: "pi-loops pair", recipe: "pi-loops recipe", inbox: "pi-loops inbox",
791
+ };
792
+
793
+ /** The flags every subcommand reads. */
794
+ const GLOBAL_FLAGS = new Set(["cwd", "help"]);
795
+
796
+ /** The flags each subcommand reads beside the global ones; any other is refused, not ignored. */
797
+ const VERB_FLAGS: Record<string, readonly string[]> = {
798
+ upgrade: ["check"], "install-launcher": ["dir"], import: ["activate-triggers"], export: ["session", "output", "exclude-triggers"],
799
+ sessions: ["limit", "all"], recipe: ["level", "overwrite"], inbox: ["all", "json", "reason"], start: ["foreground"], status: ["url"],
800
+ inspect: [], stop: [], restart: [], abort: [], pair: [],
801
+ };
802
+
803
+ /** How many words each subcommand reads after its name. */
804
+ const VERB_POSITIONALS: Record<string, number> = {
805
+ upgrade: 0, "install-launcher": 0, export: 0, sessions: 0, start: 0, stop: 0, restart: 0, status: 0, pair: 0,
806
+ import: 1, inspect: 1, abort: 1, recipe: 2, inbox: 2,
807
+ };
808
+
809
+ /**
810
+ * Refuses what `verb` does not read. Ignoring it did what nobody asked: `export <path>` wrote into
811
+ * the project, and `status --json` printed prose to a program.
812
+ */
813
+ function refuseUnread(verb: string, positional: readonly string[], flags: ReadonlyMap<string, unknown>): void {
814
+ const known = Object.hasOwn(VERB_FLAGS, verb) ? VERB_FLAGS[verb] : undefined;
815
+ if (!known) return;
816
+ for (const flag of flags.keys()) if (!GLOBAL_FLAGS.has(flag) && !known.includes(flag)) throw new UsageError(`${verb} does not take --${flag}`);
817
+ const extra = positional[VERB_POSITIONALS[verb]];
818
+ if (extra !== undefined) throw new UsageError(`${verb}: unexpected argument ${JSON.stringify(extra)}`);
819
+ }
820
+
821
+ /** The usage for `command`: its own section of `CLI_USAGE`, or all of it. */
822
+ export function usageFor(command: string | undefined): string {
823
+ const head = command !== undefined && Object.hasOwn(USAGE_SECTIONS, command) ? USAGE_SECTIONS[command] : undefined;
824
+ if (!head) return CLI_USAGE;
825
+ const lines = CLI_USAGE.split("\n");
826
+ const at = lines.findIndex((line) => line.startsWith(head));
827
+ const end = lines.indexOf("", at);
828
+ return lines.slice(at, end === -1 ? undefined : end).join("\n");
829
+ }
830
+
765
831
  /**
766
832
  * Where the launcher goes: `--dir` if given, otherwise the first of ~/.local/bin and /usr/local/bin
767
833
  * that is on PATH. One function, because the question asked before writing and the write itself
@@ -990,27 +1056,280 @@ function staleReason(old: string, entryDir: string, node: string): string {
990
1056
  }
991
1057
 
992
1058
  /**
993
- * Start a session: the browser front end, or pi itself. Both are complete pi sessions — the browser
994
- * one runs `pi --mode rpc` behind a page (src/web.mjs) — so this is a choice of window, not of
1059
+ * Start a session: the browser page, or pi itself. Both are complete pi sessions — the page's is a
1060
+ * `pi --mode rpc` that pi-loops runs in the background — so this is a choice of window, not of
995
1061
  * product, and `--tui` is always there when the guess is wrong.
996
1062
  */
997
1063
  async function launch(argv: string[], out: (line: string) => void): Promise<number> {
998
- const { ours, pi } = splitLaunchArgs(argv[0] === "web" || argv[0] === "tui" ? argv.slice(1) : argv);
999
- const has = (name: string) => ours.includes(`--${name}`);
1064
+ const { ours, pi, misplaced } = splitLaunchArgs(argv[0] === "web" || argv[0] === "tui" ? argv.slice(1) : argv);
1065
+ const has = (name: string) => ours.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
1000
1066
  if (has("help")) {
1001
1067
  out(CLI_USAGE);
1002
1068
  return 0;
1003
1069
  }
1004
- const mode = resolveUiMode({
1005
- web: argv[0] === "web" || has("web"),
1006
- tui: argv[0] === "tui" || has("tui"),
1007
- interactiveTty: !!process.stdout.isTTY && !!process.stdin.isTTY,
1008
- remoteTty: isRemoteTty(),
1009
- });
1010
- const withDefaults = applyRememberedModel(pi, readUiPrefs(loopsDir(ours)));
1070
+ if (misplaced.length) {
1071
+ out(`pi-loops: ${misplaced[0]} goes to pi, so it belongs after --: ${["pi-loops", ...ours, "--", ...misplaced, ...pi].join(" ")}`);
1072
+ return 2;
1073
+ }
1074
+ const asked = { web: argv[0] === "web" || has("web"), tui: argv[0] === "tui" || has("tui"), interactiveTty: !!process.stdout.isTTY && !!process.stdin.isTTY };
1075
+ const noTerminal = noTerminalRefusal({ ...asked, piArgs: pi });
1076
+ if (noTerminal) {
1077
+ out(noTerminal);
1078
+ return 1;
1079
+ }
1080
+ const mode = resolveUiMode({ ...asked, remoteTty: isRemoteTty() });
1081
+ const dir = loopsDir(ours);
1082
+ const withDefaults = applyRememberedModel(pi, readUiPrefs(dir));
1011
1083
  if (mode === "terminal") return runChild(process.env.PI_BIN || "pi", withDefaults);
1012
- const web = path.join(path.dirname(fileURLToPath(import.meta.url)), "web.mjs");
1013
- return runChild(process.execPath, [web, ...ours.filter((a) => a !== "--web" && a !== "--tui"), ...(withDefaults.length ? ["--", ...withDefaults] : [])]);
1084
+
1085
+ const refused = pageFlagRefusal(ours);
1086
+ if (refused) {
1087
+ for (const line of refused) out(line);
1088
+ return 2;
1089
+ }
1090
+ if (withDefaults.some((a) => a === "--resume" || a === "-r" || a.startsWith("--resume="))) {
1091
+ out("pi-loops: --resume is pi's picker, which the page has as /resume; pi-loops --session <id> opens one by id");
1092
+ return 2;
1093
+ }
1094
+ const target = pageTarget(withDefaults, process.cwd());
1095
+ if ("error" in target) {
1096
+ out(`pi-loops: ${target.error}`);
1097
+ return 1;
1098
+ }
1099
+
1100
+ // Refused here, before anything starts: the page would refuse it after pi-loops was up.
1101
+ if (target.piArgs.some((a) => a === "--api-key" || a.startsWith("--api-key="))) {
1102
+ out(`pi-loops: ${API_KEY_REFUSAL}`);
1103
+ return 1;
1104
+ }
1105
+ // Asking for the page is asking for pi-loops: a stop by hand ends here, or the process it starts
1106
+ // would find the mark and leave again. Every loop and rule resumes with it, which is said once
1107
+ // pi-loops is up; if it does not come up, the stop stays as it was.
1108
+ const liftedStop = readUiPrefs(dir).host === "stopped";
1109
+ if (liftedStop) clearUiPref(dir, "host");
1110
+ const ensured = await ensureHost(pageLaunchHost(dir));
1111
+ // Running, started, or being started by a window right now: each of these has a page coming.
1112
+ const why = ensured.error ?? (ensured.action.kind === "none" && ensured.action.reason !== "running" && ensured.action.reason !== IN_PROGRESS ? ensured.action.reason : undefined);
1113
+ const rec = await waitForSocket(dir, why ? 0 : PAGE_WAIT_MS);
1114
+ if (liftedStop) {
1115
+ if (rec) out(STOP_LIFTED_LINE);
1116
+ else writeUiPref(dir, "host", "stopped");
1117
+ }
1118
+ if (!rec) {
1119
+ out(why ? `pi-loops did not start: ${why}` : `pi-loops: the page did not come up within ${PAGE_WAIT_MS / 1000} s; see ${path.join(dir, HOST_LOG)}`);
1120
+ return 1;
1121
+ }
1122
+ const waiting = (r: HostServiceRecord | undefined) => (r && !r.url && r.pageWaiting ? pageWaitingLine(r.pageWaiting.reason, pageAddress(dir)) : undefined);
1123
+ const held = waiting(rec);
1124
+ if (held) {
1125
+ out(held);
1126
+ return 1;
1127
+ }
1128
+ // Asked while the page may still be binding: pi starts beside the bind, and the answer brings the
1129
+ // address. Opening may also wait for an older pi in that conversation to stop, up to 15 s.
1130
+ const tty = !!process.stdout.isTTY;
1131
+ const wantOpen = tty && !has("no-open") && !isRemoteTty();
1132
+ const slow = setTimeout(() => process.stderr.write("waiting for pi-loops to open the conversation…\n"), PAGE_OPEN_SLOW_MS);
1133
+ const res = await askHost(dir, { op: "page-open", cwd: process.cwd(), sessionId: target.sessionId, sessionFile: target.sessionFile, piArgs: target.piArgs, ...(wantOpen ? { openFile: true } : {}), ...(tty ? { copyKey: true } : {}) }, PAGE_OPEN_WAIT_MS).finally(() => clearTimeout(slow));
1134
+ if (!res) {
1135
+ out(`pi-loops (pid ${rec.pid}) is not answering; see ${path.join(dir, HOST_LOG)}`);
1136
+ return 1;
1137
+ }
1138
+ if (!res.ok) {
1139
+ out(waiting(readHostService(dir)) ?? `pi-loops: ${res.error}`);
1140
+ return 1;
1141
+ }
1142
+ // The browser opens on a file only this user can read, which holds the key: an argv is anyone's.
1143
+ // "Opened" only when an opener started; whether the browser could read the file it cannot tell.
1144
+ const opened = wantOpen && !!res.openFile && (await openBrowser(openFileUrl(res.openFile!)));
1145
+ const lines = pageLaunchLines({
1146
+ startedPid: ensured.up && ensured.pid === rec.pid ? rec.pid : undefined,
1147
+ url: res.url!,
1148
+ copyKey: res.copyKey,
1149
+ opened,
1150
+ tty,
1151
+ ignoredArgs: res.ignoredArgs,
1152
+ });
1153
+ for (const line of lines) out(line);
1154
+ return 0;
1155
+ }
1156
+
1157
+ /**
1158
+ * What a page launch asks `ensureHost` for: a start with nothing to run, but not a start by name.
1159
+ * Under `[host] auto = false` the page and its conversations count as windows, and the process
1160
+ * leaves with the last of them.
1161
+ */
1162
+ export function pageLaunchHost(dir: string): EnsureHostOptions {
1163
+ return {
1164
+ dir,
1165
+ packageDir: packageRoot(),
1166
+ piPackage: process.env.PI_LOOPS_PI_PACKAGE,
1167
+ startedBy: "the shell that ran pi-loops",
1168
+ automation: { loops: 0, rules: 0, pushServers: 0 },
1169
+ auto: true,
1170
+ stopped: false,
1171
+ page: true,
1172
+ myVersion: PI_LOOPS_VERSION,
1173
+ };
1174
+ }
1175
+
1176
+ /** What `pi-loops` prints when opening the page ends a stop by hand. */
1177
+ export const STOP_LIFTED_LINE = "pi-loops was stopped by hand; it runs again now (loops and rules resume)";
1178
+ /** What starts pi-loops again after `pi-loops stop`. */
1179
+ const STARTS_AGAIN = "pi-loops start, /pi-loops start or pi-loops opening the page";
1180
+
1181
+ /** How long `pi-loops` waits for pi-loops to take `page-open`. */
1182
+ export const PAGE_WAIT_MS = 10_000;
1183
+ /** `page-open` answers once a new conversation's pi has said its id: as long as the socket keeps it open, and a second for the answer. */
1184
+ const PAGE_OPEN_WAIT_MS = CONTROL_TIMEOUT_MS["page-open"] + 1_000;
1185
+ /** After this long, `pi-loops` says what it is waiting for. */
1186
+ const PAGE_OPEN_SLOW_MS = 3_000;
1187
+ /** How long `pi-loops start` waits for a start another window has under way. */
1188
+ const START_WAIT_MS = 10_000;
1189
+ /** What `ensureHost` answers while another window is starting pi-loops: wait for that start. */
1190
+ const IN_PROGRESS = "a start is in progress";
1191
+
1192
+ /**
1193
+ * The record of a live pi-loops once its control socket takes connections, or undefined after `ms`
1194
+ * with no live record at all. The socket opens a moment after the record is written, before the
1195
+ * page binds; a record that already has the page's address, or why it has none, is past that. A
1196
+ * start another window has under way counts: it is waited for, not reported as a failure. A socket
1197
+ * still shut at the deadline is left for `page-open` to find not answering.
1198
+ */
1199
+ async function waitForSocket(dir: string, ms: number): Promise<HostServiceRecord | undefined> {
1200
+ const deadline = Date.now() + ms;
1201
+ let live: HostServiceRecord | undefined;
1202
+ for (;;) {
1203
+ const rec = readHostService(dir);
1204
+ if (rec && hostServiceAlive(rec)) {
1205
+ if (rec.url || rec.pageWaiting || (await socketOpen(hostSocketPath(dir)))) return rec;
1206
+ live = rec;
1207
+ }
1208
+ if (Date.now() >= deadline) return live;
1209
+ await new Promise((r) => setTimeout(r, 20));
1210
+ }
1211
+ }
1212
+
1213
+ /** Whether something listens on the socket: connected, then closed again without a word. */
1214
+ function socketOpen(socketPath: string): Promise<boolean> {
1215
+ return new Promise((resolve) => {
1216
+ const socket = net.createConnection(socketPath);
1217
+ socket.once("connect", () => {
1218
+ socket.destroy();
1219
+ resolve(true);
1220
+ });
1221
+ socket.once("error", () => resolve(false));
1222
+ });
1223
+ }
1224
+
1225
+ /**
1226
+ * The address the page binds, as the port-held lines name it: the choice src/host.ts makes
1227
+ * (`pageBindPort`). A port chosen when it binds is not known here, and is not made up.
1228
+ */
1229
+ export function pageAddress(dir: string): string {
1230
+ const config = loadConfig(dir);
1231
+ const port = pageBindPort(config, { defaultDir: path.resolve(dir) === path.resolve(homeLoopsDir(getAgentDir())), env: process.env.PI_LOOPS_PAGE_PORT });
1232
+ return port ? `${config.pageHost}:${port}` : `${config.pageHost} (on a free port; pi-loops status names it)`;
1233
+ }
1234
+
1235
+ /** Why `pi-loops` opened nothing: the page's port is held. */
1236
+ export function pageWaitingLine(reason: "older-page" | "other-program", where: string): string {
1237
+ return reason === "older-page"
1238
+ ? `pi-loops: an older pi-loops page is still open on ${where}; quit it (Ctrl-C in its terminal) and this page takes its place within 30 seconds`
1239
+ : `pi-loops: ${where} is used by another program; set [page] port in config.toml, then pi-loops restart`;
1240
+ }
1241
+
1242
+ /**
1243
+ * The flags 0.23 took for the page's address, refused with the config.toml lines that replace them:
1244
+ * the page outlives this command, so a flag on it would last only until the next restart.
1245
+ */
1246
+ export function pageFlagRefusal(ours: string[]): string[] | undefined {
1247
+ const valueOf = (name: string): string | undefined => {
1248
+ const eq = ours.find((a) => a.startsWith(`--${name}=`));
1249
+ if (eq) return eq.slice(name.length + 3);
1250
+ const at = ours.indexOf(`--${name}`);
1251
+ return at !== -1 && ours[at + 1] && !ours[at + 1].startsWith("-") ? ours[at + 1] : undefined;
1252
+ };
1253
+ const has = (name: string) => ours.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
1254
+ const keys: string[] = [];
1255
+ if (has("port")) {
1256
+ const v = valueOf("port");
1257
+ keys.push(`port = ${v && /^\d+$/.test(v) ? Number(v) : 4200}`);
1258
+ }
1259
+ if (has("host")) keys.push(`host = ${JSON.stringify(valueOf("host") ?? "0.0.0.0")}`);
1260
+ if (has("allow-host")) {
1261
+ const names = (valueOf("allow-host") ?? "name").split(",").map((n) => n.trim()).filter(Boolean);
1262
+ keys.push(`allow_host = [${names.map((n) => JSON.stringify(n)).join(", ")}]`);
1263
+ }
1264
+ const lines: string[] = [];
1265
+ if (has("no-auth")) lines.push("pi-loops: --no-auth is gone in 0.24: the page is always on and always behind its token");
1266
+ if (keys.length) lines.push("pi-loops: the page outlives this command, so its address is set in config.toml:", " [page]", ...keys.map((k) => ` ${k}`), "then: pi-loops restart");
1267
+ return lines.length ? lines : undefined;
1268
+ }
1269
+
1270
+ /**
1271
+ * Which conversation the page opens: `--continue` (`-c`) and `--session <id|prefix>` name one pi
1272
+ * already has, found the way `pi-loops export` finds it and taken out of pi's arguments; without
1273
+ * either, a new one in `cwd` with the arguments as they are.
1274
+ */
1275
+ export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.join(getAgentDir(), "sessions")): { sessionId?: string; sessionFile?: string; piArgs: string[] } | { error: string } {
1276
+ const rest: string[] = [];
1277
+ let id: string | undefined;
1278
+ let latest = false;
1279
+ for (let i = 0; i < piArgs.length; i++) {
1280
+ const arg = piArgs[i];
1281
+ if (arg === "--continue" || arg === "-c") latest = true;
1282
+ else if (arg.startsWith("--session=")) id = arg.slice("--session=".length);
1283
+ else if (arg === "--session" && piArgs[i + 1] !== undefined) id = piArgs[++i];
1284
+ else rest.push(arg);
1285
+ }
1286
+ if (id === undefined && !latest) return { piArgs: rest };
1287
+ try {
1288
+ const picked = pickSession(listSessions(sessionsRoot), { id, cwd });
1289
+ return { sessionId: picked.id, sessionFile: picked.file, piArgs: rest };
1290
+ } catch (err: any) {
1291
+ return { error: err?.message ?? String(err) };
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * What `pi-loops` prints once the page has the conversation. The token is on a terminal only: it is
1297
+ * a way in, and stdout redirected to a file would keep it. No pairing code: one armed on every launch
1298
+ * is one any local process can burn; `pi-loops pair` arms one when somebody asks.
1299
+ */
1300
+ export function pageLaunchLines(i: { startedPid?: number; url: string; copyKey?: string; opened: boolean; tty: boolean; ignoredArgs?: string[] }): string[] {
1301
+ const lines: string[] = [];
1302
+ if (i.startedPid !== undefined) lines.push("started pi-loops in the background");
1303
+ lines.push(`pi-loops page: ${i.url} (${i.opened ? "opened in your browser" : "open it in a browser"})`);
1304
+ // A one-use key, not the token: scrollback, recordings and shared screens keep what is printed,
1305
+ // and the token is a shell for as long as pi-loops runs.
1306
+ if (i.tty && i.copyKey) lines.push(` ${i.opened ? "if it did not open, or" : "first visit"} from another browser: ${i.url}?open=${i.copyKey} (works once, for ${COPY_KEY_MS / 60_000} minutes)`);
1307
+ if (i.ignoredArgs?.length) {
1308
+ const flags = i.ignoredArgs.filter((a) => a.startsWith("-"));
1309
+ const named = flags.length ? flags : i.ignoredArgs;
1310
+ lines.push(` this conversation was already open; ${named.join(" ")} ${named.length === 1 ? "was" : "were"} not applied`);
1311
+ }
1312
+ return lines;
1313
+ }
1314
+
1315
+ /** What the opener is given for the launch file: a file: URL, which holds no key. */
1316
+ export function openFileUrl(file: string): string {
1317
+ return pathToFileURL(file).href;
1318
+ }
1319
+
1320
+ /** Hand `url` to the system's opener; true once it has started, false when there is none. */
1321
+ function openBrowser(url: string): Promise<boolean> {
1322
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1323
+ return new Promise((resolve) => {
1324
+ try {
1325
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1326
+ child.once("spawn", () => resolve(true));
1327
+ child.once("error", () => resolve(false)); // no opener on this machine: the address is printed
1328
+ child.unref();
1329
+ } catch {
1330
+ resolve(false); // a URL on stdout is enough
1331
+ }
1332
+ });
1014
1333
  }
1015
1334
 
1016
1335
  /**
@@ -1042,3 +1361,305 @@ export function applyRememberedModel(piArgs: string[], prefs: UiPrefs): string[]
1042
1361
  if (prefs.thinking && !said("thinking")) out.push("--thinking", prefs.thinking);
1043
1362
  return out;
1044
1363
  }
1364
+
1365
+ export type AbortTarget = { kind: "run"; runId: string } | { kind: "trace"; traceId: string } | { kind: "session"; sessionId: string } | { kind: "ambiguous"; matches: string[] };
1366
+
1367
+ /**
1368
+ * What `pi-loops abort <ref>` names. A run id (`run-…`) and anything that matches no session are
1369
+ * the socket's: a loop run, a rule check. A session id pi-loops knows (a lease or a queue), or a
1370
+ * unique prefix of one, names that conversation's turn. The ref is typed by hand and becomes a file
1371
+ * name, so one `sessionFileName` refuses is refused before any match.
1372
+ */
1373
+ export function abortTarget(ref: string, sessions: readonly string[]): AbortTarget {
1374
+ if (ref.startsWith("run-")) return { kind: "run", runId: ref };
1375
+ sessionFileName(ref);
1376
+ if (sessions.includes(ref)) return { kind: "session", sessionId: ref };
1377
+ // A short trace id can be the start of a session id too: fewer characters name a trace, as before.
1378
+ if (ref.length < SESSION_PREFIX_MIN) return { kind: "trace", traceId: ref };
1379
+ const matches = [...new Set(sessions.filter((id) => id.startsWith(ref)))].sort();
1380
+ if (matches.length === 1) return { kind: "session", sessionId: matches[0] };
1381
+ if (matches.length > 1) return { kind: "ambiguous", matches };
1382
+ return { kind: "trace", traceId: ref };
1383
+ }
1384
+
1385
+ /** How long `pi-loops abort` waits for the stop row to be read before it reports it as queued. */
1386
+ export const STOP_WAIT_MS = 5000;
1387
+ /** The shortest session prefix `pi-loops abort` resolves to a session; anything shorter is a trace id. */
1388
+ export const SESSION_PREFIX_MIN = 8;
1389
+
1390
+ /**
1391
+ * The line `pi-loops abort` ends with for a stop row's audit state, and the exit code. `undefined`:
1392
+ * the row is still queued. `done` and `stale` are the two states a stop ends in; any other is named,
1393
+ * so a new one is never read as queued.
1394
+ */
1395
+ export function stopReport(state: string | undefined, sessionId: string): { line: string; code: number } {
1396
+ const id8 = shortSessionId(sessionId);
1397
+ switch (state) {
1398
+ case "done":
1399
+ return { line: `stopped the turn in session ${id8}`, code: 0 };
1400
+ case "stale":
1401
+ return { line: `nothing was running in session ${id8}`, code: 1 };
1402
+ case undefined:
1403
+ return { line: `stop queued for session ${id8}; it takes effect when that conversation's pi or pi-loops reads it`, code: 0 };
1404
+ default:
1405
+ return { line: `the stop for session ${id8} ended as ${state}; see /triggers audit`, code: 1 };
1406
+ }
1407
+ }
1408
+
1409
+ /**
1410
+ * Stop a conversation's turn through a request row: written, rung to pi-loops in the background (a
1411
+ * window hears the file change itself), then waited for. The row leaves the file once its holder read
1412
+ * it, and the audit line says what it did; still in the file after STOP_WAIT_MS, it is queued.
1413
+ */
1414
+ async function stopThroughRow(loopsDir: string, sessionId: string, target: NonNullable<Parameters<typeof stopRow>[1]>, out: (line: string) => void): Promise<number> {
1415
+ const requests = new RequestStore(loopsDir);
1416
+ const row = stopRow(sessionId, target, "cli", { pid: process.pid, instance: "cli", procStart: procStart(process.pid), version: PI_LOOPS_VERSION });
1417
+ requests.append(row);
1418
+ await ringHost(loopsDir, `request:${sessionId}`);
1419
+ const deadline = Date.now() + STOP_WAIT_MS;
1420
+ while (requests.list(sessionId).some((r) => r.id === row.id) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
1421
+ const state = new TriggerStore(loopsDir).listAudit(1, (r) => r.type === "request" && r.traceId === row.id)[0]?.state;
1422
+ const report = stopReport(state, sessionId);
1423
+ out(report.line);
1424
+ return report.code;
1425
+ }
1426
+
1427
+ /**
1428
+ * `pi-loops start | stop | restart | status | abort`: the process that runs loops and rule checks
1429
+ * while no pi is open. It is pi-loops to the person using it; "host" is the code's word for it.
1430
+ */
1431
+ async function serviceCommand(verb: string, args: string[], flags: Map<string, string | true>, loopsDir: string, out: (line: string) => void): Promise<number> {
1432
+ // Asking how a command works must not run it: `pi-loops stop --help` stopped every loop.
1433
+ if (flags.has("help") || args.includes("help")) {
1434
+ const lines = CLI_USAGE.split("\n");
1435
+ const at = lines.findIndex((line) => line.startsWith(verb === "pair" ? "pi-loops pair" : "pi-loops start | stop"));
1436
+ for (const line of lines.slice(at, lines.indexOf("", at))) out(line);
1437
+ return 0;
1438
+ }
1439
+ const live = (): HostServiceRecord | undefined => {
1440
+ const rec = readHostService(loopsDir);
1441
+ return rec && hostServiceAlive(rec) ? rec : undefined;
1442
+ };
1443
+ if (verb === "status" && flags.has("url")) {
1444
+ // The token is in the URL so the page can be opened with one click; it is printed only when asked.
1445
+ const rec = live();
1446
+ if (!rec) {
1447
+ out("pi-loops is not running");
1448
+ return 1;
1449
+ }
1450
+ if (!rec.url) {
1451
+ out(`pi-loops (pid ${rec.pid}) has no page: its port could not be bound (see ${path.join(loopsDir, HOST_LOG)})`);
1452
+ return 1;
1453
+ }
1454
+ out(`${rec.url}?token=${rec.token}`);
1455
+ return 0;
1456
+ }
1457
+ if (verb === "pair") {
1458
+ const rec = live();
1459
+ if (!rec) {
1460
+ out("pi-loops is not running; pi-loops start starts it");
1461
+ return 1;
1462
+ }
1463
+ const res = await askHost(loopsDir, { op: "pair" });
1464
+ if (!res) {
1465
+ out(`pi-loops (pid ${rec.pid}) is not answering; see ${path.join(loopsDir, HOST_LOG)}`);
1466
+ return 1;
1467
+ }
1468
+ if (!res.ok) {
1469
+ out(`pi-loops: ${res.error}`);
1470
+ return 1;
1471
+ }
1472
+ out(`pairing code for another device: ${res.code} (10 minutes)`);
1473
+ for (const address of res.addresses ?? []) out(` on that device, open ${address} and type it`);
1474
+ if (!res.addresses?.length) out(` only this machine can reach ${rec.url ?? "the page"}: tailscale serve, or [page] host in config.toml, gives that device an address`);
1475
+ return 0;
1476
+ }
1477
+ if (verb === "start" && flags.has("foreground")) return runInForeground(loopsDir);
1478
+ if (verb === "start") {
1479
+ // A person asking by name: the stop mark goes, and it starts even with nothing to run yet. The
1480
+ // "started" mark is what keeps it, under `[host] auto = false`, after the last window: also when it
1481
+ // is already running, started by a window.
1482
+ writeUiPref(loopsDir, "host", "started");
1483
+ const running = live();
1484
+ if (running) {
1485
+ out("pi-loops is already running");
1486
+ return 0;
1487
+ }
1488
+ const res = await ensureHost({
1489
+ dir: loopsDir,
1490
+ packageDir: packageRoot(),
1491
+ piPackage: process.env.PI_LOOPS_PI_PACKAGE,
1492
+ startedBy: "the shell that ran pi-loops start",
1493
+ automation: { loops: 0, rules: 0, pushServers: 0 },
1494
+ auto: true,
1495
+ stopped: false,
1496
+ asked: true,
1497
+ myVersion: PI_LOOPS_VERSION,
1498
+ });
1499
+ let rec = live();
1500
+ // A window starting it at this moment is not a failure: that start is waited for.
1501
+ if (!rec && res.action.kind === "none" && res.action.reason === IN_PROGRESS) {
1502
+ for (const deadline = Date.now() + START_WAIT_MS; !rec && Date.now() < deadline; rec = live()) await new Promise((r) => setTimeout(r, 100));
1503
+ }
1504
+ if (rec) {
1505
+ out("pi-loops is running");
1506
+ return 0;
1507
+ }
1508
+ out(`pi-loops did not start: ${res.error ?? (res.action.kind === "none" ? res.action.reason : `see ${path.join(loopsDir, HOST_LOG)}`)}`);
1509
+ return 1;
1510
+ }
1511
+ if (verb === "restart") {
1512
+ const old = live();
1513
+ if (old) out(RESTART_WAIT_LINE);
1514
+ if (old && !(await stopAndWait(loopsDir, old, () => undefined, { reason: "restart" }))) {
1515
+ out(`pi-loops (pid ${old.pid}) has not exited after ${LEAVE_WAIT_MS / 1000} s, and it is marked stopped: run pi-loops start once it has gone (see ${path.join(loopsDir, HOST_LOG)})`);
1516
+ return 1;
1517
+ }
1518
+ return serviceCommand("start", [], new Map(), loopsDir, out);
1519
+ }
1520
+ if (verb === "status") {
1521
+ const res = await askHost(loopsDir, { op: "status" });
1522
+ if (!res) {
1523
+ // What waits for each conversation, from the files: a live host says it in its snapshot, and
1524
+ // a row waiting for a closed conversation is exactly what a person wants to see when it is down.
1525
+ const queues = () => requestLines(requestQueues(new RequestStore(loopsDir), new LeaseStore(loopsDir)), Date.now());
1526
+ // No answer is not the same as not running: one wedged on a hung MCP read still holds the
1527
+ // record. Fall back to it, so the person gets a pid and a log to look at.
1528
+ const rec = live();
1529
+ if (rec) {
1530
+ out(`pi-loops (pid ${rec.pid}) is not answering (started ${formatLocal(Date.parse(rec.startedAt))})`);
1531
+ out(` log: ${path.join(loopsDir, HOST_LOG)}`);
1532
+ out(" stop it with: pi-loops stop");
1533
+ for (const l of queues()) out(l);
1534
+ return 1;
1535
+ }
1536
+ // Why, when something keeps it from starting: each of these pauses every loop and check.
1537
+ const spawns = readSpawnLog(loopsDir);
1538
+ const why = readUiPrefs(loopsDir).host === "stopped" ? "it was stopped by hand; pi-loops start, or pi-loops opening the page, starts it" : process.env.PI_LOOPS_HOST?.toLowerCase() === "off" ? "PI_LOOPS_HOST=off is set" : !loadConfig(loopsDir).hostAuto ? "[host] auto = false in config.toml; pi-loops start starts it" : crashPaused(spawns, Date.now()) ? `${crashPauseText(spawns).replace("/pi-loops start", "pi-loops start")} (last: ${spawns.lastError ?? "?"})` : spawns.notBefore && Date.now() < Date.parse(spawns.notBefore) ? `it could not start (${spawns.lastError ?? `see ${HOST_LOG}`}); retrying after ${formatLocal(Date.parse(spawns.notBefore))}` : undefined;
1539
+ out(why ? `pi-loops is not running: ${why}` : "pi-loops is not running (a pi window starts it when there is work to run)");
1540
+ for (const l of queues()) out(l);
1541
+ return 1;
1542
+ }
1543
+ if (!res.ok) {
1544
+ // An error it answered with is about it, not about how this command was typed: no usage.
1545
+ out(`pi-loops is running but could not say how it is: ${res.error}`);
1546
+ out(` log: ${path.join(loopsDir, HOST_LOG)}`);
1547
+ return 1;
1548
+ }
1549
+ out("pi-loops");
1550
+ for (const line of renderHostSnapshot(res.snapshot!)) out(line);
1551
+ return 0;
1552
+ }
1553
+ if (verb === "abort") {
1554
+ const ref = args[0];
1555
+ if (!ref) throw new UsageError("abort needs a run id, a trigger trace id or a session id");
1556
+ const leases = new LeaseStore(loopsDir);
1557
+ let target: AbortTarget;
1558
+ try {
1559
+ target = abortTarget(ref, [...leases.list().map((l) => l.sessionId), ...new RequestStore(loopsDir).sessions()]);
1560
+ } catch {
1561
+ throw new Error(`${JSON.stringify(ref)} is not a run id, a trigger trace id or a session id`);
1562
+ }
1563
+ if (target.kind === "ambiguous") {
1564
+ out(`${ref} is a prefix of ${target.matches.length} sessions; give more of the id:`);
1565
+ for (const id of target.matches) out(` ${id}`);
1566
+ return 1;
1567
+ }
1568
+ if (target.kind === "session") return stopThroughRow(loopsDir, target.sessionId, { current: true, started_before: stamp() }, out);
1569
+ const res = await askHost(loopsDir, target.kind === "run" ? { op: "abort", runId: ref } : { op: "abort", traceId: ref });
1570
+ if (res) {
1571
+ if (!res.ok) throw new Error(res.error);
1572
+ out(res.aborted ? `aborted ${ref}` : `nothing running with id ${ref}`);
1573
+ return res.aborted ? 0 : 1;
1574
+ }
1575
+ // No answer, but a turn in a conversation is on the leases: the row reaches it where the socket could not.
1576
+ const held = target.kind === "run" ? heldTurnFor(leases.list(), ref) : undefined;
1577
+ if (held?.kind === "ambiguous") {
1578
+ out(`${ref} is a prefix of ${held.matches.length} running turns; give more of the id:`);
1579
+ for (const id of held.matches) out(` ${id}`);
1580
+ return 1;
1581
+ }
1582
+ if (!held) throw new Error("pi-loops is not running");
1583
+ return stopThroughRow(loopsDir, held.sessionId, { id: held.runId }, out);
1584
+ }
1585
+ if (verb === "stop") {
1586
+ // Stopped by hand stays stopped: without this mark the next window's tick starts it again.
1587
+ writeUiPref(loopsDir, "host", "stopped");
1588
+ const rec = live();
1589
+ if (!rec) {
1590
+ out(`pi-loops is not running; the stop is recorded, so nothing starts it until ${STARTS_AGAIN}`);
1591
+ return 0;
1592
+ }
1593
+ // How many conversations the page had open, asked before it goes: they close with it.
1594
+ const before = await askHost(loopsDir, { op: "status" });
1595
+ const open = before?.ok ? Number(before.snapshot?.pageOpen) || 0 : 0;
1596
+ // Waited on, so a process too wedged to run its SIGTERM handler is killed rather than left running.
1597
+ const stopped = await stopAndWait(loopsDir, rec, out, { reason: "stop", waitMs: 15_000 });
1598
+ if (open) out(` the page and its ${openConversations(open)} closed with it; pi-loops start opens it again`);
1599
+ return stopped ? 0 : 1;
1600
+ }
1601
+ throw new Error(`unknown command ${JSON.stringify(verb)}: pi-loops start | stop | restart | status | abort <id>`);
1602
+ }
1603
+
1604
+ /**
1605
+ * Stop it and wait until that process has gone (its pid, and its start time against a reused pid);
1606
+ * false when it is still there after `waitMs`. A restart starts the new one only after this, and
1607
+ * waits `LEAVE_WAIT_MS`: the old process first lets a turn in the browser page finish.
1608
+ */
1609
+ export async function stopAndWait(loopsDir: string, rec: HostServiceRecord, out: (line: string) => void, how: { reason: "stop" | "restart"; waitMs?: number }): Promise<boolean> {
1610
+ const waitMs = how.waitMs ?? LEAVE_WAIT_MS;
1611
+ await stopService(loopsDir, rec, out, how.reason);
1612
+ const deadline = Date.now() + waitMs;
1613
+ while (hostServiceAlive(rec) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
1614
+ if (!hostServiceAlive(rec)) return true;
1615
+ // Still there: a process whose event loop is blocked never runs its SIGTERM handler, and a person
1616
+ // asked for it to stop. As a window evicting a wedged one does.
1617
+ try {
1618
+ process.kill(rec.pid, "SIGKILL");
1619
+ } catch {
1620
+ /* it left between the look and the signal */
1621
+ }
1622
+ removeHostService(loopsDir, rec.pid);
1623
+ out(`pi-loops (pid ${rec.pid}) did not stop within ${Math.round(waitMs / 1000)}s; sent SIGKILL`);
1624
+ const killed = Date.now() + 5_000;
1625
+ while (hostServiceAlive(rec) && Date.now() < killed) await new Promise((r) => setTimeout(r, 100));
1626
+ return !hostServiceAlive(rec);
1627
+ }
1628
+
1629
+ /** Ask it to stop over its socket; one that does not answer gets SIGTERM, and its record is forgotten. */
1630
+ export async function stopService(loopsDir: string, rec: HostServiceRecord, out: (line: string) => void, reason: "stop" | "restart" = "stop"): Promise<void> {
1631
+ writeUiPref(loopsDir, "host", "stopped");
1632
+ const res = await askHost(loopsDir, { op: "stop", reason });
1633
+ if (res?.ok) {
1634
+ out(`asked pi-loops to stop; nothing starts it again until ${STARTS_AGAIN}`);
1635
+ return;
1636
+ }
1637
+ try {
1638
+ process.kill(rec.pid, "SIGTERM");
1639
+ } catch {
1640
+ /* gone already */
1641
+ }
1642
+ removeHostService(loopsDir, rec.pid);
1643
+ out(`pi-loops (pid ${rec.pid}) was not answering; sent SIGTERM`);
1644
+ }
1645
+
1646
+ /**
1647
+ * `pi-loops start --foreground`: run in this terminal until stopped, for a service manager the person
1648
+ * set up (a systemd user unit, a LaunchAgent). It exits 0 when stopped, so the manager does not
1649
+ * restart it, and 78 when it cannot run here at all; during a crash-pause it waits the pause out.
1650
+ */
1651
+ function runInForeground(loopsDir: string): Promise<number> {
1652
+ // A start like any other, as `systemctl start` is: a stop by hand lasts until the next start.
1653
+ clearUiPref(loopsDir, "host");
1654
+ const { node, args, env } = hostCommand({ dir: loopsDir, packageDir: packageRoot(), piPackage: process.env.PI_LOOPS_PI_PACKAGE, startedBy: "the service manager that ran pi-loops start --foreground" });
1655
+ const child = spawn(node, args, { stdio: "inherit", cwd: os.homedir(), env: { ...env, PI_LOOPS_HOST_MANAGED: "foreground" } });
1656
+ const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
1657
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) process.on(signal, forward(signal));
1658
+ return new Promise((resolve) => {
1659
+ child.once("error", (err) => {
1660
+ process.stderr.write(`pi-loops: ${err.message}\n`);
1661
+ resolve(78);
1662
+ });
1663
+ child.once("exit", (code, signal) => resolve(code ?? (signal ? 128 + (os.constants.signals[signal] ?? 0) : 1)));
1664
+ });
1665
+ }