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