@alphacoder-v0/pi-loops 0.22.1 → 0.22.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/CHANGELOG.md +23 -0
- package/README.md +8 -6
- package/README.zh-CN.md +5 -5
- package/docs/cli.md +13 -4
- package/docs/design.md +25 -0
- package/package.json +1 -1
- package/src/cli.ts +139 -19
- package/src/pi-loops.ts +50 -1
- package/src/ui-prefs.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ All notable changes to pi-loops are documented here. The format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.22.2] - 2026-09-16
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **The extension puts `pi-loops` on your PATH, instead of you typing a node path out of the
|
|
12
|
+
README.** `pi install` leaves this package under pi's directory and nothing on your `PATH`, so
|
|
13
|
+
`pi-loops` only existed after `install-launcher` had been run once — and the first time there was
|
|
14
|
+
no `pi-loops` to run it with, which is why the install instructions ended in a bare `node
|
|
15
|
+
~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops/src/cli-entry.mjs install-launcher`. The
|
|
16
|
+
launcher that wrote then went stale by itself when pi reinstalled the package by the other route
|
|
17
|
+
(0.22.1). pi loads this extension at every session start and knows, at that moment, both where it
|
|
18
|
+
runs and where a launcher would go: so the first session that finds none anywhere on your `PATH`
|
|
19
|
+
asks once — the same question `/pi-loops install-launcher` asks — and a no is remembered in
|
|
20
|
+
`ui.json` and never asked again. A launcher of ours is rewritten at the next start in two cases
|
|
21
|
+
and only two — the copy it names is not on disk any more, or the copy it names is this one and the
|
|
22
|
+
node or the shape of the script changed — with one line saying that it was and why; one naming
|
|
23
|
+
another copy that is still there belongs to that copy, so an install and a checkout on the same
|
|
24
|
+
machine never trade the file back and forth. Nothing else is touched: a `pi-loops` this project
|
|
25
|
+
did not write is left alone, a checkout is never asked for a launcher that is not there (one
|
|
26
|
+
written from a checkout is still kept current), and `/pi-loops install-launcher` still writes one
|
|
27
|
+
whenever you want it, clearing the remembered no. The script itself now also lists the copy it
|
|
28
|
+
runs once rather than twice, which is what an installed copy — where the entry it records is one
|
|
29
|
+
of pi's own two locations — used to get.
|
|
30
|
+
|
|
8
31
|
## [0.22.1] - 2026-09-16
|
|
9
32
|
|
|
10
33
|
### Fixed
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ the first sign of credentials that do not work should not be an empty inbox tomo
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
pi install npm:@alphacoder-v0/pi-loops # from npm, following new releases
|
|
41
|
-
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.
|
|
41
|
+
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.2 # or from GitHub, at a pinned tag
|
|
42
42
|
pi install /path/to/pi-loops # or a local checkout — `pi install .` in this repo
|
|
43
43
|
pi -e /path/to/pi-loops # or none of them: try it for one run, installing nothing
|
|
44
44
|
```
|
|
@@ -184,22 +184,24 @@ you are at the machine, and hands it back the next time you open pi (`/cron host
|
|
|
184
184
|
## The browser window, and the command line
|
|
185
185
|
|
|
186
186
|
`pi-loops` is the part that needs a launcher: `pi install` puts the package under pi's managed
|
|
187
|
-
directory rather than on your `PATH`, so the command does not exist yet
|
|
188
|
-
`install
|
|
187
|
+
directory rather than on your `PATH`, so the command does not exist yet. You are not asked to fix
|
|
188
|
+
that by hand — the first pi session after `pi install` offers to put `pi-loops` on your `PATH`, and
|
|
189
|
+
keeps that launcher working afterwards if pi ever moves the package. Say no and nothing is written,
|
|
190
|
+
and you are not asked again; `/pi-loops install-launcher` writes one whenever you change your mind:
|
|
189
191
|
|
|
190
192
|
```text
|
|
191
193
|
/pi-loops install-launcher # from inside pi, where the extension is already loaded
|
|
192
194
|
```
|
|
193
195
|
|
|
194
196
|
```bash
|
|
195
|
-
# or from a shell,
|
|
197
|
+
# or from a shell, on a machine where pi is never opened — the same command, by absolute path
|
|
196
198
|
cd ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops # installed from npm
|
|
197
199
|
cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops # installed from GitHub
|
|
198
200
|
node src/cli-entry.mjs install-launcher
|
|
199
201
|
```
|
|
200
202
|
|
|
201
|
-
|
|
202
|
-
your `PATH`, and refuses rather than guessing if neither is — pass `--dir <dir>` to say where.
|
|
203
|
+
Any of them writes a launcher into the first of `~/.local/bin` and `/usr/local/bin` that is already
|
|
204
|
+
on your `PATH`, and refuses rather than guessing if neither is — pass `--dir <dir>` to say where.
|
|
203
205
|
After that, `pi-loops` works from anywhere:
|
|
204
206
|
|
|
205
207
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -45,28 +45,28 @@ 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.22.
|
|
48
|
+
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.2 # 或者从 GitHub 装,钉住一个 tag
|
|
49
49
|
pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
**几种装法挑一种,不要装两份。** 两份副本注册同名工具,pi 会拒绝加载第二份并直接退出(`Tool "cron_create" conflicts with …`)。如果你在改这份代码,留本地检出那份。
|
|
53
53
|
|
|
54
|
-
重启 pi
|
|
54
|
+
重启 pi,装到这里就够了——那四个斜杠命令现在就能用。下面这段只跟 `pi-loops` 这个命令有关:浏览器窗口和几个 shell 子命令靠它,不要浏览器窗口可以先跳过。
|
|
55
55
|
|
|
56
|
-
`pi install` 把包放在 pi 自己的托管目录里、**不进 `PATH`**,所以此刻 `pi-loops`
|
|
56
|
+
`pi install` 把包放在 pi 自己的托管目录里、**不进 `PATH`**,所以此刻 `pi-loops` 这个命令还不存在。这一步不用你自己动手:`pi install` 之后的第一个 pi 会话会问你要不要把 `pi-loops` 放进 `PATH`,写下之后也一直替你维护着——包被 pi 挪到另一个位置,下一个会话会把启动器改回能用的样子,并用一行告诉你改了什么。答 no 就什么都不写,也不会再问第二次;改了主意,`/pi-loops install-launcher` 随时给你写一个:
|
|
57
57
|
|
|
58
58
|
```text
|
|
59
59
|
/pi-loops install-launcher # 在 pi 里面,扩展本来就加载着
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
# 或者在 shell
|
|
63
|
+
# 或者在 shell 里用绝对路径跑同一个命令——给那种根本不开 pi 的机器留的路
|
|
64
64
|
cd ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops # 从 npm 装的
|
|
65
65
|
cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops # 从 GitHub 装的
|
|
66
66
|
node src/cli-entry.mjs install-launcher
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
几条路都会往 `~/.local/bin`(或其它已在 `PATH` 里的目录,用 `--dir` 指定)写一个启动器。之后 `pi-loops` 在任何目录都能用。
|
|
70
70
|
|
|
71
71
|
### 3. 开一个会话
|
|
72
72
|
|
package/docs/cli.md
CHANGED
|
@@ -153,16 +153,25 @@ by the id in its header.
|
|
|
153
153
|
## Getting the command onto your PATH
|
|
154
154
|
|
|
155
155
|
`pi install` puts this package under pi's managed directory rather than on your `PATH`, so the
|
|
156
|
-
command that is meant to start your sessions is at first reachable only by absolute path
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
command that is meant to start your sessions is at first reachable only by absolute path. The
|
|
157
|
+
extension does that step for you: the first pi session that finds no `pi-loops` anywhere on your
|
|
158
|
+
`PATH` asks once whether to write one, and a no is remembered — nothing is written and you are not
|
|
159
|
+
asked again. Afterwards it keeps that launcher current: if the copy it runs is not there any more
|
|
160
|
+
(pi installed the package by the other route), or that copy is the one now running and the node has
|
|
161
|
+
changed, the next session rewrites it and says so in one line. A `pi-loops` this project did not
|
|
162
|
+
write — your own wrapper — is left alone, and so is one that runs another copy of this package still
|
|
163
|
+
on disk, which is that copy's to keep. A checkout is never asked to write a launcher that is not
|
|
164
|
+
there, though one written from a checkout is still kept working.
|
|
165
|
+
|
|
166
|
+
To write one yourself, or after a no, run it from inside pi, where the extension is already loaded:
|
|
159
167
|
|
|
160
168
|
```text
|
|
161
169
|
/pi-loops install-launcher
|
|
162
170
|
```
|
|
163
171
|
|
|
164
172
|
or from the directory pi installed the package into — `~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops`
|
|
165
|
-
for a `pi install npm:` package, `~/.pi/agent/git/<host>/<owner>/<repo>` for a `pi install git:` one
|
|
173
|
+
for a `pi install npm:` package, `~/.pi/agent/git/<host>/<owner>/<repo>` for a `pi install git:` one,
|
|
174
|
+
which is the way in on a machine where pi itself is never opened:
|
|
166
175
|
|
|
167
176
|
```bash
|
|
168
177
|
node src/cli-entry.mjs install-launcher
|
package/docs/design.md
CHANGED
|
@@ -174,6 +174,31 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
174
174
|
The cost: two machines syncing one `$HOME` both run every job. That is not supported, and it
|
|
175
175
|
is said rather than half-handled.
|
|
176
176
|
|
|
177
|
+
20. **The extension keeps the launcher.** `pi install` puts this package under pi's directory and
|
|
178
|
+
nothing on your PATH, so `pi-loops` existed only after someone ran `install-launcher` once — and
|
|
179
|
+
the first time there was no `pi-loops` to run it with, which is why the README handed people a
|
|
180
|
+
bare `node ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops/src/cli-entry.mjs
|
|
181
|
+
install-launcher`. The launcher then went stale by itself: a package reinstalled by the other
|
|
182
|
+
route moved, and every `pi-loops` after that ended in Node's `Cannot find module` (0.22.1). Both
|
|
183
|
+
endings were a path a person had to type out. pi loads this extension at every session start,
|
|
184
|
+
and at that moment it knows where it runs and where a launcher would go — so `session_start`
|
|
185
|
+
does it. A launcher of ours is rewritten in two cases and only two: the copy it names is not on
|
|
186
|
+
disk any more (the package moved, which is the reading that ends in `Cannot find module`), or
|
|
187
|
+
the copy it names is this one and the node or the shape of the script has changed. Either way
|
|
188
|
+
one line says that it was rewritten and why. One naming another copy that is still there is that
|
|
189
|
+
copy's to keep current, so an install and a checkout both being opened never trade the file back
|
|
190
|
+
and forth. With no launcher anywhere, the question `install-launcher` already asks is asked once,
|
|
191
|
+
and a no is remembered in `ui.json` and never asked again; `/pi-loops install-launcher` still
|
|
192
|
+
writes one whenever you want it, and clears that mark. A `pi-loops` carrying no marker of ours
|
|
193
|
+
is somebody's own wrapper and is left alone, and a checkout is never asked for one that is not
|
|
194
|
+
there — nobody ran `pi install`. The decision is `launcherState` / `refreshLauncher` in
|
|
195
|
+
[../src/cli.ts](../src/cli.ts), beside the one function that renders the script, so it is
|
|
196
|
+
testable without pi; the extension is the caller.
|
|
197
|
+
|
|
198
|
+
The cost: a write outside the loops directory, which the Non-invasive invariant now names as its
|
|
199
|
+
one exception, and a question on somebody's first start — an interruption this project otherwise
|
|
200
|
+
does not make.
|
|
201
|
+
|
|
177
202
|
## Where the browser front end came from
|
|
178
203
|
|
|
179
204
|
pi owns the `pi` command and its terminal, so a second front end cannot replace the first one. It
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alphacoder-v0/pi-loops",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
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
|
@@ -21,7 +21,7 @@ import { FINDING_FIELDS, Inbox, type InboxEntry, inProject, resolveInboxRef } fr
|
|
|
21
21
|
import { sameProject } from "./presence.ts";
|
|
22
22
|
import { redact } from "./redact.ts";
|
|
23
23
|
import { liveHost, stopHost } from "./host-control.ts";
|
|
24
|
-
import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
|
|
24
|
+
import { type UiPrefs, clearUiPref, readUiPrefs } from "./ui-prefs.ts";
|
|
25
25
|
import { JobStore, defaultLoopsDir } from "./store.ts";
|
|
26
26
|
import { automationBadge, previewText, readSessionHead } from "./session-head.ts";
|
|
27
27
|
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
|
|
@@ -828,18 +828,48 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
|
|
|
828
828
|
return 1;
|
|
829
829
|
}
|
|
830
830
|
if (dir && !isOnPath(target)) out(`note: ${target} is not on your PATH, so the command will not be found there yet`);
|
|
831
|
-
const
|
|
831
|
+
const { entryDir, node, agentDir } = launcherSource();
|
|
832
832
|
const file = path.join(target, "pi-loops");
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
833
|
+
try {
|
|
834
|
+
fs.mkdirSync(target, { recursive: true });
|
|
835
|
+
fs.writeFileSync(file, launcherScript(entryDir, node, agentDir), { mode: 0o755 });
|
|
836
|
+
fs.chmodSync(file, 0o755);
|
|
837
|
+
} catch (err: any) {
|
|
838
|
+
out(`could not write ${file}: ${err?.message ?? err}`);
|
|
839
|
+
return 1;
|
|
840
|
+
}
|
|
841
|
+
// Whatever a session start was told once, this is the answer now: a launcher exists because
|
|
842
|
+
// someone asked for it, so the offer is live again if the file ever goes.
|
|
843
|
+
clearUiPref(defaultLoopsDir(getAgentDir()), "launcher");
|
|
844
|
+
out(`wrote ${file}`);
|
|
845
|
+
out(` it runs ${path.join(entryDir, "cli-entry.mjs")}`);
|
|
846
|
+
out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
|
|
847
|
+
return 0;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** The marker line that says a `pi-loops` on someone's PATH is one this project wrote. */
|
|
851
|
+
const LAUNCHER_MARKER = "# pi-loops launcher, written by";
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The launcher, as text. The one place the script is spelled: `installLauncher` writes this, and a
|
|
855
|
+
* session start compares what is on disk against it, so the two cannot drift into scripts that
|
|
856
|
+
* differ by a tab and call each other stale for ever.
|
|
857
|
+
*
|
|
858
|
+
* A launcher rather than a symlink: it names the node that is running now, which is the one known
|
|
859
|
+
* to be new enough for this code. What it survives is this package being installed again by the
|
|
860
|
+
* other route — `pi install npm:` over a `git:` copy moves it, and a launcher that knew only where
|
|
861
|
+
* it used to be died in Node's `Cannot find module`. So the entry it recorded is the first place it
|
|
862
|
+
* looks and not the only one, and the recorded node is a path it checks rather than trusts. On a
|
|
863
|
+
* copy pi installed, that recorded entry *is* one of the two it falls back to, so the duplicate is
|
|
864
|
+
* dropped rather than printed twice in a file people read.
|
|
865
|
+
*/
|
|
866
|
+
export function launcherScript(entryDir: string, node: string, agentDir: string): string {
|
|
867
|
+
const entry = path.join(entryDir, "cli-entry.mjs");
|
|
868
|
+
const candidates = [entry, ...piInstallEntries(agentDir).filter((e) => e !== entry)].map((e) => JSON.stringify(e)).join(" ");
|
|
869
|
+
return [
|
|
840
870
|
"#!/bin/sh",
|
|
841
|
-
|
|
842
|
-
`node=${JSON.stringify(
|
|
871
|
+
`${LAUNCHER_MARKER} \`pi-loops install-launcher\`.`,
|
|
872
|
+
`node=${JSON.stringify(node)}`,
|
|
843
873
|
'[ -x "$node" ] || node=$(command -v node) || node=node',
|
|
844
874
|
`for entry in ${candidates}; do`,
|
|
845
875
|
'\t[ -f "$entry" ] || continue',
|
|
@@ -849,18 +879,108 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
|
|
|
849
879
|
"exit 1",
|
|
850
880
|
"",
|
|
851
881
|
].join("\n");
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** What a launcher for *this* copy is written from, so a caller needs to know none of the three. */
|
|
885
|
+
export function launcherSource(): { entryDir: string; node: string; agentDir: string } {
|
|
886
|
+
return { entryDir: path.dirname(fileURLToPath(import.meta.url)), node: process.execPath, agentDir: getAgentDir() };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Whether pi installed this copy, by either route. The same two locations `upgradeSpec` reads — it
|
|
891
|
+
* is asked through it so the two can never disagree — and the tag is a placeholder, only the
|
|
892
|
+
* yes-or-no is used. A checkout is not an install: nobody ran `pi install`, so nobody is waiting
|
|
893
|
+
* for a command that was never put on their PATH.
|
|
894
|
+
*/
|
|
895
|
+
export function installedByPi(): boolean {
|
|
896
|
+
return upgradeSpec(packageRoot(), manifest().name ?? "", getAgentDir(), "v0.0.0") !== undefined;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export type LauncherState = "absent" | "current" | "stale" | "other" | "foreign";
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* What is at `<target>/pi-loops`, as far as this copy is concerned: nothing at all (`absent`), a
|
|
903
|
+
* launcher this project wrote that matches this node and this copy (`current`), one of ours that
|
|
904
|
+
* this copy should rewrite (`stale`), one of ours that belongs to another copy still on disk
|
|
905
|
+
* (`other`), or a file carrying no marker of ours — someone's own wrapper, which is not ours to
|
|
906
|
+
* rewrite either (`foreign`).
|
|
907
|
+
*
|
|
908
|
+
* It takes the three things a launcher is written from rather than reading them, so the decision is
|
|
909
|
+
* a function of its arguments and the session-start caller is a few lines.
|
|
910
|
+
*/
|
|
911
|
+
export function launcherState(entryDir: string, node: string, agentDir: string, target: string): { file: string; state: LauncherState } {
|
|
912
|
+
const file = path.join(target, "pi-loops");
|
|
913
|
+
const found = readLauncher(file);
|
|
914
|
+
if (found === undefined) return { file, state: "absent" };
|
|
915
|
+
if (!found.includes(LAUNCHER_MARKER)) return { file, state: "foreign" };
|
|
916
|
+
if (found === launcherScript(entryDir, node, agentDir)) return { file, state: "current" };
|
|
917
|
+
return { file, state: oursToRewrite(found, entryDir) ? "stale" : "other" };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Whether a launcher of ours that no longer matches is this copy's to rewrite. Two cases, and only
|
|
922
|
+
* these two: the entry it recorded is gone — the package moved, which is the reading that ends in
|
|
923
|
+
* `Cannot find module` — or that entry is this copy's own, so the node or the shape of the script
|
|
924
|
+
* changed under it. A launcher recording another copy that is still there belongs to that copy: an
|
|
925
|
+
* install and a checkout both get opened, and two live copies each rewriting the file to point at
|
|
926
|
+
* itself would trade it back and forth for ever, with a line about it every time.
|
|
927
|
+
*/
|
|
928
|
+
function oursToRewrite(script: string, entryDir: string): boolean {
|
|
929
|
+
const recorded = launcherRecords(script).entries[0];
|
|
930
|
+
// One of ours recording nothing this can read is nobody else's either, so it is ours to fix.
|
|
931
|
+
if (recorded === undefined) return true;
|
|
932
|
+
return recorded === path.join(entryDir, "cli-entry.mjs") || !fs.existsSync(recorded);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** The file, or undefined when there is nothing there to read — which is the same news. */
|
|
936
|
+
function readLauncher(file: string): string | undefined {
|
|
852
937
|
try {
|
|
853
|
-
fs.
|
|
854
|
-
|
|
938
|
+
return fs.readFileSync(file, "utf8");
|
|
939
|
+
} catch {
|
|
940
|
+
// Missing, a directory, unreadable: in all three there is no launcher of ours here.
|
|
941
|
+
return undefined;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Keep a launcher of ours current, and say what changed. Undefined when there is nothing to do —
|
|
947
|
+
* no launcher, somebody else's, another copy's, or already this one — so this is safe to call on
|
|
948
|
+
* every start.
|
|
949
|
+
*
|
|
950
|
+
* It writes exactly one file, and only one that is already a launcher of ours: nothing new appears
|
|
951
|
+
* anywhere from a start nobody answered a question at.
|
|
952
|
+
*/
|
|
953
|
+
export function refreshLauncher(entryDir: string, node: string, agentDir: string, target: string): { message: string; ok: boolean } | undefined {
|
|
954
|
+
const { file, state } = launcherState(entryDir, node, agentDir, target);
|
|
955
|
+
if (state !== "stale") return undefined;
|
|
956
|
+
const why = staleReason(readLauncher(file) ?? "", entryDir, node);
|
|
957
|
+
try {
|
|
958
|
+
fs.writeFileSync(file, launcherScript(entryDir, node, agentDir), { mode: 0o755 });
|
|
855
959
|
fs.chmodSync(file, 0o755);
|
|
856
960
|
} catch (err: any) {
|
|
857
|
-
|
|
858
|
-
return 1;
|
|
961
|
+
return { ok: false, message: `could not refresh the \`pi-loops\` launcher at ${file}: ${err?.message ?? err}` };
|
|
859
962
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
963
|
+
return { ok: true, message: `refreshed the \`pi-loops\` launcher at ${file}: ${why}` };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** The node and the entries a launcher records, from this script or from the one 0.22.0 wrote. */
|
|
967
|
+
function launcherRecords(script: string): { node?: string; entries: string[] } {
|
|
968
|
+
const quoted = (s: string): string[] => (s.match(/"(?:[^"\\]|\\.)*"/g) ?? []).map((q) => JSON.parse(q) as string);
|
|
969
|
+
const node = /^node=("(?:[^"\\]|\\.)*")$/m.exec(script)?.[1];
|
|
970
|
+
const list = /^for entry in (.*); do$/m.exec(script)?.[1];
|
|
971
|
+
if (node !== undefined && list !== undefined) return { node: JSON.parse(node) as string, entries: quoted(list) };
|
|
972
|
+
// Older launchers are one `exec <node> <entry> "$@"` line and nothing else.
|
|
973
|
+
const exec = quoted(/^exec (.*)$/m.exec(script)?.[1] ?? "");
|
|
974
|
+
return { node: exec[0], entries: exec[1] ? [exec[1]] : [] };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Why a launcher of ours no longer matches — the half-sentence a person gets told. */
|
|
978
|
+
function staleReason(old: string, entryDir: string, node: string): string {
|
|
979
|
+
const rec = launcherRecords(old);
|
|
980
|
+
if (rec.node === undefined && !rec.entries.length) return "this version of pi-loops writes it differently";
|
|
981
|
+
if (rec.entries[0] !== path.join(entryDir, "cli-entry.mjs")) return "the package moved";
|
|
982
|
+
if (rec.node !== node) return "the node it runs changed";
|
|
983
|
+
return "this version of pi-loops writes it differently";
|
|
864
984
|
}
|
|
865
985
|
|
|
866
986
|
/**
|
package/src/pi-loops.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { McpSource, PI_BUILTIN_TOOL_NAMES, type McpServerConfig, type McpToolDef
|
|
|
33
33
|
import { capRedacted, previewRedacted, redact } from "./redact.ts";
|
|
34
34
|
import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
|
|
35
35
|
import { type ShareMessage, renderShare, shareSummary } from "./share.ts";
|
|
36
|
-
import { installLauncherWithConfirm } from "./cli.ts";
|
|
36
|
+
import { installLauncherWithConfirm, installedByPi, launcherSource, launcherState, launcherTarget, refreshLauncher } from "./cli.ts";
|
|
37
37
|
import { execFile } from "node:child_process";
|
|
38
38
|
import { createHash } from "node:crypto";
|
|
39
39
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
@@ -2484,6 +2484,52 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2484
2484
|
|
|
2485
2485
|
/* -------------------------------------------------------- lifecycle */
|
|
2486
2486
|
|
|
2487
|
+
/**
|
|
2488
|
+
* The `pi-loops` command, kept on the PATH of whoever asked for it there.
|
|
2489
|
+
*
|
|
2490
|
+
* `pi install` puts this package under pi's directory and nothing on the PATH, so until 0.22.1
|
|
2491
|
+
* the first thing a new user had to do was type a bare `node …/cli-entry.mjs install-launcher`
|
|
2492
|
+
* out of the README — the one step that only existed because the command could not yet run
|
|
2493
|
+
* itself. pi loads this extension at every session start, and at that moment it knows both where
|
|
2494
|
+
* it runs and where a launcher would go, so the step belongs here.
|
|
2495
|
+
*
|
|
2496
|
+
* This is the extension's only write outside the loops directory (AGENTS.md). Its limit is one
|
|
2497
|
+
* file, `pi-loops` in a directory already on the PATH, and only where a launcher of ours already
|
|
2498
|
+
* is or after a yes. A file with no marker of ours is somebody's own wrapper and is left alone;
|
|
2499
|
+
* one naming another copy of this package that is still on disk is that copy's to keep current,
|
|
2500
|
+
* so an install and a checkout both being opened never trade the file back and forth; a no is
|
|
2501
|
+
* remembered in `ui.json`, so it is asked once rather than in every window; and a checkout is
|
|
2502
|
+
* never asked for a launcher that is not there at all — nobody ran `pi install`, so nothing is
|
|
2503
|
+
* missing from their PATH — though one written from a checkout is still kept working.
|
|
2504
|
+
*/
|
|
2505
|
+
async function keepLauncher(ctx: ExtensionContext): Promise<void> {
|
|
2506
|
+
const { entryDir, node, agentDir } = launcherSource();
|
|
2507
|
+
const target = launcherTarget(undefined);
|
|
2508
|
+
// Nothing on the PATH to write into. `/pi-loops install-launcher`, run by hand, is where that
|
|
2509
|
+
// is explained; a session start saying it unprompted would be a warning about nothing.
|
|
2510
|
+
if (!target) return;
|
|
2511
|
+
const { state } = launcherState(entryDir, node, agentDir, target);
|
|
2512
|
+
if (state === "stale") {
|
|
2513
|
+
const done = refreshLauncher(entryDir, node, agentDir, target);
|
|
2514
|
+
if (!done) return;
|
|
2515
|
+
log.info(`launcher: ${done.message}`);
|
|
2516
|
+
// No question: they asked for a launcher once, and keeping that one working is what they
|
|
2517
|
+
// asked for. The line is so that a file changing under them is never a silent change.
|
|
2518
|
+
if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${done.message}`, done.ok ? "info" : "warning");
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
if (state !== "absent") return; // ours and current, another copy's, or somebody else's entirely
|
|
2522
|
+
if (!ctx.hasUI || readUiPrefs(dir).launcher === "declined" || !installedByPi()) return;
|
|
2523
|
+
const lines: string[] = [];
|
|
2524
|
+
const code = await installLauncherWithConfirm([], (title, body) => ctx.ui.confirm(title, body), (l) => lines.push(l));
|
|
2525
|
+
if (code === undefined) {
|
|
2526
|
+
writeUiPref(dir, "launcher", "declined");
|
|
2527
|
+
ctx.ui.notify("[pi-loops] not installed — `/pi-loops install-launcher` writes one whenever you want it", "info");
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
ctx.ui.notify(`[pi-loops] ${lines.map((l) => l.trim()).join(" · ")}`, code === 0 ? "info" : "warning");
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2487
2533
|
pi.on("session_start", async (_event, ctx) => {
|
|
2488
2534
|
lastCtx = ctx;
|
|
2489
2535
|
if (startHandled) return; // the same start, delivered a second time by a second rebind
|
|
@@ -2506,6 +2552,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2506
2552
|
loadMcpConfig(ctx.isProjectTrusted());
|
|
2507
2553
|
for (const d of mcpDiagnostics) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${d}`, "warning");
|
|
2508
2554
|
await startMcpSources(); // tools for every process; pushes are consumed by interactive processes only
|
|
2555
|
+
// Not awaited: the question below is a dialog, and the clock must not wait behind someone who
|
|
2556
|
+
// has walked away from it.
|
|
2557
|
+
void keepLauncher(ctx).catch((err: any) => log.info(`launcher: ${err?.message ?? err}`));
|
|
2509
2558
|
// tui and rpc processes stay alive and host the timer; `PI_LOOPS_HOST=1` lets a `pi -p`
|
|
2510
2559
|
// run (a long headless prompt) host it too.
|
|
2511
2560
|
const hostMode = ctx.mode === "tui" || ctx.mode === "rpc" || envFlag("LOOPS_HOST");
|
package/src/ui-prefs.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface UiPrefs {
|
|
|
25
25
|
/** The model and thinking level last chosen in the browser front end. */
|
|
26
26
|
model?: string;
|
|
27
27
|
thinking?: string;
|
|
28
|
+
/**
|
|
29
|
+
* `"declined"` once someone has said no to the launcher a session start offers, so the offer is
|
|
30
|
+
* made once and not in every window. `/pi-loops install-launcher` clears it when it writes one.
|
|
31
|
+
*/
|
|
32
|
+
launcher?: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
const fileIn = (dir: string): string => path.join(dir, "ui.json");
|
|
@@ -52,6 +57,7 @@ export function readUiPrefs(dir: string): UiPrefs {
|
|
|
52
57
|
if (typeof doc.panel === "boolean") prefs.panel = doc.panel;
|
|
53
58
|
if (typeof doc.model === "string") prefs.model = doc.model;
|
|
54
59
|
if (typeof doc.thinking === "string") prefs.thinking = doc.thinking;
|
|
60
|
+
if (typeof doc.launcher === "string") prefs.launcher = doc.launcher;
|
|
55
61
|
return prefs;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -75,6 +81,22 @@ export function writeUiPref<K extends keyof UiPrefs>(dir: string, key: K, value:
|
|
|
75
81
|
const doc = readRaw(dir);
|
|
76
82
|
if (doc[key] === value) return;
|
|
77
83
|
doc[key] = value;
|
|
84
|
+
save(dir, doc);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Forget one preference, keeping every other key. A remembered "no" has to be forgettable by the
|
|
89
|
+
* thing that answers it — a launcher written by hand is the yes the session-start question was
|
|
90
|
+
* asking for — and removing the key is what "nobody has answered" is spelled as here.
|
|
91
|
+
*/
|
|
92
|
+
export function clearUiPref(dir: string, key: keyof UiPrefs): void {
|
|
93
|
+
const doc = readRaw(dir);
|
|
94
|
+
if (!(key in doc)) return;
|
|
95
|
+
delete doc[key];
|
|
96
|
+
save(dir, doc);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function save(dir: string, doc: Record<string, unknown>): void {
|
|
78
100
|
try {
|
|
79
101
|
writeFileAtomic(fileIn(dir), `${JSON.stringify(doc, null, 2)}\n`);
|
|
80
102
|
} catch {
|