@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
package/src/cli.ts CHANGED
@@ -8,26 +8,36 @@
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
18
  import { fileURLToPath } from "node:url";
17
19
  import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
20
+ import { isRemoteTty, openFileTarget, openUrl, thisMachine } from "./browser-open.ts";
18
21
  import { applyImport, automationOf, exportSession, defaultExportPath, importSession, inspectArchive, offerToEnable, parseActivateMode } from "./archive.ts";
19
- import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
22
+ import { API_KEY_REFUSAL, CONTROL_TIMEOUT_MS, COPY_KEY_MS, askHost, hostSocketPath, openConversations, renderHostSnapshot, requestLines, requestQueues, ringHost } from "./host-control-channel.ts";
23
+ import { heldTurnFor, stopRow } from "./engine.ts";
24
+ import { LeaseStore, procStart, sessionFileName } from "./lease.ts";
25
+ import { RequestStore } from "./requests.ts";
20
26
  import { FINDING_FIELDS, Inbox, type InboxEntry, inProject, resolveInboxRef } from "./inbox.ts";
21
- import { sameProject } from "./presence.ts";
27
+ import { cardLine, cardOrigin, inboxCard } from "./inbox-card.ts";
28
+ import { sameProject } from "./paths.ts";
22
29
  import { redact } from "./redact.ts";
23
- import { liveHost, stopHost } from "./host-control.ts";
30
+ 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";
31
+ import { loadConfig, pageBindPort } from "./config.ts";
32
+ import { writeUiPref } from "./ui-prefs.ts";
24
33
  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";
34
+ import { JobStore, defaultLoopsDir, homeLoopsDir } from "./store.ts";
35
+ import { automationBadge, previewText, readSessionHead, shortSessionId } from "./session-head.ts";
27
36
  import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
28
37
  import { TriggerStore } from "./triggers.ts";
29
38
  import { PI_LOOPS_VERSION } from "./version.ts";
30
- import { stamp } from "./schedule.ts";
39
+ import { formatLocal, stamp } from "./schedule.ts";
40
+ import { canonicalDir } from "./trust.ts";
31
41
 
32
42
  /**
33
43
  * Which front end `pi-loops` opens when you do not say. A browser is the better window when one is
@@ -44,79 +54,97 @@ export function resolveUiMode(input: { web: boolean; tui: boolean; interactiveTt
44
54
  return input.remoteTty ? "terminal" : "web";
45
55
  }
46
56
 
47
- /** ssh and mosh set at least one of these; opening a browser on the far end helps nobody. */
48
- export function isRemoteTty(env: NodeJS.ProcessEnv = process.env): boolean {
49
- return ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "MOSH_CONNECTION"].some((k) => !!env[k]);
57
+ /** pi's flags that run it without drawing in a terminal: one answer, a protocol, or a listing. */
58
+ const PI_NO_TERMINAL_FLAGS = new Set(["-p", "--print", "--mode", "--help", "-h", "--version", "-v", "--list-models", "--export"]);
59
+
60
+ /**
61
+ * The line `pi-loops` says, and exits 1 on, when it would start pi in a terminal that is not there:
62
+ * no `--web` or `--tui`, stdin or stdout not a terminal, and nothing in pi's arguments that runs
63
+ * without one. Started anyway, pi had nowhere to draw and exited with nothing said.
64
+ */
65
+ export function noTerminalRefusal(input: { web: boolean; tui: boolean; interactiveTty: boolean; piArgs: readonly string[] }): string | undefined {
66
+ if (input.web || input.tui || input.interactiveTty) return undefined;
67
+ if (input.piArgs.some((a) => PI_NO_TERMINAL_FLAGS.has(a.split("=")[0]))) return undefined;
68
+ 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`;
50
69
  }
51
70
 
52
- /** Flags `pi-loops` reads for itself when launching; everything else is pi's. */
71
+ /**
72
+ * Flags `pi-loops` reads for itself when launching; everything else is pi's. `port`, `host`,
73
+ * `allow-host` and `no-auth` are claimed only to be refused: the page outlives the command now, so
74
+ * its address lives in config.toml.
75
+ */
53
76
  const LAUNCH_FLAGS = new Set(["web", "tui", "port", "host", "allow-host", "open", "no-open", "no-auth", "loops-dir", "help"]);
54
77
  /** The ones that take the next argument as their value. */
55
78
  const LAUNCH_FLAGS_WITH_VALUE = new Set(["port", "host", "allow-host", "loops-dir"]);
56
79
 
57
80
  /**
58
81
  * 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`.
82
+ * of this command is that you can type what you would have typed after `pi`. With `--`, everything
83
+ * after it is pi's, and a flag before it that is not ours is `misplaced`: it used to be dropped
84
+ * without a word, which lost a `--session` and opened a new conversation instead.
60
85
  */
61
- export function splitLaunchArgs(argv: string[]): { ours: string[]; pi: string[] } {
86
+ export function splitLaunchArgs(argv: string[]): { ours: string[]; pi: string[]; misplaced: string[] } {
62
87
  const dashdash = argv.indexOf("--");
63
- if (dashdash !== -1) return { ours: argv.slice(0, dashdash), pi: argv.slice(dashdash + 1) };
88
+ const before = dashdash === -1 ? argv : argv.slice(0, dashdash);
64
89
  const ours: string[] = [];
65
- const pi: string[] = [];
66
- for (let i = 0; i < argv.length; i++) {
67
- const arg = argv[i];
90
+ const rest: string[] = [];
91
+ for (let i = 0; i < before.length; i++) {
92
+ const arg = before[i];
68
93
  const name = arg.startsWith("--") ? arg.slice(2).split("=")[0] : undefined;
69
94
  if (name && LAUNCH_FLAGS.has(name)) {
70
95
  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]);
96
+ // `--loops-dir <dir>` takes a value; `--web` does not.
97
+ if (!arg.includes("=") && LAUNCH_FLAGS_WITH_VALUE.has(name) && before[i + 1] && !before[i + 1].startsWith("-")) ours.push(before[++i]);
73
98
  continue;
74
99
  }
75
- pi.push(arg);
100
+ rest.push(arg);
76
101
  }
77
- return { ours, pi };
102
+ if (dashdash === -1) return { ours, pi: rest, misplaced: [] };
103
+ return { ours, pi: argv.slice(dashdash + 1), misplaced: rest };
78
104
  }
79
105
 
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.",
106
+ /** A command typed wrong: the entry point prints the message and that command's usage, and exits 2. */
107
+ export class UsageError extends Error {}
108
+
109
+ const CLI_USAGE = [
110
+ "pi-loops [--web | --tui] [--no-open] [--loops-dir <dir>] [<pi flags>] [-- <pi flags>]",
111
+ " Open a conversation: the browser page on a local terminal, pi itself over ssh (pi-loops web and pi-loops tui say which).",
112
+ "",
113
+ "pi-loops open [--cwd <dir>] [--loops-dir <dir>]",
114
+ " Open the page on this project's newest conversation, with a one-use link that signs a browser in.",
89
115
  "",
90
116
  "pi-loops upgrade [--check]",
91
- " Install the newest release from the repository this copy came from. --check only looks.",
117
+ " Install the newest release from the repository this copy came from.",
92
118
  "",
93
119
  "pi-loops install-launcher [--dir <dir>]",
94
- " Put a `pi-loops` launcher on your PATH, so this command works from anywhere.",
120
+ " Put a `pi-loops` launcher on your PATH.",
121
+ "",
122
+ "pi-loops uninstall-launcher [--dir <dir>]",
123
+ " Remove the `pi-loops` launcher, so no pi session writes it again.",
95
124
  "",
96
125
  "pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]",
97
126
  " Bundle a session and its automation into a .pisession archive.",
98
- " Default session: the newest one for --cwd (default: the current directory).",
99
127
  "",
100
128
  "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.",
129
+ " Restore an archive into a project.",
103
130
  "",
104
131
  "pi-loops sessions [--all] [--limit <n>]",
105
- " List sessions, newest first: short id, when it started, its automation, what was first said in it.",
132
+ " List sessions, newest first.",
106
133
  "",
107
134
  "pi-loops inspect <file>",
108
135
  " Show what an archive contains without writing anything.",
109
136
  "",
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.",
137
+ "pi-loops start | stop | restart | status [--url] | abort <run-id|trace-id|session-id>",
138
+ " Run, stop or check pi-loops in the background, or stop one run or turn.",
139
+ "",
140
+ "pi-loops pair",
141
+ " Print a one-use code that signs another device in to the page.",
112
142
  "",
113
143
  "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.",
144
+ " List, show or install the packaged recipes.",
116
145
  "",
117
146
  "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.",
147
+ " List, claim or dismiss findings with no pi open.",
120
148
  ].join("\n");
121
149
 
122
150
  /**
@@ -184,9 +212,19 @@ export function pickSession(sessions: SessionFile[], opts: { id?: string; cwd: s
184
212
  if (byPrefix.length > 1) throw new Error(`session id ${opts.id} is ambiguous (${byPrefix.length} matches)`);
185
213
  throw new Error(`no session with id ${opts.id}`);
186
214
  }
215
+ // The nearest directory's newest session: this directory's own, else the closest one above or below
216
+ // it, which finds the project's session from a subdirectory without reaching past it to an
217
+ // unrelated parent (a `/tmp` session, say) that happens to be newer.
218
+ const cwd = canonicalDir(opts.cwd);
219
+ const distance = (dir: string): number => {
220
+ const d = canonicalDir(dir);
221
+ const rel = path.relative(d, cwd) || path.relative(cwd, d);
222
+ return rel ? rel.split(path.sep).length : 0;
223
+ };
187
224
  const here = sessions.filter((s) => sameProject(s.cwd, opts.cwd));
188
225
  if (!here.length) throw new Error(`no sessions recorded for ${opts.cwd}; pass --session <id>`);
189
- return here[0];
226
+ // Sorting is stable, so within one distance the newest (the order `sessions` came in) stays first.
227
+ return [...here].sort((a, b) => distance(a.cwd) - distance(b.cwd))[0];
190
228
  }
191
229
 
192
230
  interface Parsed {
@@ -238,19 +276,25 @@ function runChild(command: string, args: string[], env?: NodeJS.ProcessEnv): Pro
238
276
  process.stderr.write(`pi-loops: could not start ${command}: ${err?.message ?? err}\n`);
239
277
  resolve(1);
240
278
  });
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.
279
+ // A signal that reaches us reaches this child too: pi in a terminal shares the terminal's
280
+ // process group, so Ctrl-C arrives at both. Wait for it to finish rather than exiting first and
281
+ // leaving a session with no terminal attached to it. The browser page's pis are not started
282
+ // here: they are children of pi-loops in the background.
247
283
  child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 0)));
248
284
  });
249
285
  }
250
286
 
251
287
  export async function runCli(argv: string[], out: (line: string) => void = console.log): Promise<number> {
252
- if (cliRoute(argv) === "launch") return launch(argv, out);
288
+ const route = cliRoute(argv);
289
+ if (route === "launch") return launch(argv, out);
290
+ if (route === "help") {
291
+ out(usageFor(argv[0]));
292
+ return 0;
293
+ }
253
294
  const { command, positional, flags } = parseCliArgs(argv);
295
+ // `host` is the 0.22.8 spelling of the service verbs; `stop help` asks how stop works.
296
+ const [verb, words] = command === "host" ? [positional[0] ?? "status", positional.slice(1)] : [command, positional];
297
+ if (!(SERVICE_VERBS.has(verb) && words.includes("help"))) refuseUnread(verb, words, flags);
254
298
  const str = (name: string): string | undefined => {
255
299
  const v = flags.get(name);
256
300
  return typeof v === "string" ? v : undefined;
@@ -260,8 +304,17 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
260
304
  const cwd = path.resolve(str("cwd") ?? process.cwd());
261
305
 
262
306
  if (command === "web" || command === "tui") return launch(argv, out);
307
+ // What the door tells a browser with no way in to run: the project's newest conversation, so
308
+ // signing in adds none, or a new one where there is none yet. The keyed link is printed with no
309
+ // terminal too: printing it is what this command is for.
310
+ if (command === "open") {
311
+ const named = str("loops-dir");
312
+ const args = [...(named ? [`--loops-dir=${named}`] : []), ...(listSessions(path.join(agentDir, "sessions")).some((s) => sameProject(s.cwd, cwd)) ? ["--continue"] : [])];
313
+ return launch(["web", ...args], out, cwd, { key: true });
314
+ }
263
315
  if (command === "upgrade") return upgrade(flags.has("check"), out);
264
- if (command === "install-launcher") return installLauncher(str("dir"), out);
316
+ if (command === "install-launcher") return installLauncher(launcherDirFlag(flags), out);
317
+ if (command === "uninstall-launcher") return uninstallLauncher(launcherDirFlag(flags), out);
265
318
  if (command === "recipe") return recipeCommand(positional, { level: str("level"), overwrite: flags.has("overwrite") }, cwd, loopsDir, out);
266
319
  if (command === "inbox") return inboxCommand(positional, { all: flags.has("all"), json: flags.has("json"), reason: flags.get("reason") }, cwd, loopsDir, out);
267
320
 
@@ -270,7 +323,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
270
323
  // negative offset or a fraction, and change what is listed without ever saying so.
271
324
  const limitFlag = flags.get("limit");
272
325
  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)}`);
326
+ throw new UsageError(`--limit takes a whole number of sessions ≥ 1; got ${limitFlag === true ? "nothing" : JSON.stringify(limitFlag)}`);
274
327
  const limit = limitFlag === undefined ? 20 : Number(limitFlag);
275
328
  // `pickSession` refuses an unknown id, and there was no way to discover one.
276
329
  const sessions = listSessions(path.join(agentDir, "sessions"));
@@ -293,7 +346,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
293
346
  if (command === "inspect") {
294
347
  // What is in this archive, without writing anything.
295
348
  const file = positional[0];
296
- if (!file) throw new Error("inspect needs an archive path");
349
+ if (!file) throw new UsageError("inspect needs an archive path");
297
350
  const info = inspectArchive(path.resolve(file));
298
351
  out(`${plain(info.schema)} exported ${plain(info.createdAt)} from ${plain(info.sourceCwd)}`);
299
352
  // The counts come out of the same manifest and are only numbers if it says so.
@@ -324,7 +377,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
324
377
 
325
378
  if (command === "import") {
326
379
  const file = positional[0];
327
- if (!file) throw new Error("import needs an archive path");
380
+ if (!file) throw new UsageError("import needs an archive path");
328
381
  const mode = parseActivateMode(str("activate-triggers"));
329
382
  const jobStore = new JobStore(loopsDir);
330
383
  const triggerStore = new TriggerStore(loopsDir);
@@ -348,55 +401,18 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
348
401
  if (process.stdin.isTTY) out(detail); // what the dialog shows above the question
349
402
  return askYesNo(question);
350
403
  } : undefined);
351
- if (enabled.jobs || enabled.rules) out(`enabled ${enabled.jobs} cron job(s) and ${enabled.rules} trigger rule(s)`);
404
+ if (enabled.jobs || enabled.rules) out(`enabled ${enabled.jobs} ${enabled.jobs === 1 ? "loop" : "loops"} and ${enabled.rules} ${enabled.rules === 1 ? "rule" : "rules"}`);
352
405
  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
406
  return 0;
354
407
  }
355
408
 
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)}`);
409
+ // 0.22.8 shipped `pi-loops host status|abort|stop`: they stay spellings of the same commands.
410
+ if (SERVICE_VERBS.has(command) || command === "host") {
411
+ const code = await serviceCommand(verb, words, flags, loopsDir, out);
412
+ // In 0.22.8 this spelling stopped the running process and recorded nothing; a script written then
413
+ // should learn that it now pauses everything.
414
+ 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)");
415
+ return code;
400
416
  }
401
417
 
402
418
  const asked = command && command !== "help" && command !== "--help";
@@ -411,12 +427,17 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
411
427
  const spec = upgradeSpec(packageRoot(), manifest().name ?? "", agentDir, "<newer release>", configuredNpmSource(packageRoot(), manifest().name ?? ""));
412
428
  out(`if you expected it, the copy you are running may be older than the command: ${spec ? `pi install ${spec}` : checkoutUpgradeHint()}`);
413
429
  out("");
430
+ out("pi-loops --help lists the commands");
431
+ return 2;
414
432
  }
415
433
  out(CLI_USAGE);
416
- return asked ? 2 : 0;
434
+ return 0;
417
435
  }
418
436
 
419
- const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "inbox", "web", "upgrade", "install-launcher", "help"]);
437
+ /** What runs loops while no pi is open, started, stopped and looked at by name. */
438
+ const SERVICE_VERBS = new Set(["start", "stop", "restart", "status", "abort", "pair"]);
439
+
440
+ const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "start", "stop", "restart", "status", "abort", "pair", "host", "recipe", "inbox", "web", "open", "upgrade", "install-launcher", "uninstall-launcher", "help"]);
420
441
 
421
442
  /**
422
443
  * `pi-loops inbox`: `/inbox list|claim|dismiss` for a shell with no pi open, and — with `--json` —
@@ -449,7 +470,12 @@ async function inboxCommandBody(positional: string[], opts: { all: boolean; json
449
470
  return 0;
450
471
  }
451
472
  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));
473
+ const names = new Map(new JobStore(loopsDir).load().map((j) => [j.id, j.name]));
474
+ const ctx = { loopName: (id: string) => names.get(id) };
475
+ for (const e of entries) {
476
+ const card = inboxCard(e, ctx);
477
+ if (card) out(plain(cardLine(card, `${e.id} `, ` (${path.basename(e.cwd) || e.cwd || "—"}, ${cardOrigin(card)}, ${formatLocal(Date.parse(e.created_at))})`)));
478
+ }
453
479
  return 0;
454
480
  }
455
481
  if (sub === "claim" || sub === "dismiss") {
@@ -462,8 +488,12 @@ async function inboxCommandBody(positional: string[], opts: { all: boolean; json
462
488
  // digits only pick the wording, since "no entry matching '1'" reads like a missing finding.
463
489
  const entry = resolveInboxRef(inbox.listNew(), ref);
464
490
  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`);
491
+ // Resolved outside the lock; `decide` checks `new` again under it, so a claim racing this one
492
+ // (another shell, a terminal, the page) wins or loses as a whole.
493
+ const decided = await inbox.decide(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? { by: "cli" } : { reason: opts.reason });
494
+ if ("missing" in decided) return fail(`inbox entry ${entry.id} is gone`);
495
+ if ("already" in decided) return fail(`inbox entry ${entry.id} is already ${decided.already}`);
496
+ const updated = decided.ok;
467
497
  out(opts.json ? JSON.stringify({ finding: findingJson(updated) }) : `${updated.status} ${updated.id}`);
468
498
  return 0;
469
499
  }
@@ -482,12 +512,6 @@ function findingJson(e: InboxEntry): Record<string, unknown> {
482
512
  return out;
483
513
  }
484
514
 
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
515
  /**
492
516
  * `pi-loops recipe`: the deterministic part of `/recipe`, for a shell with no pi open. `add` copies
493
517
  * the playbooks, lists the directory in `.git/info/exclude` and writes the record — and stops there:
@@ -503,7 +527,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
503
527
  for (const r of packaged) {
504
528
  const rec = installed.get(r.manifest.name);
505
529
  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}`);
530
+ out(`${r.manifest.name.padEnd(17)} ${rec ? `[installed: ${rec.level}, ${plural(n, "loop")}] ` : ""}${r.manifest.line ?? r.manifest.summary}`);
507
531
  }
508
532
  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
533
  if (!packaged.length && !installed.size) out("no recipes packaged, none installed here");
@@ -511,7 +535,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
511
535
  }
512
536
  if (sub === "show" || sub === "add") {
513
537
  const ref = positional[1];
514
- if (!ref) throw new Error(`recipe ${sub} needs a recipe name or a directory`);
538
+ if (!ref) throw new UsageError(`recipe ${sub} needs a recipe name or a directory`);
515
539
  const resolved = resolveRecipeRef(ref, { cwd });
516
540
  const recipe = loadRecipe(resolved.dir);
517
541
  const m = recipe.manifest;
@@ -535,7 +559,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
535
559
  const record = installFiles(recipe, cwd, level, { overwrite: opts.overwrite, source: resolved.source });
536
560
  const excluded = ensureExcluded(cwd, rel);
537
561
  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"})`);
562
+ out(`copied ${plural(record.files.length, "file")} → ${plan.targetDir}/ (level ${level}${opts.level ? "" : ", the lowest; --level to choose"})`);
539
563
  for (const f of plan.changed) out(` ${f}: ${opts.overwrite ? "overwritten" : "kept as edited (--overwrite replaces it)"}`);
540
564
  out(excluded === "no-git" ? "not a git repository: nothing to exclude" : `.git/info/exclude: ${excluded === "added" ? "added" : "already listed"}`);
541
565
  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 +775,70 @@ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise
751
775
  *
752
776
  * A bare word is never passed on, though: `pi-loops exprot` is a typo, and turning it into an
753
777
  * argument for pi would start a session and hide the mistake, which is the worst of both.
778
+ *
779
+ * `-h` or `--help` anywhere before a `--` asks how a subcommand works, and asking must not run it:
780
+ * `install-launcher --help` wrote the launcher and `upgrade --help` ran the upgrade. After `--`
781
+ * they are pi's.
754
782
  */
755
- export function cliRoute(argv: string[]): "launch" | "subcommand" {
783
+ export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" {
756
784
  const first = argv[0];
757
785
  if (first === undefined) return "launch";
758
- if (first === "--help" || first === "-h" || first === "help") return "subcommand";
786
+ if (first === "--help" || first === "-h" || first === "help") return "help";
759
787
  if (first.startsWith("-")) return "launch";
788
+ const dashdash = argv.indexOf("--");
789
+ const before = dashdash === -1 ? argv.slice(1) : argv.slice(1, dashdash);
790
+ if (Object.hasOwn(USAGE_SECTIONS, first) && before.some((a) => a === "-h" || a === "--help")) return "help";
760
791
  // Known or not, a word goes down the subcommand path — which is where the unknown-command
761
792
  // message lives, and `web` and `tui` are handled there as launch words.
762
793
  return "subcommand";
763
794
  }
764
795
 
796
+ /** Each subcommand, and the line that starts its section of `CLI_USAGE`. */
797
+ const USAGE_SECTIONS: Record<string, string> = {
798
+ web: "pi-loops [--web", tui: "pi-loops [--web", open: "pi-loops open", upgrade: "pi-loops upgrade", "install-launcher": "pi-loops install-launcher", "uninstall-launcher": "pi-loops uninstall-launcher",
799
+ export: "pi-loops export", import: "pi-loops import", sessions: "pi-loops sessions", inspect: "pi-loops inspect",
800
+ start: "pi-loops start | stop", stop: "pi-loops start | stop", restart: "pi-loops start | stop", status: "pi-loops start | stop",
801
+ abort: "pi-loops start | stop", host: "pi-loops start | stop", pair: "pi-loops pair", recipe: "pi-loops recipe", inbox: "pi-loops inbox",
802
+ };
803
+
804
+ /** The flags every subcommand reads. */
805
+ const GLOBAL_FLAGS = new Set(["cwd", "help"]);
806
+
807
+ /** The flags each subcommand reads beside the global ones; any other is refused, not ignored. */
808
+ const VERB_FLAGS: Record<string, readonly string[]> = {
809
+ upgrade: ["check"], "install-launcher": ["dir"], "uninstall-launcher": ["dir"], import: ["activate-triggers"], export: ["session", "output", "exclude-triggers"],
810
+ sessions: ["limit", "all"], recipe: ["level", "overwrite"], inbox: ["all", "json", "reason"], start: ["foreground"], status: ["url"],
811
+ inspect: [], stop: [], restart: [], abort: [], pair: [], open: ["loops-dir"],
812
+ };
813
+
814
+ /** How many words each subcommand reads after its name. */
815
+ const VERB_POSITIONALS: Record<string, number> = {
816
+ upgrade: 0, "install-launcher": 0, "uninstall-launcher": 0, export: 0, sessions: 0, start: 0, stop: 0, restart: 0, status: 0, pair: 0, open: 0,
817
+ import: 1, inspect: 1, abort: 1, recipe: 2, inbox: 2,
818
+ };
819
+
820
+ /**
821
+ * Refuses what `verb` does not read. Ignoring it did what nobody asked: `export <path>` wrote into
822
+ * the project, and `status --json` printed prose to a program.
823
+ */
824
+ function refuseUnread(verb: string, positional: readonly string[], flags: ReadonlyMap<string, unknown>): void {
825
+ const known = Object.hasOwn(VERB_FLAGS, verb) ? VERB_FLAGS[verb] : undefined;
826
+ if (!known) return;
827
+ for (const flag of flags.keys()) if (!GLOBAL_FLAGS.has(flag) && !known.includes(flag)) throw new UsageError(`${verb} does not take --${flag}`);
828
+ const extra = positional[VERB_POSITIONALS[verb]];
829
+ if (extra !== undefined) throw new UsageError(`${verb}: unexpected argument ${JSON.stringify(extra)}`);
830
+ }
831
+
832
+ /** The usage for `command`: its own section of `CLI_USAGE`, or all of it. */
833
+ export function usageFor(command: string | undefined): string {
834
+ const head = command !== undefined && Object.hasOwn(USAGE_SECTIONS, command) ? USAGE_SECTIONS[command] : undefined;
835
+ if (!head) return CLI_USAGE;
836
+ const lines = CLI_USAGE.split("\n");
837
+ const at = lines.findIndex((line) => line.startsWith(head));
838
+ const end = lines.indexOf("", at);
839
+ return lines.slice(at, end === -1 ? undefined : end).join("\n");
840
+ }
841
+
765
842
  /**
766
843
  * Where the launcher goes: `--dir` if given, otherwise the first of ~/.local/bin and /usr/local/bin
767
844
  * that is on PATH. One function, because the question asked before writing and the write itself
@@ -783,10 +860,7 @@ function isOnPath(dir: string, pathEnv: string = process.env.PATH ?? ""): boolea
783
860
  * is no; with nowhere to write there is nothing to ask, and the refusal says why.
784
861
  */
785
862
  export async function installLauncherWithConfirm(args: string[], confirm: (title: string, body: string) => Promise<boolean>, out: (line: string) => void): Promise<number | undefined> {
786
- const flag = parseCliArgs(["install-launcher", ...args]).flags.get("dir");
787
- // No shell has expanded `~` on the way here, so it is expanded now or becomes a directory named `~`.
788
- const dir = typeof flag === "string" ? flag.replace(/^~(?=$|\/)/, os.homedir()) : undefined;
789
- const target = launcherTarget(dir);
863
+ const target = launcherTarget(slashDir(args));
790
864
  if (!target) return installLauncher(undefined, out);
791
865
  const reach = isOnPath(target) ? ["Afterwards `pi-loops` starts a session from any directory."] : [`${target} is not on your PATH, so \`pi-loops\` will not be found until it is.`];
792
866
  const ok = await confirm("Put `pi-loops` on your PATH?", [`This writes a launcher into ${target}.`, "", "It is a small shell script that runs this package with this node.", ...reach].join("\n"));
@@ -796,6 +870,66 @@ export async function installLauncherWithConfirm(args: string[], confirm: (title
796
870
  return installLauncher(target, out);
797
871
  }
798
872
 
873
+ /**
874
+ * The `--dir` of a `/pi-loops install-launcher` or `uninstall-launcher` line. No shell has expanded
875
+ * `~` on the way here, so it is expanded now or becomes a directory named `~`.
876
+ */
877
+ export function slashDir(args: string[]): string | undefined {
878
+ return launcherDirFlag(parseCliArgs(["install-launcher", ...args]).flags)?.replace(/^~(?=$|\/)/, os.homedir());
879
+ }
880
+
881
+ /**
882
+ * The directory `--dir` names. A `--dir` with nothing after it, or with a flag after it, is an
883
+ * error rather than no `--dir`: read as the default, `uninstall-launcher --dir` removed the
884
+ * launcher on PATH that nobody had named.
885
+ */
886
+ function launcherDirFlag(flags: ReadonlyMap<string, string | true>): string | undefined {
887
+ const dir = flags.get("dir");
888
+ if (dir === undefined) return undefined;
889
+ if (dir === true || dir === "" || dir.startsWith("-")) throw new UsageError(`--dir takes a directory; got ${dir === true || dir === "" ? "nothing" : JSON.stringify(dir)}`);
890
+ return dir;
891
+ }
892
+
893
+ /**
894
+ * `uninstall-launcher`: remove the launcher, and remember that it was removed so no session start
895
+ * writes it again (`ui.json` `launcher: "declined"`, which `install-launcher` clears). Only a
896
+ * launcher of ours that this copy keeps (`current` or `stale`) is removed. Somebody's own
897
+ * `pi-loops`, or one of ours that runs another copy still on disk, is left as it is and the line
898
+ * says whose it is. With nothing there, the state asked for is already the state on disk.
899
+ */
900
+ export function uninstallLauncher(dir: string | undefined, out: (line: string) => void): number {
901
+ writeUiPref(defaultLoopsDir(getAgentDir()), "launcher", "declined");
902
+ const target = launcherTarget(dir);
903
+ const again = "no pi session writes it again; `/pi-loops install-launcher` in pi puts it back";
904
+ if (!target) {
905
+ out("no launcher to remove: neither ~/.local/bin nor /usr/local/bin is on your PATH; pass the directory with --dir <dir>");
906
+ out(` ${again}`);
907
+ return 0;
908
+ }
909
+ const { entryDir, node, agentDir } = launcherSource();
910
+ const { file, state } = launcherState(entryDir, node, agentDir, target);
911
+ if (state === "foreign") {
912
+ out(`left ${file} as it is: it is not a pi-loops launcher, so it is somebody's own`);
913
+ return 1;
914
+ }
915
+ if (state === "other") {
916
+ out(`left ${file} as it is: it runs another copy of pi-loops, ${launcherRecords(readLauncher(file) ?? "").entries[0]}, so remove it from that copy`);
917
+ return 1;
918
+ }
919
+ if (state === "absent") out(`no launcher at ${file}`);
920
+ else {
921
+ try {
922
+ fs.rmSync(file);
923
+ } catch (err: any) {
924
+ out(`could not remove ${file}: ${err?.message ?? err}`);
925
+ return 1;
926
+ }
927
+ out(`removed ${file}`);
928
+ }
929
+ out(` ${again}`);
930
+ return 0;
931
+ }
932
+
799
933
  /**
800
934
  * What a launcher says when no copy of this package is at any of the paths it knows. One line and
801
935
  * an exit code, because the alternative a person actually saw was Node's `Cannot find module` stack
@@ -847,14 +981,19 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
847
981
  // Whatever a session start was told once, this is the answer now: a launcher exists because
848
982
  // someone asked for it, so the offer is live again if the file ever goes.
849
983
  clearUiPref(defaultLoopsDir(getAgentDir()), "launcher");
984
+ clearUiPref(defaultLoopsDir(getAgentDir()), "launcherFailedIn");
850
985
  out(`wrote ${file}`);
851
986
  out(` it runs ${path.join(entryDir, "cli-entry.mjs")}`);
852
987
  out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
853
988
  return 0;
854
989
  }
855
990
 
856
- /** The marker line that says a `pi-loops` on someone's PATH is one this project wrote. */
857
- const LAUNCHER_MARKER = "# pi-loops launcher, written by";
991
+ /**
992
+ * The first two lines of every launcher this project has written, and the only thing that says a
993
+ * `pi-loops` on someone's PATH is ours. In that position and nowhere else: a script that quotes
994
+ * the marker further down is somebody's own, and uninstall-launcher deletes what this calls ours.
995
+ */
996
+ const LAUNCHER_HEADER = "#!/bin/sh\n# pi-loops launcher, written by `pi-loops install-launcher`.\n";
858
997
 
859
998
  /**
860
999
  * The launcher, as text. The one place the script is spelled: `installLauncher` writes this, and a
@@ -873,9 +1012,7 @@ export function launcherScript(entryDir: string, node: string, agentDir: string)
873
1012
  const entry = path.join(entryDir, "cli-entry.mjs");
874
1013
  const candidates = [entry, ...piInstallEntries(agentDir).filter((e) => e !== entry)].map((e) => JSON.stringify(e)).join(" ");
875
1014
  return [
876
- "#!/bin/sh",
877
- `${LAUNCHER_MARKER} \`pi-loops install-launcher\`.`,
878
- `node=${JSON.stringify(node)}`,
1015
+ `${LAUNCHER_HEADER}node=${JSON.stringify(node)}`,
879
1016
  '[ -x "$node" ] || node=$(command -v node) || node=node',
880
1017
  `for entry in ${candidates}; do`,
881
1018
  '\t[ -f "$entry" ] || continue',
@@ -908,21 +1045,47 @@ export type LauncherState = "absent" | "current" | "stale" | "other" | "foreign"
908
1045
  * What is at `<target>/pi-loops`, as far as this copy is concerned: nothing at all (`absent`), a
909
1046
  * launcher this project wrote that matches this node and this copy (`current`), one of ours that
910
1047
  * this copy should rewrite (`stale`), one of ours that belongs to another copy still on disk
911
- * (`other`), or a file carrying no marker of ours — someone's own wrapper, which is not ours to
912
- * rewrite either (`foreign`).
1048
+ * (`other`), or a file that does not open with our header — someone's own wrapper, which is not
1049
+ * ours to rewrite either (`foreign`).
913
1050
  *
914
1051
  * It takes the three things a launcher is written from rather than reading them, so the decision is
915
1052
  * a function of its arguments and the session-start caller is a few lines.
916
1053
  */
917
1054
  export function launcherState(entryDir: string, node: string, agentDir: string, target: string): { file: string; state: LauncherState } {
918
1055
  const file = path.join(target, "pi-loops");
919
- const found = readLauncher(file);
920
- if (found === undefined) return { file, state: "absent" };
921
- if (!found.includes(LAUNCHER_MARKER)) return { file, state: "foreign" };
1056
+ let found: string;
1057
+ try {
1058
+ // A link, dangling or not, a directory or a file nobody can read: this project writes none
1059
+ // of these, so whatever is there is somebody's own, and absent means only that nothing is.
1060
+ if (!fs.lstatSync(file).isFile()) return { file, state: "foreign" };
1061
+ found = fs.readFileSync(file, "utf8");
1062
+ } catch (err: any) {
1063
+ return { file, state: err?.code === "ENOENT" ? "absent" : "foreign" };
1064
+ }
1065
+ if (!found.startsWith(LAUNCHER_HEADER)) return { file, state: "foreign" };
922
1066
  if (found === launcherScript(entryDir, node, agentDir)) return { file, state: "current" };
923
1067
  return { file, state: oursToRewrite(found, entryDir) ? "stale" : "other" };
924
1068
  }
925
1069
 
1070
+ /**
1071
+ * What a session start does about the launcher: write one with no question, rewrite one of ours,
1072
+ * or nothing. pi's own installer links `pi` into a directory on PATH and says so, and one extra
1073
+ * keystroke after `pi install` is a cost the owner counts (#180), so a write asks nothing.
1074
+ *
1075
+ * A write needs all of these: a directory on PATH (`target`), nothing named `pi-loops` there, a
1076
+ * UI to say it in, a copy that `pi install` put there (a checkout is missing nothing), no earlier
1077
+ * `uninstall-launcher` (`declined`), and no failed write into this same directory (`failedIn`).
1078
+ * The last one is so that a directory nobody can write to is not tried, and warned about, in
1079
+ * every window; a PATH that names another directory is a new attempt.
1080
+ */
1081
+ export function launcherAction(o: { target: string | undefined; state: LauncherState | undefined; hasUI: boolean; declined: boolean; failedIn: string | undefined; installedByPi: boolean }): "write" | "refresh" | "none" {
1082
+ if (!o.target) return "none";
1083
+ if (o.state === "stale") return "refresh";
1084
+ if (o.state !== "absent") return "none";
1085
+ if (!o.hasUI || o.declined || !o.installedByPi || o.failedIn === o.target) return "none";
1086
+ return "write";
1087
+ }
1088
+
926
1089
  /**
927
1090
  * Whether a launcher of ours that no longer matches is this copy's to rewrite. Two cases, and only
928
1091
  * these two: the entry it recorded is gone — the package moved, which is the reading that ends in
@@ -938,6 +1101,43 @@ function oursToRewrite(script: string, entryDir: string): boolean {
938
1101
  return recorded === path.join(entryDir, "cli-entry.mjs") || !fs.existsSync(recorded);
939
1102
  }
940
1103
 
1104
+ /**
1105
+ * The write a session start makes: a new file in a directory that is already there, created
1106
+ * exclusively, so a path that is already there in any form, a dangling link included, fails the
1107
+ * create and is left as it is. It is quietly nothing, because the same file appearing between the
1108
+ * check and the write is somebody else's.
1109
+ */
1110
+ export function addLauncher(entryDir: string, node: string, agentDir: string, target: string): { kind: "added"; file: string } | { kind: "none" } | { kind: "failed"; message: string } {
1111
+ const file = path.join(target, "pi-loops");
1112
+ try {
1113
+ // A directory already on your PATH, as the docs promise: PATH can name ~/.local/bin before
1114
+ // anything has made it, and making it is for an install-launcher somebody asked for.
1115
+ if (!fs.statSync(target).isDirectory()) return { kind: "none" };
1116
+ } catch {
1117
+ // Not there, or not reachable: either way there is no directory on PATH to write into.
1118
+ return { kind: "none" };
1119
+ }
1120
+ let fd: number;
1121
+ try {
1122
+ fd = fs.openSync(file, "wx", 0o755);
1123
+ } catch (err: any) {
1124
+ if (err?.code === "EEXIST") return { kind: "none" };
1125
+ return { kind: "failed", message: `could not write ${file}: ${err?.message ?? err}` };
1126
+ }
1127
+ try {
1128
+ fs.writeSync(fd, launcherScript(entryDir, node, agentDir));
1129
+ fs.fchmodSync(fd, 0o755);
1130
+ } catch (err: any) {
1131
+ fs.closeSync(fd);
1132
+ // The file is ours, made a moment ago: a half-written one would read as somebody else's
1133
+ // from then on and never be written again, so it goes.
1134
+ fs.rmSync(file, { force: true });
1135
+ return { kind: "failed", message: `could not write ${file}: ${err?.message ?? err}` };
1136
+ }
1137
+ fs.closeSync(fd);
1138
+ return { kind: "added", file };
1139
+ }
1140
+
941
1141
  /** The file, or undefined when there is nothing there to read — which is the same news. */
942
1142
  function readLauncher(file: string): string | undefined {
943
1143
  try {
@@ -990,27 +1190,342 @@ function staleReason(old: string, entryDir: string, node: string): string {
990
1190
  }
991
1191
 
992
1192
  /**
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
1193
+ * Start a session: the browser page, or pi itself. Both are complete pi sessions — the page's is a
1194
+ * `pi --mode rpc` that pi-loops runs in the background — so this is a choice of window, not of
995
1195
  * product, and `--tui` is always there when the guess is wrong.
996
1196
  */
997
- 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}`);
1197
+ async function launch(argv: string[], out: (line: string) => void, cwd = process.cwd(), opts: { key?: boolean } = {}): Promise<number> {
1198
+ const { ours, pi, misplaced } = splitLaunchArgs(argv[0] === "web" || argv[0] === "tui" ? argv.slice(1) : argv);
1199
+ const has = (name: string) => ours.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
1000
1200
  if (has("help")) {
1001
1201
  out(CLI_USAGE);
1002
1202
  return 0;
1003
1203
  }
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)));
1204
+ if (misplaced.length) {
1205
+ out(`pi-loops: ${misplaced[0]} goes to pi, so it belongs after --: ${["pi-loops", ...ours, "--", ...misplaced, ...pi].join(" ")}`);
1206
+ return 2;
1207
+ }
1208
+ const asked = { web: argv[0] === "web" || has("web"), tui: argv[0] === "tui" || has("tui"), interactiveTty: !!process.stdout.isTTY && !!process.stdin.isTTY };
1209
+ const noTerminal = noTerminalRefusal({ ...asked, piArgs: pi });
1210
+ if (noTerminal) {
1211
+ out(noTerminal);
1212
+ return 1;
1213
+ }
1214
+ const mode = resolveUiMode({ ...asked, remoteTty: isRemoteTty() });
1215
+ const dir = loopsDir(ours);
1216
+ const withDefaults = applyRememberedModel(pi, readUiPrefs(dir));
1011
1217
  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] : [])]);
1218
+
1219
+ const refused = pageFlagRefusal(ours);
1220
+ if (refused) {
1221
+ for (const line of refused) out(line);
1222
+ return 2;
1223
+ }
1224
+ if (withDefaults.some((a) => a === "--resume" || a === "-r" || a.startsWith("--resume="))) {
1225
+ out("pi-loops: --resume is pi's picker, which the page has as /resume; pi-loops --session <id> opens one by id");
1226
+ return 2;
1227
+ }
1228
+ const target = pageTarget(withDefaults, cwd);
1229
+ if ("error" in target) {
1230
+ out(`pi-loops: ${target.error}`);
1231
+ return 1;
1232
+ }
1233
+
1234
+ // Refused here, before anything starts: the page would refuse it after pi-loops was up.
1235
+ if (target.piArgs.some((a) => a === "--api-key" || a.startsWith("--api-key="))) {
1236
+ out(`pi-loops: ${API_KEY_REFUSAL}`);
1237
+ return 1;
1238
+ }
1239
+ const tty = !!process.stdout.isTTY;
1240
+ const browser = !tty || has("no-open") ? "no" : isRemoteTty() ? "remote" : "yes";
1241
+ const key = tty || !!opts.key;
1242
+ const opened = await openPage(dir, { cwd, sessionId: target.sessionId, sessionFile: target.sessionFile, piArgs: target.piArgs }, { key, browser, host: pageLaunchHost(dir) }, out, () =>
1243
+ process.stderr.write("waiting for pi-loops to open the conversation…\n"),
1244
+ );
1245
+ return opened.code;
1246
+ }
1247
+
1248
+ /** Whether `openPage` tries a browser: `remote` is a terminal over ssh, where it never does. */
1249
+ export type PageBrowser = "yes" | "no" | "remote";
1250
+
1251
+ /**
1252
+ * Open the page on a conversation and say so in `out`: start pi-loops in the background as `host`
1253
+ * says when it is not running, ask it for `page-open`, print the one-use link, then try the browser
1254
+ * and say what happened. `pi-loops`, `pi-loops open` and `/pi-loops web` all come here. `link` is
1255
+ * the printed address with its key, for a caller that also copies it.
1256
+ */
1257
+ export async function openPage(
1258
+ dir: string,
1259
+ req: { cwd: string; sessionId?: string; sessionFile?: string; piArgs: string[] },
1260
+ how: { key: boolean; browser: PageBrowser; host: EnsureHostOptions },
1261
+ out: (line: string) => void,
1262
+ slowNote: () => void = () => {},
1263
+ onLink: (link: string) => void | Promise<void> = () => {},
1264
+ ): Promise<{ code: number; link?: string }> {
1265
+ // Asking for the page is asking for pi-loops: a stop by hand ends here, or the process it starts
1266
+ // would find the mark and leave again. Every loop and rule resumes with it, which is said once
1267
+ // pi-loops is up; if it does not come up, the stop stays as it was.
1268
+ const liftedStop = readUiPrefs(dir).host === "stopped";
1269
+ if (liftedStop) clearUiPref(dir, "host");
1270
+ const ensured = await ensureHost(how.host);
1271
+ // Running, started, or being started by a window right now: each of these has a page coming.
1272
+ const why = ensured.error ?? (ensured.action.kind === "none" && ensured.action.reason !== "running" && ensured.action.reason !== IN_PROGRESS ? ensured.action.reason : undefined);
1273
+ const rec = await waitForSocket(dir, why ? 0 : PAGE_WAIT_MS);
1274
+ if (liftedStop) {
1275
+ if (rec) out(STOP_LIFTED_LINE);
1276
+ else writeUiPref(dir, "host", "stopped");
1277
+ }
1278
+ if (!rec) {
1279
+ 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)}`);
1280
+ return { code: 1 };
1281
+ }
1282
+ const waiting = (r: HostServiceRecord | undefined) => (r && !r.url && r.pageWaiting ? pageWaitingLine(r.pageWaiting.reason, pageAddress(dir)) : undefined);
1283
+ const held = waiting(rec);
1284
+ if (held) {
1285
+ out(held);
1286
+ return { code: 1 };
1287
+ }
1288
+ // Asked while the page may still be binding: pi starts beside the bind, and the answer brings the
1289
+ // address. Opening may also wait for an older pi in that conversation to stop, up to 15 s.
1290
+ const wantOpen = how.browser === "yes";
1291
+ const slow = setTimeout(slowNote, PAGE_OPEN_SLOW_MS);
1292
+ const res = await askHost(dir, { op: "page-open", ...req, ...(wantOpen ? { openFile: true } : {}), ...(how.key ? { copyKey: true } : {}) }, PAGE_OPEN_WAIT_MS).finally(() => clearTimeout(slow));
1293
+ if (!res) {
1294
+ out(`pi-loops (pid ${rec.pid}) is not answering; see ${path.join(dir, HOST_LOG)}`);
1295
+ return { code: 1 };
1296
+ }
1297
+ if (!res.ok) {
1298
+ out(waiting(readHostService(dir)) ?? `pi-loops: ${res.error}`);
1299
+ return { code: 1 };
1300
+ }
1301
+ const link = pageLink({ url: res.url!, copyKey: res.copyKey, key: how.key });
1302
+ if (ensured.up && ensured.pid === rec.pid) out("started pi-loops in the background");
1303
+ out(`pi-loops: ${link}`);
1304
+ if (how.key) await onLink(link);
1305
+ // The browser opens on a file only this user can read, which holds the key: an argv is anyone's.
1306
+ const openTarget = wantOpen && res.openFile ? openFileTarget(res.openFile, thisMachine()) : undefined;
1307
+ const outcome: OpenOutcome = how.browser === "remote" ? "remote" : how.browser === "no" ? "not-asked" : openTarget ? await openOutcome(openTarget, res.openFile!) : "failed";
1308
+ for (const line of pageOutcomeLines({ outcome, key: how.key, ignoredArgs: res.ignoredArgs })) out(line);
1309
+ return { code: 0, ...(how.key ? { link } : {}) };
1310
+ }
1311
+
1312
+ /**
1313
+ * What a page launch asks `ensureHost` for: a start with nothing to run, but not a start by name.
1314
+ * Under `[host] auto = false` the page and its conversations count as windows, and the process
1315
+ * leaves with the last of them.
1316
+ */
1317
+ export function pageLaunchHost(dir: string): EnsureHostOptions {
1318
+ return {
1319
+ dir,
1320
+ packageDir: packageRoot(),
1321
+ piPackage: process.env.PI_LOOPS_PI_PACKAGE,
1322
+ startedBy: "the shell that ran pi-loops",
1323
+ automation: { loops: 0, rules: 0, pushServers: 0 },
1324
+ auto: true,
1325
+ stopped: false,
1326
+ page: true,
1327
+ myVersion: PI_LOOPS_VERSION,
1328
+ };
1329
+ }
1330
+
1331
+ /** What `pi-loops` prints when opening the page ends a stop by hand. */
1332
+ export const STOP_LIFTED_LINE = "pi-loops was stopped by hand; it runs again now (loops and rules resume)";
1333
+ /** What starts pi-loops again after `pi-loops stop`. */
1334
+ const STARTS_AGAIN = "pi-loops start, /pi-loops start or pi-loops opening the page";
1335
+
1336
+ /** How long `pi-loops` waits for pi-loops to take `page-open`. */
1337
+ export const PAGE_WAIT_MS = 10_000;
1338
+ /** `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. */
1339
+ const PAGE_OPEN_WAIT_MS = CONTROL_TIMEOUT_MS["page-open"] + 1_000;
1340
+ /** After this long, `pi-loops` says what it is waiting for. */
1341
+ const PAGE_OPEN_SLOW_MS = 3_000;
1342
+ /** How long `pi-loops start` waits for a start another window has under way. */
1343
+ const START_WAIT_MS = 10_000;
1344
+ /** What `ensureHost` answers while another window is starting pi-loops: wait for that start. */
1345
+ const IN_PROGRESS = "a start is in progress";
1346
+
1347
+ /**
1348
+ * The record of a live pi-loops once its control socket takes connections, or undefined after `ms`
1349
+ * with no live record at all. The socket opens a moment after the record is written, before the
1350
+ * page binds; a record that already has the page's address, or why it has none, is past that. A
1351
+ * start another window has under way counts: it is waited for, not reported as a failure. A socket
1352
+ * still shut at the deadline is left for `page-open` to find not answering.
1353
+ */
1354
+ async function waitForSocket(dir: string, ms: number): Promise<HostServiceRecord | undefined> {
1355
+ const deadline = Date.now() + ms;
1356
+ let live: HostServiceRecord | undefined;
1357
+ for (;;) {
1358
+ const rec = readHostService(dir);
1359
+ if (rec && hostServiceAlive(rec)) {
1360
+ if (rec.url || rec.pageWaiting || (await socketOpen(hostSocketPath(dir)))) return rec;
1361
+ live = rec;
1362
+ }
1363
+ if (Date.now() >= deadline) return live;
1364
+ await new Promise((r) => setTimeout(r, 20));
1365
+ }
1366
+ }
1367
+
1368
+ /** Whether something listens on the socket: connected, then closed again without a word. */
1369
+ function socketOpen(socketPath: string): Promise<boolean> {
1370
+ return new Promise((resolve) => {
1371
+ const socket = net.createConnection(socketPath);
1372
+ socket.once("connect", () => {
1373
+ socket.destroy();
1374
+ resolve(true);
1375
+ });
1376
+ socket.once("error", () => resolve(false));
1377
+ });
1378
+ }
1379
+
1380
+ /**
1381
+ * The address the page binds, as the port-held lines name it: the choice src/host.ts makes
1382
+ * (`pageBindPort`). A port chosen when it binds is not known here, and is not made up.
1383
+ */
1384
+ export function pageAddress(dir: string): string {
1385
+ const config = loadConfig(dir);
1386
+ const port = pageBindPort(config, { defaultDir: path.resolve(dir) === path.resolve(homeLoopsDir(getAgentDir())), env: process.env.PI_LOOPS_PAGE_PORT });
1387
+ return port ? `${config.pageHost}:${port}` : `${config.pageHost} (on a free port; pi-loops status names it)`;
1388
+ }
1389
+
1390
+ /** Why `pi-loops` opened nothing: the page's port is held. */
1391
+ export function pageWaitingLine(reason: "older-page" | "other-program", where: string): string {
1392
+ return reason === "older-page"
1393
+ ? `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`
1394
+ : `pi-loops: ${where} is used by another program; set [page] port in config.toml, then pi-loops restart`;
1395
+ }
1396
+
1397
+ /**
1398
+ * The flags 0.23 took for the page's address, refused with the config.toml lines that replace them:
1399
+ * the page outlives this command, so a flag on it would last only until the next restart.
1400
+ */
1401
+ export function pageFlagRefusal(ours: string[]): string[] | undefined {
1402
+ const valueOf = (name: string): string | undefined => {
1403
+ const eq = ours.find((a) => a.startsWith(`--${name}=`));
1404
+ if (eq) return eq.slice(name.length + 3);
1405
+ const at = ours.indexOf(`--${name}`);
1406
+ return at !== -1 && ours[at + 1] && !ours[at + 1].startsWith("-") ? ours[at + 1] : undefined;
1407
+ };
1408
+ const has = (name: string) => ours.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
1409
+ const keys: string[] = [];
1410
+ if (has("port")) {
1411
+ const v = valueOf("port");
1412
+ keys.push(`port = ${v && /^\d+$/.test(v) ? Number(v) : 4200}`);
1413
+ }
1414
+ if (has("host")) keys.push(`host = ${JSON.stringify(valueOf("host") ?? "0.0.0.0")}`);
1415
+ if (has("allow-host")) {
1416
+ const names = (valueOf("allow-host") ?? "name").split(",").map((n) => n.trim()).filter(Boolean);
1417
+ keys.push(`allow_host = [${names.map((n) => JSON.stringify(n)).join(", ")}]`);
1418
+ }
1419
+ const lines: string[] = [];
1420
+ 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");
1421
+ 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");
1422
+ return lines.length ? lines : undefined;
1423
+ }
1424
+
1425
+ /**
1426
+ * Which conversation the page opens: `--continue` (`-c`) and `--session <id|prefix>` name one pi
1427
+ * already has, found the way `pi-loops export` finds it and taken out of pi's arguments; without
1428
+ * either, a new one in `cwd` with the arguments as they are.
1429
+ */
1430
+ export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.join(getAgentDir(), "sessions")): { sessionId?: string; sessionFile?: string; piArgs: string[] } | { error: string } {
1431
+ const rest: string[] = [];
1432
+ let id: string | undefined;
1433
+ let latest = false;
1434
+ for (let i = 0; i < piArgs.length; i++) {
1435
+ const arg = piArgs[i];
1436
+ if (arg === "--continue" || arg === "-c") latest = true;
1437
+ else if (arg.startsWith("--session=")) id = arg.slice("--session=".length);
1438
+ else if (arg === "--session" && piArgs[i + 1] !== undefined) id = piArgs[++i];
1439
+ else rest.push(arg);
1440
+ }
1441
+ if (id === undefined && !latest) return { piArgs: rest };
1442
+ try {
1443
+ const picked = pickSession(listSessions(sessionsRoot), { id, cwd });
1444
+ return { sessionId: picked.id, sessionFile: picked.file, piArgs: rest };
1445
+ } catch (err: any) {
1446
+ return { error: err?.message ?? String(err) };
1447
+ }
1448
+ }
1449
+
1450
+ /** What the browser did, as `openPage` says it on the second line. */
1451
+ export type OpenOutcome = "opened" | "unconfirmed" | "failed" | "remote" | "not-asked";
1452
+
1453
+ /**
1454
+ * What `/pi-loops web` asks `page-open` for: the conversation it is in once pi has saved it. Before
1455
+ * the first answer (a path with no file) and under `--no-session` (no path) the page has nothing to
1456
+ * show under that id, so it asks with no id, which opens a new conversation in the project.
1457
+ */
1458
+ export function webPageRequest(here: { cwd: string; sessionId?: string; sessionFile?: string }): { cwd: string; sessionId?: string; sessionFile?: string; piArgs: string[] } {
1459
+ const saved = here.sessionId && here.sessionFile && fs.existsSync(here.sessionFile);
1460
+ return saved ? { cwd: here.cwd, sessionId: here.sessionId, sessionFile: here.sessionFile, piArgs: [] } : { cwd: here.cwd, piArgs: [] };
1461
+ }
1462
+
1463
+ /**
1464
+ * The address `pi-loops` prints: on a terminal, with a one-use key that works for `COPY_KEY_MS`.
1465
+ * Never the token: scrollback, recordings and shared screens keep what is printed, and the token is
1466
+ * a shell for as long as pi-loops runs. `key` is false when stdout is not a terminal, since a
1467
+ * redirected stdout would keep the key, except for `pi-loops open`, which is asked for exactly that
1468
+ * link. No pairing code: one armed on every launch is one any local
1469
+ * process can burn; `pi-loops pair` arms one when somebody asks.
1470
+ */
1471
+ export function pageLink(i: { url: string; copyKey?: string; key: boolean }): string {
1472
+ return i.key && i.copyKey ? `${i.url}?open=${i.copyKey}` : i.url;
1473
+ }
1474
+
1475
+ /**
1476
+ * An opener's exit code does not say a page loaded: rundll32 exits 0 for a target it cannot open.
1477
+ * The background process removes the launch file when the browser spends its key, so the file
1478
+ * going away is the one sign that a browser got in.
1479
+ */
1480
+ export async function keySpent(file: string, waitMs = OPEN_CONFIRM_MS, stopped: () => boolean = () => false): Promise<boolean> {
1481
+ const until = Date.now() + waitMs;
1482
+ while (Date.now() < until && !stopped()) {
1483
+ if (!fs.existsSync(file)) return true;
1484
+ await new Promise((r) => setTimeout(r, 100));
1485
+ }
1486
+ return !fs.existsSync(file);
1487
+ }
1488
+
1489
+ /**
1490
+ * The opener and the key watch run side by side: a browser that spends the key is "opened" at once,
1491
+ * even while `xdg-open` is still running; an opener that fails before the key is spent is "failed".
1492
+ */
1493
+ export async function openOutcome(target: string, file: string, open: (t: string) => Promise<boolean> = openUrl, waitMs = OPEN_CONFIRM_MS): Promise<OpenOutcome> {
1494
+ let openerFailed = false;
1495
+ const spent = keySpent(file, waitMs, () => openerFailed);
1496
+ const opener = open(target).then((ok) => ((openerFailed = !ok), ok));
1497
+ const first = await Promise.race([spent.then((s) => (s ? "spent" : "not-spent")), opener.then((ok) => (ok ? "ran" : "failed"))]);
1498
+ if (first === "spent") return "opened";
1499
+ if (first === "failed") return "failed";
1500
+ if (first === "ran") return (await spent) ? "opened" : "unconfirmed";
1501
+ return (await opener) ? "unconfirmed" : "failed";
1502
+ }
1503
+
1504
+ /** How long a launch waits for the browser to spend the key before it says it cannot tell. */
1505
+ const OPEN_CONFIRM_MS = 8_000;
1506
+
1507
+ /** The lines after the link: what the browser did, and the pi arguments an open conversation did not take. */
1508
+ export function pageOutcomeLines(i: { outcome: OpenOutcome; key: boolean; ignoredArgs?: string[] }): string[] {
1509
+ const once = `it works once, for ${COPY_KEY_MS / 60_000} minutes`;
1510
+ const lines = [
1511
+ !i.key
1512
+ ? " pi-loops open prints a link that signs a browser in"
1513
+ : i.outcome === "opened"
1514
+ ? " opened in your browser"
1515
+ : i.outcome === "remote"
1516
+ ? " open the link above in a browser on this machine"
1517
+ : i.outcome === "unconfirmed"
1518
+ ? ` if no browser opened, open the link above (${once})`
1519
+ : i.outcome === "failed"
1520
+ ? ` could not open a browser here; open the link above (${once})`
1521
+ : ` open the link above in a browser (${once})`,
1522
+ ];
1523
+ if (i.ignoredArgs?.length) {
1524
+ const flags = i.ignoredArgs.filter((a) => a.startsWith("-"));
1525
+ const named = flags.length ? flags : i.ignoredArgs;
1526
+ lines.push(` this conversation was already open; ${named.join(" ")} ${named.length === 1 ? "was" : "were"} not applied`);
1527
+ }
1528
+ return lines;
1014
1529
  }
1015
1530
 
1016
1531
  /**
@@ -1042,3 +1557,305 @@ export function applyRememberedModel(piArgs: string[], prefs: UiPrefs): string[]
1042
1557
  if (prefs.thinking && !said("thinking")) out.push("--thinking", prefs.thinking);
1043
1558
  return out;
1044
1559
  }
1560
+
1561
+ export type AbortTarget = { kind: "run"; runId: string } | { kind: "trace"; traceId: string } | { kind: "session"; sessionId: string } | { kind: "ambiguous"; matches: string[] };
1562
+
1563
+ /**
1564
+ * What `pi-loops abort <ref>` names. A run id (`run-…`) and anything that matches no session are
1565
+ * the socket's: a loop run, a rule check. A session id pi-loops knows (a lease or a queue), or a
1566
+ * unique prefix of one, names that conversation's turn. The ref is typed by hand and becomes a file
1567
+ * name, so one `sessionFileName` refuses is refused before any match.
1568
+ */
1569
+ export function abortTarget(ref: string, sessions: readonly string[]): AbortTarget {
1570
+ if (ref.startsWith("run-")) return { kind: "run", runId: ref };
1571
+ sessionFileName(ref);
1572
+ if (sessions.includes(ref)) return { kind: "session", sessionId: ref };
1573
+ // A short trace id can be the start of a session id too: fewer characters name a trace, as before.
1574
+ if (ref.length < SESSION_PREFIX_MIN) return { kind: "trace", traceId: ref };
1575
+ const matches = [...new Set(sessions.filter((id) => id.startsWith(ref)))].sort();
1576
+ if (matches.length === 1) return { kind: "session", sessionId: matches[0] };
1577
+ if (matches.length > 1) return { kind: "ambiguous", matches };
1578
+ return { kind: "trace", traceId: ref };
1579
+ }
1580
+
1581
+ /** How long `pi-loops abort` waits for the stop row to be read before it reports it as queued. */
1582
+ export const STOP_WAIT_MS = 5000;
1583
+ /** The shortest session prefix `pi-loops abort` resolves to a session; anything shorter is a trace id. */
1584
+ export const SESSION_PREFIX_MIN = 8;
1585
+
1586
+ /**
1587
+ * The line `pi-loops abort` ends with for a stop row's audit state, and the exit code. `undefined`:
1588
+ * the row is still queued. `done` and `stale` are the two states a stop ends in; any other is named,
1589
+ * so a new one is never read as queued.
1590
+ */
1591
+ export function stopReport(state: string | undefined, sessionId: string): { line: string; code: number } {
1592
+ const id8 = shortSessionId(sessionId);
1593
+ switch (state) {
1594
+ case "done":
1595
+ return { line: `stopped the turn in session ${id8}`, code: 0 };
1596
+ case "stale":
1597
+ return { line: `nothing was running in session ${id8}`, code: 1 };
1598
+ case undefined:
1599
+ return { line: `stop queued for session ${id8}; it takes effect when that conversation's pi or pi-loops reads it`, code: 0 };
1600
+ default:
1601
+ return { line: `the stop for session ${id8} ended as ${state}; see /triggers audit`, code: 1 };
1602
+ }
1603
+ }
1604
+
1605
+ /**
1606
+ * Stop a conversation's turn through a request row: written, rung to pi-loops in the background (a
1607
+ * window hears the file change itself), then waited for. The row leaves the file once its holder read
1608
+ * it, and the audit line says what it did; still in the file after STOP_WAIT_MS, it is queued.
1609
+ */
1610
+ async function stopThroughRow(loopsDir: string, sessionId: string, target: NonNullable<Parameters<typeof stopRow>[1]>, out: (line: string) => void): Promise<number> {
1611
+ const requests = new RequestStore(loopsDir);
1612
+ const row = stopRow(sessionId, target, "cli", { pid: process.pid, instance: "cli", procStart: procStart(process.pid), version: PI_LOOPS_VERSION });
1613
+ requests.append(row);
1614
+ await ringHost(loopsDir, `request:${sessionId}`);
1615
+ const deadline = Date.now() + STOP_WAIT_MS;
1616
+ while (requests.list(sessionId).some((r) => r.id === row.id) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50));
1617
+ const state = new TriggerStore(loopsDir).listAudit(1, (r) => r.type === "request" && r.traceId === row.id)[0]?.state;
1618
+ const report = stopReport(state, sessionId);
1619
+ out(report.line);
1620
+ return report.code;
1621
+ }
1622
+
1623
+ /**
1624
+ * `pi-loops start | stop | restart | status | abort`: the process that runs loops and rule checks
1625
+ * while no pi is open. It is pi-loops to the person using it; "host" is the code's word for it.
1626
+ */
1627
+ async function serviceCommand(verb: string, args: string[], flags: Map<string, string | true>, loopsDir: string, out: (line: string) => void): Promise<number> {
1628
+ // Asking how a command works must not run it: `pi-loops stop --help` stopped every loop.
1629
+ if (flags.has("help") || args.includes("help")) {
1630
+ const lines = CLI_USAGE.split("\n");
1631
+ const at = lines.findIndex((line) => line.startsWith(verb === "pair" ? "pi-loops pair" : "pi-loops start | stop"));
1632
+ for (const line of lines.slice(at, lines.indexOf("", at))) out(line);
1633
+ return 0;
1634
+ }
1635
+ const live = (): HostServiceRecord | undefined => {
1636
+ const rec = readHostService(loopsDir);
1637
+ return rec && hostServiceAlive(rec) ? rec : undefined;
1638
+ };
1639
+ if (verb === "status" && flags.has("url")) {
1640
+ // The token is in the URL so the page can be opened with one click; it is printed only when asked.
1641
+ const rec = live();
1642
+ if (!rec) {
1643
+ out("pi-loops is not running");
1644
+ return 1;
1645
+ }
1646
+ if (!rec.url) {
1647
+ out(`pi-loops (pid ${rec.pid}) has no page: its port could not be bound (see ${path.join(loopsDir, HOST_LOG)})`);
1648
+ return 1;
1649
+ }
1650
+ out(`${rec.url}?token=${rec.token}`);
1651
+ return 0;
1652
+ }
1653
+ if (verb === "pair") {
1654
+ const rec = live();
1655
+ if (!rec) {
1656
+ out("pi-loops is not running; pi-loops start starts it");
1657
+ return 1;
1658
+ }
1659
+ const res = await askHost(loopsDir, { op: "pair" });
1660
+ if (!res) {
1661
+ out(`pi-loops (pid ${rec.pid}) is not answering; see ${path.join(loopsDir, HOST_LOG)}`);
1662
+ return 1;
1663
+ }
1664
+ if (!res.ok) {
1665
+ out(`pi-loops: ${res.error}`);
1666
+ return 1;
1667
+ }
1668
+ out(`pairing code for another device: ${res.code} (10 minutes)`);
1669
+ for (const address of res.addresses ?? []) out(` on that device, open ${address} and type it`);
1670
+ 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`);
1671
+ return 0;
1672
+ }
1673
+ if (verb === "start" && flags.has("foreground")) return runInForeground(loopsDir);
1674
+ if (verb === "start") {
1675
+ // A person asking by name: the stop mark goes, and it starts even with nothing to run yet. The
1676
+ // "started" mark is what keeps it, under `[host] auto = false`, after the last window: also when it
1677
+ // is already running, started by a window.
1678
+ writeUiPref(loopsDir, "host", "started");
1679
+ const running = live();
1680
+ if (running) {
1681
+ out("pi-loops is already running");
1682
+ return 0;
1683
+ }
1684
+ const res = await ensureHost({
1685
+ dir: loopsDir,
1686
+ packageDir: packageRoot(),
1687
+ piPackage: process.env.PI_LOOPS_PI_PACKAGE,
1688
+ startedBy: "the shell that ran pi-loops start",
1689
+ automation: { loops: 0, rules: 0, pushServers: 0 },
1690
+ auto: true,
1691
+ stopped: false,
1692
+ asked: true,
1693
+ myVersion: PI_LOOPS_VERSION,
1694
+ });
1695
+ let rec = live();
1696
+ // A window starting it at this moment is not a failure: that start is waited for.
1697
+ if (!rec && res.action.kind === "none" && res.action.reason === IN_PROGRESS) {
1698
+ for (const deadline = Date.now() + START_WAIT_MS; !rec && Date.now() < deadline; rec = live()) await new Promise((r) => setTimeout(r, 100));
1699
+ }
1700
+ if (rec) {
1701
+ out("pi-loops is running");
1702
+ return 0;
1703
+ }
1704
+ out(`pi-loops did not start: ${res.error ?? (res.action.kind === "none" ? res.action.reason : `see ${path.join(loopsDir, HOST_LOG)}`)}`);
1705
+ return 1;
1706
+ }
1707
+ if (verb === "restart") {
1708
+ const old = live();
1709
+ if (old) out(RESTART_WAIT_LINE);
1710
+ if (old && !(await stopAndWait(loopsDir, old, () => undefined, { reason: "restart" }))) {
1711
+ 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)})`);
1712
+ return 1;
1713
+ }
1714
+ return serviceCommand("start", [], new Map(), loopsDir, out);
1715
+ }
1716
+ if (verb === "status") {
1717
+ const res = await askHost(loopsDir, { op: "status" });
1718
+ if (!res) {
1719
+ // What waits for each conversation, from the files: a live host says it in its snapshot, and
1720
+ // a row waiting for a closed conversation is exactly what a person wants to see when it is down.
1721
+ const queues = () => requestLines(requestQueues(new RequestStore(loopsDir), new LeaseStore(loopsDir)), Date.now());
1722
+ // No answer is not the same as not running: one wedged on a hung MCP read still holds the
1723
+ // record. Fall back to it, so the person gets a pid and a log to look at.
1724
+ const rec = live();
1725
+ if (rec) {
1726
+ out(`pi-loops (pid ${rec.pid}) is not answering (started ${formatLocal(Date.parse(rec.startedAt))})`);
1727
+ out(` log: ${path.join(loopsDir, HOST_LOG)}`);
1728
+ out(" stop it with: pi-loops stop");
1729
+ for (const l of queues()) out(l);
1730
+ return 1;
1731
+ }
1732
+ // Why, when something keeps it from starting: each of these pauses every loop and check.
1733
+ const spawns = readSpawnLog(loopsDir);
1734
+ 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;
1735
+ out(why ? `pi-loops is not running: ${why}` : "pi-loops is not running (a pi window starts it when there is work to run)");
1736
+ for (const l of queues()) out(l);
1737
+ return 1;
1738
+ }
1739
+ if (!res.ok) {
1740
+ // An error it answered with is about it, not about how this command was typed: no usage.
1741
+ out(`pi-loops is running but could not say how it is: ${res.error}`);
1742
+ out(` log: ${path.join(loopsDir, HOST_LOG)}`);
1743
+ return 1;
1744
+ }
1745
+ out("pi-loops");
1746
+ for (const line of renderHostSnapshot(res.snapshot!)) out(line);
1747
+ return 0;
1748
+ }
1749
+ if (verb === "abort") {
1750
+ const ref = args[0];
1751
+ if (!ref) throw new UsageError("abort needs a run id, a trigger trace id or a session id");
1752
+ const leases = new LeaseStore(loopsDir);
1753
+ let target: AbortTarget;
1754
+ try {
1755
+ target = abortTarget(ref, [...leases.list().map((l) => l.sessionId), ...new RequestStore(loopsDir).sessions()]);
1756
+ } catch {
1757
+ throw new Error(`${JSON.stringify(ref)} is not a run id, a trigger trace id or a session id`);
1758
+ }
1759
+ if (target.kind === "ambiguous") {
1760
+ out(`${ref} is a prefix of ${target.matches.length} sessions; give more of the id:`);
1761
+ for (const id of target.matches) out(` ${id}`);
1762
+ return 1;
1763
+ }
1764
+ if (target.kind === "session") return stopThroughRow(loopsDir, target.sessionId, { current: true, started_before: stamp() }, out);
1765
+ const res = await askHost(loopsDir, target.kind === "run" ? { op: "abort", runId: ref } : { op: "abort", traceId: ref });
1766
+ if (res) {
1767
+ if (!res.ok) throw new Error(res.error);
1768
+ out(res.aborted ? `aborted ${ref}` : `nothing running with id ${ref}`);
1769
+ return res.aborted ? 0 : 1;
1770
+ }
1771
+ // No answer, but a turn in a conversation is on the leases: the row reaches it where the socket could not.
1772
+ const held = target.kind === "run" ? heldTurnFor(leases.list(), ref) : undefined;
1773
+ if (held?.kind === "ambiguous") {
1774
+ out(`${ref} is a prefix of ${held.matches.length} running turns; give more of the id:`);
1775
+ for (const id of held.matches) out(` ${id}`);
1776
+ return 1;
1777
+ }
1778
+ if (!held) throw new Error("pi-loops is not running");
1779
+ return stopThroughRow(loopsDir, held.sessionId, { id: held.runId }, out);
1780
+ }
1781
+ if (verb === "stop") {
1782
+ // Stopped by hand stays stopped: without this mark the next window's tick starts it again.
1783
+ writeUiPref(loopsDir, "host", "stopped");
1784
+ const rec = live();
1785
+ if (!rec) {
1786
+ out(`pi-loops is not running; the stop is recorded, so nothing starts it until ${STARTS_AGAIN}`);
1787
+ return 0;
1788
+ }
1789
+ // How many conversations the page had open, asked before it goes: they close with it.
1790
+ const before = await askHost(loopsDir, { op: "status" });
1791
+ const open = before?.ok ? Number(before.snapshot?.pageOpen) || 0 : 0;
1792
+ // Waited on, so a process too wedged to run its SIGTERM handler is killed rather than left running.
1793
+ const stopped = await stopAndWait(loopsDir, rec, out, { reason: "stop", waitMs: 15_000 });
1794
+ if (open) out(` the page and its ${openConversations(open)} closed with it; pi-loops start opens it again`);
1795
+ return stopped ? 0 : 1;
1796
+ }
1797
+ throw new Error(`unknown command ${JSON.stringify(verb)}: pi-loops start | stop | restart | status | abort <id>`);
1798
+ }
1799
+
1800
+ /**
1801
+ * Stop it and wait until that process has gone (its pid, and its start time against a reused pid);
1802
+ * false when it is still there after `waitMs`. A restart starts the new one only after this, and
1803
+ * waits `LEAVE_WAIT_MS`: the old process first lets a turn in the browser page finish.
1804
+ */
1805
+ export async function stopAndWait(loopsDir: string, rec: HostServiceRecord, out: (line: string) => void, how: { reason: "stop" | "restart"; waitMs?: number }): Promise<boolean> {
1806
+ const waitMs = how.waitMs ?? LEAVE_WAIT_MS;
1807
+ await stopService(loopsDir, rec, out, how.reason);
1808
+ const deadline = Date.now() + waitMs;
1809
+ while (hostServiceAlive(rec) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
1810
+ if (!hostServiceAlive(rec)) return true;
1811
+ // Still there: a process whose event loop is blocked never runs its SIGTERM handler, and a person
1812
+ // asked for it to stop. As a window evicting a wedged one does.
1813
+ try {
1814
+ process.kill(rec.pid, "SIGKILL");
1815
+ } catch {
1816
+ /* it left between the look and the signal */
1817
+ }
1818
+ removeHostService(loopsDir, rec.pid);
1819
+ out(`pi-loops (pid ${rec.pid}) did not stop within ${Math.round(waitMs / 1000)}s; sent SIGKILL`);
1820
+ const killed = Date.now() + 5_000;
1821
+ while (hostServiceAlive(rec) && Date.now() < killed) await new Promise((r) => setTimeout(r, 100));
1822
+ return !hostServiceAlive(rec);
1823
+ }
1824
+
1825
+ /** Ask it to stop over its socket; one that does not answer gets SIGTERM, and its record is forgotten. */
1826
+ export async function stopService(loopsDir: string, rec: HostServiceRecord, out: (line: string) => void, reason: "stop" | "restart" = "stop"): Promise<void> {
1827
+ writeUiPref(loopsDir, "host", "stopped");
1828
+ const res = await askHost(loopsDir, { op: "stop", reason });
1829
+ if (res?.ok) {
1830
+ out(`asked pi-loops to stop; nothing starts it again until ${STARTS_AGAIN}`);
1831
+ return;
1832
+ }
1833
+ try {
1834
+ process.kill(rec.pid, "SIGTERM");
1835
+ } catch {
1836
+ /* gone already */
1837
+ }
1838
+ removeHostService(loopsDir, rec.pid);
1839
+ out(`pi-loops (pid ${rec.pid}) was not answering; sent SIGTERM`);
1840
+ }
1841
+
1842
+ /**
1843
+ * `pi-loops start --foreground`: run in this terminal until stopped, for a service manager the person
1844
+ * set up (a systemd user unit, a LaunchAgent). It exits 0 when stopped, so the manager does not
1845
+ * restart it, and 78 when it cannot run here at all; during a crash-pause it waits the pause out.
1846
+ */
1847
+ function runInForeground(loopsDir: string): Promise<number> {
1848
+ // A start like any other, as `systemctl start` is: a stop by hand lasts until the next start.
1849
+ clearUiPref(loopsDir, "host");
1850
+ 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" });
1851
+ const child = spawn(node, args, { stdio: "inherit", cwd: os.homedir(), env: { ...env, PI_LOOPS_HOST_MANAGED: "foreground" } });
1852
+ const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
1853
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) process.on(signal, forward(signal));
1854
+ return new Promise((resolve) => {
1855
+ child.once("error", (err) => {
1856
+ process.stderr.write(`pi-loops: ${err.message}\n`);
1857
+ resolve(78);
1858
+ });
1859
+ child.once("exit", (code, signal) => resolve(code ?? (signal ? 128 + (os.constants.signals[signal] ?? 0) : 1)));
1860
+ });
1861
+ }