@alphacoder-v0/pi-loops 0.17.4 → 0.17.5

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 CHANGED
@@ -3,6 +3,27 @@
3
3
  All notable changes to pi-loops are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
5
5
 
6
+ ## [0.17.5] - 2026-09-14
7
+
8
+ ### Fixed
9
+ - **`/pi-loops install-launcher` ignored `--dir`, and asked about a directory it was not going to
10
+ use.** Found by following the README, which says either route takes `--dir <dir>`: the
11
+ command-line `install-launcher` did, the one inside pi called `installLauncher(undefined)` and
12
+ showed a confirmation naming `~/.local/bin` whatever it was about to do. Answering yes to that
13
+ question, with a launcher you already rely on in `~/.local/bin`, replaced it. The slash command now
14
+ reads `--dir` (quoted, or starting with a `~` that no shell expanded on the way in) and the
15
+ question names the absolute directory that will be written, which is then the one written — and says when that directory is not on your PATH instead
16
+ of promising that `pi-loops` will work from anywhere. With nowhere to write it asks nothing and
17
+ prints the refusal, which already explains `--dir`. The menu shows the flag.
18
+ - **The browser window used `~/.pi/agent/loops` even when pi's agent directory was somewhere else.**
19
+ `PI_CODING_AGENT_DIR` moves everything pi keeps and the extension follows it; the launcher and
20
+ `web.mjs` did not. With the variable set, the page's token, its automation panel and the
21
+ `ui.json` holding the model you pick all lived in the default directory while the session behind
22
+ the page wrote to the moved one — so the page opened on another setup's remembered model (a
23
+ `Model "…" not found` warning, in the case that found it) and picking a model overwrote that
24
+ setup's choice. Both now resolve the directory the way the extension does, `~` included.
25
+ `PI_LOOPS_DIR` and `--loops-dir` still come first.
26
+
6
27
  ## [0.17.4] - 2026-09-14
7
28
 
8
29
  ### Added
package/README.md CHANGED
@@ -37,7 +37,7 @@ the first sign of credentials that do not work should not be an empty inbox tomo
37
37
 
38
38
  ```bash
39
39
  pi install npm:@alphacoder-v0/pi-loops # from npm, following new releases
40
- pi install git:github.com/alphacoder-v0/pi-loops@v0.17.4 # or from GitHub, at a pinned tag
40
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.17.5 # or from GitHub, at a pinned tag
41
41
  pi install /path/to/pi-loops # or a local checkout — `pi install .` in this repo
42
42
  pi -e /path/to/pi-loops # or none of them: try it for one run, installing nothing
43
43
  ```
package/README.zh-CN.md CHANGED
@@ -45,7 +45,7 @@ pi-loops 自己没有任何运行时依赖。
45
45
 
46
46
  ```bash
47
47
  pi install npm:@alphacoder-v0/pi-loops # 从 npm 装,跟着新版本走
48
- pi install git:github.com/alphacoder-v0/pi-loops@v0.17.4 # 或者从 GitHub 装,钉住一个 tag
48
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.17.5 # 或者从 GitHub 装,钉住一个 tag
49
49
  pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
50
50
  ```
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacoder-v0/pi-loops",
3
- "version": "0.17.4",
3
+ "version": "0.17.5",
4
4
  "description": "An automation layer for pi: cron + stateful loops with a triage inbox, maker/checker verification, dynamic triggers, MCP notifications and tools, lifecycle hooks, session archives. A non-invasive pi extension.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -583,6 +583,40 @@ export function cliRoute(argv: string[]): "launch" | "subcommand" {
583
583
  return "subcommand";
584
584
  }
585
585
 
586
+ /**
587
+ * Where the launcher goes: `--dir` if given, otherwise the first of ~/.local/bin and /usr/local/bin
588
+ * that is on PATH. One function, because the question asked before writing and the write itself
589
+ * have to name the same directory.
590
+ */
591
+ export function launcherTarget(dir: string | undefined, pathEnv: string = process.env.PATH ?? ""): string | undefined {
592
+ if (dir) return path.resolve(dir);
593
+ return [path.join(os.homedir(), ".local", "bin"), "/usr/local/bin"].find((d) => isOnPath(d, pathEnv));
594
+ }
595
+
596
+ function isOnPath(dir: string, pathEnv: string = process.env.PATH ?? ""): boolean {
597
+ return pathEnv.split(path.delimiter).filter(Boolean).some((p) => path.resolve(p) === path.resolve(dir));
598
+ }
599
+
600
+ /**
601
+ * `/pi-loops install-launcher [--dir <dir>]`: the command line's `install-launcher`, asked first,
602
+ * because inside pi it is a click away from replacing a launcher you already have. The question
603
+ * names the directory that will be written and nothing else. Resolves undefined when the answer
604
+ * is no; with nowhere to write there is nothing to ask, and the refusal says why.
605
+ */
606
+ export async function installLauncherWithConfirm(args: string[], confirm: (title: string, body: string) => Promise<boolean>, out: (line: string) => void): Promise<number | undefined> {
607
+ const flag = parseCliArgs(["install-launcher", ...args]).flags.get("dir");
608
+ // No shell has expanded `~` on the way here, so it is expanded now or becomes a directory named `~`.
609
+ const dir = typeof flag === "string" ? flag.replace(/^~(?=$|\/)/, os.homedir()) : undefined;
610
+ const target = launcherTarget(dir);
611
+ if (!target) return installLauncher(undefined, out);
612
+ 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.`];
613
+ const ok = await confirm("Put `pi-loops` on your PATH?", [`This writes a launcher into ${target}.`, "", "It is a two-line shell script that runs this package with this node.", ...reach].join("\n"));
614
+ if (!ok) return undefined;
615
+ // The absolute directory the question named, not the flag again: resolving a relative --dir a
616
+ // second time, after the dialog, would follow a working directory that may have moved meanwhile.
617
+ return installLauncher(target, out);
618
+ }
619
+
586
620
  /**
587
621
  * `pi install` puts this package under pi's managed directory rather than on your PATH, so the
588
622
  * command that is supposed to start your sessions is reachable only by absolute path. This writes
@@ -590,14 +624,13 @@ export function cliRoute(argv: string[]): "launch" | "subcommand" {
590
624
  * that fixes it without asking you to publish or install anything else.
591
625
  */
592
626
  export async function installLauncher(dir: string | undefined, out: (line: string) => void): Promise<number> {
593
- const onPath = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((p) => path.resolve(p));
594
- const target = dir ? path.resolve(dir) : [path.join(os.homedir(), ".local", "bin"), "/usr/local/bin"].find((d) => onPath.includes(path.resolve(d)));
627
+ const target = launcherTarget(dir);
595
628
  if (!target) {
596
629
  out("no directory to install into: neither ~/.local/bin nor /usr/local/bin is on your PATH.");
597
630
  out("pass one with --dir <dir>, or add ~/.local/bin to PATH and run this again.");
598
631
  return 1;
599
632
  }
600
- if (dir && !onPath.includes(target)) out(`note: ${target} is not on your PATH, so the command will not be found there yet`);
633
+ if (dir && !isOnPath(target)) out(`note: ${target} is not on your PATH, so the command will not be found there yet`);
601
634
  const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "cli-entry.mjs");
602
635
  const file = path.join(target, "pi-loops");
603
636
  // A launcher rather than a symlink: it survives the package moving, and it names the node that
@@ -641,13 +674,17 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
641
674
  return runChild(process.execPath, [web, ...ours.filter((a) => a !== "--web" && a !== "--tui"), ...(withDefaults.length ? ["--", ...withDefaults] : [])]);
642
675
  }
643
676
 
644
- /** Where the data lives for this run: the flag, then the environment, then the default. */
645
- function loopsDir(ours: string[]): string {
677
+ /**
678
+ * Where the data lives for this run: the flag, then the environment, then the default — the default
679
+ * being the extension's own (`loops` under pi's agent directory, which `PI_CODING_AGENT_DIR` moves),
680
+ * so the window reads what the session behind it writes.
681
+ */
682
+ export function loopsDir(ours: string[]): string {
646
683
  const at = ours.indexOf("--loops-dir");
647
684
  if (at !== -1 && ours[at + 1]) return ours[at + 1];
648
685
  const eq = ours.find((a) => a.startsWith("--loops-dir="));
649
686
  if (eq) return eq.slice("--loops-dir=".length);
650
- return process.env.PI_LOOPS_DIR || path.join(os.homedir(), ".pi", "agent", "loops");
687
+ return process.env.PI_LOOPS_DIR || defaultLoopsDir(getAgentDir());
651
688
  }
652
689
 
653
690
  /**
package/src/pi-loops.ts CHANGED
@@ -18,7 +18,7 @@ import { ProjectTrustStore, VERSION as PI_VERSION, getAgentDir, readStoredCreden
18
18
  import { ARCHIVE_EXT, defaultExportPath, exportSession, importSession } from "./archive.ts";
19
19
  import { Box, Text } from "@earendil-works/pi-tui";
20
20
  import { Type } from "typebox";
21
- import { parseAddArgs, parseSetArgs, splitCommand } from "./args.ts";
21
+ import { parseAddArgs, parseSetArgs, splitCommand, tokenize } from "./args.ts";
22
22
  import { envFlag, loadConfig } from "./config.ts";
23
23
  import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, applyDecision, branchMovedSince, continuationPrompt, evaluatorPrompt, latestGoal, newGoal, parseDecision, pauseFor, transcriptFromMessages } from "./goal.ts";
24
24
  import { withinProject } from "./presence.ts";
@@ -32,7 +32,7 @@ import { McpSource, PI_BUILTIN_TOOL_NAMES, type McpServerConfig, type McpToolDef
32
32
  import { capRedacted, previewRedacted, redact } from "./redact.ts";
33
33
  import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
34
34
  import { type ShareMessage, renderShare, shareSummary } from "./share.ts";
35
- import { installLauncher } from "./cli.ts";
35
+ import { installLauncherWithConfirm } from "./cli.ts";
36
36
  import { createHash } from "node:crypto";
37
37
  import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
38
38
  import { applyJobEdit } from "./job-edit.ts";
@@ -1821,13 +1821,12 @@ export default function piLoops(pi: ExtensionAPI) {
1821
1821
  // `pi install` does not put the package on your PATH. You are already inside pi with
1822
1822
  // this extension loaded, so here is where that circle can be broken.
1823
1823
  const lines: string[] = [];
1824
- const target = path.join(os.homedir(), ".local", "bin");
1825
- const ok = await ctx.ui.confirm("Put `pi-loops` on your PATH?", [`This writes a launcher into ${target} (or another directory on your PATH).`, "", "It is a two-line shell script that runs this package with this node.", "Afterwards `pi-loops` starts a session from any directory."].join("\n"));
1826
- if (!ok) {
1824
+ const words = tokenize(splitCommand(args).rest).map((t) => t.value);
1825
+ const code = await installLauncherWithConfirm(words, (title, body) => ctx.ui.confirm(title, body), (l) => lines.push(l));
1826
+ if (code === undefined) {
1827
1827
  ctx.ui.notify("not installed", "info");
1828
1828
  return;
1829
1829
  }
1830
- const code = await installLauncher(undefined, (l) => lines.push(l));
1831
1830
  show(ctx, code === 0 ? "installed the pi-loops launcher" : "could not install the launcher", lines.map((l) => ` ${l.trim()}`));
1832
1831
  return;
1833
1832
  }
@@ -1835,7 +1834,7 @@ export default function piLoops(pi: ExtensionAPI) {
1835
1834
  ` loops directory: ${homeRel(dir)}`,
1836
1835
  ` package: ${homeRel(PACKAGE_DIR)}`,
1837
1836
  "",
1838
- " /pi-loops install-launcher put the `pi-loops` command on your PATH",
1837
+ " /pi-loops install-launcher [--dir <dir>] put the `pi-loops` command on your PATH",
1839
1838
  " /cron · /triggers · /inbox · /goal the automation itself",
1840
1839
  ]);
1841
1840
  },
package/src/web.mjs CHANGED
@@ -91,7 +91,12 @@ if (!/^\d+$/.test(portArg) || Number(portArg) > 65535) {
91
91
  process.exit(1);
92
92
  }
93
93
  const PORT = Number(portArg);
94
- const LOOPS_DIR = optionValue("loops-dir", process.env.PI_LOOPS_DIR || path.join(os.homedir(), ".pi", "agent", "loops"));
94
+ // The directory the extension inside the pi behind this page uses (`defaultLoopsDir(getAgentDir())`,
95
+ // which this file cannot import): PI_LOOPS_DIR, else `loops` under pi's agent directory — moved by
96
+ // PI_CODING_AGENT_DIR, with a leading `~` expanded as pi expands it. Reading ~/.pi/agent/loops
97
+ // regardless put the panel, the token and the remembered model in a directory the session was not using.
98
+ const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ? process.env.PI_CODING_AGENT_DIR.replace(process.platform === "win32" ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, os.homedir()) : path.join(os.homedir(), ".pi", "agent");
99
+ const LOOPS_DIR = optionValue("loops-dir", process.env.PI_LOOPS_DIR || path.join(AGENT_DIR, "loops"));
95
100
  /**
96
101
  * The token is kept in a file rather than made fresh each launch, so the address stays the same
97
102
  * one every time: bookmark http://127.0.0.1:4173/ and it works tomorrow. It is a boring secret —