@alphacoder-v0/pi-loops 0.17.3 → 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,53 @@
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
+
27
+ ## [0.17.4] - 2026-09-14
28
+
29
+ ### Added
30
+ - **pi-loops is on npm, and the documents say so.** `@alphacoder-v0/pi-loops` was published at
31
+ 0.17.3, installed from the registry into a clean directory, its `pi-loops` bin run from that
32
+ `node_modules` layout, and loaded with `pi -e npm:@alphacoder-v0/pi-loops` before anything here
33
+ changed. `piLoops.publishedToNpm` is now `true`, which is the one line `check:docs` was waiting
34
+ for, and both READMEs lead with `pi install npm:@alphacoder-v0/pi-loops` — without a version,
35
+ because pi treats an npm spec without an exact version as one `pi update --extensions` moves, and
36
+ that is what someone who did not ask for a pin wants. GitHub stays the pinned route. The launcher
37
+ instructions, the upgrade notes, the uninstall line and `docs/troubleshooting.md` name the npm
38
+ install beside the git one.
39
+
40
+ ### Fixed
41
+ - **`pi-loops upgrade` pinned an npm install that was following releases.** It answered every npm
42
+ copy with `npm:<name>@<version>`, and pi skips an exact version in every later
43
+ `pi update --extensions` — so the first upgrade of a bare install would also have been the last one
44
+ anything but `upgrade` ever did. It now reads the entry pi recorded in the `settings.json` beside
45
+ the install root (`~/.pi/agent/settings.json` for `~/.pi/agent/npm`, `.pi/settings.json` for a
46
+ project's `.pi/npm`): an exact pin stays a pin, moved to the new version; a bare name, `@latest` or
47
+ a range gets `npm:<name>@latest`. Not the bare name, which reads as the obvious answer and is wrong:
48
+ over an existing install, `npm install <name>` keeps the range it saved the first time and installs
49
+ nothing, so `upgrade` would have printed "upgraded" over the version it started from — checked
50
+ against npm itself, not assumed. With no entry to read, it pins, as before. The unknown-command hint
51
+ names the same spec.
52
+
6
53
  ## [0.17.3] - 2026-09-13
7
54
 
8
55
  ### Fixed
package/README.md CHANGED
@@ -36,7 +36,8 @@ one message, make sure you get an answer. pi-loops runs sub-agents while you are
36
36
  the first sign of credentials that do not work should not be an empty inbox tomorrow morning.
37
37
 
38
38
  ```bash
39
- pi install git:github.com/alphacoder-v0/pi-loops@v0.17.3 # from GitHub, at a pinned tag
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.5 # or from GitHub, at a pinned tag
40
41
  pi install /path/to/pi-loops # or a local checkout — `pi install .` in this repo
41
42
  pi -e /path/to/pi-loops # or none of them: try it for one run, installing nothing
42
43
  ```
@@ -182,7 +183,8 @@ directory rather than on your `PATH`, so the command does not exist yet — the
182
183
 
183
184
  ```bash
184
185
  # or from a shell, in the directory pi installed the package into
185
- cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops
186
+ cd ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops # installed from npm
187
+ cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops # installed from GitHub
186
188
  node src/cli-entry.mjs install-launcher
187
189
  ```
188
190
 
@@ -235,18 +237,23 @@ pi-loops upgrade --check # just say whether there is one
235
237
  ```
236
238
 
237
239
  It reads the release tags from the repository this copy came from, compares them with what you are
238
- running, and installs the newest — which `pi update --extensions` will not do, because pi pins the
239
- ref you asked for and keeps the clone on *that* ref ([docs/cli.md](docs/cli.md)).
240
+ running, and installs the newest the same way this copy was installed. For a GitHub install that is
241
+ the only way to move: `pi update --extensions` keeps the clone on the ref you pinned
242
+ ([docs/cli.md](docs/cli.md)). An npm install made without a version is one `pi update --extensions`
243
+ moves as well, and `upgrade` leaves it that way; one pinned to an exact version stays pinned, to the
244
+ new one.
240
245
 
241
- Restart pi (or run `pi-loops` again) to load it. The launcher does not need reinstalling: pi keeps
242
- each git package at `~/.pi/agent/git/<host>/<owner>/<repo>`, so a version change keeps the path.
246
+ Restart pi (or run `pi-loops` again) to load it. The launcher does not need reinstalling: pi keeps a
247
+ package at the same path whatever its version — `~/.pi/agent/npm/node_modules/<name>` or
248
+ `~/.pi/agent/git/<host>/<owner>/<repo>`.
243
249
 
244
- Releases are tags on GitHub; [CHANGELOG.md](CHANGELOG.md) says what is in each one.
250
+ Releases are tags on GitHub, published to npm under the same version; [CHANGELOG.md](CHANGELOG.md)
251
+ says what is in each one.
245
252
 
246
253
  ### Uninstall
247
254
 
248
255
  ```bash
249
- pi remove git:github.com/alphacoder-v0/pi-loops # or the checkout path — whatever you installed
256
+ pi remove npm:@alphacoder-v0/pi-loops # or git:github.com/alphacoder-v0/pi-loops, or the checkout path — whatever you installed
250
257
  ```
251
258
 
252
259
  State stays in `~/.pi/agent/loops` until you delete it.
package/README.zh-CN.md CHANGED
@@ -27,7 +27,7 @@ pi-loops --tui # 猜错时强制终端
27
27
  pi-loops --continue # 接着这个目录里最新的那个会话
28
28
  pi-loops sessions [--all] # 这个目录(或整台机器)记下的会话
29
29
  pi-loops inspect <file> # 别人发来的 .pisession,导入前先看清里面是什么
30
- pi-loops upgrade # 从 GitHub 装最新的发布版
30
+ pi-loops upgrade # 装最新的发布版,当初从哪装就从哪装
31
31
  pi-loops host status # 看一眼没有 pi 开着时在跑的自动化
32
32
  ```
33
33
 
@@ -44,11 +44,12 @@ pi-loops 自己没有任何运行时依赖。
44
44
  ### 2. 装上
45
45
 
46
46
  ```bash
47
- pi install git:github.com/alphacoder-v0/pi-loops@v0.17.3 # 从 GitHub 装,钉住一个 tag
47
+ pi install npm:@alphacoder-v0/pi-loops # 从 npm 装,跟着新版本走
48
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.17.5 # 或者从 GitHub 装,钉住一个 tag
48
49
  pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
49
50
  ```
50
51
 
51
- **两种装法挑一种,不要装两份。** 两份副本注册同名工具,pi 会拒绝加载第二份并直接退出(`Tool "cron_create" conflicts with …`)。如果你在改这份代码,留本地检出那份。
52
+ **几种装法挑一种,不要装两份。** 两份副本注册同名工具,pi 会拒绝加载第二份并直接退出(`Tool "cron_create" conflicts with …`)。如果你在改这份代码,留本地检出那份。
52
53
 
53
54
  重启 pi,装到这里就够了——那四个斜杠命令现在就能用。下面这步只跟 `pi-loops` 这个命令有关:浏览器窗口和几个 shell 子命令靠它,不要浏览器窗口可以先跳过。
54
55
 
@@ -60,7 +61,8 @@ pi install /path/to/pi-loops # 或者本地检出
60
61
 
61
62
  ```bash
62
63
  # 或者在 shell 里,进到 pi 装包的那个目录
63
- cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops
64
+ cd ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops # 从 npm 装的
65
+ cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops # 从 GitHub 装的
64
66
  node src/cli-entry.mjs install-launcher
65
67
  ```
66
68
 
@@ -152,18 +154,19 @@ pi-loops upgrade # 装最新的发布版
152
154
  pi-loops upgrade --check # 只看看有没有新的
153
155
  ```
154
156
 
155
- 它从这份副本的来源仓库读 release tag,和你正在跑的版本比,然后装最新那个。
157
+ 它从这份副本的来源仓库读 release tag,和你正在跑的版本比,然后按这份副本当初的装法装最新那个。
156
158
 
157
- `pi update --extensions` **有意**不做这件事:pi 钉住你写的那个 ref,并且只把克隆对齐到**那个** ref。换版本是另一个决定,而做这个决定得先知道哪个 tag 最新——本该命令替你查,不是你查完再手打回来。
159
+ 从 GitHub 装的,只能靠它换版本:`pi update --extensions` **有意**不做这件事,pi 钉住你写的那个 ref,并且只把克隆对齐到**那个** ref。换版本是另一个决定,而做这个决定得先知道哪个 tag 最新——本该命令替你查,不是你查完再手打回来。从 npm 装、没写版本号的,`pi update --extensions` 本来就会升,`upgrade` 升完也保持不钉版本;钉了精确版本的,升完仍然钉着,钉到新版本上。
158
160
 
159
- 装完重启 pi(或者再跑一次 `pi-loops`)就生效。启动器不用重装:pi 把每个 git 包固定放在 `~/.pi/agent/git/<host>/<owner>/<repo>`,换版本路径不变。
161
+ 装完重启 pi(或者再跑一次 `pi-loops`)就生效。启动器不用重装:pi 放包的路径不随版本变——`~/.pi/agent/npm/node_modules/<包名>` 或 `~/.pi/agent/git/<host>/<owner>/<repo>`。
160
162
 
161
- 版本就是 GitHub 上的 tag,每个 tag 里有什么见 [CHANGELOG.md](CHANGELOG.md)。
163
+ 版本就是 GitHub 上的 tag,同一个版本号也发到 npm,每个 tag 里有什么见 [CHANGELOG.md](CHANGELOG.md)。
162
164
 
163
165
  ## 卸载
164
166
 
165
167
  ```bash
166
- pi remove git:github.com/alphacoder-v0/pi-loops # 当初怎么装的就怎么删:git: ref 或检出路径
168
+ pi remove npm:@alphacoder-v0/pi-loops # 当初怎么装的就怎么删:npm: 包名、git: ref 或检出路径
169
+ pi remove git:github.com/alphacoder-v0/pi-loops
167
170
  pi -e /path/to/pi-loops # 或者:只在这次启动试用,什么都不装
168
171
  pi update --extensions # 对齐已安装的包
169
172
  ```
package/docs/cli.md CHANGED
@@ -184,9 +184,19 @@ It reads release tags from the repository this copy came from (`repository.url`
184
184
  numerically, so `v0.10.0` beats `v0.9.0` — and runs `pi install` for it. Release candidates and
185
185
  branch-shaped tags are ignored: those are not things to move someone onto without being asked.
186
186
 
187
- `pi update --extensions` does something different and both are useful: it reconciles every package
188
- to the ref already pinned in your settings, which is how you repair a clone, not how you take a new
189
- version.
187
+ The spec it installs follows how this copy was installed, because a spec from another source adds a
188
+ second package instead of replacing this one. A git install gets `git:<host>/<owner>/<repo>@<tag>`.
189
+ An npm install gets its spec from the entry pi recorded in `settings.json`: pinned to an exact
190
+ version, it is pinned to the new one (`npm:@alphacoder-v0/pi-loops@<version>`); installed without a
191
+ version, with `@latest` or with a range, it gets `npm:@alphacoder-v0/pi-loops@latest` — not the bare
192
+ name, which over an existing install makes npm keep the range it saved and install nothing, and not
193
+ an exact version, which pi would then skip in every later update. If there is no entry to read, it
194
+ pins.
195
+
196
+ `pi update --extensions` does something different and both are useful: for a git package it
197
+ reconciles the clone to the ref already pinned in your settings, which is how you repair a clone,
198
+ not how you take a new version. For an npm package installed without an exact version it does take
199
+ the newest release, and `upgrade` and it then agree.
190
200
 
191
201
  ## The tools
192
202
 
@@ -15,14 +15,15 @@ card and in `/cron trace <job> 1 checker`.
15
15
 
16
16
  **`Tool "cron_create" conflicts with …`, and pi exits.** The package is installed twice, and this
17
17
  is fatal rather than cosmetic: the second copy fails to load and pi stops. It happens easily —
18
- a local checkout you are working on plus `pi install git:…` of the published one both sit in
19
- `packages` in `~/.pi/agent/settings.json`, and both register the same tools. (An older, milder
20
- symptom of the same thing was commands appearing as `/cron:1`.)
18
+ a local checkout you are working on plus `pi install npm:…` or `pi install git:…` of the published
19
+ one all sit in `packages` in `~/.pi/agent/settings.json`, and each registers the same tools. (An
20
+ older, milder symptom of the same thing was commands appearing as `/cron:1`.)
21
21
 
22
22
  Keep one:
23
23
 
24
24
  ```bash
25
- pi remove git:github.com/alphacoder-v0/pi-loops # keep the checkout you are working on
25
+ pi remove npm:@alphacoder-v0/pi-loops # keep the checkout you are working on (installed from npm)
26
+ pi remove git:github.com/alphacoder-v0/pi-loops # keep the checkout you are working on (installed from GitHub)
26
27
  pi remove /path/to/your/checkout # or keep the installed one
27
28
  ```
28
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacoder-v0/pi-loops",
3
- "version": "0.17.3",
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",
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "piLoops": {
44
- "publishedToNpm": false
44
+ "publishedToNpm": true
45
45
  },
46
46
  "scripts": {
47
47
  "ci": "npm run typecheck && npm run lint && npm run check:scripts && npm run check:docs && npm test",
package/src/cli.ts CHANGED
@@ -367,7 +367,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
367
367
  out(`unknown command ${JSON.stringify(command)} (this is pi-loops v${PI_LOOPS_VERSION})`);
368
368
  // The route has to match how this copy was installed, for the same reason `upgrade` does: a
369
369
  // spec from the other one installs a second copy rather than replacing this one.
370
- const spec = upgradeSpec(packageRoot(), manifest().name ?? "", agentDir, "<newer release>");
370
+ const spec = upgradeSpec(packageRoot(), manifest().name ?? "", agentDir, "<newer release>", configuredNpmSource(packageRoot(), manifest().name ?? ""));
371
371
  out(`if you expected it, the copy you are running may be older than the command: ${spec ? `pi install ${spec}` : checkoutUpgradeHint()}`);
372
372
  out("");
373
373
  }
@@ -442,11 +442,17 @@ function manifest(): { name?: string; repositoryUrl?: string } {
442
442
  * path — and a local checkout is in neither place. A checkout has no install to redo: it has a
443
443
  * remote, and `git pull` is its upgrade.
444
444
  */
445
- export function upgradeSpec(packageDir: string, packageName: string, agentDir: string, tag: string): string | undefined {
446
- const segments = path.resolve(packageDir).split(path.sep).filter(Boolean);
447
- // A scoped name is two segments on disk, so compare as many as the name has.
448
- const name = packageName.split("/").filter(Boolean);
449
- if (name.length && segments.slice(-name.length).join("/") === name.join("/") && segments[segments.length - name.length - 1] === "node_modules") {
445
+ export function upgradeSpec(packageDir: string, packageName: string, agentDir: string, tag: string, configuredSource?: string): string | undefined {
446
+ if (npmInstallRoot(packageDir, packageName)) {
447
+ // pi skips an exact npm version when it updates packages and moves anything else, so the
448
+ // answer has to keep whichever of the two the install was. Pinning a bare install here would
449
+ // make this upgrade the last one `pi update` ever does. The moving answer is `@latest`, not the
450
+ // bare name: `npm install <name>` over an existing install keeps the range it saved and changes
451
+ // nothing, while `@latest` is what pi's own update asks npm for and is not a pin to pi. Not
452
+ // knowing how it was installed keeps the exact pin: it is the reading that cannot move a
453
+ // package somewhere it was not asked to go.
454
+ const configured = configuredSource === undefined ? undefined : npmSourceVersion(configuredSource, packageName);
455
+ if (configured !== undefined && !isExactNpmVersion(configured)) return `npm:${packageName}@latest`;
450
456
  // Release tags are `v0.17.0`; the version npm knows the same release by is `0.17.0`.
451
457
  return `npm:${packageName}@${tag.replace(/^v/, "")}`;
452
458
  }
@@ -459,6 +465,58 @@ export function upgradeSpec(packageDir: string, packageName: string, agentDir: s
459
465
  return undefined;
460
466
  }
461
467
 
468
+ /** The directory `npm install` ran in (the one holding `node_modules`), if this copy is an npm install. */
469
+ function npmInstallRoot(packageDir: string, packageName: string): string | undefined {
470
+ const segments = path.resolve(packageDir).split(path.sep).filter(Boolean);
471
+ // A scoped name is two segments on disk, so compare as many as the name has.
472
+ const name = packageName.split("/").filter(Boolean);
473
+ if (!name.length || segments.slice(-name.length).join("/") !== name.join("/") || segments[segments.length - name.length - 1] !== "node_modules") return undefined;
474
+ return path.join(path.parse(path.resolve(packageDir)).root, ...segments.slice(0, -name.length - 1));
475
+ }
476
+
477
+ /**
478
+ * The version an `npm:` source gives `packageName` — `""` for none — or undefined if it names some
479
+ * other package. The name is known, so this compares a prefix rather than parsing a spec: a project's
480
+ * `.pi/settings.json` can come from a cloned repository, and a general spec regex backtracks
481
+ * quadratically on an entry built for it.
482
+ */
483
+ function npmSourceVersion(source: string, packageName: string): string | undefined {
484
+ const spec = source.trim().replace(/^npm:/, "").trim();
485
+ if (!spec.startsWith(packageName)) return undefined;
486
+ const rest = spec.slice(packageName.length);
487
+ if (rest === "") return "";
488
+ return rest.startsWith("@") ? rest.slice(1) : undefined;
489
+ }
490
+
491
+ /** What pi counts as pinned: a valid semver, which it also accepts with a leading `v`. */
492
+ function isExactNpmVersion(version: string): boolean {
493
+ return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version);
494
+ }
495
+
496
+ /**
497
+ * The `npm:` source this install is recorded under, as written. pi keeps a user install in
498
+ * `<agent dir>/npm` and records it in `<agent dir>/settings.json`, and a project one in `.pi/npm`
499
+ * recorded in `.pi/settings.json` — in both, the settings file sits beside the install root.
500
+ */
501
+ export function configuredNpmSource(packageDir: string, packageName: string): string | undefined {
502
+ const root = npmInstallRoot(packageDir, packageName);
503
+ if (!root) return undefined;
504
+ let packages: unknown;
505
+ try {
506
+ packages = JSON.parse(fs.readFileSync(path.join(path.dirname(root), "settings.json"), "utf8"))?.packages;
507
+ } catch {
508
+ // Missing (an older pi's global install has no settings beside it) or unreadable: not knowing
509
+ // how this was installed is an answer `upgradeSpec` handles, not a reason to fail an upgrade.
510
+ return undefined;
511
+ }
512
+ if (!Array.isArray(packages)) return undefined;
513
+ for (const entry of packages) {
514
+ const source = typeof entry === "string" ? entry : typeof entry?.source === "string" ? entry.source : undefined;
515
+ if (source?.trim().startsWith("npm:") && npmSourceVersion(source, packageName) !== undefined) return source;
516
+ }
517
+ return undefined;
518
+ }
519
+
462
520
  /** What to tell someone whose copy is a checkout: there is nothing for `pi install` to do. */
463
521
  function checkoutUpgradeHint(): string {
464
522
  return `git pull in ${packageRoot()}, then restart pi`;
@@ -490,7 +548,7 @@ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise
490
548
  out("already up to date");
491
549
  return 0;
492
550
  }
493
- const spec = upgradeSpec(packageRoot(), name ?? "", getAgentDir(), latest);
551
+ const spec = upgradeSpec(packageRoot(), name ?? "", getAgentDir(), latest, configuredNpmSource(packageRoot(), name ?? ""));
494
552
  if (!spec) {
495
553
  // Nothing failed, so this is not an error: the newest release is out and the way to it is a
496
554
  // pull, not an install that would leave a second copy beside the checkout.
@@ -525,6 +583,40 @@ export function cliRoute(argv: string[]): "launch" | "subcommand" {
525
583
  return "subcommand";
526
584
  }
527
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
+
528
620
  /**
529
621
  * `pi install` puts this package under pi's managed directory rather than on your PATH, so the
530
622
  * command that is supposed to start your sessions is reachable only by absolute path. This writes
@@ -532,14 +624,13 @@ export function cliRoute(argv: string[]): "launch" | "subcommand" {
532
624
  * that fixes it without asking you to publish or install anything else.
533
625
  */
534
626
  export async function installLauncher(dir: string | undefined, out: (line: string) => void): Promise<number> {
535
- const onPath = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((p) => path.resolve(p));
536
- 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);
537
628
  if (!target) {
538
629
  out("no directory to install into: neither ~/.local/bin nor /usr/local/bin is on your PATH.");
539
630
  out("pass one with --dir <dir>, or add ~/.local/bin to PATH and run this again.");
540
631
  return 1;
541
632
  }
542
- 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`);
543
634
  const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "cli-entry.mjs");
544
635
  const file = path.join(target, "pi-loops");
545
636
  // A launcher rather than a symlink: it survives the package moving, and it names the node that
@@ -583,13 +674,17 @@ async function launch(argv: string[], out: (line: string) => void): Promise<numb
583
674
  return runChild(process.execPath, [web, ...ours.filter((a) => a !== "--web" && a !== "--tui"), ...(withDefaults.length ? ["--", ...withDefaults] : [])]);
584
675
  }
585
676
 
586
- /** Where the data lives for this run: the flag, then the environment, then the default. */
587
- 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 {
588
683
  const at = ours.indexOf("--loops-dir");
589
684
  if (at !== -1 && ours[at + 1]) return ours[at + 1];
590
685
  const eq = ours.find((a) => a.startsWith("--loops-dir="));
591
686
  if (eq) return eq.slice("--loops-dir=".length);
592
- return process.env.PI_LOOPS_DIR || path.join(os.homedir(), ".pi", "agent", "loops");
687
+ return process.env.PI_LOOPS_DIR || defaultLoopsDir(getAgentDir());
593
688
  }
594
689
 
595
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 —