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