@alphacoder-v0/pi-loops 0.24.0 → 0.24.1

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,59 @@ All notable changes to pi-loops are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.24.1] - 2026-11-10
9
+
10
+ Two first-run fixes: the `pi-loops` command exists after the first pi session with no question, and
11
+ the first browser gets into the page from one link on every platform, WSL included.
12
+
13
+ ### Changed
14
+
15
+ - **The first pi session after `pi install` puts `pi-loops` on your `PATH` without asking.** It
16
+ used to ask in a confirm dialog first. Now it writes the launcher into the first of
17
+ `~/.local/bin` and `/usr/local/bin` that is on your `PATH`, the way pi's own installer links
18
+ `pi`, and says so in one line: `[pi-loops] added pi-loops to ~/.local/bin; /pi-loops
19
+ uninstall-launcher removes it`. It writes only where nothing named `pi-loops` is there, and a
20
+ checkout never writes one. A link, even one that points nowhere, a directory, or a file it cannot
21
+ read counts as something there, and the file is created only if it does not exist, so the write
22
+ never goes through a link at that name or replaces a file; the directory itself may be a link to
23
+ a directory. A `pi-loops` counts as one that pi-loops wrote only when its first two lines are the
24
+ ones pi-loops writes, so `uninstall-launcher` never removes a script that only quotes them. It
25
+ writes only into a directory that is already there, and never makes `~/.local/bin`;
26
+ `install-launcher`, run by hand, still makes it. When the write fails, one warning line says why,
27
+ no half-written file is left, and no later session tries that directory again (#180).
28
+
29
+ Upgrade note: if you said no to the dialog before, that answer still holds and nothing is
30
+ written. If you never answered it, the next session writes the launcher.
31
+
32
+ ### Fixed
33
+
34
+ - `pi-loops install-launcher --dir`, and `/pi-loops install-launcher --dir`, with no directory after
35
+ `--dir` is a usage error (exit 2). It used to write into the default directory, as if no `--dir`
36
+ had been given. `uninstall-launcher` reads `--dir` the same way.
37
+ - The first page visit works on every platform. On WSL the browser never opened, the terminal said
38
+ it had, and the printed address had no key, so Windows Chrome met a door that asked for a code
39
+ nobody could find. `pi-loops` now prints one link with a one-use key, opens the browser with a
40
+ per-platform opener (`rundll32.exe`, then `cmd.exe`, on WSL), and says `opened in your browser`
41
+ only once a browser has spent the key, because `rundll32` exits 0 even when nothing opened. When
42
+ the opener ran but no browser spent the key within 8 s, it says to open the link if no browser
43
+ opened. Otherwise, and on WSL when the loops directory's path holds a character Windows cannot
44
+ name, it says it could not open a browser and that the link works once, for 10 minutes (#179).
45
+ - The door names the ways in that a first browser can take: `pi-loops open` on this machine, and
46
+ `pi-loops pair` for another device.
47
+
48
+ ### Added
49
+
50
+ - `pi-loops uninstall-launcher [--dir <dir>]` and `/pi-loops uninstall-launcher` remove the
51
+ launcher when pi-loops wrote it, and leave any other file named `pi-loops` as it is. No later
52
+ session writes the launcher again until `install-launcher` puts it back (#180).
53
+ - `pi-loops open [--loops-dir <dir>]` prints a fresh link that signs a browser in, for the
54
+ project's newest conversation, and opens the browser on it. With no terminal it still prints the
55
+ link with its key, and opens no browser.
56
+ - `/pi-loops web` opens the conversation you are in from inside pi (a new one in the project before
57
+ its first answer or under `--no-session`) and copies the link to the clipboard. It says
58
+ `copied to the clipboard` only when a clipboard command took the link. It needs no `pi-loops` on
59
+ your PATH.
60
+
8
61
  ## [0.24.0] - 2026-11-09
9
62
 
10
63
  ### Upgrade notes
package/README.md CHANGED
@@ -46,11 +46,11 @@ one run with `pi -e .`, which installs nothing. Keep one copy, not two: two copi
46
46
  same tools, and pi refuses to load the second — `Tool "cron_create" conflicts with …`, and it
47
47
  exits.
48
48
 
49
- Then restart pi, and that is the whole install: `/cron`, `/inbox`, `/triggers` and `/goal` are
50
- registered by the extension itself, so they work with nothing on your `PATH` and no launcher. The
51
- `pi-loops` command is a separate thing, needed only for the browser window and the shell
52
- subcommands — [The browser window, and the command line](#the-browser-window-and-the-command-line)
53
- sets it up when you want it. The package also ships a skill (`skills/pi-loops`), so the agent knows
49
+ Then start `pi` once, and that is the whole install. That first session puts the `pi-loops` command
50
+ on your `PATH` and says so in one line, so from then on `pi-loops` works from any shell (see
51
+ [The browser window, and the command line](#the-browser-window-and-the-command-line)). `/cron`,
52
+ `/inbox`, `/triggers` and `/goal` are registered by the extension itself, so they work with or
53
+ without that command. The package also ships a skill (`skills/pi-loops`), so the agent knows
54
54
  when to reach for `cron_create`, `new_trigger` and the inbox on its own.
55
55
 
56
56
  ## Your first loop
@@ -178,24 +178,29 @@ you run `pi-loops start --foreground` from your own systemd unit ([docs/cli.md](
178
178
  ## The browser window, and the command line
179
179
 
180
180
  `pi-loops` is the part that needs a launcher: `pi install` puts the package under pi's managed
181
- directory rather than on your `PATH`, so the command does not exist yet. You are not asked to fix
182
- that by hand — the first pi session after `pi install` offers to put `pi-loops` on your `PATH`, and
183
- keeps that launcher working afterwards if pi ever moves the package. Say no and nothing is written,
184
- and you are not asked again; `/pi-loops install-launcher` writes one whenever you change your mind:
181
+ directory rather than on your `PATH`. You do not fix that by hand. The first interactive pi session
182
+ after `pi install` writes `pi-loops` into the first of `~/.local/bin` and `/usr/local/bin` that is
183
+ already on your `PATH`, says so in one line, and keeps that launcher working afterwards if pi ever
184
+ moves the package:
185
185
 
186
186
  ```text
187
- /pi-loops install-launcher # from inside pi, where the extension is already loaded
187
+ [pi-loops] added pi-loops to ~/.local/bin; /pi-loops uninstall-launcher removes it
188
+ ```
189
+
190
+ It never replaces a `pi-loops` it did not write. When neither directory is on your `PATH`, or on a
191
+ machine where pi itself is never opened, write it yourself and pass `--dir <dir>` to say where:
192
+
193
+ ```text
194
+ /pi-loops install-launcher --dir ~/bin # from inside pi, where the extension is already loaded
188
195
  ```
189
196
 
190
197
  ```bash
191
- # or from a shell, on a machine where pi is never opened — the same command, by absolute path
198
+ # or from a shell — the same command, by absolute path
192
199
  cd ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops # installed from npm
193
200
  cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops # installed from GitHub
194
- node src/cli-entry.mjs install-launcher
201
+ node src/cli-entry.mjs install-launcher --dir ~/bin
195
202
  ```
196
203
 
197
- Any of them writes a launcher into the first of `~/.local/bin` and `/usr/local/bin` that is already
198
- on your `PATH`, and refuses rather than guessing if neither is — pass `--dir <dir>` to say where.
199
204
  After that, `pi-loops` works from anywhere:
200
205
 
201
206
  ```bash
@@ -227,8 +232,14 @@ deletes anything; the session you leave is a file that `resume` lists.
227
232
  The page is served by pi-loops in the background on **`http://127.0.0.1:4173/`**, so `pi-loops`
228
233
  opens it and exits, and the page stays open when that terminal closes. Its token lives in a file, so
229
234
  the address is the same one tomorrow and is worth bookmarking (`[page] port` in `config.toml` moves
230
- it if 4173 is spoken for). The first visit leaves a cookie and you never see the token again. There
231
- is no way to drop the token: the page is always on, so it is always behind it.
235
+ it if 4173 is spoken for). `pi-loops` prints one link with a one-use key and opens your browser on
236
+ it, on macOS, Windows, WSL and Linux; when no browser opens, paste that link. The first visit leaves
237
+ a cookie and you never see the token again. There is no way to drop the token: the page is always
238
+ on, so it is always behind it.
239
+
240
+ A browser that shows "This browser has not been here before." needs a fresh link: run
241
+ `pi-loops open` in a terminal, or `/pi-loops web` inside pi, which also copies the link to the
242
+ clipboard and needs no `pi-loops` on your PATH.
232
243
 
233
244
  From a phone, the best route is `tailscale serve --bg 4173`: this server stays on loopback and the
234
245
  tailnet does TLS and identity. On the same wifi, `[page] host = "0.0.0.0"` in `config.toml` works
@@ -258,22 +269,18 @@ says what is in each one.
258
269
 
259
270
  ### Uninstall
260
271
 
261
- Stop pi-loops before removing the extension. It keeps running in the background after the extension
262
- is removed, and nothing else stops it:
272
+ Stop pi-loops and remove the `pi-loops` launcher before removing the extension. pi-loops keeps
273
+ running in the background after the extension is removed, and nothing else stops it; the launcher
274
+ needs the package to run. `uninstall-launcher` removes only a launcher that pi-loops wrote:
263
275
 
264
276
  ```bash
265
277
  pi-loops stop
278
+ pi-loops uninstall-launcher # add --dir <dir> if install-launcher --dir <dir> wrote it
266
279
  pi remove npm:@alphacoder-v0/pi-loops # or git:github.com/alphacoder-v0/pi-loops, or the checkout path — whatever you installed
267
280
  ```
268
281
 
269
- State stays in `~/.pi/agent/loops` until you delete it. The `pi-loops` launcher stays too. No
270
- command removes it, so delete the file `install-launcher` wrote:
271
-
272
- ```bash
273
- rm ~/.local/bin/pi-loops # or /usr/local/bin/pi-loops, if ~/.local/bin was not on your PATH
274
- ```
275
-
276
- If you gave `install-launcher --dir <dir>`, the launcher is `<dir>/pi-loops`.
282
+ State stays in `~/.pi/agent/loops` until you delete it. After a reinstall, `/pi-loops install-launcher`
283
+ in pi puts the command back, because a removed launcher is not written again by itself.
277
284
 
278
285
  To go back to 0.22.8, stop pi-loops while this version is still installed, then install 0.22.8:
279
286
 
@@ -325,7 +332,8 @@ pi-loops stays stopped until `pi-loops start`, or until `pi-loops` opens the bro
325
332
  | `/session-export [path]`, `/session-import <path>` | Portable `.pisession` archive: transcript + jobs + rules + loop state (in the browser front end, `/session export` and `/session import` too; `/sessions` lists) |
326
333
  | `/session-share [--public]` | Upload a redacted transcript as a GitHub gist via `gh`, after showing you what it contains. (pi has its own `/share`, which sends the raw session elsewhere first — see [docs/session-archive.md](docs/session-archive.md)) |
327
334
  | `/recipe [list\|show\|add\|update\|remove <name>]` | Install a packaged way of running this project on loops — the issue tracker as a state machine, or one research experiment per run — with one question and one confirmation ([docs/recipes.md](docs/recipes.md)) |
328
- | `/pi-loops [install-launcher]` | Version and paths; `install-launcher` puts the `pi-loops` command on your `PATH` |
335
+ | `/pi-loops web` | Open this conversation in the browser page, and copy its one-use link |
336
+ | `/pi-loops [install-launcher \| uninstall-launcher]` | Version and paths; `install-launcher` puts the `pi-loops` command on your `PATH`, `uninstall-launcher` removes it |
329
337
 
330
338
  A schedule is a 5-field cron expression, or one of `hourly` / `daily` / `weekly` (also `每天`), or
331
339
  `every 30m`, `in 10m`, `at <ISO>`. The `@hourly` / `@daily` / `@weekly` / `@monthly` spellings work
package/README.zh-CN.md CHANGED
@@ -51,7 +51,7 @@ pi install npm:@alphacoder-v0/pi-loops
51
51
 
52
52
  重启 pi,装到这里就够了——那四个斜杠命令现在就能用。下面这段只跟 `pi-loops` 这个命令有关:浏览器窗口和几个 shell 子命令靠它,不要浏览器窗口可以先跳过。
53
53
 
54
- `pi install` 把包放在 pi 自己的托管目录里、**不进 `PATH`**,所以此刻 `pi-loops` 这个命令还不存在。这一步不用你自己动手:`pi install` 之后的第一个 pi 会话会问你要不要把 `pi-loops` 放进 `PATH`,写下之后也一直替你维护着——包被 pi 挪到另一个位置,下一个会话会把启动器改回能用的样子,并用一行告诉你改了什么。答 no 就什么都不写,也不会再问第二次;改了主意,`/pi-loops install-launcher` 随时给你写一个:
54
+ `pi install` 把包放在 pi 自己的托管目录里、**不进 `PATH`**,所以此刻 `pi-loops` 这个命令还不存在。这一步不用你自己动手:`pi install` 之后第一次打开 pi,它会直接把 `pi-loops` 放进 `PATH` 上已有的目录,并用一行告诉你放在哪、怎么删;之后也一直替你维护着——包被 pi 挪到另一个位置,下一个会话会把启动器改回能用的样子,并用一行告诉你改了什么。不想要的话,`pi-loops uninstall-launcher` 删掉它,以后也不会再写;改了主意,`/pi-loops install-launcher` 随时给你写一个:
55
55
 
56
56
  ```text
57
57
  /pi-loops install-launcher # 在 pi 里面,扩展本来就加载着
@@ -162,17 +162,18 @@ pi-loops upgrade --check # 只看看有没有新的
162
162
 
163
163
  ## 卸载
164
164
 
165
- 卸载扩展前先停止 pi-loops:
165
+ 卸载扩展前先停止 pi-loops,并删掉 `pi-loops` 启动器(`uninstall-launcher` 只删 pi-loops 自己写的那个):
166
166
 
167
167
  ```bash
168
168
  pi-loops stop
169
+ pi-loops uninstall-launcher
169
170
  pi remove npm:@alphacoder-v0/pi-loops # 当初怎么装的就怎么删:npm: 包名、git: ref 或检出路径
170
171
  pi remove git:github.com/alphacoder-v0/pi-loops
171
172
  pi -e /path/to/pi-loops # 或者:只在这次启动试用,什么都不装
172
173
  pi update --extensions # 对齐已安装的包
173
174
  ```
174
175
 
175
- 数据留在 `~/.pi/agent/loops`,想清就删目录。
176
+ 数据留在 `~/.pi/agent/loops`,想清就删目录。以后重新安装时,在 pi 里运行 `/pi-loops install-launcher` 把命令放回来:删掉的启动器不会再自动写回。
176
177
 
177
178
  要退回 0.22.8:趁 0.23 还装着,先停止 pi-loops,再安装 0.22.8:
178
179
 
package/docs/cli.md CHANGED
@@ -18,31 +18,68 @@ pi-loops --model anthropic/claude-opus-5 -e . # anything it does not recognise
18
18
  ```
19
19
 
20
20
  The page is served by pi-loops in the background, not by this command. `pi-loops` starts pi-loops
21
- in the background if it is not running, asks it to open the conversation, opens your browser and
22
- exits:
21
+ in the background if it is not running, asks it to open the conversation, prints a link, opens your
22
+ browser and exits:
23
23
 
24
24
  ```
25
25
  $ pi-loops
26
26
  started pi-loops in the background
27
- pi-loops page: http://127.0.0.1:4173/c/424aba43-…/ (opened in your browser)
28
- if it did not open, or from another browser: http://127.0.0.1:4173/c/424aba43-…/?open=c0ff… (works once, for 10 minutes)
27
+ pi-loops: http://127.0.0.1:4173/c/424aba43-…/?open=c0ff…
28
+ opened in your browser
29
29
  ```
30
30
 
31
- The first line appears only when this command started pi-loops. The browser is opened on a file
32
- that only you can read, under the loops directory's `pages/open/`, which sends it to the
33
- conversation with a one-use key; the key is in no command line, and the file is removed once the
34
- browser has used it, or after a minute. "Opened in your browser" means an opener started; it cannot
35
- tell whether the browser could read the file. A browser confined by snap or flatpak (Ubuntu's
36
- default Firefox is one) cannot read a hidden directory such as `~/.pi`, so it shows an error
37
- instead: use the address on the next line. That address, printed only on a terminal, holds a second
38
- one-use key that works for 10 minutes, never the token, because what a terminal prints stays in its
39
- scrollback. Over ssh, with `--no-open`, or with no terminal, nothing is opened, no file is written,
40
- and the line ends `(open it in a browser)`. A conversation the page already has open is not started
41
- again, so pi arguments given for it are not applied, and the last line says which:
31
+ The first line appears only when this command started pi-loops. The link holds a one-use key that
32
+ works for 10 minutes, never the token, because what a terminal prints stays in its scrollback. It is
33
+ printed before the browser is tried, so a browser on this machine that did not open gets in with a
34
+ paste. The last line says what happened:
35
+
36
+ - ` opened in your browser`: a browser loaded the page and spent the launch key within 8 s,
37
+ watched while the opener runs. The opener is `open` on macOS,
38
+ `rundll32 url.dll,FileProtocolHandler` on Windows, `rundll32.exe url.dll,FileProtocolHandler` on
39
+ WSL (`WSL_DISTRO_NAME` set, or a kernel release that names Microsoft; then `cmd.exe /c start`
40
+ when `rundll32.exe` cannot start), and `xdg-open` on other Linux, which gets 10 s because it takes
41
+ about 5 s to give up when there is no browser. An opener's exit code is not enough: `rundll32`
42
+ exits 0 for a target it cannot open.
43
+ - ` if no browser opened, open the link above (it works once, for 10 minutes)`: the opener ran, but
44
+ no browser spent the key within 8 s.
45
+ - ` could not open a browser here; open the link above (it works once, for 10 minutes)`: every
46
+ opener failed, or Linux has no `DISPLAY` or `WAYLAND_DISPLAY`.
47
+ - ` open the link above in a browser on this machine`: over ssh or mosh, where nothing is opened.
48
+ - ` open the link above in a browser (it works once, for 10 minutes)`: with `--no-open`.
49
+
50
+ The browser is opened on a file that only you can read, under the loops directory's `pages/open/`,
51
+ which sends it to the conversation with a second one-use key; that key is in no command line, and
52
+ the file is removed once the browser has used it, or after a minute. On WSL the Windows browser
53
+ reads that file as `file://wsl.localhost/<distro>/…`, which it cannot do when the loops directory's
54
+ path holds a character Windows does not allow in a name (`\ : * ? " < > |`); then the last line says
55
+ it could not open a browser. A browser confined by snap or flatpak
56
+ (Ubuntu's default Firefox is one) cannot read a hidden directory such as `~/.pi`, so it shows an
57
+ error instead: use the printed link. With no terminal (stdout piped or redirected), no key is
58
+ printed, because a file would keep it: the link is the page's address, and the next line is
59
+ ` pi-loops open prints a link that signs a browser in`. A conversation the page already has open
60
+ is not started again, so pi arguments given for it are not applied, and the last line says which:
42
61
  ` this conversation was already open; --model was not applied`. Opening can wait up to 15 s for
43
62
  an older pi in that conversation to stop; after 3 s the command says
44
63
  `waiting for pi-loops to open the conversation…` on stderr, and it waits up to 30 s in all.
45
64
 
65
+ ### A fresh link: `pi-loops open` and `/pi-loops web`
66
+
67
+ ```
68
+ pi-loops open [--cwd <dir>] [--loops-dir <dir>]
69
+ ```
70
+
71
+ opens the page on the project's newest conversation (a new one when the project has none), with
72
+ the same output as `pi-loops`, except that it prints the link with its key with no terminal too,
73
+ because that link is what it is for; it opens no browser then. `--loops-dir` names the loops
74
+ directory, as it does for `pi-loops` and as `PI_LOOPS_DIR` does. Run it when a browser shows the door ("This browser has not been
75
+ here before."), or when the printed link has been used or is older than 10 minutes. Inside pi,
76
+ `/pi-loops web` does the same for the conversation you are in (a new one in the project when pi
77
+ has saved nothing of it yet, before its first answer or under `--no-session`), shows the same
78
+ lines, and copies the link to the clipboard (on WSL, the Windows clipboard). It adds
79
+ ` copied to the clipboard` only when `pbcopy`, `clip`, `clip.exe`, `wl-copy`, `xclip` or `xsel`
80
+ said it took the link; a copy through the terminal (OSC 52), which no terminal confirms, is tried
81
+ without a line. It needs no `pi-loops` on your PATH.
82
+
46
83
  Both windows are complete pi sessions: each conversation in the page is a `pi --mode rpc` that
47
84
  pi-loops runs for it, so the session file, your models, tools and extensions are the same either
48
85
  way. What the browser cannot do is pi's own built-in slash commands, which do not exist in that
@@ -219,16 +256,49 @@ by the id in its header.
219
256
 
220
257
  `pi install` puts this package under pi's managed directory rather than on your `PATH`, so the
221
258
  command that is meant to start your sessions is at first reachable only by absolute path. The
222
- extension does that step for you: the first pi session that finds no `pi-loops` anywhere on your
223
- `PATH` asks once whether to write one, and a no is remembered — nothing is written and you are not
224
- asked again. Afterwards it keeps that launcher current: if the copy it runs is not there any more
259
+ extension does that step for you, with no question: the first interactive pi session after
260
+ `pi install` writes `pi-loops` into the first of `~/.local/bin` and `/usr/local/bin` that is on your
261
+ `PATH`, and says so in one line:
262
+
263
+ ```text
264
+ [pi-loops] added pi-loops to ~/.local/bin; /pi-loops uninstall-launcher removes it
265
+ ```
266
+
267
+ It writes only where nothing named `pi-loops` is there yet. A link, even one that points nowhere, a
268
+ directory, or a file it cannot read counts as something there, and the file is created only if it
269
+ does not exist, so the write never goes through a link at that name. The directory itself may be a
270
+ link to a directory, as a dotfiles `~/.local/bin` often is. A `pi-loops` this project did not write,
271
+ such as your own wrapper, is left alone, and so is one that runs another copy of this package still
272
+ on disk, which is that copy's to keep. With neither directory on your `PATH`, or with
273
+ the one on your `PATH` not made yet, nothing is written and nothing is said: a session start never
274
+ makes a directory. When the write fails, for example because `/usr/local/bin` belongs
275
+ to root, one warning line says why, and no later session tries that directory again; a `PATH` that
276
+ names another directory is a new try. A checkout never writes a launcher that is not there.
277
+
278
+ Afterwards the extension keeps that launcher current: if the copy it runs is not there any more
225
279
  (pi installed the package by the other route), or that copy is the one now running and the node has
226
- changed, the next session rewrites it and says so in one line. A `pi-loops` this project did not
227
- write — your own wrapper — is left alone, and so is one that runs another copy of this package still
228
- on disk, which is that copy's to keep. A checkout is never asked to write a launcher that is not
229
- there, though one written from a checkout is still kept working.
280
+ changed, the next session rewrites it and says so in one line.
230
281
 
231
- To write one yourself, or after a no, run it from inside pi, where the extension is already loaded:
282
+ To remove it, and stop every later session from writing it again:
283
+
284
+ ```text
285
+ /pi-loops uninstall-launcher
286
+ ```
287
+
288
+ ```bash
289
+ pi-loops uninstall-launcher
290
+ pi-loops uninstall-launcher --dir ~/bin
291
+ ```
292
+
293
+ `--dir` needs a directory after it, here and on `install-launcher`: a `--dir` with nothing after it,
294
+ or with a flag after it, is a usage error (exit 2), not the default directory.
295
+
296
+ It removes only a launcher of ours that this copy keeps. For any other file named `pi-loops` it
297
+ says whose file it is, leaves it as it is, and exits 1. Either way it remembers the choice in
298
+ `ui.json`.
299
+
300
+ To write one yourself, after `uninstall-launcher` or into another directory, run it from inside pi,
301
+ where the extension is already loaded:
232
302
 
233
303
  ```text
234
304
  /pi-loops install-launcher
@@ -243,6 +313,8 @@ node src/cli-entry.mjs install-launcher
243
313
  node src/cli-entry.mjs install-launcher --dir ~/bin
244
314
  ```
245
315
 
316
+ `install-launcher` clears the choice `uninstall-launcher` remembered.
317
+
246
318
  It writes a small `sh` script that names the node you ran it with and the package it lives in — a
247
319
  launcher rather than a symlink. Neither path is trusted for ever: if the package is not where it
248
320
  was, the launcher looks in both of the directories above (the ones pi installs into, under the agent
@@ -350,7 +422,7 @@ spellings `pi-loops host status|abort|stop|start` and `/cron host` still work.
350
422
  $ pi-loops status
351
423
  pi-loops
352
424
  pid 2949521 on box, started 2026-09-09 13:33, model openai-codex/gpt-5.5
353
- pi-loops 0.24.0 · 1/1 loops, 0/0 rules enabled · inbox: 0 new
425
+ pi-loops 0.24.1 · 1/1 loops, 0/0 rules enabled · inbox: 0 new
354
426
  started by the shell that ran pi-loops
355
427
  conversation 424aba43-0c1f "fix the flaky login test" open in pid 939915 (tui)
356
428
  running nightly (run-97ffb250) since 2026-09-09 13:34: check the repo issues
@@ -8,7 +8,7 @@ Everything lives under `~/.pi/agent/loops/` (override: `PI_LOOPS_DIR`).
8
8
  | `logs/pi-<pid>.log` | what each pi process diagnosed: jobs disabled, writes that failed, sub-agent warnings. Rotated at 2 MB, newest 5 processes kept |
9
9
  | `mcp.toml` | MCP servers — see [mcp.md](mcp.md) |
10
10
  | `hooks.toml` | lifecycle hooks — see [hooks.md](hooks.md) |
11
- | `ui.json` | preferences that outlive a session: `panel` (written by `/cron panel on\|off`); `model` / `thinking` — the last ones you chose in the browser page, applied to the next session that does not say otherwise; `host` (`"stopped"` after a manual stop); `launcher` (your answer to the launcher offer); `heldJobsNotice` (the one-time notice about plain jobs of closed conversations); `lastPage` (the conversation `GET /` opens) |
11
+ | `ui.json` | preferences that outlive a session: `panel` (written by `/cron panel on\|off`); `model` / `thinking` — the last ones you chose in the browser page, applied to the next session that does not say otherwise; `host` (`"stopped"` after a manual stop); `launcher` (`"declined"` after `uninstall-launcher`, so the launcher is not written again; `"failed"`, with `launcherFailedIn`, the directory a write failed in, so it is retried only when that changes); `heldJobsNotice` (the one-time notice about plain jobs of closed conversations); `lastPage` (the conversation `GET /` opens) |
12
12
  | `jobs.json`, `triggers.json` | cron jobs and trigger rules (machine-global, each with a `cwd`) |
13
13
  | `jobs.json.bak` | the last `jobs.json` that read cleanly, kept so a damaged store can be restored |
14
14
  | `state/<id>.md` | loop notes; plain Markdown, editable |
package/docs/design.md CHANGED
@@ -236,11 +236,15 @@ machine. Everything here follows from that, and each one has a price.
236
236
  the copy it names is this one and the node or the shape of the script has changed. Either way
237
237
  one line says that it was rewritten and why. One naming another copy that is still there is that
238
238
  copy's to keep current, so an install and a checkout both being opened never trade the file back
239
- and forth. With no launcher anywhere, the question `install-launcher` already asks is asked once,
240
- and a no is remembered in `ui.json` and never asked again; `/pi-loops install-launcher` still
241
- writes one whenever you want it, and clears that mark. A `pi-loops` carrying no marker of ours
242
- is somebody's own wrapper and is left alone, and a checkout is never asked for one that is not
243
- there — nobody ran `pi install`. The decision is `launcherState` / `refreshLauncher` in
239
+ and forth. With no launcher anywhere, the first interactive session writes one into a directory
240
+ already on the PATH without asking, and says so in one line with the command that removes it,
241
+ because a confirm dialog and a second start cost more than the file does (0.24.1, #180).
242
+ `uninstall-launcher` removes only a launcher of ours and records `declined` in `ui.json`, so it
243
+ is not written again; `/pi-loops install-launcher` still writes one whenever you want it, and
244
+ clears that mark. A write that failed is recorded with its directory and not retried until the
245
+ target changes. A `pi-loops` carrying no marker of ours is somebody's own wrapper and is left
246
+ alone, and a checkout is never given one that is not there — nobody ran `pi install`. The
247
+ decision is `launcherAction`, `launcherState` and `refreshLauncher` in
244
248
  [../src/cli.ts](../src/cli.ts), beside the one function that renders the script, so it is
245
249
  testable without pi; the extension is the caller.
246
250
 
@@ -43,8 +43,10 @@ longer loaded.
43
43
 
44
44
  **The browser window says "This browser has not been here before."** It has no cookie for this
45
45
  address, which is normal for a new device, a new browser, a private window, or after clearing site
46
- data. Press **add device** in a browser that is already signed in and scan the QR, or type the six
47
- digits into the box on that page. The terminal that started the session printed a code too.
46
+ data. On this machine, run `pi-loops open` in a terminal (or `/pi-loops web` inside pi) for a link
47
+ that signs this browser in. For another device, run `pi-loops pair`, or press **add device** in a
48
+ browser that is already signed in and scan the QR, then type the six digits into the box on that
49
+ page.
48
50
 
49
51
  **The phone cannot reach it at all.** The server binds loopback by default, and a phone has no
50
52
  route to that. Either `tailscale serve --bg 4173` (the server stays on loopback; the tailnet does
@@ -969,6 +969,17 @@ weaker, and worth converting whenever one of them breaks.
969
969
  otherwise reloading a bookmark with a stale code on it burns the budget the phone needs.
970
970
  `test/web.test.ts`: *a signed-in browser reloading a stale pairing code does not spend a
971
971
  guess*.
972
+ - [x] The first browser on this machine gets in with one action. `pi-loops` prints one link with a
973
+ one-use key and opens the browser on it, or says it could not, so a paste of that link works
974
+ on the first try; on WSL the Windows browser is opened through `rundll32.exe`, then
975
+ `cmd.exe`, and "opened" is said only once a browser has spent the key. `pi-loops open` prints a fresh link, and the door names it.
976
+ `test/browser-open.test.ts`: *opened means the opener exited with a success code or is still
977
+ running*. `test/cli.test.ts`: *what pi-loops prints once the page has the conversation*,
978
+ *pi-loops open is a word of its own*. `test/web-page.test.ts`: *the door is a heading, one
979
+ line per way in, and the code field*.
980
+ - [x] `/pi-loops web` opens the conversation you are in from inside pi, with no `pi-loops` on your
981
+ PATH: the same link and opener, and the link copied to the clipboard. Checked by reading
982
+ (`src/pi-loops.ts` cannot be imported by a test).
972
983
  - [x] Pairing survives an upgrade: the token and the paired devices are files outside the package,
973
984
  so a device stays signed in across restarts and new versions. A browser paired before 0.24.0
974
985
  pairs once more, because its cookie held the token, which no cookie may hold now.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacoder-v0/pi-loops",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
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",
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Which command opens a browser here, and whether one did. The table copies pi's own opener (macOS
3
+ * `open`, Windows `rundll32`) and adds the WSL branch pi lacks: there `xdg-open` finds no browser and
4
+ * exits 3 after five seconds, so the Windows side is asked directly, with pi's Windows opener. "Opened" comes from the exit
5
+ * code, never from the spawn alone: a terminal that says it opened a browser when nothing opened
6
+ * leaves a person waiting for a window that never comes.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import * as os from "node:os";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ /**
14
+ * One command to try: what to run, from where, which exit codes mean the browser got it, and how
15
+ * long it may run before still running counts as opened.
16
+ */
17
+ export type Opener = { cmd: string; args: string[]; cwd?: string; okExit: number[]; waitMs: number };
18
+
19
+ /** For an opener that exits as soon as it has handed the address on. */
20
+ const QUICK_MS = 1_500;
21
+ /** xdg-open that finds no browser exits 3 after about five seconds. */
22
+ const XDG_OPEN_MS = 10_000;
23
+
24
+ /** What the choice depends on, passed in so a test can be any machine. */
25
+ export type OpenEnv = { platform: NodeJS.Platform; release: string; env: NodeJS.ProcessEnv };
26
+
27
+ /** ssh and mosh set at least one of these; opening a browser on the far end helps nobody. */
28
+ export function isRemoteTty(env: NodeJS.ProcessEnv = process.env): boolean {
29
+ return ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "MOSH_CONNECTION"].some((k) => !!env[k]);
30
+ }
31
+
32
+ function isWsl(e: Pick<OpenEnv, "platform" | "release" | "env">): boolean {
33
+ return e.platform === "linux" && (!!e.env.WSL_DISTRO_NAME || /microsoft/i.test(e.release));
34
+ }
35
+
36
+ /** The commands to try in order, or why none is tried. */
37
+ export function openerFor(e: OpenEnv & { target: string }): Opener[] | "no-display" | "remote" {
38
+ if (isRemoteTty(e.env)) return "remote";
39
+ const t = e.target;
40
+ if (e.platform === "darwin") return [{ cmd: "open", args: [t], okExit: [0], waitMs: QUICK_MS }];
41
+ // No shell: cmd would read `&` in the address as the end of the command.
42
+ if (e.platform === "win32") return [{ cmd: "rundll32", args: ["url.dll,FileProtocolHandler", t], okExit: [0], waitMs: QUICK_MS }];
43
+ // Both start from a Windows drive: a long \\wsl.localhost cwd breaks them. Not wslview: wslu 3.2.3
44
+ // reads a wsl.localhost address as a relative Linux path and exits 0 without opening anything. Not
45
+ // explorer.exe: it exits 1 whether or not anything opened. rundll32 exits 0 even for a missing
46
+ // file, so cmd.exe is reached only when rundll32.exe cannot start.
47
+ if (isWsl(e))
48
+ return [
49
+ { cmd: "rundll32.exe", args: ["url.dll,FileProtocolHandler", t], cwd: "/mnt/c", okExit: [0], waitMs: QUICK_MS },
50
+ // cmd re-reads its line: `^` escapes the characters it would act on.
51
+ { cmd: "cmd.exe", args: ["/c", "start", "", t.replace(/[\^&|]/g, "^$&")], cwd: "/mnt/c", okExit: [0], waitMs: QUICK_MS },
52
+ ];
53
+ if (!e.env.DISPLAY && !e.env.WAYLAND_DISPLAY) return "no-display";
54
+ return [{ cmd: "xdg-open", args: [t], okExit: [0], waitMs: XDG_OPEN_MS }];
55
+ }
56
+
57
+ /**
58
+ * The address the opener is given for the launch file, which holds the one-use key so no argv does.
59
+ * On WSL the browser is a Windows one, which reads the file through `\\wsl.localhost`: measured on
60
+ * WSL 2 with rundll32 and `cmd.exe /c start`, each loads the file and follows it to the page.
61
+ * Undefined on WSL without `WSL_DISTRO_NAME`, which that address needs, and for a path holding a
62
+ * character Windows does not allow in a name: `\\wsl.localhost` shows those under other names, so
63
+ * the browser cannot find the file while rundll32 still exits 0.
64
+ */
65
+ export function openFileTarget(file: string, e: Pick<OpenEnv, "platform" | "release" | "env">): string | undefined {
66
+ const href = pathToFileURL(file).href;
67
+ if (!isWsl(e)) return href;
68
+ const distro = e.env.WSL_DISTRO_NAME;
69
+ return distro && !/[\\:*?"<>|]/.test(file) ? `file://wsl.localhost/${encodeURIComponent(distro)}${href.slice("file://".length)}` : undefined;
70
+ }
71
+
72
+ /** This machine, as `openerFor` reads it. */
73
+ export function thisMachine(): OpenEnv {
74
+ return { platform: process.platform, release: os.release(), env: process.env };
75
+ }
76
+
77
+ /**
78
+ * Hand `target` to each opener in turn until one takes it: true when one exits with a code it
79
+ * counts as success, or is still running at its own deadline, which is how a browser started
80
+ * straight from the opener looks. `waitMs` replaces every deadline, for a test.
81
+ */
82
+ export async function openUrl(target: string, e: OpenEnv = thisMachine(), waitMs?: number): Promise<boolean> {
83
+ const openers = openerFor({ ...e, target });
84
+ if (typeof openers === "string") return false;
85
+ for (const opener of openers) if (await tryOpener(opener, e.env, waitMs ?? opener.waitMs)) return true;
86
+ return false;
87
+ }
88
+
89
+ function tryOpener(o: Opener, env: NodeJS.ProcessEnv, waitMs: number): Promise<boolean> {
90
+ return new Promise((resolve) => {
91
+ let child: ReturnType<typeof spawn>;
92
+ try {
93
+ child = spawn(o.cmd, o.args, { stdio: "ignore", detached: true, cwd: o.cwd, env });
94
+ } catch {
95
+ resolve(false); // the next opener, or the printed link
96
+ return;
97
+ }
98
+ const timer = setTimeout(() => {
99
+ child.unref();
100
+ resolve(true);
101
+ }, waitMs);
102
+ child.once("error", () => {
103
+ clearTimeout(timer);
104
+ resolve(false);
105
+ });
106
+ child.once("exit", (code) => {
107
+ clearTimeout(timer);
108
+ resolve(code !== null && o.okExit.includes(code));
109
+ });
110
+ });
111
+ }
112
+
113
+ /** One command that copies its stdin to the clipboard and exits 0 when it has. */
114
+ export type CopyCommand = { cmd: string; args: string[]; cwd?: string };
115
+
116
+ /**
117
+ * The clipboard commands to try in order, the ones pi's `copyToClipboard` runs. Not OSC 52, which
118
+ * pi also writes and which no terminal confirms: tmux in Windows Terminal drops it, so a terminal
119
+ * that said "copied" after it could be wrong. None over ssh or mosh, where this machine's clipboard
120
+ * is not the one in front of you.
121
+ */
122
+ export function copyCommandsFor(e: OpenEnv): CopyCommand[] {
123
+ if (isRemoteTty(e.env)) return [];
124
+ if (e.platform === "darwin") return [{ cmd: "pbcopy", args: [] }];
125
+ if (e.platform === "win32") return [{ cmd: "clip", args: [] }];
126
+ if (e.platform !== "linux") return [];
127
+ // The link is ASCII, so clip.exe reading it in the console code page changes nothing.
128
+ const windows = isWsl(e) ? [{ cmd: "clip.exe", args: [], cwd: "/mnt/c" }] : [];
129
+ return [
130
+ ...windows,
131
+ ...(e.env.WAYLAND_DISPLAY ? [{ cmd: "wl-copy", args: [] }] : []),
132
+ ...(e.env.DISPLAY ? [{ cmd: "xclip", args: ["-selection", "clipboard"] }, { cmd: "xsel", args: ["--clipboard", "--input"] }] : []),
133
+ ];
134
+ }
135
+
136
+ /** Copy `text` with the first command that takes it: true only when one exited 0. */
137
+ export async function copyText(text: string, e: OpenEnv = thisMachine()): Promise<boolean> {
138
+ for (const c of copyCommandsFor(e)) if (await runCopy(c, text, e.env)) return true;
139
+ return false;
140
+ }
141
+
142
+ const COPY_WAIT_MS = 5_000;
143
+
144
+ function runCopy(c: CopyCommand, text: string, env: NodeJS.ProcessEnv): Promise<boolean> {
145
+ return new Promise((resolve) => {
146
+ let child: ReturnType<typeof spawn>;
147
+ try {
148
+ // xclip and xsel stay behind to own the selection; "exit" is the command that answered.
149
+ child = spawn(c.cmd, c.args, { stdio: ["pipe", "ignore", "ignore"], cwd: c.cwd, env });
150
+ } catch {
151
+ resolve(false); // the next command
152
+ return;
153
+ }
154
+ const timer = setTimeout(() => {
155
+ child.kill();
156
+ resolve(false);
157
+ }, COPY_WAIT_MS);
158
+ child.once("error", () => {
159
+ clearTimeout(timer);
160
+ resolve(false);
161
+ });
162
+ child.once("exit", (code) => {
163
+ clearTimeout(timer);
164
+ resolve(code === 0);
165
+ });
166
+ child.stdin?.once("error", () => {}); // EPIPE from a command that exited first: its exit code decides
167
+ child.stdin?.end(text);
168
+ });
169
+ }