@alphacoder-v0/pi-loops 0.22.1 → 0.22.3

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
@@ -5,6 +5,40 @@ All notable changes to pi-loops are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.22.3] - 2026-09-16
9
+
10
+ ### Changed
11
+ - **A job or rule card opens instead of being cut off.** The Automation panel sliced a job's prompt
12
+ to ninety characters, a rule's condition and action to eighty, and the last error to one hundred
13
+ and twenty, with no way to see the rest. Every card is a `<details>` now — closed it shows a
14
+ two-line preview, open it shows the whole text and the fields the terminal's `/cron` line has —
15
+ and it keeps that open or closed state across the panel's eight-second redraw. The full prompt,
16
+ condition, action and last error are masked with the same secret patterns as the stderr tail
17
+ before they leave the server.
18
+
19
+ ## [0.22.2] - 2026-09-16
20
+
21
+ ### Changed
22
+ - **The extension puts `pi-loops` on your PATH, instead of you typing a node path out of the
23
+ README.** `pi install` leaves this package under pi's directory and nothing on your `PATH`, so
24
+ `pi-loops` only existed after `install-launcher` had been run once — and the first time there was
25
+ no `pi-loops` to run it with, which is why the install instructions ended in a bare `node
26
+ ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops/src/cli-entry.mjs install-launcher`. The
27
+ launcher that wrote then went stale by itself when pi reinstalled the package by the other route
28
+ (0.22.1). pi loads this extension at every session start and knows, at that moment, both where it
29
+ runs and where a launcher would go: so the first session that finds none anywhere on your `PATH`
30
+ asks once — the same question `/pi-loops install-launcher` asks — and a no is remembered in
31
+ `ui.json` and never asked again. A launcher of ours is rewritten at the next start in two cases
32
+ and only two — the copy it names is not on disk any more, or the copy it names is this one and the
33
+ node or the shape of the script changed — with one line saying that it was and why; one naming
34
+ another copy that is still there belongs to that copy, so an install and a checkout on the same
35
+ machine never trade the file back and forth. Nothing else is touched: a `pi-loops` this project
36
+ did not write is left alone, a checkout is never asked for a launcher that is not there (one
37
+ written from a checkout is still kept current), and `/pi-loops install-launcher` still writes one
38
+ whenever you want it, clearing the remembered no. The script itself now also lists the copy it
39
+ runs once rather than twice, which is what an installed copy — where the entry it records is one
40
+ of pi's own two locations — used to get.
41
+
8
42
  ## [0.22.1] - 2026-09-16
9
43
 
10
44
  ### 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.1 # or from GitHub, at a pinned tag
41
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.22.3 # 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 — the one thing
188
- `install-launcher` cannot do for itself. Either way round works:
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, in the directory pi installed the package into
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
- Either writes a launcher into the first of `~/.local/bin` and `/usr/local/bin` that is already on
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.1 # 或者从 GitHub 装,钉住一个 tag
48
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.22.3 # 或者从 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,装到这里就够了——那四个斜杠命令现在就能用。下面这步只跟 `pi-loops` 这个命令有关:浏览器窗口和几个 shell 子命令靠它,不要浏览器窗口可以先跳过。
54
+ 重启 pi,装到这里就够了——那四个斜杠命令现在就能用。下面这段只跟 `pi-loops` 这个命令有关:浏览器窗口和几个 shell 子命令靠它,不要浏览器窗口可以先跳过。
55
55
 
56
- `pi install` 把包放在 pi 自己的托管目录里、**不进 `PATH`**,所以此刻 `pi-loops` 这个命令还不存在——这恰好是 `install-launcher` 唯一没法替自己做的事。两条路随便走一条:
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 里,进到 pi 装包的那个目录
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
- 两条都会往 `~/.local/bin`(或其它已在 `PATH` 里的目录,用 `--dir` 指定)写一个启动器。之后 `pi-loops` 在任何目录都能用。
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 — and
157
- putting itself there is the one thing it cannot do for itself. Run it from inside pi, where the
158
- extension is already loaded:
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
@@ -143,6 +143,18 @@ weaker, and worth converting whenever one of them breaks.
143
143
  `test/web-page.test.ts`: *a next run in April does not render as a time of day*.
144
144
  - [x] The name of a job is never abbreviated: it is what `/cron set <name> …` takes, so a clipped
145
145
  one is a name you cannot act on. It wraps; the schedule beside it does not break in half.
146
+ - [x] **The whole prompt, condition and action are one click away, and a last error is one line
147
+ when closed and whole when open.** They used to be cut — a job's prompt at ninety characters,
148
+ a rule's condition and action at eighty, the error at one hundred and twenty — with no way to
149
+ see the rest, which made the panel a place where things looked shorter than they were. Each
150
+ card is a `<details>` now: closed, a two-line preview and, for a failing job, a one-line error
151
+ hint; open, the whole text and the fields the terminal's `/cron` line has. What is shown in
152
+ full is masked with the same secret patterns as the stderr tail, and a card keeps its open or
153
+ closed state across the panel's eight-second redraw.
154
+ `test/web-page.test.ts`: *a job's whole prompt is one click away, and stays open across a
155
+ redraw*; *a rule's condition and action are shown in full*; *a job with a last error says so
156
+ before it is opened*.
157
+ `test/web.test.ts`: *a key in a job's prompt does not reach the page*.
146
158
  - [x] Run a job now.
147
159
  - [x] Inbox count.
148
160
  - [x] Runtime: scheduler state and whether this pi owns the clock, MCP servers and their state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacoder-v0/pi-loops",
3
- "version": "0.22.1",
3
+ "version": "0.22.3",
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 entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "cli-entry.mjs");
831
+ const { entryDir, node, agentDir } = launcherSource();
832
832
  const file = path.join(target, "pi-loops");
833
- // A launcher rather than a symlink: it names the node that is running now, which is the one known
834
- // to be new enough for this code. What it survives is this package being installed again by the
835
- // other route — `pi install npm:` over a `git:` copy moves it, and a launcher that knew only where
836
- // it used to be died in Node's `Cannot find module`. So the entry it recorded is the first place
837
- // it looks and not the only one, and the recorded node is a path it checks rather than trusts.
838
- const candidates = [entry, ...piInstallEntries(getAgentDir())].map((e) => JSON.stringify(e)).join(" ");
839
- const script = [
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
- "# pi-loops launcher, written by `pi-loops install-launcher`.",
842
- `node=${JSON.stringify(process.execPath)}`,
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.mkdirSync(target, { recursive: true });
854
- fs.writeFileSync(file, script, { mode: 0o755 });
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
- out(`could not write ${file}: ${err?.message ?? err}`);
858
- return 1;
961
+ return { ok: false, message: `could not refresh the \`pi-loops\` launcher at ${file}: ${err?.message ?? err}` };
859
962
  }
860
- out(`wrote ${file}`);
861
- out(` it runs ${entry}`);
862
- out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
863
- return 0;
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 {
package/src/web.mjs CHANGED
@@ -237,16 +237,31 @@ const SECRET_SHAPES = [
237
237
  * reasons pi exits at all, and what it prints is the key it was refused with — so the one event
238
238
  * whose whole purpose is to explain a failure was the one event that could carry a credential.
239
239
  */
240
- function recentStderr() {
241
- // Redact the whole kept tail, then cut. The other order cut first, so a key that straddled the
242
- // 4000-character boundary lost the `sk-` its pattern starts at — nothing matched, and the second
243
- // half of it went out to every attached browser.
244
- let out = stderrTail.trim();
240
+ /**
241
+ * Redact every credential shape a line of output could carry.
242
+ *
243
+ * A job's prompt and last error, and a rule's condition and action, now leave this process in
244
+ * `/state` in full rather than cut short — and full is what makes a prompt with an API key in it,
245
+ * or an error a provider printed, reach every attached browser unless it is masked first. The same
246
+ * patterns that guard the stderr tail have to guard these, which is why this is one function and
247
+ * not two copies of the list.
248
+ */
249
+ function maskSecrets(text) {
250
+ // A field the store does not have stays absent: /state said undefined before and still does.
251
+ if (text === undefined || text === null) return text;
252
+ let out = String(text);
245
253
  for (const re of SECRET_SHAPES) {
246
254
  re.lastIndex = 0;
247
255
  out = out.replace(re, "[REDACTED]");
248
256
  }
249
- return out.slice(-STDERR_SEND);
257
+ return out;
258
+ }
259
+
260
+ function recentStderr() {
261
+ // Redact the whole kept tail, then cut. The other order cut first, so a key that straddled the
262
+ // 4000-character boundary lost the `sk-` its pattern starts at — nothing matched, and the second
263
+ // half of it went out to every attached browser.
264
+ return maskSecrets(stderrTail.trim()).slice(-STDERR_SEND);
250
265
  }
251
266
  pi.stderr.on("data", (d) => {
252
267
  process.stderr.write(d);
@@ -483,10 +498,14 @@ function automation(cwd) {
483
498
  schedule: formatSchedule(j.schedule),
484
499
  stateful: !!j.stateful,
485
500
  enabled: !!j.enabled,
486
- prompt: j.prompt,
501
+ prompt: maskSecrets(j.prompt),
487
502
  runCount: j.runCount ?? 0,
488
- lastError: j.lastError,
503
+ lastError: maskSecrets(j.lastError),
489
504
  running: !!j.running,
505
+ // The open body of a card shows these two, which the terminal's `/cron` line has and the
506
+ // panel did not; they leave the store as they were written, no reshaping.
507
+ cwd: j.cwd,
508
+ lastFiredAt: j.lastFiredAt,
490
509
  // Set only when it is not this machine's: the panel says so, and nothing else has to guess.
491
510
  // What to type to fix it. The terminal's version of this line says `/cron set <n>`, where n
492
511
  // is the position in a numbered list — which this panel does not have, so telling someone to
@@ -504,7 +523,7 @@ function automation(cwd) {
504
523
  const allRules = readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? [];
505
524
  const rules = allRules
506
525
  .filter(inThisProject)
507
- .map((r) => ({ id: r.id, condition: r.condition, action: r.action, enabled: !!r.enabled, fireOnce: !!r.fireOnce, firedAt: r.firedAt }));
526
+ .map((r) => ({ id: r.id, condition: maskSecrets(r.condition), action: maskSecrets(r.action), enabled: !!r.enabled, fireOnce: !!r.fireOnce, firedAt: r.firedAt }));
508
527
  // Two counts, not one. They used to be added together, and `/cron` counts jobs only — so the
509
528
  // panel's number disagreed with the command's whenever a rule lived somewhere else, and the line
510
529
  // that exists to say "your job is not gone, it is elsewhere" pointed at `/cron all`, where a
@@ -2261,6 +2280,31 @@ aside h2{font-size:12px;font-weight:700;color:var(--ink);margin:0 0 9px;letter-s
2261
2280
  .card .t .m{white-space:nowrap}
2262
2281
  .card .m{color:var(--muted);font-size:12px}
2263
2282
  .card button{padding:2px 9px;font-size:12px;margin-left:auto;min-height:0}
2283
+ /* A job or rule is now a <details>: closed it is the name, a two-line preview and a one-line error
2284
+ hint, open it is the whole prompt and the fields the terminal /cron listing has. The summary is
2285
+ the only part shown closed, so the runs line, the preview and the error hint live there and the
2286
+ full text lives in the body. */
2287
+ details.card{padding:0;overflow:hidden}
2288
+ details.card>summary{cursor:pointer;list-style:none;padding:7px 11px;display:flex;flex-wrap:wrap;align-items:center;gap:8px}
2289
+ details.card>summary::-webkit-details-marker{display:none}
2290
+ details.card>summary::before{content:"▸";color:var(--faint);flex:0 0 auto}
2291
+ details.card[open]>summary::before{content:"▾"}
2292
+ details.card>summary .t{flex:1 1 auto;min-width:0}
2293
+ details.card>summary>.m{flex-basis:100%}
2294
+ /* A summary must hold phrasing content, so these are spans; the summary is a flex row, which
2295
+ blockifies its children anyway, and the block is stated here for the lines that need it. */
2296
+ details.card>summary>.m:not(.peek){display:block}
2297
+ details.card .peek{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-width:0}
2298
+ details.card[open] .peek{display:none}
2299
+ details.card .peek.failing{-webkit-line-clamp:1;color:#c66}
2300
+ details.card .body{position:relative;padding:0 11px 9px}
2301
+ /* The id, cwd and last error are one unbroken run each — a path, a URL in an error — and the card
2302
+ clips what it cannot wrap, so they break the way the full prompt does. */
2303
+ details.card .body .m{overflow-wrap:anywhere}
2304
+ details.card pre.full{margin:0;padding:3px 3.5em 6px 0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:22em;overflow:auto;color:var(--muted);font-size:12px;font-family:inherit}
2305
+ details.card button.copy{border:0;background:none;color:var(--faint);padding:0 5px;font-size:11px;min-height:0;opacity:0;position:absolute;top:4px;right:6px}
2306
+ details.card:hover button.copy,details.card:focus-within button.copy{opacity:1}
2307
+ @media (pointer:coarse){details.card button.copy{opacity:.7}}
2264
2308
  .off{opacity:.5}
2265
2309
  .dot{display:inline-block;width:7px;height:7px;border-radius:99px;margin-right:6px;vertical-align:middle}
2266
2310
  .up{background:var(--accent)}.down{background:var(--bad)}.idle{background:var(--faint)}
@@ -3430,6 +3474,11 @@ function str(v) { return v === undefined || v === null ? "" : String(v); }
3430
3474
  */
3431
3475
  function safeText(v) { return esc(plain(str(v))); }
3432
3476
 
3477
+ // Which cards were open when the panel last drew. The panel is rebuilt as one string and assigned
3478
+ // every eight seconds, so the open flag has nowhere to live but here: read before the draw, restore
3479
+ // after it, and a card toggled between draws updates this at once through its own toggle event.
3480
+ const openCards = new Set();
3481
+
3433
3482
  function renderSidebar(s) {
3434
3483
  const a = s.automation || {};
3435
3484
  const box = $("auto");
@@ -3438,19 +3487,38 @@ function renderSidebar(s) {
3438
3487
  let html = "";
3439
3488
  html += '<div class="notice">inbox <b>' + num(a.inboxNew) + "</b> new" + (a.inboxDecisions ? " (" + num(a.inboxDecisions) + " decision" + (a.inboxDecisions === 1 ? "" : "s") + ")" : "") + " · " + num(a.jobs.length) + " job(s) · " + num(a.rules.length) + " rule(s)</div>";
3440
3489
  for (const j of a.jobs) {
3441
- html += '<div class="card' + (j.enabled ? "" : " off") + '"><div class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
3490
+ // The prompt is shown twice — a two-line peek in the summary, the whole of it in the body —
3491
+ // rather than once and cut, which is what the 90-character slice was: a preview that hid the
3492
+ // rest with no way to see it. Both copies go through safeText; neither is sliced here.
3493
+ const prompt = safeText(j.prompt);
3494
+ html += '<details class="card job' + (j.enabled ? "" : " off") + '" data-key="job:' + safeText(j.id) + '">' +
3495
+ '<summary><span class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
3442
3496
  '<span class="m">' + safeText(j.schedule) + (j.stateful ? " · loop" : "") + "</span>" +
3443
- '<button data-run="' + safeText(j.id) + '">run</button></div>' +
3444
- '<div class="m">' + safeText(str(j.prompt).slice(0, 90)) + "</div>" +
3445
- '<div class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</div>" +
3446
- (j.asleep ? '<div class="m" style="color:#c93">' + safeText(j.asleep) + "</div>" : "") +
3497
+ '<button data-run="' + safeText(j.id) + '">run</button></span>' +
3498
+ '<span class="m peek">' + prompt + "</span>" +
3499
+ '<span class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</span>" +
3500
+ // A one-line hint so a job that is failing says so before it is opened; the whole error is in
3501
+ // the body. It is a .peek like the prompt, hidden once the card is open.
3502
+ (j.lastError ? '<span class="m peek failing">' + safeText(j.lastError) + "</span>" : "") +
3503
+ (j.asleep ? '<span class="m" style="color:#c93">' + safeText(j.asleep) + "</span>" : "") +
3504
+ "</summary>" +
3505
+ '<div class="body">' +
3506
+ '<pre class="full">' + prompt + "</pre>" +
3507
+ '<button class="copy" data-copy="' + safeText(j.id) + '">copy</button>' +
3508
+ '<div class="m">id ' + safeText(j.id) + " · cwd " + safeText(j.cwd) + (j.lastFiredAt ? " · last fired " + safeText(j.lastFiredAt) : "") + "</div>" +
3447
3509
  // A job belonging to a hostname this machine no longer has: it is listed, because it exists,
3448
3510
  // and it says why nothing is happening rather than leaving you to find out from the silence.
3449
- (j.lastError ? '<div class="m" style="color:#c66">' + safeText(str(j.lastError).slice(0, 120)) + "</div>" : "") + "</div>";
3511
+ (j.lastError ? '<div class="m" style="color:#c66">' + safeText(j.lastError) + "</div>" : "") +
3512
+ "</div></details>";
3450
3513
  }
3451
3514
  for (const r of a.rules) {
3452
- html += '<div class="card' + (r.enabled ? "" : " off") + '"><div class="t"><b>rule</b><span class="m">' + (r.fireOnce ? "once" : "repeat") + "</span></div>" +
3453
- '<div class="m">when ' + safeText(str(r.condition).slice(0, 80)) + "</div><div class=\"m\">→ " + safeText(str(r.action).slice(0, 80)) + "</div></div>";
3515
+ html += '<details class="card rule' + (r.enabled ? "" : " off") + '" data-key="rule:' + safeText(r.id) + '">' +
3516
+ '<summary><span class="t"><b>rule</b><span class="m">' + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
3517
+ '<span class="m peek">when ' + safeText(r.condition) + "</span></summary>" +
3518
+ '<div class="body">' +
3519
+ '<pre class="full">when ' + safeText(r.condition) + "</pre>" +
3520
+ '<pre class="full">→ ' + safeText(r.action) + "</pre>" +
3521
+ "</div></details>";
3454
3522
  }
3455
3523
  if (!a.jobs.length && !a.rules.length) html += '<div class="notice">no jobs or rules in this project</div>';
3456
3524
  // The store is machine-wide and this list is not: without these lines, a job made in another
@@ -3462,8 +3530,55 @@ function renderSidebar(s) {
3462
3530
  if (jobsAway) html += '<div class="notice">+ ' + jobsAway + " job" + (jobsAway === 1 ? "" : "s") + " in other projects — /cron all</div>";
3463
3531
  if (rulesAway) html += '<div class="notice">+ ' + rulesAway + " rule" + (rulesAway === 1 ? "" : "s") + " elsewhere — /triggers rules --all</div>";
3464
3532
  if (s.lastPoll) html += '<div class="notice">last check: ' + safeText(s.lastPoll.state || "") + " · " + esc(new Date(s.lastPoll.at).toLocaleTimeString()) + "</div>";
3533
+ // The open state survives the redraw by being read out before the panel is rebuilt and put back
3534
+ // after: the cards themselves are new elements each time, so the flag has to live in this set.
3535
+ box.querySelectorAll("details[data-key]").forEach((d) => {
3536
+ if (d.open) openCards.add(d.dataset.key); else openCards.delete(d.dataset.key);
3537
+ });
3465
3538
  box.innerHTML = html;
3466
- box.querySelectorAll("[data-run]").forEach((b) => (b.onclick = () => api("/trigger/immediate", { id: b.dataset.run })));
3539
+ box.querySelectorAll("details[data-key]").forEach((d) => {
3540
+ if (openCards.has(d.dataset.key)) d.open = true;
3541
+ // The toggle event does not bubble, so it is heard per card, not once on the box: a card
3542
+ // closed by hand between draws stays closed when the next draw runs.
3543
+ d.addEventListener("toggle", () => {
3544
+ if (d.open) openCards.add(d.dataset.key); else openCards.delete(d.dataset.key);
3545
+ });
3546
+ });
3547
+ box.querySelectorAll("[data-run]").forEach((b) => (b.onclick = (e) => {
3548
+ // A click anywhere in a summary toggles the card; the run button is a summary's own button,
3549
+ // not a way to open the prompt.
3550
+ e.preventDefault(); e.stopPropagation();
3551
+ api("/trigger/immediate", { id: b.dataset.run });
3552
+ }));
3553
+ box.querySelectorAll("[data-copy]").forEach((b) => {
3554
+ const card = b.closest?.("details[data-key]");
3555
+ b.type = "button";
3556
+ // Reads the prompt back from the DOM rather than holding a second copy in JS, the way the
3557
+ // feed's tool rows do, so the two can never fall out of step.
3558
+ b.onclick = async (e) => {
3559
+ e.stopPropagation();
3560
+ const text = card?.querySelector?.("pre.full")?.textContent ?? "";
3561
+ try {
3562
+ if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(text);
3563
+ else {
3564
+ const ta = document.createElement("textarea");
3565
+ ta.value = text;
3566
+ ta.style.position = "fixed";
3567
+ ta.style.opacity = "0";
3568
+ document.body.append(ta);
3569
+ ta.focus();
3570
+ ta.select();
3571
+ let ok = false;
3572
+ try { ok = document.execCommand("copy"); } finally { ta.remove(); }
3573
+ if (!ok) throw new Error("copy refused");
3574
+ }
3575
+ b.textContent = "copied";
3576
+ } catch (_) {
3577
+ b.textContent = "copy failed";
3578
+ }
3579
+ setTimeout(() => { b.textContent = "copy"; }, 1400);
3580
+ };
3581
+ });
3467
3582
  }
3468
3583
  renderRuntime(s.runtime);
3469
3584
  $("goal").textContent = s.goal ? plain((s.goal.condition || "") + " — " + (s.goal.status || "") + " (" + (s.goal.iterations ?? 0) + ")") : "none";