@alphacoder-v0/pi-loops 0.17.3

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 (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
package/src/cli.ts ADDED
@@ -0,0 +1,610 @@
1
+ /**
2
+ * `pi-loops export|import` — session archives from a command line.
3
+ *
4
+ * An archive is a backup and migration format, and a backup you can only drive by hand from inside
5
+ * a running TUI cannot be put in a cron entry, a CI step, or run on a fresh machine before opening
6
+ * anything. This dispatches before any pi session exists.
7
+ *
8
+ * pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]
9
+ * pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
10
+ */
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import * as readline from "node:readline";
16
+ import { fileURLToPath } from "node:url";
17
+ import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import { exportSession, defaultExportPath, importSession, inspectArchive } from "./archive.ts";
19
+ import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
20
+ import { liveHost, stopHost } from "./host-control.ts";
21
+ import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
22
+ import { JobStore, defaultLoopsDir } from "./store.ts";
23
+ import { TriggerStore } from "./triggers.ts";
24
+ import { PI_LOOPS_VERSION } from "./version.ts";
25
+ import { stamp } from "./schedule.ts";
26
+
27
+ /**
28
+ * Which front end `pi-loops` opens when you do not say. A browser is the better window when one is
29
+ * reachable, and useless when it is not — so the rule is: a local terminal
30
+ * gets the browser, an ssh session gets the terminal it is already looking at, and anything that is
31
+ * not a terminal at all gets pi in whatever mode its own flags ask for.
32
+ */
33
+ export type UiMode = "web" | "terminal";
34
+
35
+ export function resolveUiMode(input: { web: boolean; tui: boolean; interactiveTty: boolean; remoteTty: boolean }): UiMode {
36
+ if (input.web) return "web";
37
+ if (input.tui) return "terminal";
38
+ if (!input.interactiveTty) return "terminal";
39
+ return input.remoteTty ? "terminal" : "web";
40
+ }
41
+
42
+ /** ssh and mosh set at least one of these; opening a browser on the far end helps nobody. */
43
+ export function isRemoteTty(env: NodeJS.ProcessEnv = process.env): boolean {
44
+ return ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "MOSH_CONNECTION"].some((k) => !!env[k]);
45
+ }
46
+
47
+ /** Flags `pi-loops` reads for itself when launching; everything else is pi's. */
48
+ const LAUNCH_FLAGS = new Set(["web", "tui", "port", "host", "allow-host", "open", "no-open", "no-auth", "loops-dir", "help"]);
49
+ /** The ones that take the next argument as their value. */
50
+ const LAUNCH_FLAGS_WITH_VALUE = new Set(["port", "host", "allow-host", "loops-dir"]);
51
+
52
+ /**
53
+ * Split `pi-loops <flags> <rest>` into ours and pi's. Unknown flags go to pi on purpose: the point
54
+ * of this command is that you can type what you would have typed after `pi`.
55
+ */
56
+ export function splitLaunchArgs(argv: string[]): { ours: string[]; pi: string[] } {
57
+ const dashdash = argv.indexOf("--");
58
+ if (dashdash !== -1) return { ours: argv.slice(0, dashdash), pi: argv.slice(dashdash + 1) };
59
+ const ours: string[] = [];
60
+ const pi: string[] = [];
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const arg = argv[i];
63
+ const name = arg.startsWith("--") ? arg.slice(2).split("=")[0] : undefined;
64
+ if (name && LAUNCH_FLAGS.has(name)) {
65
+ ours.push(arg);
66
+ // `--port 4173` takes a value; `--web` does not.
67
+ if (!arg.includes("=") && LAUNCH_FLAGS_WITH_VALUE.has(name) && argv[i + 1] && !argv[i + 1].startsWith("-")) ours.push(argv[++i]);
68
+ continue;
69
+ }
70
+ pi.push(arg);
71
+ }
72
+ return { ours, pi };
73
+ }
74
+
75
+ export const CLI_USAGE = [
76
+ "pi-loops [--web | --tui] [--port <n>] [--no-auth] [<pi flags>]",
77
+ " Start a session. On a local terminal that means the browser UI; over ssh, and anywhere",
78
+ " without a terminal, it means pi itself. --web and --tui say which, and anything this",
79
+ " command does not recognise is passed to pi (pi-loops --model anthropic/claude-opus-5).",
80
+ " --no-auth drops the token: the browser UI is then open to anything on this machine.",
81
+ " --host <addr> binds somewhere other than loopback, so a phone can reach it; the terminal",
82
+ " prints a six-digit pairing code for that device. --allow-host <name,...> accepts a proxy,",
83
+ " and a name resolved by public DNS puts the token back in charge of keeping strangers out.",
84
+ "",
85
+ "pi-loops upgrade [--check]",
86
+ " Install the newest release from the repository this copy came from. --check only looks.",
87
+ "",
88
+ "pi-loops install-launcher [--dir <dir>]",
89
+ " Put a `pi-loops` launcher on your PATH, so this command works from anywhere.",
90
+ "",
91
+ "pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]",
92
+ " Bundle a session and its automation into a .pisession archive.",
93
+ " Default session: the newest one for --cwd (default: the current directory).",
94
+ "",
95
+ "pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]",
96
+ " Restore an archive into --cwd (default: the current directory).",
97
+ " Imported automation stays disabled unless --activate-triggers=on (ask: prompt on a terminal).",
98
+ "",
99
+ "pi-loops sessions [--all] [--limit <n>]",
100
+ " List session ids you can export, newest first.",
101
+ "",
102
+ "pi-loops inspect <file>",
103
+ " Show what an archive contains without writing anything.",
104
+ "",
105
+ "pi-loops host status | abort <run-id|trace-id> | stop",
106
+ " Look in on the background host that keeps the clock while no pi is open, or interrupt it.",
107
+ ].join("\n");
108
+
109
+ /**
110
+ * An archive is a file someone sent you, and `inspect` is the command you run *before* trusting it.
111
+ * Escape sequences in a prompt could clear the screen, repaint earlier lines or hide the rest of
112
+ * the listing, so nothing from the archive reaches the terminal with control characters intact.
113
+ */
114
+ function plain(text: string): string {
115
+ // Newlines go too: every field printed through here is one line, and a prompt carrying one could
116
+ // forge a whole extra row in the listing. The last group is the bidi overrides and isolates,
117
+ // which reorder what is around them without being visible themselves.
118
+ return text.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, " ");
119
+ }
120
+
121
+ interface SessionFile {
122
+ id: string;
123
+ file: string;
124
+ cwd: string;
125
+ mtimeMs: number;
126
+ }
127
+
128
+ /** pi keeps `<agentDir>/sessions/<encoded cwd>/<timestamp>_<uuid>.jsonl`; the header names the cwd. */
129
+ export function listSessions(sessionsRoot: string): SessionFile[] {
130
+ const out: SessionFile[] = [];
131
+ let projects: string[];
132
+ try {
133
+ projects = fs.readdirSync(sessionsRoot);
134
+ } catch {
135
+ return out;
136
+ }
137
+ for (const project of projects) {
138
+ const dir = path.join(sessionsRoot, project);
139
+ let files: string[];
140
+ try {
141
+ files = fs.readdirSync(dir);
142
+ } catch {
143
+ continue;
144
+ }
145
+ for (const name of files) {
146
+ if (!name.endsWith(".jsonl")) continue;
147
+ const file = path.join(dir, name);
148
+ try {
149
+ const header = JSON.parse(fs.readFileSync(file, "utf8").split("\n", 1)[0] ?? "{}");
150
+ if (typeof header?.id !== "string") continue;
151
+ out.push({ id: header.id, file, cwd: typeof header.cwd === "string" ? header.cwd : "", mtimeMs: fs.statSync(file).mtimeMs });
152
+ } catch {
153
+ /* not a readable session */
154
+ }
155
+ }
156
+ }
157
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
158
+ }
159
+
160
+ /** A full id or a unique prefix; with neither, the newest session of `cwd`. */
161
+ export function pickSession(sessions: SessionFile[], opts: { id?: string; cwd: string }): SessionFile {
162
+ if (opts.id) {
163
+ const exact = sessions.find((s) => s.id === opts.id);
164
+ if (exact) return exact;
165
+ const byPrefix = sessions.filter((s) => s.id.startsWith(opts.id!));
166
+ if (byPrefix.length === 1) return byPrefix[0];
167
+ if (byPrefix.length > 1) throw new Error(`session id ${opts.id} is ambiguous (${byPrefix.length} matches)`);
168
+ throw new Error(`no session with id ${opts.id}`);
169
+ }
170
+ const here = sessions.filter((s) => s.cwd === opts.cwd);
171
+ if (!here.length) throw new Error(`no sessions recorded for ${opts.cwd}; pass --session <id>`);
172
+ return here[0];
173
+ }
174
+
175
+ interface Parsed {
176
+ command: string;
177
+ positional: string[];
178
+ flags: Map<string, string | true>;
179
+ }
180
+
181
+ export function parseCliArgs(argv: string[]): Parsed {
182
+ const [command = "", ...rest] = argv;
183
+ const positional: string[] = [];
184
+ const flags = new Map<string, string | true>();
185
+ for (let i = 0; i < rest.length; i++) {
186
+ const arg = rest[i];
187
+ if (!arg.startsWith("--")) {
188
+ positional.push(arg);
189
+ continue;
190
+ }
191
+ const eq = arg.indexOf("=");
192
+ if (eq > 0) flags.set(arg.slice(2, eq), arg.slice(eq + 1));
193
+ else if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("--")) flags.set(arg.slice(2), rest[++i]);
194
+ else flags.set(arg.slice(2), true);
195
+ }
196
+ return { command, positional, flags };
197
+ }
198
+
199
+ async function askYesNo(question: string): Promise<boolean> {
200
+ if (!process.stdin.isTTY) return false;
201
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
202
+ try {
203
+ const answer = await new Promise<string>((r) => rl.question(`${question} [y/N] `, r));
204
+ return /^y(es)?$/i.test(answer.trim());
205
+ } finally {
206
+ rl.close();
207
+ }
208
+ }
209
+
210
+ /** Run a child to completion, sharing this terminal, and answer with its exit code. */
211
+ function runChild(command: string, args: string[], env?: NodeJS.ProcessEnv): Promise<number> {
212
+ return new Promise((resolve) => {
213
+ const child = spawn(command, args, { stdio: "inherit", env: env ?? process.env });
214
+ child.on("error", (err: any) => {
215
+ process.stderr.write(`pi-loops: could not start ${command}: ${err?.message ?? err}\n`);
216
+ resolve(1);
217
+ });
218
+ // A signal that reaches us reaches this child too: the launcher and what it starts — pi in a
219
+ // terminal, or `web.mjs` — share the terminal's process group, so Ctrl-C arrives at both. The
220
+ // browser front end starts its own pi *outside* that group on purpose (see `src/web.mjs`), so
221
+ // that pi is ended by web.mjs asking it to quit rather than by the group's SIGINT, and gets to
222
+ // hand the clock to a headless host on the way out. Either way, wait for the child to finish
223
+ // rather than exiting first and leaving a session with no terminal attached to it.
224
+ child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 0)));
225
+ });
226
+ }
227
+
228
+ export async function runCli(argv: string[], out: (line: string) => void = console.log): Promise<number> {
229
+ if (cliRoute(argv) === "launch") return launch(argv, out);
230
+ const { command, positional, flags } = parseCliArgs(argv);
231
+ const str = (name: string): string | undefined => {
232
+ const v = flags.get(name);
233
+ return typeof v === "string" ? v : undefined;
234
+ };
235
+ const agentDir = getAgentDir();
236
+ const loopsDir = process.env.PI_LOOPS_DIR || defaultLoopsDir(agentDir);
237
+ const cwd = path.resolve(str("cwd") ?? process.cwd());
238
+
239
+ if (command === "web" || command === "tui") return launch(argv, out);
240
+ if (command === "upgrade") return upgrade(flags.has("check"), out);
241
+ if (command === "install-launcher") return installLauncher(str("dir"), out);
242
+
243
+ if (command === "sessions") {
244
+ // `pickSession` refuses an unknown id, and there was no way to discover one.
245
+ const sessions = listSessions(path.join(agentDir, "sessions"));
246
+ const here = flags.has("all") ? sessions : sessions.filter((s) => s.cwd === cwd);
247
+ if (!here.length) {
248
+ out(flags.has("all") ? "no sessions recorded" : `no sessions recorded for ${cwd} (use --all)`);
249
+ return 1;
250
+ }
251
+ for (const s of here.slice(0, Number(str("limit") ?? 20))) out(`${s.id} ${stamp(s.mtimeMs)} ${s.cwd}`);
252
+ return 0;
253
+ }
254
+
255
+ if (command === "inspect") {
256
+ // What is in this archive, without writing anything.
257
+ const file = positional[0];
258
+ if (!file) throw new Error("inspect needs an archive path");
259
+ const info = inspectArchive(path.resolve(file));
260
+ out(`${plain(info.schema)} exported ${plain(info.createdAt)} from ${plain(info.sourceCwd)}`);
261
+ // The counts come out of the same manifest and are only numbers if it says so.
262
+ out(`entries=${plain(String(info.entryCount))} cron=${info.jobs.length} triggers=${info.rules.length} loop-state=${plain(String(info.loopStateCount))}`);
263
+ for (const j of info.jobs) out(` cron ${j.enabled ? "enabled " : "disabled"} ${plain(j.schedule)} ${plain(j.prompt)}`);
264
+ for (const r of info.rules) out(` rule ${r.enabled ? "enabled " : "disabled"} when ${plain(r.condition)} -> ${plain(r.action)}`);
265
+ return 0;
266
+ }
267
+
268
+ if (command === "export") {
269
+ const sessions = listSessions(path.join(agentDir, "sessions"));
270
+ const picked = pickSession(sessions, { id: str("session"), cwd });
271
+ const jobStore = new JobStore(loopsDir);
272
+ // The archive carries this project's automation, the way the slash command does.
273
+ const jobs = jobStore.load().filter((job) => job.cwd === picked.cwd);
274
+ const rules = new TriggerStore(loopsDir).load().filter((rule) => rule.cwd === picked.cwd);
275
+ const states: Record<string, string> = {};
276
+ for (const job of jobs) {
277
+ const loopState = job.stateful ? jobStore.readState(job.id) : undefined;
278
+ if (loopState) states[job.id] = loopState;
279
+ }
280
+ const outputPath = path.resolve(str("output") ?? defaultExportPath(process.cwd(), picked.id));
281
+ const summary = exportSession({ sessionFile: picked.file, cwd: picked.cwd, jobs, rules, states, excludeTriggers: flags.has("exclude-triggers"), outputPath, piVersion: process.env.PI_VERSION ?? "unknown", piLoopsVersion: PI_LOOPS_VERSION });
282
+ out(`exported ${picked.id} → ${outputPath}`);
283
+ out(`entries=${summary.entryCount} cron=${summary.hasCron ? jobs.length : 0} triggers=${summary.hasTriggers ? rules.length : 0} loop-state=${summary.loopStateCount}`);
284
+ return 0;
285
+ }
286
+
287
+ if (command === "import") {
288
+ const file = positional[0];
289
+ if (!file) throw new Error("import needs an archive path");
290
+ const mode = str("activate-triggers") ?? "off";
291
+ if (!["off", "ask", "on"].includes(mode)) throw new Error(`--activate-triggers must be off, ask or on (got ${mode})`);
292
+ const jobStore = new JobStore(loopsDir);
293
+ const triggerStore = new TriggerStore(loopsDir);
294
+ // pi encodes a project as `--home-u-proj--` and `SessionManager.list()` reads only that one
295
+ // directory, with no fallback — a hand-rolled name here would restore a session pi never sees.
296
+ const sessionDir = SessionManager.create(cwd).getSessionDir();
297
+ const activate = mode === "on" || (mode === "ask" && (await askYesNo("Activate the imported automation now?")));
298
+ const existingJobs = jobStore.load();
299
+ const existingRules = triggerStore.load();
300
+ const summary = importSession({ archivePath: path.resolve(file), sessionDir, targetCwd: cwd, activate, existingJobs, existingRules, existingJobIds: new Set(existingJobs.map((j) => j.id)), existingRuleIds: new Set(existingRules.map((r) => r.id)) });
301
+ // The same order the slash command uses, so a half-import leaves neither store inconsistent.
302
+ if (summary.jobs.length) await jobStore.mutate((jobs) => ({ jobs: [...jobs, ...summary.jobs], result: undefined }));
303
+ for (const [id, text] of Object.entries(summary.states)) jobStore.writeState(id, text);
304
+ if (summary.rules.length) await triggerStore.mutate((rules) => rules.push(...summary.rules));
305
+ const skipped = (summary.skippedJobs ?? 0) + (summary.skippedRules ?? 0);
306
+ // The id and the notes come out of the archive's own header, like everything `inspect` prints.
307
+ out(`imported ${plain(summary.originalSessionId)} → ${summary.sessionId}`);
308
+ out(`entries=${plain(String(summary.entryCount))} cron=${summary.jobs.length} triggers=${summary.rules.length} automation=${summary.automationEnabled ? "enabled" : "disabled"}${skipped ? ` skipped=${skipped} (already imported)` : ""}`);
309
+ for (const note of summary.notes ?? []) out(`note: ${plain(note)}`);
310
+ out(`session: ${summary.sessionPath}`);
311
+ 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");
312
+ return 0;
313
+ }
314
+
315
+ if (command === "host") {
316
+ const sub = positional[0] ?? "status";
317
+ if (sub === "status") {
318
+ const res = await askHost(loopsDir, { op: "status" });
319
+ if (!res) {
320
+ // No answer is not the same as no host: one wedged on a hung MCP read still holds the
321
+ // record. Fall back to it, so the user gets a pid and a log to look at.
322
+ const rec = liveHost(loopsDir);
323
+ if (rec) {
324
+ out(`background host pid ${rec.pid} on ${rec.host} is not answering (started ${rec.startedAt})`);
325
+ out(` log: ${path.join(loopsDir, "host.log")}`);
326
+ out(` stop it with: pi-loops host stop`);
327
+ return 1;
328
+ }
329
+ out("no background host is running (it runs only while no pi is open)");
330
+ return 1;
331
+ }
332
+ if (!res.ok) throw new Error(res.error);
333
+ out("background host");
334
+ for (const line of renderHostSnapshot(res.snapshot!)) out(line);
335
+ return 0;
336
+ }
337
+ if (sub === "abort") {
338
+ const ref = positional[1];
339
+ if (!ref) throw new Error("host abort needs a run id or a trigger trace id");
340
+ // A run id is `run-<32 hex>`; anything else is treated as a trigger trace.
341
+ const res = await askHost(loopsDir, ref.startsWith("run-") ? { op: "abort", runId: ref } : { op: "abort", traceId: ref });
342
+ if (!res) throw new Error("no background host is running");
343
+ if (!res.ok) throw new Error(res.error);
344
+ out(res.aborted ? `aborted ${ref}` : `nothing running with id ${ref}`);
345
+ return res.aborted ? 0 : 1;
346
+ }
347
+ if (sub === "stop") {
348
+ const res = await askHost(loopsDir, { op: "stop" });
349
+ if (res?.ok) {
350
+ out("asked the background host to stop");
351
+ return 0;
352
+ }
353
+ // It did not answer; SIGTERM the recorded pid, which is what `/cron host stop` does.
354
+ const pid = stopHost(loopsDir);
355
+ out(pid ? `background host (pid ${pid}) was not answering; sent SIGTERM` : "no background host is running");
356
+ return pid ? 0 : 1;
357
+ }
358
+ throw new Error(`unknown host command ${JSON.stringify(sub)}`);
359
+ }
360
+
361
+ const asked = command && command !== "help" && command !== "--help";
362
+ if (asked) {
363
+ // Naming the command, and the version running, because the likeliest reason a subcommand is
364
+ // unknown is that it was added after the copy you have — which is exactly the case where
365
+ // printing a usage list and nothing else leaves you staring at it. An upgrade command can
366
+ // never be in the version that predates it.
367
+ out(`unknown command ${JSON.stringify(command)} (this is pi-loops v${PI_LOOPS_VERSION})`);
368
+ // The route has to match how this copy was installed, for the same reason `upgrade` does: a
369
+ // spec from the other one installs a second copy rather than replacing this one.
370
+ const spec = upgradeSpec(packageRoot(), manifest().name ?? "", agentDir, "<newer release>");
371
+ out(`if you expected it, the copy you are running may be older than the command: ${spec ? `pi install ${spec}` : checkoutUpgradeHint()}`);
372
+ out("");
373
+ }
374
+ out(CLI_USAGE);
375
+ return asked ? 2 : 0;
376
+ }
377
+
378
+ const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "web", "upgrade", "install-launcher", "help"]);
379
+
380
+ /** `v1.2.3` → comparable parts; anything else is not a release and is ignored. */
381
+ function semver(tag: string): [number, number, number] | undefined {
382
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag);
383
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
384
+ }
385
+
386
+ /** Is `candidate` a later release than `current`? Compared numerically, so v0.10.0 beats v0.9.0. */
387
+ export function isNewerVersion(candidate: string, current: string): boolean {
388
+ const left = semver(candidate);
389
+ const right = semver(current);
390
+ if (!left || !right) return false;
391
+ for (let i = 0; i < 3; i++) {
392
+ if (left[i] !== right[i]) return left[i] > right[i];
393
+ }
394
+ return false;
395
+ }
396
+
397
+ /**
398
+ * The newest release tag in `git ls-remote --tags` output. Release tags only: a branch, an rc or a
399
+ * `^{}` peeled entry is not something to upgrade someone to without being asked.
400
+ */
401
+ export function newestReleaseTag(lsRemote: string): string | undefined {
402
+ let best: string | undefined;
403
+ for (const line of lsRemote.split("\n")) {
404
+ const ref = line.split(/\s+/)[1];
405
+ if (!ref?.startsWith("refs/tags/") || ref.endsWith("^{}")) continue;
406
+ const tag = ref.slice("refs/tags/".length);
407
+ if (!semver(tag)) continue;
408
+ if (!best || isNewerVersion(tag, best)) best = tag;
409
+ }
410
+ return best;
411
+ }
412
+
413
+ /** The directory this copy of the package lives in — one above the sources. */
414
+ function packageRoot(): string {
415
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
416
+ }
417
+
418
+ /** Where this copy came from and what it is called, so an upgrade goes back to the same place. */
419
+ function manifest(): { name?: string; repositoryUrl?: string } {
420
+ try {
421
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
422
+ const url = String(pkg?.repository?.url ?? "").replace(/^git\+/, "").replace(/\.git$/, "");
423
+ return { name: typeof pkg?.name === "string" ? pkg.name : undefined, repositoryUrl: url || undefined };
424
+ } catch {
425
+ return {}; // installed without its package.json: nothing to ask
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Which `pi install` argument replaces *this* copy with `tag` — and `undefined` when none does.
431
+ *
432
+ * The one thing this must never do is offer a route the copy did not come down. `pi install` with a
433
+ * different source does not replace anything: it adds a second package, both copies register
434
+ * `cron_create` and the rest, and pi refuses to load the second one and exits with
435
+ * `Tool "cron_create" conflicts with …`. Someone who installed from npm and then ran the upgrade
436
+ * command this project ships would have done only what the README told them to.
437
+ *
438
+ * pi's layout answers the question, because it is where pi put the package:
439
+ * an `npm:` package sits in a `node_modules` directory under its own name (the managed
440
+ * `<agent dir>/npm/node_modules/<name>`, or the global one an older pi used), a `git:` package sits
441
+ * at `<agent dir>/git/<host>/<owner>/<repo>` — which is the spec it was installed by, spelled as a
442
+ * path — and a local checkout is in neither place. A checkout has no install to redo: it has a
443
+ * remote, and `git pull` is its upgrade.
444
+ */
445
+ export function upgradeSpec(packageDir: string, packageName: string, agentDir: string, tag: string): string | undefined {
446
+ const segments = path.resolve(packageDir).split(path.sep).filter(Boolean);
447
+ // A scoped name is two segments on disk, so compare as many as the name has.
448
+ const name = packageName.split("/").filter(Boolean);
449
+ if (name.length && segments.slice(-name.length).join("/") === name.join("/") && segments[segments.length - name.length - 1] === "node_modules") {
450
+ // Release tags are `v0.17.0`; the version npm knows the same release by is `0.17.0`.
451
+ return `npm:${packageName}@${tag.replace(/^v/, "")}`;
452
+ }
453
+ const rel = path.relative(path.join(agentDir, "git"), path.resolve(packageDir));
454
+ const parts = rel.split(path.sep);
455
+ // Exactly host/owner/repo under that root: deeper or shallower is not a package pi installed,
456
+ // and a checkout that merely happens to live under some other directory called `git` is not one
457
+ // either — being wrong here is what produces the second copy.
458
+ if (rel && !rel.startsWith("..") && parts.length === 3) return `git:${parts.join("/")}@${tag}`;
459
+ return undefined;
460
+ }
461
+
462
+ /** What to tell someone whose copy is a checkout: there is nothing for `pi install` to do. */
463
+ function checkoutUpgradeHint(): string {
464
+ return `git pull in ${packageRoot()}, then restart pi`;
465
+ }
466
+
467
+ /**
468
+ * Find the newest release and install it. `pi update --extensions` deliberately does not do this —
469
+ * it reconciles a git package to the ref you pinned — so taking a release otherwise means looking
470
+ * up a tag by hand and retyping it, which is a thing a command should do for you.
471
+ */
472
+ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise<number> {
473
+ const { name, repositoryUrl: repo } = manifest();
474
+ if (!repo) {
475
+ out("cannot tell where this copy came from (no package.json next to it)");
476
+ return 1;
477
+ }
478
+ const ls = spawnSync("git", ["ls-remote", "--tags", "--refs", repo], { encoding: "utf8" });
479
+ if (ls.status !== 0) {
480
+ out(`could not read tags from ${repo}: ${(ls.stderr || "").trim() || `git exited ${ls.status}`}`);
481
+ return 1;
482
+ }
483
+ const latest = newestReleaseTag(ls.stdout ?? "");
484
+ if (!latest) {
485
+ out(`no release tags at ${repo}`);
486
+ return 1;
487
+ }
488
+ out(`installed: v${PI_LOOPS_VERSION} latest: ${latest}`);
489
+ if (!isNewerVersion(latest, `v${PI_LOOPS_VERSION}`)) {
490
+ out("already up to date");
491
+ return 0;
492
+ }
493
+ const spec = upgradeSpec(packageRoot(), name ?? "", getAgentDir(), latest);
494
+ if (!spec) {
495
+ // Nothing failed, so this is not an error: the newest release is out and the way to it is a
496
+ // pull, not an install that would leave a second copy beside the checkout.
497
+ out("this copy is a checkout, not something pi installed");
498
+ out(`upgrade it with: ${checkoutUpgradeHint()}`);
499
+ return 0;
500
+ }
501
+ if (checkOnly) {
502
+ out(`upgrade with: pi install ${spec}`);
503
+ return 0;
504
+ }
505
+ out(`pi install ${spec}`);
506
+ const code = await runChild(process.env.PI_BIN || "pi", ["install", spec]);
507
+ if (code === 0) out("upgraded — restart pi, or `pi-loops` again, to load it");
508
+ return code;
509
+ }
510
+
511
+ /**
512
+ * Starting a session is the thing you do most often, so bare `pi-loops` does it, and so does
513
+ * anything that begins with a flag — `pi-loops --model x` should mean what `pi --model x` means.
514
+ *
515
+ * A bare word is never passed on, though: `pi-loops exprot` is a typo, and turning it into an
516
+ * argument for pi would start a session and hide the mistake, which is the worst of both.
517
+ */
518
+ export function cliRoute(argv: string[]): "launch" | "subcommand" {
519
+ const first = argv[0];
520
+ if (first === undefined) return "launch";
521
+ if (first === "--help" || first === "-h" || first === "help") return "subcommand";
522
+ if (first.startsWith("-")) return "launch";
523
+ // Known or not, a word goes down the subcommand path — which is where the unknown-command
524
+ // message lives, and `web` and `tui` are handled there as launch words.
525
+ return "subcommand";
526
+ }
527
+
528
+ /**
529
+ * `pi install` puts this package under pi's managed directory rather than on your PATH, so the
530
+ * command that is supposed to start your sessions is reachable only by absolute path. This writes
531
+ * a two-line launcher into a directory that is already on your PATH, which is the smallest thing
532
+ * that fixes it without asking you to publish or install anything else.
533
+ */
534
+ export async function installLauncher(dir: string | undefined, out: (line: string) => void): Promise<number> {
535
+ const onPath = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((p) => path.resolve(p));
536
+ const target = dir ? path.resolve(dir) : [path.join(os.homedir(), ".local", "bin"), "/usr/local/bin"].find((d) => onPath.includes(path.resolve(d)));
537
+ if (!target) {
538
+ out("no directory to install into: neither ~/.local/bin nor /usr/local/bin is on your PATH.");
539
+ out("pass one with --dir <dir>, or add ~/.local/bin to PATH and run this again.");
540
+ return 1;
541
+ }
542
+ if (dir && !onPath.includes(target)) out(`note: ${target} is not on your PATH, so the command will not be found there yet`);
543
+ const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "cli-entry.mjs");
544
+ const file = path.join(target, "pi-loops");
545
+ // A launcher rather than a symlink: it survives the package moving, and it names the node that
546
+ // is running now, which is the one known to be new enough for this code.
547
+ const script = `#!/bin/sh\n# pi-loops launcher, written by \`pi-loops install-launcher\`.\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(entry)} "$@"\n`;
548
+ try {
549
+ fs.mkdirSync(target, { recursive: true });
550
+ fs.writeFileSync(file, script, { mode: 0o755 });
551
+ fs.chmodSync(file, 0o755);
552
+ } catch (err: any) {
553
+ out(`could not write ${file}: ${err?.message ?? err}`);
554
+ return 1;
555
+ }
556
+ out(`wrote ${file}`);
557
+ out(` it runs ${entry}`);
558
+ out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
559
+ return 0;
560
+ }
561
+
562
+ /**
563
+ * Start a session: the browser front end, or pi itself. Both are complete pi sessions — the browser
564
+ * one runs `pi --mode rpc` behind a page (src/web.mjs) — so this is a choice of window, not of
565
+ * product, and `--tui` is always there when the guess is wrong.
566
+ */
567
+ async function launch(argv: string[], out: (line: string) => void): Promise<number> {
568
+ const { ours, pi } = splitLaunchArgs(argv[0] === "web" || argv[0] === "tui" ? argv.slice(1) : argv);
569
+ const has = (name: string) => ours.includes(`--${name}`);
570
+ if (has("help")) {
571
+ out(CLI_USAGE);
572
+ return 0;
573
+ }
574
+ const mode = resolveUiMode({
575
+ web: argv[0] === "web" || has("web"),
576
+ tui: argv[0] === "tui" || has("tui"),
577
+ interactiveTty: !!process.stdout.isTTY && !!process.stdin.isTTY,
578
+ remoteTty: isRemoteTty(),
579
+ });
580
+ const withDefaults = applyRememberedModel(pi, readUiPrefs(loopsDir(ours)));
581
+ if (mode === "terminal") return runChild(process.env.PI_BIN || "pi", withDefaults);
582
+ const web = path.join(path.dirname(fileURLToPath(import.meta.url)), "web.mjs");
583
+ return runChild(process.execPath, [web, ...ours.filter((a) => a !== "--web" && a !== "--tui"), ...(withDefaults.length ? ["--", ...withDefaults] : [])]);
584
+ }
585
+
586
+ /** Where the data lives for this run: the flag, then the environment, then the default. */
587
+ function loopsDir(ours: string[]): string {
588
+ const at = ours.indexOf("--loops-dir");
589
+ if (at !== -1 && ours[at + 1]) return ours[at + 1];
590
+ const eq = ours.find((a) => a.startsWith("--loops-dir="));
591
+ if (eq) return eq.slice("--loops-dir=".length);
592
+ return process.env.PI_LOOPS_DIR || path.join(os.homedir(), ".pi", "agent", "loops");
593
+ }
594
+
595
+ /**
596
+ * The model you last chose, for the next session that has no opinion of its own.
597
+ *
598
+ * Not for a session that is being resumed — that one already has a model, and the one it was
599
+ * having the conversation with is the right one — and not when you said which model on the command
600
+ * line, because you just said. Everything else is a new session starting on pi's default, which is
601
+ * how you ended up choosing the same model every morning.
602
+ */
603
+ export function applyRememberedModel(piArgs: string[], prefs: UiPrefs): string[] {
604
+ const said = (name: string) => piArgs.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
605
+ if (said("continue") || piArgs.includes("-c") || said("resume") || piArgs.includes("-r") || said("session") || said("session-id")) return piArgs;
606
+ const out = [...piArgs];
607
+ if (prefs.model && !said("model")) out.push("--model", prefs.model);
608
+ if (prefs.thinking && !said("thinking")) out.push("--thinking", prefs.thinking);
609
+ return out;
610
+ }