@alphacoder-v0/pi-loops 0.24.0 → 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 +53 -0
- package/README.md +35 -27
- package/README.zh-CN.md +4 -3
- package/docs/cli.md +96 -24
- package/docs/configuration.md +1 -1
- package/docs/design.md +9 -5
- package/docs/troubleshooting.md +4 -2
- package/docs/web-ui-parity.md +11 -0
- package/package.json +1 -1
- package/src/browser-open.ts +169 -0
- package/src/cli.ts +275 -79
- package/src/pi-loops.ts +74 -28
- package/src/ui-prefs.ts +6 -2
- package/src/web.mjs +2 -1
package/src/cli.ts
CHANGED
|
@@ -15,8 +15,9 @@ import * as net from "node:net";
|
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import * as readline from "node:readline";
|
|
18
|
-
import { fileURLToPath
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { isRemoteTty, openFileTarget, openUrl, thisMachine } from "./browser-open.ts";
|
|
20
21
|
import { applyImport, automationOf, exportSession, defaultExportPath, importSession, inspectArchive, offerToEnable, parseActivateMode } from "./archive.ts";
|
|
21
22
|
import { API_KEY_REFUSAL, CONTROL_TIMEOUT_MS, COPY_KEY_MS, askHost, hostSocketPath, openConversations, renderHostSnapshot, requestLines, requestQueues, ringHost } from "./host-control-channel.ts";
|
|
22
23
|
import { heldTurnFor, stopRow } from "./engine.ts";
|
|
@@ -67,11 +68,6 @@ export function noTerminalRefusal(input: { web: boolean; tui: boolean; interacti
|
|
|
67
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`;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
/** ssh and mosh set at least one of these; opening a browser on the far end helps nobody. */
|
|
71
|
-
export function isRemoteTty(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
72
|
-
return ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "MOSH_CONNECTION"].some((k) => !!env[k]);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
71
|
/**
|
|
76
72
|
* Flags `pi-loops` reads for itself when launching; everything else is pi's. `port`, `host`,
|
|
77
73
|
* `allow-host` and `no-auth` are claimed only to be refused: the page outlives the command now, so
|
|
@@ -114,12 +110,18 @@ const CLI_USAGE = [
|
|
|
114
110
|
"pi-loops [--web | --tui] [--no-open] [--loops-dir <dir>] [<pi flags>] [-- <pi flags>]",
|
|
115
111
|
" Open a conversation: the browser page on a local terminal, pi itself over ssh (pi-loops web and pi-loops tui say which).",
|
|
116
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.",
|
|
115
|
+
"",
|
|
117
116
|
"pi-loops upgrade [--check]",
|
|
118
117
|
" Install the newest release from the repository this copy came from.",
|
|
119
118
|
"",
|
|
120
119
|
"pi-loops install-launcher [--dir <dir>]",
|
|
121
120
|
" Put a `pi-loops` launcher on your PATH.",
|
|
122
121
|
"",
|
|
122
|
+
"pi-loops uninstall-launcher [--dir <dir>]",
|
|
123
|
+
" Remove the `pi-loops` launcher, so no pi session writes it again.",
|
|
124
|
+
"",
|
|
123
125
|
"pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]",
|
|
124
126
|
" Bundle a session and its automation into a .pisession archive.",
|
|
125
127
|
"",
|
|
@@ -302,8 +304,17 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
302
304
|
const cwd = path.resolve(str("cwd") ?? process.cwd());
|
|
303
305
|
|
|
304
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
|
+
}
|
|
305
315
|
if (command === "upgrade") return upgrade(flags.has("check"), out);
|
|
306
|
-
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);
|
|
307
318
|
if (command === "recipe") return recipeCommand(positional, { level: str("level"), overwrite: flags.has("overwrite") }, cwd, loopsDir, out);
|
|
308
319
|
if (command === "inbox") return inboxCommand(positional, { all: flags.has("all"), json: flags.has("json"), reason: flags.get("reason") }, cwd, loopsDir, out);
|
|
309
320
|
|
|
@@ -426,7 +437,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
426
437
|
/** What runs loops while no pi is open, started, stopped and looked at by name. */
|
|
427
438
|
const SERVICE_VERBS = new Set(["start", "stop", "restart", "status", "abort", "pair"]);
|
|
428
439
|
|
|
429
|
-
const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "start", "stop", "restart", "status", "abort", "pair", "host", "recipe", "inbox", "web", "upgrade", "install-launcher", "help"]);
|
|
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"]);
|
|
430
441
|
|
|
431
442
|
/**
|
|
432
443
|
* `pi-loops inbox`: `/inbox list|claim|dismiss` for a shell with no pi open, and — with `--json` —
|
|
@@ -784,7 +795,7 @@ export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" {
|
|
|
784
795
|
|
|
785
796
|
/** Each subcommand, and the line that starts its section of `CLI_USAGE`. */
|
|
786
797
|
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",
|
|
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",
|
|
788
799
|
export: "pi-loops export", import: "pi-loops import", sessions: "pi-loops sessions", inspect: "pi-loops inspect",
|
|
789
800
|
start: "pi-loops start | stop", stop: "pi-loops start | stop", restart: "pi-loops start | stop", status: "pi-loops start | stop",
|
|
790
801
|
abort: "pi-loops start | stop", host: "pi-loops start | stop", pair: "pi-loops pair", recipe: "pi-loops recipe", inbox: "pi-loops inbox",
|
|
@@ -795,14 +806,14 @@ const GLOBAL_FLAGS = new Set(["cwd", "help"]);
|
|
|
795
806
|
|
|
796
807
|
/** The flags each subcommand reads beside the global ones; any other is refused, not ignored. */
|
|
797
808
|
const VERB_FLAGS: Record<string, readonly string[]> = {
|
|
798
|
-
upgrade: ["check"], "install-launcher": ["dir"], import: ["activate-triggers"], export: ["session", "output", "exclude-triggers"],
|
|
809
|
+
upgrade: ["check"], "install-launcher": ["dir"], "uninstall-launcher": ["dir"], import: ["activate-triggers"], export: ["session", "output", "exclude-triggers"],
|
|
799
810
|
sessions: ["limit", "all"], recipe: ["level", "overwrite"], inbox: ["all", "json", "reason"], start: ["foreground"], status: ["url"],
|
|
800
|
-
inspect: [], stop: [], restart: [], abort: [], pair: [],
|
|
811
|
+
inspect: [], stop: [], restart: [], abort: [], pair: [], open: ["loops-dir"],
|
|
801
812
|
};
|
|
802
813
|
|
|
803
814
|
/** How many words each subcommand reads after its name. */
|
|
804
815
|
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,
|
|
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,
|
|
806
817
|
import: 1, inspect: 1, abort: 1, recipe: 2, inbox: 2,
|
|
807
818
|
};
|
|
808
819
|
|
|
@@ -849,10 +860,7 @@ function isOnPath(dir: string, pathEnv: string = process.env.PATH ?? ""): boolea
|
|
|
849
860
|
* is no; with nowhere to write there is nothing to ask, and the refusal says why.
|
|
850
861
|
*/
|
|
851
862
|
export async function installLauncherWithConfirm(args: string[], confirm: (title: string, body: string) => Promise<boolean>, out: (line: string) => void): Promise<number | undefined> {
|
|
852
|
-
const
|
|
853
|
-
// No shell has expanded `~` on the way here, so it is expanded now or becomes a directory named `~`.
|
|
854
|
-
const dir = typeof flag === "string" ? flag.replace(/^~(?=$|\/)/, os.homedir()) : undefined;
|
|
855
|
-
const target = launcherTarget(dir);
|
|
863
|
+
const target = launcherTarget(slashDir(args));
|
|
856
864
|
if (!target) return installLauncher(undefined, out);
|
|
857
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.`];
|
|
858
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"));
|
|
@@ -862,6 +870,66 @@ export async function installLauncherWithConfirm(args: string[], confirm: (title
|
|
|
862
870
|
return installLauncher(target, out);
|
|
863
871
|
}
|
|
864
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
|
+
|
|
865
933
|
/**
|
|
866
934
|
* What a launcher says when no copy of this package is at any of the paths it knows. One line and
|
|
867
935
|
* an exit code, because the alternative a person actually saw was Node's `Cannot find module` stack
|
|
@@ -913,14 +981,19 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
|
|
|
913
981
|
// Whatever a session start was told once, this is the answer now: a launcher exists because
|
|
914
982
|
// someone asked for it, so the offer is live again if the file ever goes.
|
|
915
983
|
clearUiPref(defaultLoopsDir(getAgentDir()), "launcher");
|
|
984
|
+
clearUiPref(defaultLoopsDir(getAgentDir()), "launcherFailedIn");
|
|
916
985
|
out(`wrote ${file}`);
|
|
917
986
|
out(` it runs ${path.join(entryDir, "cli-entry.mjs")}`);
|
|
918
987
|
out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
|
|
919
988
|
return 0;
|
|
920
989
|
}
|
|
921
990
|
|
|
922
|
-
/**
|
|
923
|
-
|
|
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";
|
|
924
997
|
|
|
925
998
|
/**
|
|
926
999
|
* The launcher, as text. The one place the script is spelled: `installLauncher` writes this, and a
|
|
@@ -939,9 +1012,7 @@ export function launcherScript(entryDir: string, node: string, agentDir: string)
|
|
|
939
1012
|
const entry = path.join(entryDir, "cli-entry.mjs");
|
|
940
1013
|
const candidates = [entry, ...piInstallEntries(agentDir).filter((e) => e !== entry)].map((e) => JSON.stringify(e)).join(" ");
|
|
941
1014
|
return [
|
|
942
|
-
|
|
943
|
-
`${LAUNCHER_MARKER} \`pi-loops install-launcher\`.`,
|
|
944
|
-
`node=${JSON.stringify(node)}`,
|
|
1015
|
+
`${LAUNCHER_HEADER}node=${JSON.stringify(node)}`,
|
|
945
1016
|
'[ -x "$node" ] || node=$(command -v node) || node=node',
|
|
946
1017
|
`for entry in ${candidates}; do`,
|
|
947
1018
|
'\t[ -f "$entry" ] || continue',
|
|
@@ -974,21 +1045,47 @@ export type LauncherState = "absent" | "current" | "stale" | "other" | "foreign"
|
|
|
974
1045
|
* What is at `<target>/pi-loops`, as far as this copy is concerned: nothing at all (`absent`), a
|
|
975
1046
|
* launcher this project wrote that matches this node and this copy (`current`), one of ours that
|
|
976
1047
|
* this copy should rewrite (`stale`), one of ours that belongs to another copy still on disk
|
|
977
|
-
* (`other`), or a file
|
|
978
|
-
* 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`).
|
|
979
1050
|
*
|
|
980
1051
|
* It takes the three things a launcher is written from rather than reading them, so the decision is
|
|
981
1052
|
* a function of its arguments and the session-start caller is a few lines.
|
|
982
1053
|
*/
|
|
983
1054
|
export function launcherState(entryDir: string, node: string, agentDir: string, target: string): { file: string; state: LauncherState } {
|
|
984
1055
|
const file = path.join(target, "pi-loops");
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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" };
|
|
988
1066
|
if (found === launcherScript(entryDir, node, agentDir)) return { file, state: "current" };
|
|
989
1067
|
return { file, state: oursToRewrite(found, entryDir) ? "stale" : "other" };
|
|
990
1068
|
}
|
|
991
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
|
+
|
|
992
1089
|
/**
|
|
993
1090
|
* Whether a launcher of ours that no longer matches is this copy's to rewrite. Two cases, and only
|
|
994
1091
|
* these two: the entry it recorded is gone — the package moved, which is the reading that ends in
|
|
@@ -1004,6 +1101,43 @@ function oursToRewrite(script: string, entryDir: string): boolean {
|
|
|
1004
1101
|
return recorded === path.join(entryDir, "cli-entry.mjs") || !fs.existsSync(recorded);
|
|
1005
1102
|
}
|
|
1006
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
|
+
|
|
1007
1141
|
/** The file, or undefined when there is nothing there to read — which is the same news. */
|
|
1008
1142
|
function readLauncher(file: string): string | undefined {
|
|
1009
1143
|
try {
|
|
@@ -1060,7 +1194,7 @@ function staleReason(old: string, entryDir: string, node: string): string {
|
|
|
1060
1194
|
* `pi --mode rpc` that pi-loops runs in the background — so this is a choice of window, not of
|
|
1061
1195
|
* product, and `--tui` is always there when the guess is wrong.
|
|
1062
1196
|
*/
|
|
1063
|
-
async function launch(argv: string[], out: (line: string) => void): Promise<number> {
|
|
1197
|
+
async function launch(argv: string[], out: (line: string) => void, cwd = process.cwd(), opts: { key?: boolean } = {}): Promise<number> {
|
|
1064
1198
|
const { ours, pi, misplaced } = splitLaunchArgs(argv[0] === "web" || argv[0] === "tui" ? argv.slice(1) : argv);
|
|
1065
1199
|
const has = (name: string) => ours.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
1066
1200
|
if (has("help")) {
|
|
@@ -1091,7 +1225,7 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
|
|
|
1091
1225
|
out("pi-loops: --resume is pi's picker, which the page has as /resume; pi-loops --session <id> opens one by id");
|
|
1092
1226
|
return 2;
|
|
1093
1227
|
}
|
|
1094
|
-
const target = pageTarget(withDefaults,
|
|
1228
|
+
const target = pageTarget(withDefaults, cwd);
|
|
1095
1229
|
if ("error" in target) {
|
|
1096
1230
|
out(`pi-loops: ${target.error}`);
|
|
1097
1231
|
return 1;
|
|
@@ -1102,12 +1236,38 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
|
|
|
1102
1236
|
out(`pi-loops: ${API_KEY_REFUSAL}`);
|
|
1103
1237
|
return 1;
|
|
1104
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 }> {
|
|
1105
1265
|
// Asking for the page is asking for pi-loops: a stop by hand ends here, or the process it starts
|
|
1106
1266
|
// would find the mark and leave again. Every loop and rule resumes with it, which is said once
|
|
1107
1267
|
// pi-loops is up; if it does not come up, the stop stays as it was.
|
|
1108
1268
|
const liftedStop = readUiPrefs(dir).host === "stopped";
|
|
1109
1269
|
if (liftedStop) clearUiPref(dir, "host");
|
|
1110
|
-
const ensured = await ensureHost(
|
|
1270
|
+
const ensured = await ensureHost(how.host);
|
|
1111
1271
|
// Running, started, or being started by a window right now: each of these has a page coming.
|
|
1112
1272
|
const why = ensured.error ?? (ensured.action.kind === "none" && ensured.action.reason !== "running" && ensured.action.reason !== IN_PROGRESS ? ensured.action.reason : undefined);
|
|
1113
1273
|
const rec = await waitForSocket(dir, why ? 0 : PAGE_WAIT_MS);
|
|
@@ -1117,41 +1277,36 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
|
|
|
1117
1277
|
}
|
|
1118
1278
|
if (!rec) {
|
|
1119
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)}`);
|
|
1120
|
-
return 1;
|
|
1280
|
+
return { code: 1 };
|
|
1121
1281
|
}
|
|
1122
1282
|
const waiting = (r: HostServiceRecord | undefined) => (r && !r.url && r.pageWaiting ? pageWaitingLine(r.pageWaiting.reason, pageAddress(dir)) : undefined);
|
|
1123
1283
|
const held = waiting(rec);
|
|
1124
1284
|
if (held) {
|
|
1125
1285
|
out(held);
|
|
1126
|
-
return 1;
|
|
1286
|
+
return { code: 1 };
|
|
1127
1287
|
}
|
|
1128
1288
|
// Asked while the page may still be binding: pi starts beside the bind, and the answer brings the
|
|
1129
1289
|
// address. Opening may also wait for an older pi in that conversation to stop, up to 15 s.
|
|
1130
|
-
const
|
|
1131
|
-
const
|
|
1132
|
-
const
|
|
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));
|
|
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));
|
|
1134
1293
|
if (!res) {
|
|
1135
1294
|
out(`pi-loops (pid ${rec.pid}) is not answering; see ${path.join(dir, HOST_LOG)}`);
|
|
1136
|
-
return 1;
|
|
1295
|
+
return { code: 1 };
|
|
1137
1296
|
}
|
|
1138
1297
|
if (!res.ok) {
|
|
1139
1298
|
out(waiting(readHostService(dir)) ?? `pi-loops: ${res.error}`);
|
|
1140
|
-
return 1;
|
|
1299
|
+
return { code: 1 };
|
|
1141
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);
|
|
1142
1305
|
// The browser opens on a file only this user can read, which holds the key: an argv is anyone's.
|
|
1143
|
-
|
|
1144
|
-
const
|
|
1145
|
-
const
|
|
1146
|
-
|
|
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;
|
|
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 } : {}) };
|
|
1155
1310
|
}
|
|
1156
1311
|
|
|
1157
1312
|
/**
|
|
@@ -1292,18 +1447,79 @@ export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.jo
|
|
|
1292
1447
|
}
|
|
1293
1448
|
}
|
|
1294
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
|
+
|
|
1295
1453
|
/**
|
|
1296
|
-
* What
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
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.
|
|
1299
1457
|
*/
|
|
1300
|
-
export function
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
+
];
|
|
1307
1523
|
if (i.ignoredArgs?.length) {
|
|
1308
1524
|
const flags = i.ignoredArgs.filter((a) => a.startsWith("-"));
|
|
1309
1525
|
const named = flags.length ? flags : i.ignoredArgs;
|
|
@@ -1312,26 +1528,6 @@ export function pageLaunchLines(i: { startedPid?: number; url: string; copyKey?:
|
|
|
1312
1528
|
return lines;
|
|
1313
1529
|
}
|
|
1314
1530
|
|
|
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
|
-
});
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
1531
|
/**
|
|
1336
1532
|
* Where the data lives for this run: the flag, then the environment, then the default — the default
|
|
1337
1533
|
* being the extension's own (`loops` under pi's agent directory, which `PI_CODING_AGENT_DIR` moves),
|