@alphacoder-v0/pi-loops 0.24.0 → 0.24.2

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
@@ -9,14 +9,14 @@
9
9
  * pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
10
10
  */
11
11
  import { execFileSync, spawn, spawnSync } from "node:child_process";
12
- import { plural } from "./job-lines.ts";
12
+ import { plural } from "./plural.ts";
13
13
  import * as fs from "node:fs";
14
14
  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";
19
- import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import { fileURLToPath } from "node:url";
19
+ import { isRemoteTty, openFileTarget, openUrl, thisMachine } from "./browser-open.ts";
20
20
  import { applyImport, automationOf, exportSession, defaultExportPath, importSession, inspectArchive, offerToEnable, parseActivateMode } from "./archive.ts";
21
21
  import { API_KEY_REFUSAL, CONTROL_TIMEOUT_MS, COPY_KEY_MS, askHost, hostSocketPath, openConversations, renderHostSnapshot, requestLines, requestQueues, ringHost } from "./host-control-channel.ts";
22
22
  import { heldTurnFor, stopRow } from "./engine.ts";
@@ -30,13 +30,13 @@ import { crashPaused, crashPauseText, ensureHost, HOST_LOG, hostCommand, hostSer
30
30
  import { loadConfig, pageBindPort } from "./config.ts";
31
31
  import { writeUiPref } from "./ui-prefs.ts";
32
32
  import { type UiPrefs, clearUiPref, readUiPrefs } from "./ui-prefs.ts";
33
- import { JobStore, defaultLoopsDir, homeLoopsDir } from "./store.ts";
33
+ import { JobStore, defaultLoopsDir, homeLoopsDir, piAgentDir } from "./store.ts";
34
34
  import { automationBadge, previewText, readSessionHead, shortSessionId } from "./session-head.ts";
35
35
  import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
36
36
  import { TriggerStore } from "./triggers.ts";
37
37
  import { PI_LOOPS_VERSION } from "./version.ts";
38
38
  import { formatLocal, stamp } from "./schedule.ts";
39
- import { canonicalDir } from "./trust.ts";
39
+ import { canonicalDir } from "./paths.ts";
40
40
 
41
41
  /**
42
42
  * Which front end `pi-loops` opens when you do not say. A browser is the better window when one is
@@ -67,17 +67,12 @@ export function noTerminalRefusal(input: { web: boolean; tui: boolean; interacti
67
67
  return `pi-loops: there is no terminal here to open pi in; run pi-loops --web to open the page, or pi-loops -- -p "<prompt>" for one answer`;
68
68
  }
69
69
 
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
70
  /**
76
71
  * Flags `pi-loops` reads for itself when launching; everything else is pi's. `port`, `host`,
77
72
  * `allow-host` and `no-auth` are claimed only to be refused: the page outlives the command now, so
78
73
  * its address lives in config.toml.
79
74
  */
80
- const LAUNCH_FLAGS = new Set(["web", "tui", "port", "host", "allow-host", "open", "no-open", "no-auth", "loops-dir", "help"]);
75
+ const LAUNCH_FLAGS = new Set(["web", "tui", "port", "host", "allow-host", "no-open", "no-auth", "loops-dir", "help"]);
81
76
  /** The ones that take the next argument as their value. */
82
77
  const LAUNCH_FLAGS_WITH_VALUE = new Set(["port", "host", "allow-host", "loops-dir"]);
83
78
 
@@ -114,19 +109,25 @@ const CLI_USAGE = [
114
109
  "pi-loops [--web | --tui] [--no-open] [--loops-dir <dir>] [<pi flags>] [-- <pi flags>]",
115
110
  " Open a conversation: the browser page on a local terminal, pi itself over ssh (pi-loops web and pi-loops tui say which).",
116
111
  "",
112
+ "pi-loops open [--cwd <dir>] [--loops-dir <dir>]",
113
+ " Open the page on this project's newest conversation, with a one-use link that signs a browser in.",
114
+ "",
117
115
  "pi-loops upgrade [--check]",
118
116
  " Install the newest release from the repository this copy came from.",
119
117
  "",
120
118
  "pi-loops install-launcher [--dir <dir>]",
121
119
  " Put a `pi-loops` launcher on your PATH.",
122
120
  "",
121
+ "pi-loops uninstall-launcher [--dir <dir>]",
122
+ " Remove the `pi-loops` launcher, so no pi session writes it again.",
123
+ "",
123
124
  "pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]",
124
125
  " Bundle a session and its automation into a .pisession archive.",
125
126
  "",
126
127
  "pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]",
127
128
  " Restore an archive into a project.",
128
129
  "",
129
- "pi-loops sessions [--all] [--limit <n>]",
130
+ "pi-loops sessions [--all] [--limit <n>] [--cwd <dir>]",
130
131
  " List sessions, newest first.",
131
132
  "",
132
133
  "pi-loops inspect <file>",
@@ -285,6 +286,10 @@ function runChild(command: string, args: string[], env?: NodeJS.ProcessEnv): Pro
285
286
  export async function runCli(argv: string[], out: (line: string) => void = console.log): Promise<number> {
286
287
  const route = cliRoute(argv);
287
288
  if (route === "launch") return launch(argv, out);
289
+ if (route === "version") {
290
+ out(`pi-loops v${PI_LOOPS_VERSION}`);
291
+ return 0;
292
+ }
288
293
  if (route === "help") {
289
294
  out(usageFor(argv[0]));
290
295
  return 0;
@@ -297,13 +302,22 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
297
302
  const v = flags.get(name);
298
303
  return typeof v === "string" ? v : undefined;
299
304
  };
300
- const agentDir = getAgentDir();
305
+ const agentDir = piAgentDir();
301
306
  const loopsDir = process.env.PI_LOOPS_DIR || defaultLoopsDir(agentDir);
302
307
  const cwd = path.resolve(str("cwd") ?? process.cwd());
303
308
 
304
309
  if (command === "web" || command === "tui") return launch(argv, out);
310
+ // What the door tells a browser with no way in to run: the project's newest conversation, so
311
+ // signing in adds none, or a new one where there is none yet. The keyed link is printed with no
312
+ // terminal too: printing it is what this command is for.
313
+ if (command === "open") {
314
+ const named = str("loops-dir");
315
+ const args = [...(named ? [`--loops-dir=${named}`] : []), ...(listSessions(path.join(agentDir, "sessions")).some((s) => sameProject(s.cwd, cwd)) ? ["--continue"] : [])];
316
+ return launch(["web", ...args], out, cwd, { key: true });
317
+ }
305
318
  if (command === "upgrade") return upgrade(flags.has("check"), out);
306
- if (command === "install-launcher") return installLauncher(str("dir"), out);
319
+ if (command === "install-launcher") return installLauncher(launcherDirFlag(flags), out);
320
+ if (command === "uninstall-launcher") return uninstallLauncher(launcherDirFlag(flags), out);
307
321
  if (command === "recipe") return recipeCommand(positional, { level: str("level"), overwrite: flags.has("overwrite") }, cwd, loopsDir, out);
308
322
  if (command === "inbox") return inboxCommand(positional, { all: flags.has("all"), json: flags.has("json"), reason: flags.get("reason") }, cwd, loopsDir, out);
309
323
 
@@ -372,6 +386,8 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
372
386
  const triggerStore = new TriggerStore(loopsDir);
373
387
  // pi encodes a project as `--home-u-proj--` and `SessionManager.list()` reads only that one
374
388
  // directory, with no fallback — a hand-rolled name here would restore a session pi never sees.
389
+ // Loaded here, not at the top: pi's SDK takes about a second, and only this verb needs it.
390
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
375
391
  const sessionDir = SessionManager.create(cwd).getSessionDir();
376
392
  const existingJobs = jobStore.load();
377
393
  const existingRules = triggerStore.load();
@@ -426,7 +442,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
426
442
  /** What runs loops while no pi is open, started, stopped and looked at by name. */
427
443
  const SERVICE_VERBS = new Set(["start", "stop", "restart", "status", "abort", "pair"]);
428
444
 
429
- const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "start", "stop", "restart", "status", "abort", "pair", "host", "recipe", "inbox", "web", "upgrade", "install-launcher", "help"]);
445
+ 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
446
 
431
447
  /**
432
448
  * `pi-loops inbox`: `/inbox list|claim|dismiss` for a shell with no pi open, and — with `--json` —
@@ -740,7 +756,7 @@ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise
740
756
  out("already up to date");
741
757
  return 0;
742
758
  }
743
- const spec = upgradeSpec(packageRoot(), name ?? "", getAgentDir(), latest, configuredNpmSource(packageRoot(), name ?? ""));
759
+ const spec = upgradeSpec(packageRoot(), name ?? "", piAgentDir(), latest, configuredNpmSource(packageRoot(), name ?? ""));
744
760
  if (!spec) {
745
761
  // Nothing failed, so this is not an error: the newest release is out and the way to it is a
746
762
  // pull, not an install that would leave a second copy beside the checkout.
@@ -768,11 +784,14 @@ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise
768
784
  * `-h` or `--help` anywhere before a `--` asks how a subcommand works, and asking must not run it:
769
785
  * `install-launcher --help` wrote the launcher and `upgrade --help` ran the upgrade. After `--`
770
786
  * they are pi's.
787
+ *
788
+ * `--version` alone names this pi-loops; `pi-loops --tui -- --version` still asks pi.
771
789
  */
772
- export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" {
790
+ export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" | "version" {
773
791
  const first = argv[0];
774
792
  if (first === undefined) return "launch";
775
793
  if (first === "--help" || first === "-h" || first === "help") return "help";
794
+ if (argv.length === 1 && (first === "--version" || first === "-v")) return "version";
776
795
  if (first.startsWith("-")) return "launch";
777
796
  const dashdash = argv.indexOf("--");
778
797
  const before = dashdash === -1 ? argv.slice(1) : argv.slice(1, dashdash);
@@ -784,7 +803,7 @@ export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" {
784
803
 
785
804
  /** Each subcommand, and the line that starts its section of `CLI_USAGE`. */
786
805
  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",
806
+ 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
807
  export: "pi-loops export", import: "pi-loops import", sessions: "pi-loops sessions", inspect: "pi-loops inspect",
789
808
  start: "pi-loops start | stop", stop: "pi-loops start | stop", restart: "pi-loops start | stop", status: "pi-loops start | stop",
790
809
  abort: "pi-loops start | stop", host: "pi-loops start | stop", pair: "pi-loops pair", recipe: "pi-loops recipe", inbox: "pi-loops inbox",
@@ -795,14 +814,14 @@ const GLOBAL_FLAGS = new Set(["cwd", "help"]);
795
814
 
796
815
  /** The flags each subcommand reads beside the global ones; any other is refused, not ignored. */
797
816
  const VERB_FLAGS: Record<string, readonly string[]> = {
798
- upgrade: ["check"], "install-launcher": ["dir"], import: ["activate-triggers"], export: ["session", "output", "exclude-triggers"],
817
+ upgrade: ["check"], "install-launcher": ["dir"], "uninstall-launcher": ["dir"], import: ["activate-triggers"], export: ["session", "output", "exclude-triggers"],
799
818
  sessions: ["limit", "all"], recipe: ["level", "overwrite"], inbox: ["all", "json", "reason"], start: ["foreground"], status: ["url"],
800
- inspect: [], stop: [], restart: [], abort: [], pair: [],
819
+ inspect: [], stop: [], restart: [], abort: [], pair: [], open: ["loops-dir"],
801
820
  };
802
821
 
803
822
  /** How many words each subcommand reads after its name. */
804
823
  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,
824
+ 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
825
  import: 1, inspect: 1, abort: 1, recipe: 2, inbox: 2,
807
826
  };
808
827
 
@@ -849,10 +868,7 @@ function isOnPath(dir: string, pathEnv: string = process.env.PATH ?? ""): boolea
849
868
  * is no; with nowhere to write there is nothing to ask, and the refusal says why.
850
869
  */
851
870
  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);
871
+ const target = launcherTarget(slashDir(args));
856
872
  if (!target) return installLauncher(undefined, out);
857
873
  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
874
  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 +878,66 @@ export async function installLauncherWithConfirm(args: string[], confirm: (title
862
878
  return installLauncher(target, out);
863
879
  }
864
880
 
881
+ /**
882
+ * The `--dir` of a `/pi-loops install-launcher` or `uninstall-launcher` line. No shell has expanded
883
+ * `~` on the way here, so it is expanded now or becomes a directory named `~`.
884
+ */
885
+ export function slashDir(args: string[]): string | undefined {
886
+ return launcherDirFlag(parseCliArgs(["install-launcher", ...args]).flags)?.replace(/^~(?=$|\/)/, os.homedir());
887
+ }
888
+
889
+ /**
890
+ * The directory `--dir` names. A `--dir` with nothing after it, or with a flag after it, is an
891
+ * error rather than no `--dir`: read as the default, `uninstall-launcher --dir` removed the
892
+ * launcher on PATH that nobody had named.
893
+ */
894
+ function launcherDirFlag(flags: ReadonlyMap<string, string | true>): string | undefined {
895
+ const dir = flags.get("dir");
896
+ if (dir === undefined) return undefined;
897
+ if (dir === true || dir === "" || dir.startsWith("-")) throw new UsageError(`--dir takes a directory; got ${dir === true || dir === "" ? "nothing" : JSON.stringify(dir)}`);
898
+ return dir;
899
+ }
900
+
901
+ /**
902
+ * `uninstall-launcher`: remove the launcher, and remember that it was removed so no session start
903
+ * writes it again (`ui.json` `launcher: "declined"`, which `install-launcher` clears). Only a
904
+ * launcher of ours that this copy keeps (`current` or `stale`) is removed. Somebody's own
905
+ * `pi-loops`, or one of ours that runs another copy still on disk, is left as it is and the line
906
+ * says whose it is. With nothing there, the state asked for is already the state on disk.
907
+ */
908
+ export function uninstallLauncher(dir: string | undefined, out: (line: string) => void): number {
909
+ writeUiPref(defaultLoopsDir(piAgentDir()), "launcher", "declined");
910
+ const target = launcherTarget(dir);
911
+ const again = "no pi session writes it again; `/pi-loops install-launcher` in pi puts it back";
912
+ if (!target) {
913
+ out("no launcher to remove: neither ~/.local/bin nor /usr/local/bin is on your PATH; pass the directory with --dir <dir>");
914
+ out(` ${again}`);
915
+ return 0;
916
+ }
917
+ const { entryDir, node, agentDir } = launcherSource();
918
+ const { file, state } = launcherState(entryDir, node, agentDir, target);
919
+ if (state === "foreign") {
920
+ out(`left ${file} as it is: it is not a pi-loops launcher, so it is somebody's own`);
921
+ return 1;
922
+ }
923
+ if (state === "other") {
924
+ out(`left ${file} as it is: it runs another copy of pi-loops, ${launcherRecords(readLauncher(file) ?? "").entries[0]}, so remove it from that copy`);
925
+ return 1;
926
+ }
927
+ if (state === "absent") out(`no launcher at ${file}`);
928
+ else {
929
+ try {
930
+ fs.rmSync(file);
931
+ } catch (err: any) {
932
+ out(`could not remove ${file}: ${err?.message ?? err}`);
933
+ return 1;
934
+ }
935
+ out(`removed ${file}`);
936
+ }
937
+ out(` ${again}`);
938
+ return 0;
939
+ }
940
+
865
941
  /**
866
942
  * What a launcher says when no copy of this package is at any of the paths it knows. One line and
867
943
  * an exit code, because the alternative a person actually saw was Node's `Cannot find module` stack
@@ -912,15 +988,20 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
912
988
  }
913
989
  // Whatever a session start was told once, this is the answer now: a launcher exists because
914
990
  // someone asked for it, so the offer is live again if the file ever goes.
915
- clearUiPref(defaultLoopsDir(getAgentDir()), "launcher");
991
+ clearUiPref(defaultLoopsDir(piAgentDir()), "launcher");
992
+ clearUiPref(defaultLoopsDir(piAgentDir()), "launcherFailedIn");
916
993
  out(`wrote ${file}`);
917
994
  out(` it runs ${path.join(entryDir, "cli-entry.mjs")}`);
918
995
  out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
919
996
  return 0;
920
997
  }
921
998
 
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";
999
+ /**
1000
+ * The first two lines of every launcher this project has written, and the only thing that says a
1001
+ * `pi-loops` on someone's PATH is ours. In that position and nowhere else: a script that quotes
1002
+ * the marker further down is somebody's own, and uninstall-launcher deletes what this calls ours.
1003
+ */
1004
+ const LAUNCHER_HEADER = "#!/bin/sh\n# pi-loops launcher, written by `pi-loops install-launcher`.\n";
924
1005
 
925
1006
  /**
926
1007
  * The launcher, as text. The one place the script is spelled: `installLauncher` writes this, and a
@@ -939,9 +1020,7 @@ export function launcherScript(entryDir: string, node: string, agentDir: string)
939
1020
  const entry = path.join(entryDir, "cli-entry.mjs");
940
1021
  const candidates = [entry, ...piInstallEntries(agentDir).filter((e) => e !== entry)].map((e) => JSON.stringify(e)).join(" ");
941
1022
  return [
942
- "#!/bin/sh",
943
- `${LAUNCHER_MARKER} \`pi-loops install-launcher\`.`,
944
- `node=${JSON.stringify(node)}`,
1023
+ `${LAUNCHER_HEADER}node=${JSON.stringify(node)}`,
945
1024
  '[ -x "$node" ] || node=$(command -v node) || node=node',
946
1025
  `for entry in ${candidates}; do`,
947
1026
  '\t[ -f "$entry" ] || continue',
@@ -955,7 +1034,7 @@ export function launcherScript(entryDir: string, node: string, agentDir: string)
955
1034
 
956
1035
  /** What a launcher for *this* copy is written from, so a caller needs to know none of the three. */
957
1036
  export function launcherSource(): { entryDir: string; node: string; agentDir: string } {
958
- return { entryDir: path.dirname(fileURLToPath(import.meta.url)), node: process.execPath, agentDir: getAgentDir() };
1037
+ return { entryDir: path.dirname(fileURLToPath(import.meta.url)), node: process.execPath, agentDir: piAgentDir() };
959
1038
  }
960
1039
 
961
1040
  /**
@@ -965,7 +1044,7 @@ export function launcherSource(): { entryDir: string; node: string; agentDir: st
965
1044
  * for a command that was never put on their PATH.
966
1045
  */
967
1046
  export function installedByPi(): boolean {
968
- return upgradeSpec(packageRoot(), manifest().name ?? "", getAgentDir(), "v0.0.0") !== undefined;
1047
+ return upgradeSpec(packageRoot(), manifest().name ?? "", piAgentDir(), "v0.0.0") !== undefined;
969
1048
  }
970
1049
 
971
1050
  export type LauncherState = "absent" | "current" | "stale" | "other" | "foreign";
@@ -974,21 +1053,47 @@ export type LauncherState = "absent" | "current" | "stale" | "other" | "foreign"
974
1053
  * What is at `<target>/pi-loops`, as far as this copy is concerned: nothing at all (`absent`), a
975
1054
  * launcher this project wrote that matches this node and this copy (`current`), one of ours that
976
1055
  * 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`).
1056
+ * (`other`), or a file that does not open with our header — someone's own wrapper, which is not
1057
+ * ours to rewrite either (`foreign`).
979
1058
  *
980
1059
  * It takes the three things a launcher is written from rather than reading them, so the decision is
981
1060
  * a function of its arguments and the session-start caller is a few lines.
982
1061
  */
983
1062
  export function launcherState(entryDir: string, node: string, agentDir: string, target: string): { file: string; state: LauncherState } {
984
1063
  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" };
1064
+ let found: string;
1065
+ try {
1066
+ // A link, dangling or not, a directory or a file nobody can read: this project writes none
1067
+ // of these, so whatever is there is somebody's own, and absent means only that nothing is.
1068
+ if (!fs.lstatSync(file).isFile()) return { file, state: "foreign" };
1069
+ found = fs.readFileSync(file, "utf8");
1070
+ } catch (err: any) {
1071
+ return { file, state: err?.code === "ENOENT" ? "absent" : "foreign" };
1072
+ }
1073
+ if (!found.startsWith(LAUNCHER_HEADER)) return { file, state: "foreign" };
988
1074
  if (found === launcherScript(entryDir, node, agentDir)) return { file, state: "current" };
989
1075
  return { file, state: oursToRewrite(found, entryDir) ? "stale" : "other" };
990
1076
  }
991
1077
 
1078
+ /**
1079
+ * What a session start does about the launcher: write one with no question, rewrite one of ours,
1080
+ * or nothing. pi's own installer links `pi` into a directory on PATH and says so, and one extra
1081
+ * keystroke after `pi install` is a cost the owner counts (#180), so a write asks nothing.
1082
+ *
1083
+ * A write needs all of these: a directory on PATH (`target`), nothing named `pi-loops` there, a
1084
+ * UI to say it in, a copy that `pi install` put there (a checkout is missing nothing), no earlier
1085
+ * `uninstall-launcher` (`declined`), and no failed write into this same directory (`failedIn`).
1086
+ * The last one is so that a directory nobody can write to is not tried, and warned about, in
1087
+ * every window; a PATH that names another directory is a new attempt.
1088
+ */
1089
+ export function launcherAction(o: { target: string | undefined; state: LauncherState | undefined; hasUI: boolean; declined: boolean; failedIn: string | undefined; installedByPi: boolean }): "write" | "refresh" | "none" {
1090
+ if (!o.target) return "none";
1091
+ if (o.state === "stale") return "refresh";
1092
+ if (o.state !== "absent") return "none";
1093
+ if (!o.hasUI || o.declined || !o.installedByPi || o.failedIn === o.target) return "none";
1094
+ return "write";
1095
+ }
1096
+
992
1097
  /**
993
1098
  * Whether a launcher of ours that no longer matches is this copy's to rewrite. Two cases, and only
994
1099
  * these two: the entry it recorded is gone — the package moved, which is the reading that ends in
@@ -1004,6 +1109,43 @@ function oursToRewrite(script: string, entryDir: string): boolean {
1004
1109
  return recorded === path.join(entryDir, "cli-entry.mjs") || !fs.existsSync(recorded);
1005
1110
  }
1006
1111
 
1112
+ /**
1113
+ * The write a session start makes: a new file in a directory that is already there, created
1114
+ * exclusively, so a path that is already there in any form, a dangling link included, fails the
1115
+ * create and is left as it is. It is quietly nothing, because the same file appearing between the
1116
+ * check and the write is somebody else's.
1117
+ */
1118
+ export function addLauncher(entryDir: string, node: string, agentDir: string, target: string): { kind: "added"; file: string } | { kind: "none" } | { kind: "failed"; message: string } {
1119
+ const file = path.join(target, "pi-loops");
1120
+ try {
1121
+ // A directory already on your PATH, as the docs promise: PATH can name ~/.local/bin before
1122
+ // anything has made it, and making it is for an install-launcher somebody asked for.
1123
+ if (!fs.statSync(target).isDirectory()) return { kind: "none" };
1124
+ } catch {
1125
+ // Not there, or not reachable: either way there is no directory on PATH to write into.
1126
+ return { kind: "none" };
1127
+ }
1128
+ let fd: number;
1129
+ try {
1130
+ fd = fs.openSync(file, "wx", 0o755);
1131
+ } catch (err: any) {
1132
+ if (err?.code === "EEXIST") return { kind: "none" };
1133
+ return { kind: "failed", message: `could not write ${file}: ${err?.message ?? err}` };
1134
+ }
1135
+ try {
1136
+ fs.writeSync(fd, launcherScript(entryDir, node, agentDir));
1137
+ fs.fchmodSync(fd, 0o755);
1138
+ } catch (err: any) {
1139
+ fs.closeSync(fd);
1140
+ // The file is ours, made a moment ago: a half-written one would read as somebody else's
1141
+ // from then on and never be written again, so it goes.
1142
+ fs.rmSync(file, { force: true });
1143
+ return { kind: "failed", message: `could not write ${file}: ${err?.message ?? err}` };
1144
+ }
1145
+ fs.closeSync(fd);
1146
+ return { kind: "added", file };
1147
+ }
1148
+
1007
1149
  /** The file, or undefined when there is nothing there to read — which is the same news. */
1008
1150
  function readLauncher(file: string): string | undefined {
1009
1151
  try {
@@ -1060,7 +1202,7 @@ function staleReason(old: string, entryDir: string, node: string): string {
1060
1202
  * `pi --mode rpc` that pi-loops runs in the background — so this is a choice of window, not of
1061
1203
  * product, and `--tui` is always there when the guess is wrong.
1062
1204
  */
1063
- async function launch(argv: string[], out: (line: string) => void): Promise<number> {
1205
+ async function launch(argv: string[], out: (line: string) => void, cwd = process.cwd(), opts: { key?: boolean } = {}): Promise<number> {
1064
1206
  const { ours, pi, misplaced } = splitLaunchArgs(argv[0] === "web" || argv[0] === "tui" ? argv.slice(1) : argv);
1065
1207
  const has = (name: string) => ours.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
1066
1208
  if (has("help")) {
@@ -1080,7 +1222,8 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
1080
1222
  const mode = resolveUiMode({ ...asked, remoteTty: isRemoteTty() });
1081
1223
  const dir = loopsDir(ours);
1082
1224
  const withDefaults = applyRememberedModel(pi, readUiPrefs(dir));
1083
- if (mode === "terminal") return runChild(process.env.PI_BIN || "pi", withDefaults);
1225
+ // The extension in that terminal reads its directory from the environment, not from our flags.
1226
+ if (mode === "terminal") return runChild(process.env.PI_BIN || "pi", withDefaults, has("loops-dir") ? { ...process.env, PI_LOOPS_DIR: dir } : undefined);
1084
1227
 
1085
1228
  const refused = pageFlagRefusal(ours);
1086
1229
  if (refused) {
@@ -1091,7 +1234,7 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
1091
1234
  out("pi-loops: --resume is pi's picker, which the page has as /resume; pi-loops --session <id> opens one by id");
1092
1235
  return 2;
1093
1236
  }
1094
- const target = pageTarget(withDefaults, process.cwd());
1237
+ const target = pageTarget(withDefaults, cwd);
1095
1238
  if ("error" in target) {
1096
1239
  out(`pi-loops: ${target.error}`);
1097
1240
  return 1;
@@ -1102,12 +1245,38 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
1102
1245
  out(`pi-loops: ${API_KEY_REFUSAL}`);
1103
1246
  return 1;
1104
1247
  }
1248
+ const tty = !!process.stdout.isTTY;
1249
+ const browser = !tty || has("no-open") ? "no" : isRemoteTty() ? "remote" : "yes";
1250
+ const key = tty || !!opts.key;
1251
+ const opened = await openPage(dir, { cwd, sessionId: target.sessionId, sessionFile: target.sessionFile, piArgs: target.piArgs }, { key, browser, host: pageLaunchHost(dir) }, out, () =>
1252
+ process.stderr.write("waiting for pi-loops to open the conversation…\n"),
1253
+ );
1254
+ return opened.code;
1255
+ }
1256
+
1257
+ /** Whether `openPage` tries a browser: `remote` is a terminal over ssh, where it never does. */
1258
+ export type PageBrowser = "yes" | "no" | "remote";
1259
+
1260
+ /**
1261
+ * Open the page on a conversation and say so in `out`: start pi-loops in the background as `host`
1262
+ * says when it is not running, ask it for `page-open`, print the one-use link, then try the browser
1263
+ * and say what happened. `pi-loops`, `pi-loops open` and `/pi-loops web` all come here. `link` is
1264
+ * the printed address with its key, for a caller that also copies it.
1265
+ */
1266
+ export async function openPage(
1267
+ dir: string,
1268
+ req: { cwd: string; sessionId?: string; sessionFile?: string; piArgs: string[] },
1269
+ how: { key: boolean; browser: PageBrowser; host: EnsureHostOptions },
1270
+ out: (line: string) => void,
1271
+ slowNote: () => void = () => {},
1272
+ onLink: (link: string) => void | Promise<void> = () => {},
1273
+ ): Promise<{ code: number; link?: string }> {
1105
1274
  // Asking for the page is asking for pi-loops: a stop by hand ends here, or the process it starts
1106
1275
  // would find the mark and leave again. Every loop and rule resumes with it, which is said once
1107
1276
  // pi-loops is up; if it does not come up, the stop stays as it was.
1108
1277
  const liftedStop = readUiPrefs(dir).host === "stopped";
1109
1278
  if (liftedStop) clearUiPref(dir, "host");
1110
- const ensured = await ensureHost(pageLaunchHost(dir));
1279
+ const ensured = await ensureHost({ ...how.host, onLeaveWait: () => process.stderr.write(`${RESTART_WAIT_LINE}\n`) });
1111
1280
  // Running, started, or being started by a window right now: each of these has a page coming.
1112
1281
  const why = ensured.error ?? (ensured.action.kind === "none" && ensured.action.reason !== "running" && ensured.action.reason !== IN_PROGRESS ? ensured.action.reason : undefined);
1113
1282
  const rec = await waitForSocket(dir, why ? 0 : PAGE_WAIT_MS);
@@ -1117,41 +1286,36 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
1117
1286
  }
1118
1287
  if (!rec) {
1119
1288
  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;
1289
+ return { code: 1 };
1121
1290
  }
1122
1291
  const waiting = (r: HostServiceRecord | undefined) => (r && !r.url && r.pageWaiting ? pageWaitingLine(r.pageWaiting.reason, pageAddress(dir)) : undefined);
1123
1292
  const held = waiting(rec);
1124
1293
  if (held) {
1125
1294
  out(held);
1126
- return 1;
1295
+ return { code: 1 };
1127
1296
  }
1128
1297
  // Asked while the page may still be binding: pi starts beside the bind, and the answer brings the
1129
1298
  // 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));
1299
+ const wantOpen = how.browser === "yes";
1300
+ const slow = setTimeout(slowNote, PAGE_OPEN_SLOW_MS);
1301
+ const res = await askHost(dir, { op: "page-open", ...req, ...(wantOpen ? { openFile: true } : {}), ...(how.key ? { copyKey: true } : {}) }, PAGE_OPEN_WAIT_MS).finally(() => clearTimeout(slow));
1134
1302
  if (!res) {
1135
1303
  out(`pi-loops (pid ${rec.pid}) is not answering; see ${path.join(dir, HOST_LOG)}`);
1136
- return 1;
1304
+ return { code: 1 };
1137
1305
  }
1138
1306
  if (!res.ok) {
1139
1307
  out(waiting(readHostService(dir)) ?? `pi-loops: ${res.error}`);
1140
- return 1;
1308
+ return { code: 1 };
1141
1309
  }
1310
+ const link = pageLink({ url: res.url!, copyKey: res.copyKey, key: how.key });
1311
+ if (ensured.up && ensured.pid === rec.pid) out("started pi-loops in the background");
1312
+ out(`pi-loops: ${link}`);
1313
+ if (how.key) await onLink(link);
1142
1314
  // 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;
1315
+ const openTarget = wantOpen && res.openFile ? openFileTarget(res.openFile, thisMachine()) : undefined;
1316
+ const outcome: OpenOutcome = how.browser === "remote" ? "remote" : how.browser === "no" ? "not-asked" : openTarget ? await openOutcome(openTarget, res.openFile!) : "failed";
1317
+ for (const line of pageOutcomeLines({ outcome, key: how.key, ignoredArgs: res.ignoredArgs })) out(line);
1318
+ return { code: 0, ...(how.key ? { link } : {}) };
1155
1319
  }
1156
1320
 
1157
1321
  /**
@@ -1228,7 +1392,7 @@ function socketOpen(socketPath: string): Promise<boolean> {
1228
1392
  */
1229
1393
  export function pageAddress(dir: string): string {
1230
1394
  const config = loadConfig(dir);
1231
- const port = pageBindPort(config, { defaultDir: path.resolve(dir) === path.resolve(homeLoopsDir(getAgentDir())), env: process.env.PI_LOOPS_PAGE_PORT });
1395
+ const port = pageBindPort(config, { defaultDir: path.resolve(dir) === path.resolve(homeLoopsDir(piAgentDir())), env: process.env.PI_LOOPS_PAGE_PORT });
1232
1396
  return port ? `${config.pageHost}:${port}` : `${config.pageHost} (on a free port; pi-loops status names it)`;
1233
1397
  }
1234
1398
 
@@ -1272,7 +1436,7 @@ export function pageFlagRefusal(ours: string[]): string[] | undefined {
1272
1436
  * already has, found the way `pi-loops export` finds it and taken out of pi's arguments; without
1273
1437
  * either, a new one in `cwd` with the arguments as they are.
1274
1438
  */
1275
- export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.join(getAgentDir(), "sessions")): { sessionId?: string; sessionFile?: string; piArgs: string[] } | { error: string } {
1439
+ export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.join(piAgentDir(), "sessions")): { sessionId?: string; sessionFile?: string; piArgs: string[] } | { error: string } {
1276
1440
  const rest: string[] = [];
1277
1441
  let id: string | undefined;
1278
1442
  let latest = false;
@@ -1292,18 +1456,79 @@ export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.jo
1292
1456
  }
1293
1457
  }
1294
1458
 
1459
+ /** What the browser did, as `openPage` says it on the second line. */
1460
+ export type OpenOutcome = "opened" | "unconfirmed" | "failed" | "remote" | "not-asked";
1461
+
1295
1462
  /**
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.
1463
+ * What `/pi-loops web` asks `page-open` for: the conversation it is in once pi has saved it. Before
1464
+ * the first answer (a path with no file) and under `--no-session` (no path) the page has nothing to
1465
+ * show under that id, so it asks with no id, which opens a new conversation in the project.
1299
1466
  */
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)`);
1467
+ export function webPageRequest(here: { cwd: string; sessionId?: string; sessionFile?: string }): { cwd: string; sessionId?: string; sessionFile?: string; piArgs: string[] } {
1468
+ const saved = here.sessionId && here.sessionFile && fs.existsSync(here.sessionFile);
1469
+ return saved ? { cwd: here.cwd, sessionId: here.sessionId, sessionFile: here.sessionFile, piArgs: [] } : { cwd: here.cwd, piArgs: [] };
1470
+ }
1471
+
1472
+ /**
1473
+ * The address `pi-loops` prints: on a terminal, with a one-use key that works for `COPY_KEY_MS`.
1474
+ * Never the token: scrollback, recordings and shared screens keep what is printed, and the token is
1475
+ * a shell for as long as pi-loops runs. `key` is false when stdout is not a terminal, since a
1476
+ * redirected stdout would keep the key, except for `pi-loops open`, which is asked for exactly that
1477
+ * link. No pairing code: one armed on every launch is one any local
1478
+ * process can burn; `pi-loops pair` arms one when somebody asks.
1479
+ */
1480
+ export function pageLink(i: { url: string; copyKey?: string; key: boolean }): string {
1481
+ return i.key && i.copyKey ? `${i.url}?open=${i.copyKey}` : i.url;
1482
+ }
1483
+
1484
+ /**
1485
+ * An opener's exit code does not say a page loaded: rundll32 exits 0 for a target it cannot open.
1486
+ * The background process removes the launch file when the browser spends its key, so the file
1487
+ * going away is the one sign that a browser got in.
1488
+ */
1489
+ export async function keySpent(file: string, waitMs = OPEN_CONFIRM_MS, stopped: () => boolean = () => false): Promise<boolean> {
1490
+ const until = Date.now() + waitMs;
1491
+ while (Date.now() < until && !stopped()) {
1492
+ if (!fs.existsSync(file)) return true;
1493
+ await new Promise((r) => setTimeout(r, 100));
1494
+ }
1495
+ return !fs.existsSync(file);
1496
+ }
1497
+
1498
+ /**
1499
+ * The opener and the key watch run side by side: a browser that spends the key is "opened" at once,
1500
+ * even while `xdg-open` is still running; an opener that fails before the key is spent is "failed".
1501
+ */
1502
+ export async function openOutcome(target: string, file: string, open: (t: string) => Promise<boolean> = openUrl, waitMs = OPEN_CONFIRM_MS): Promise<OpenOutcome> {
1503
+ let openerFailed = false;
1504
+ const spent = keySpent(file, waitMs, () => openerFailed);
1505
+ const opener = open(target).then((ok) => ((openerFailed = !ok), ok));
1506
+ const first = await Promise.race([spent.then((s) => (s ? "spent" : "not-spent")), opener.then((ok) => (ok ? "ran" : "failed"))]);
1507
+ if (first === "spent") return "opened";
1508
+ if (first === "failed") return "failed";
1509
+ if (first === "ran") return (await spent) ? "opened" : "unconfirmed";
1510
+ return (await opener) ? "unconfirmed" : "failed";
1511
+ }
1512
+
1513
+ /** How long a launch waits for the browser to spend the key before it says it cannot tell. */
1514
+ const OPEN_CONFIRM_MS = 8_000;
1515
+
1516
+ /** The lines after the link: what the browser did, and the pi arguments an open conversation did not take. */
1517
+ export function pageOutcomeLines(i: { outcome: OpenOutcome; key: boolean; ignoredArgs?: string[] }): string[] {
1518
+ const once = `it works once, for ${COPY_KEY_MS / 60_000} minutes`;
1519
+ const lines = [
1520
+ !i.key
1521
+ ? " pi-loops open prints a link that signs a browser in"
1522
+ : i.outcome === "opened"
1523
+ ? " opened in your browser"
1524
+ : i.outcome === "remote"
1525
+ ? " open the link above in a browser on this machine"
1526
+ : i.outcome === "unconfirmed"
1527
+ ? ` if no browser opened, open the link above (${once})`
1528
+ : i.outcome === "failed"
1529
+ ? ` could not open a browser here; open the link above (${once})`
1530
+ : ` open the link above in a browser (${once})`,
1531
+ ];
1307
1532
  if (i.ignoredArgs?.length) {
1308
1533
  const flags = i.ignoredArgs.filter((a) => a.startsWith("-"));
1309
1534
  const named = flags.length ? flags : i.ignoredArgs;
@@ -1312,26 +1537,6 @@ export function pageLaunchLines(i: { startedPid?: number; url: string; copyKey?:
1312
1537
  return lines;
1313
1538
  }
1314
1539
 
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
1540
  /**
1336
1541
  * Where the data lives for this run: the flag, then the environment, then the default — the default
1337
1542
  * being the extension's own (`loops` under pi's agent directory, which `PI_CODING_AGENT_DIR` moves),
@@ -1342,7 +1547,7 @@ export function loopsDir(ours: string[]): string {
1342
1547
  if (at !== -1 && ours[at + 1]) return ours[at + 1];
1343
1548
  const eq = ours.find((a) => a.startsWith("--loops-dir="));
1344
1549
  if (eq) return eq.slice("--loops-dir=".length);
1345
- return process.env.PI_LOOPS_DIR || defaultLoopsDir(getAgentDir());
1550
+ return process.env.PI_LOOPS_DIR || defaultLoopsDir(piAgentDir());
1346
1551
  }
1347
1552
 
1348
1553
  /**