@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 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.1 # or from GitHub, at a pinned tag
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 — 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.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,装到这里就够了——那四个斜杠命令现在就能用。下面这步只跟 `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
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.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 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 {