@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/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, pathToFileURL } from "node:url";
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(str("dir"), out);
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 flag = parseCliArgs(["install-launcher", ...args]).flags.get("dir");
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
- /** The marker line that says a `pi-loops` on someone's PATH is one this project wrote. */
923
- const LAUNCHER_MARKER = "# pi-loops launcher, written by";
991
+ /**
992
+ * The first two lines of every launcher this project has written, and the only thing that says a
993
+ * `pi-loops` on someone's PATH is ours. In that position and nowhere else: a script that quotes
994
+ * the marker further down is somebody's own, and uninstall-launcher deletes what this calls ours.
995
+ */
996
+ const LAUNCHER_HEADER = "#!/bin/sh\n# pi-loops launcher, written by `pi-loops install-launcher`.\n";
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
- "#!/bin/sh",
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 carrying no marker of ours — someone's own wrapper, which is not ours to
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
- const found = readLauncher(file);
986
- if (found === undefined) return { file, state: "absent" };
987
- if (!found.includes(LAUNCHER_MARKER)) return { file, state: "foreign" };
1056
+ let found: string;
1057
+ try {
1058
+ // A link, dangling or not, a directory or a file nobody can read: this project writes none
1059
+ // of these, so whatever is there is somebody's own, and absent means only that nothing is.
1060
+ if (!fs.lstatSync(file).isFile()) return { file, state: "foreign" };
1061
+ found = fs.readFileSync(file, "utf8");
1062
+ } catch (err: any) {
1063
+ return { file, state: err?.code === "ENOENT" ? "absent" : "foreign" };
1064
+ }
1065
+ if (!found.startsWith(LAUNCHER_HEADER)) return { file, state: "foreign" };
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, process.cwd());
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(pageLaunchHost(dir));
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 tty = !!process.stdout.isTTY;
1131
- const wantOpen = tty && !has("no-open") && !isRemoteTty();
1132
- const slow = setTimeout(() => process.stderr.write("waiting for pi-loops to open the conversation…\n"), PAGE_OPEN_SLOW_MS);
1133
- const res = await askHost(dir, { op: "page-open", cwd: process.cwd(), sessionId: target.sessionId, sessionFile: target.sessionFile, piArgs: target.piArgs, ...(wantOpen ? { openFile: true } : {}), ...(tty ? { copyKey: true } : {}) }, PAGE_OPEN_WAIT_MS).finally(() => clearTimeout(slow));
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
- // "Opened" only when an opener started; whether the browser could read the file it cannot tell.
1144
- const opened = wantOpen && !!res.openFile && (await openBrowser(openFileUrl(res.openFile!)));
1145
- const lines = pageLaunchLines({
1146
- startedPid: ensured.up && ensured.pid === rec.pid ? rec.pid : undefined,
1147
- url: res.url!,
1148
- copyKey: res.copyKey,
1149
- opened,
1150
- tty,
1151
- ignoredArgs: res.ignoredArgs,
1152
- });
1153
- for (const line of lines) out(line);
1154
- return 0;
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 `pi-loops` prints once the page has the conversation. The token is on a terminal only: it is
1297
- * a way in, and stdout redirected to a file would keep it. No pairing code: one armed on every launch
1298
- * is one any local process can burn; `pi-loops pair` arms one when somebody asks.
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 pageLaunchLines(i: { startedPid?: number; url: string; copyKey?: string; opened: boolean; tty: boolean; ignoredArgs?: string[] }): string[] {
1301
- const lines: string[] = [];
1302
- if (i.startedPid !== undefined) lines.push("started pi-loops in the background");
1303
- lines.push(`pi-loops page: ${i.url} (${i.opened ? "opened in your browser" : "open it in a browser"})`);
1304
- // A one-use key, not the token: scrollback, recordings and shared screens keep what is printed,
1305
- // and the token is a shell for as long as pi-loops runs.
1306
- if (i.tty && i.copyKey) lines.push(` ${i.opened ? "if it did not open, or" : "first visit"} from another browser: ${i.url}?open=${i.copyKey} (works once, for ${COPY_KEY_MS / 60_000} minutes)`);
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),