@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 +47 -0
- package/README.md +15 -8
- package/README.zh-CN.md +12 -9
- package/docs/cli.md +13 -3
- package/docs/troubleshooting.md +5 -4
- package/package.json +2 -2
- package/src/cli.ts +108 -13
- package/src/pi-loops.ts +6 -7
- package/src/web.mjs +6 -1
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
|
|
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/
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 #
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
159
|
+
从 GitHub 装的,只能靠它换版本:`pi update --extensions` **有意**不做这件事,pi 钉住你写的那个 ref,并且只把克隆对齐到**那个** ref。换版本是另一个决定,而做这个决定得先知道哪个 tag 最新——本该命令替你查,不是你查完再手打回来。从 npm 装、没写版本号的,`pi update --extensions` 本来就会升,`upgrade` 升完也保持不钉版本;钉了精确版本的,升完仍然钉着,钉到新版本上。
|
|
158
160
|
|
|
159
|
-
装完重启 pi(或者再跑一次 `pi-loops`)就生效。启动器不用重装:pi
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
19
|
-
`packages` in `~/.pi/agent/settings.json`, and
|
|
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
|
|
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
|
+
"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":
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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 && !
|
|
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
|
-
/**
|
|
587
|
-
|
|
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 ||
|
|
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 {
|
|
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
|
|
1825
|
-
const
|
|
1826
|
-
if (
|
|
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
|
-
|
|
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 —
|