@alphacoder-v0/pi-loops 0.24.1 → 0.24.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 +83 -0
- package/README.md +2 -3
- package/docs/cli.md +9 -5
- package/docs/configuration.md +1 -1
- package/docs/design.md +3 -2
- package/docs/triggers.md +11 -7
- package/docs/web-ui-parity.md +7 -6
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -0
- package/src/activity.ts +4 -3
- package/src/cli.ts +28 -19
- package/src/engine.ts +4 -3
- package/src/host-control-channel.ts +2 -1
- package/src/host-control.ts +16 -1
- package/src/host-runtime.ts +11 -11
- package/src/inbox-card.ts +2 -2
- package/src/inbox.ts +7 -0
- package/src/job-lines.ts +1 -5
- package/src/job-signal.ts +2 -1
- package/src/mcp.ts +2 -0
- package/src/page-children.ts +3 -58
- package/src/page-server.ts +71 -24
- package/src/path-precheck.ts +2 -0
- package/src/paths.ts +15 -2
- package/src/pi-loops.ts +8 -7
- package/src/plural.ts +4 -0
- package/src/run-card.ts +3 -2
- package/src/schedule.ts +20 -0
- package/src/seen.ts +1 -1
- package/src/share.ts +1 -1
- package/src/store.ts +11 -2
- package/src/tools.ts +18 -19
- package/src/triggers.ts +2 -3
- package/src/trust.ts +1 -11
- package/src/web.mjs +68 -37
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,89 @@ All notable changes to pi-loops are documented here. The format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.24.2] - 2026-11-11
|
|
9
|
+
|
|
10
|
+
Fixes from the 0.24.2 milestone and a review of what merged since 0.24.1: a path rule that never
|
|
11
|
+
fired, faster `pi-loops` commands, page edges that showed what nobody could use, and fewer tools
|
|
12
|
+
offered to a sub-agent that could only fail.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **A path rule with an aside in parentheses is checked by the model.** The path precheck dropped
|
|
17
|
+
a trailing parenthetical, so `/tmp/a exists (or /tmp/b exists)` waited on `/tmp/a` alone and never
|
|
18
|
+
fired when only `/tmp/b` appeared. No word list can tell such an aside from a harmless one, so a
|
|
19
|
+
condition with one is model-checked again, and `new_trigger` asks the model to keep the person's
|
|
20
|
+
words with nothing added.
|
|
21
|
+
- **Every `pi-loops` command answers in a fraction of a second.** The command line loaded pi's SDK
|
|
22
|
+
on every call, about a second of each `status`, `stop` and `--help`; it now loads it only for
|
|
23
|
+
`import`, the one command that needs it. `pi-loops --version` went from about 1.2 s to 0.17 s here
|
|
24
|
+
(#168).
|
|
25
|
+
- **The page's inbox matches each project once per request.** `GET /inbox` resolved a finding's
|
|
26
|
+
directory on disk for every finding, in three passes; 2000 findings took about 24 ms a pass.
|
|
27
|
+
Each distinct directory is now resolved once per request (#169).
|
|
28
|
+
- **Logs name a conversation the way every screen does.** pi-loops' log lines cut a session id to
|
|
29
|
+
8 characters while the page, the terminal and the CLI show 13, so an id copied from the page was
|
|
30
|
+
not found in the log (#177).
|
|
31
|
+
- **Starting or resuming a conversation in the page says why, once.** The new session's events
|
|
32
|
+
arrived before the page moved to its id, so the page read the old id's history (which the server
|
|
33
|
+
opened in a pi of its own) and reloaded again on each event after, ending on "the session
|
|
34
|
+
restarted". The page now waits for the answer, moves to the id it names, and reloads once (#198).
|
|
35
|
+
- **A stopped MCP connection pushes nothing more.** A push read from the server just before the
|
|
36
|
+
connection stopped could still reach the host after it, and its trust check then read a project
|
|
37
|
+
directory that might be gone (#188).
|
|
38
|
+
- **An upgrade no longer holds a quit, or the page opening, in silence.** `pi-loops` opening the
|
|
39
|
+
page during an upgrade waited up to 180 s for the older process with nothing said; it now prints
|
|
40
|
+
`waiting up to 2 min for a turn in the browser page to finish…` when the wait passes 2 s. A pi
|
|
41
|
+
window that is quitting asks for the upgrade and leaves at once (#165).
|
|
42
|
+
- **Two page edges no longer show something nobody can use.** An unknown conversation address
|
|
43
|
+
answers with a page and a link back, not JSON. On a page only this machine can reach, Add a
|
|
44
|
+
device no longer shows a pairing code: it says how to make the page reachable, and retires the
|
|
45
|
+
code the server made (#157).
|
|
46
|
+
- **A conversation opened in the page loads pi-loops when the launch named its entry file.** A new
|
|
47
|
+
conversation in a project with no arguments of its own takes the `-e` that loads pi-loops from
|
|
48
|
+
another page pi. `-e <package>/src/extension-entry.ts` and `--extension=<path>` were not
|
|
49
|
+
recognised, so that conversation started without pi-loops (#167).
|
|
50
|
+
- **The page token is made once, even by two first starts at once.** Two windows that started
|
|
51
|
+
pi-loops together could each write a token, and one then exited. The token and its marker are
|
|
52
|
+
now written under a lock. A `web-token.v024` that is a symlink is refused before the token is
|
|
53
|
+
touched; before, it changed the token on every start and then failed (#144).
|
|
54
|
+
- **A loop's finding can no longer pass for an undelivered prompt.** A prompt that never reached its
|
|
55
|
+
conversation was told from a loop's finding by its text alone, so a loop that wrote
|
|
56
|
+
`could not be delivered (…): <text>` had its text carried out on Yes. The engine now marks a dead
|
|
57
|
+
letter with `"undelivered": true`, and only that field changes what a claim asks (#143).
|
|
58
|
+
- **A `$HOME` reached through a symlink is never one project.** Before, a session started in such a
|
|
59
|
+
`$HOME` lent its trust to loop runs in every project under it, and the create tools accepted any
|
|
60
|
+
directory under it as the session's own (#142).
|
|
61
|
+
- **Watch for… knows more time words.** "review deps quarterly", "check CI this afternoon",
|
|
62
|
+
"at nine", "in a few minutes", "next week", "biweekly" and "今晚" were told they read like
|
|
63
|
+
something to do now, and nothing was set up. They are now watches. A word that only starts like a
|
|
64
|
+
weekday or a time word ("montage", "sunset", "everyone") no longer reads as one (#146).
|
|
65
|
+
- **`pi-loops --tui --loops-dir <dir>` uses that directory.** The terminal's extension now gets it
|
|
66
|
+
as `PI_LOOPS_DIR`; before, it used the default. `--open` goes to pi like any flag pi-loops does
|
|
67
|
+
not read, and `--port`, `--host` and `--allow-host` with `--tui` are dropped, not passed to pi as
|
|
68
|
+
the 0.24.0 notes said (#145).
|
|
69
|
+
- **`pi-loops --version` prints the pi-loops version,** not pi's. `pi-loops --tui -- --version` asks pi
|
|
70
|
+
(#148).
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- **The Loops pane says each thing once.** A named loop shows no prompt peek (the body has the
|
|
75
|
+
prompt), a failing loop no `quiet ×N` under its error, and the open row no id or cwd. Common cron
|
|
76
|
+
schedules read in words (`daily 09:00`, `weekdays 18:30`, `weekly Mon 09:00`, `every hour`). A
|
|
77
|
+
rule row is titled by its condition. The activity strip's "in chat" follows where a run's card
|
|
78
|
+
went: the conversation that asked for the run, not the one that made its loop (#154).
|
|
79
|
+
- **The page header and composer say less.** The header's clear button did what + New does in
|
|
80
|
+
this project, so it is gone (`/clear` and `/new` still work). The page says "conversation" where
|
|
81
|
+
it said "session". The cost badge appears once there is a cost. The composer says "Message pi".
|
|
82
|
+
The theme button's name is its visible state (#156).
|
|
83
|
+
- **`cron_remove` asks once.** It had a `confirm` parameter: the model asked the person in chat,
|
|
84
|
+
then the dialog asked again. The parameter is gone, and one call asks through the dialog.
|
|
85
|
+
- **A sub-agent is not offered `new_trigger`, `remove_trigger` or `cron_remove`.** Every call of
|
|
86
|
+
each was denied at hop 1, so the tools only invited a failed first try (#159, #212).
|
|
87
|
+
- **The agent is told more of what it can use.** `cron_create`'s schedule names `hourly`, `daily`
|
|
88
|
+
(09:00), `weekly` (Monday 09:00) and that `@daily` is midnight. SKILL.md names the inbox
|
|
89
|
+
commands a shell can run. The `catch_up` and `spec` descriptions are shorter (#160).
|
|
90
|
+
|
|
8
91
|
## [0.24.1] - 2026-11-10
|
|
9
92
|
|
|
10
93
|
Two first-run fixes: the `pi-loops` command exists after the first pi session with no question, and
|
package/README.md
CHANGED
|
@@ -345,9 +345,8 @@ unambiguous prefix of it, or the `--name` you gave it — and `/crontab` and `/l
|
|
|
345
345
|
|
|
346
346
|
Tools for the model: `cron_create`, `cron_list`, `cron_remove`, `set_cron_job_state`,
|
|
347
347
|
`new_trigger`, `list_triggers`, `remove_trigger`, `set_trigger_state`, plus every tool of every
|
|
348
|
-
configured MCP server. Five of them stop and ask you: creating or removing a trigger,
|
|
349
|
-
cron job
|
|
350
|
-
and any state change that turns automation *on*, or touches another project's. They are the
|
|
348
|
+
configured MCP server. Five of them stop and ask you, once each: creating or removing a trigger,
|
|
349
|
+
removing a cron job, and any state change that turns automation *on*, or touches another project's. They are the
|
|
351
350
|
operations that decide what runs while nobody is watching.
|
|
352
351
|
|
|
353
352
|
## Documentation
|
package/docs/cli.md
CHANGED
|
@@ -355,7 +355,7 @@ The rest of the command line does not need a pi session and has no build step; i
|
|
|
355
355
|
packages the way pi-loops does. `PI_LOOPS_DIR` selects the loops directory, as everywhere.
|
|
356
356
|
|
|
357
357
|
```
|
|
358
|
-
pi-loops sessions [--all] [--limit <n>]
|
|
358
|
+
pi-loops sessions [--all] [--limit <n>] [--cwd <dir>]
|
|
359
359
|
pi-loops inspect <file>
|
|
360
360
|
pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]
|
|
361
361
|
pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
|
|
@@ -365,7 +365,9 @@ pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason
|
|
|
365
365
|
```
|
|
366
366
|
|
|
367
367
|
`-h` or `--help` anywhere in a subcommand's arguments before a `--` prints that subcommand's usage
|
|
368
|
-
and exits 0 without doing anything else; `pi-loops -h`
|
|
368
|
+
and exits 0 without doing anything else; `pi-loops -h`, `pi-loops help` and `pi-loops --help` are the same. After `--` they are pi's.
|
|
369
|
+
`pi-loops --version` (or `-v`) alone prints the pi-loops version, such as `pi-loops v0.24.2`;
|
|
370
|
+
`pi-loops --tui -- --version` prints pi's.
|
|
369
371
|
A command typed wrong, such as a missing archive path, a flag the command does not read
|
|
370
372
|
(`status does not take --json`) or a word it does not read (`export: unexpected argument "out.pisession"`),
|
|
371
373
|
prints `pi-loops: <what is wrong>` and that command's usage, and exits 2. Any other error prints only its line and exits 1. An unknown command
|
|
@@ -378,7 +380,7 @@ in the session's control-plane audit.
|
|
|
378
380
|
|
|
379
381
|
## sessions, inspect
|
|
380
382
|
|
|
381
|
-
`sessions` lists this project's sessions newest first, one line each: the first sixteen
|
|
383
|
+
`sessions` lists this project's sessions (`--cwd <dir>` names another project) newest first, one line each: the first sixteen
|
|
382
384
|
characters of the id, when the session started, the automation it
|
|
383
385
|
has (`[2 cron, 1 trigger]`, or `[automation off]` when all of it is disabled), and what was first
|
|
384
386
|
said in it — the session's name if it was given one, otherwise the first message, cut at eighty
|
|
@@ -422,7 +424,7 @@ spellings `pi-loops host status|abort|stop|start` and `/cron host` still work.
|
|
|
422
424
|
$ pi-loops status
|
|
423
425
|
pi-loops
|
|
424
426
|
pid 2949521 on box, started 2026-09-09 13:33, model openai-codex/gpt-5.5
|
|
425
|
-
pi-loops 0.24.
|
|
427
|
+
pi-loops 0.24.2 · 1/1 loops, 0/0 rules enabled · inbox: 0 new
|
|
426
428
|
started by the shell that ran pi-loops
|
|
427
429
|
conversation 424aba43-0c1f "fix the flaky login test" open in pid 939915 (tui)
|
|
428
430
|
running nightly (run-97ffb250) since 2026-09-09 13:34: check the repo issues
|
|
@@ -447,7 +449,9 @@ start` starts it now, even with nothing to run yet. `pi-loops restart` stops it,
|
|
|
447
449
|
process has exited (SIGKILL after 180 seconds), and starts it. A restart, and an upgrade that a pi
|
|
448
450
|
window with a newer pi-loops asks for, first wait up to two minutes for a turn in the browser page to
|
|
449
451
|
finish, so a conversation open there is not cut off mid-answer; a restart first prints `waiting up
|
|
450
|
-
to 2 min for a turn in the browser page to finish
|
|
452
|
+
to 2 min for a turn in the browser page to finish…`, and `pi-loops` opening the page prints the same
|
|
453
|
+
line when the older process has not left after 2 seconds. A pi window that is quitting asks for the
|
|
454
|
+
upgrade and does not wait: the next window or `pi-loops` starts the new version. A plain `pi-loops stop` ends it at once, and one
|
|
451
455
|
run during that wait ends the wait. A restart or an upgrade asked for while it already waits is
|
|
452
456
|
answered `already leaving` and does not start the wait again. With `--help`, each of these prints its
|
|
453
457
|
usage and does nothing else.
|
package/docs/configuration.md
CHANGED
|
@@ -30,7 +30,7 @@ Everything lives under `~/.pi/agent/loops/` (override: `PI_LOOPS_DIR`).
|
|
|
30
30
|
| `*.lock` | short-lived locks around writes |
|
|
31
31
|
| `polls.json` | last dynamic check per project (shared with 0.22 windows during an upgrade, so a project is not checked twice in one interval) |
|
|
32
32
|
| `dedup.json` | machine-wide trigger dedup window (5 minutes) |
|
|
33
|
-
| `web-token` | the browser page's token, mode 0600. It lives in a file rather than being made per start so the address stays the same one and a signed-in device stays signed in across restarts and upgrades. Delete it and run `pi-loops restart` to sign every device out. The first 0.24 start replaces a token from an earlier version once, and writes `web-token.v024` beside it to record that; a token you write into `web-token` after that is kept |
|
|
33
|
+
| `web-token` | the browser page's token, mode 0600. It lives in a file rather than being made per start so the address stays the same one and a signed-in device stays signed in across restarts and upgrades. Delete it and run `pi-loops restart` to sign every device out. The first 0.24 start replaces a token from an earlier version once, and writes `web-token.v024` beside it to record that; a token you write into `web-token` after that is kept. A `web-token.v024` that is not a regular file (a symlink, even a dangling one) stops pi-loops from starting, as a `web-token` symlink does |
|
|
34
34
|
|
|
35
35
|
Project-level: `<project>/.pi/mcp.toml` (trusted projects only) and `<project>/.pi/hooks.toml`
|
|
36
36
|
(when allowed).
|
package/docs/design.md
CHANGED
|
@@ -153,8 +153,9 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
153
153
|
three is recorded in the control-plane audit, the self-removals included.
|
|
154
154
|
|
|
155
155
|
12. **Cycle suppression by hop count.** Sub-agents run at hop 1 and keep the cron and trigger tools
|
|
156
|
-
(`cron_create`,
|
|
157
|
-
|
|
156
|
+
(`cron_create`, listing, disabling), so a loop can manage automation; the tools whose every
|
|
157
|
+
call needs a human's yes are not offered there, the other operations that need one are
|
|
158
|
+
denied, and sub-sessions never run the
|
|
158
159
|
trigger runtime, so nothing nests.
|
|
159
160
|
|
|
160
161
|
13. **Stdio MCP servers reconnect** with backoff (20 attempts by default), each distinct error
|
package/docs/triggers.md
CHANGED
|
@@ -54,6 +54,8 @@ noun only when a space sets it apart from the path: `~/备份文件 存在` wait
|
|
|
54
54
|
`once`, `当`, `如果` or `一旦` and an optional trailing `的时候`, `时` or `后`. `~` and `$HOME` are
|
|
55
55
|
resolved in the process that creates the rule, so they are your home even when pi-loops in the
|
|
56
56
|
background was started with another one; a relative path is resolved against the rule's project.
|
|
57
|
+
An aside in parentheses is a second clause: `/tmp/a exists (or /tmp/b exists)` is true while
|
|
58
|
+
`/tmp/a` is absent, so a condition with one is checked by the model.
|
|
57
59
|
|
|
58
60
|
Anything else is refused as path-only and checked by the model, as before: a glob, a second clause
|
|
59
61
|
(`~/x appears and it contains ERROR`, `~/a exists or ~/b exists`), another verb, a bare word with no
|
|
@@ -138,7 +140,7 @@ that instant (someone wrote it in the last two minutes), the row waits and pi-lo
|
|
|
138
140
|
later tick. A row whose delivery fails waits a minute, then five, and blocks nothing behind it; at
|
|
139
141
|
the third failure, or when the conversation's file is gone, it is a dead letter: a finding in the
|
|
140
142
|
inbox marked for the conversation, then a `request` audit row (`dead`), then the row is removed, in
|
|
141
|
-
that order so a crash in between loses nothing. A rule's result has its finding already (every match is one), so its dead letter files no second one; any other row's finding reads `could not be delivered (<why>): <prompt
|
|
143
|
+
that order so a crash in between loses nothing. A rule's result has its finding already (every match is one), so its dead letter files no second one; any other row's finding reads `could not be delivered (<why>): <prompt>` and carries `"undelivered": true`. Only that field makes a claim carry the prompt out; a loop's finding in the same words is claimed as a finding. A rule with no
|
|
142
144
|
creating conversation (made from a shell, imported, or made by a loop's run) sends results to the
|
|
143
145
|
project's open window as above, and to the inbox (`redirected`, which finds the match's finding and adds nothing) when none is open. One check evaluates every
|
|
144
146
|
rule of the project at once, so it runs under **one** model: the first rule that has one recorded
|
|
@@ -207,12 +209,14 @@ poll interval is unaffected: the next scheduled check happens when it would have
|
|
|
207
209
|
|
|
208
210
|
## Cycle safety
|
|
209
211
|
|
|
210
|
-
Cycles are bounded by a hop count: sub-agents run at hop 1 and still have
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
Cycles are bounded by a hop count: sub-agents run at hop 1 and still have
|
|
213
|
+
`cron_create`, `cron_list`, `set_cron_job_state`, `list_triggers` and `set_trigger_state`, so a
|
|
214
|
+
trigger action can schedule a job; they never run the trigger runtime, so nothing nests.
|
|
215
|
+
`new_trigger`, `remove_trigger` and `cron_remove` are not offered at hop 1: every call of each
|
|
216
|
+
needs a person, and a sub-agent has nobody to ask. The other
|
|
217
|
+
prompt-class operations — re-enabling a trigger or a cron job — are denied fail-closed in
|
|
218
|
+
sub-agents (no control-plane prompt channel there). `cron_create` works there, and so does disabling a job of the sub-agent's own
|
|
219
|
+
project; the control-plane audit records `actor: sub-agent`. Re-enabling a job and
|
|
216
220
|
touching another project's job are denied there like the other prompt-class operations. Sub-agents never handle triggers themselves: they ignore MCP pushes, and the
|
|
217
221
|
runtime audits anything reaching hop ≥ 1 as `cycle_suppressed` (hops are counted per trace, but the
|
|
218
222
|
bound that matters is structural: a sub-agent session never acts on a trigger at all).
|
package/docs/web-ui-parity.md
CHANGED
|
@@ -327,7 +327,8 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
327
327
|
**Waiting on** and **If you don't** when the loop said them, then news. A card names its loop
|
|
328
328
|
only when the list holds more than one loop's findings, and says where a claim runs only when
|
|
329
329
|
that is not the conversation on screen (another conversation's or project's finding, or a
|
|
330
|
-
terminal that takes no messages). **See the run** is there only when the finding has a run
|
|
330
|
+
terminal that takes no messages). **See the run** is there only when the finding has a run this page can read
|
|
331
|
+
(`test/page-server.test.ts`: *See the run is offered only for a transcript the server would read*).
|
|
331
332
|
Every card carries its finding's whole id; no button names a position. `test/web-page.test.ts`:
|
|
332
333
|
*the inbox shows decisions before news, each row only with a value, and where a claim runs
|
|
333
334
|
only when that is not here*. `test/page-server.test.ts`: *GET /inbox: one project's cards, decisions first,
|
|
@@ -502,10 +503,10 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
502
503
|
open this on a phone. Wide, it is a column beside the conversation, which the rail's Loops
|
|
503
504
|
button gives back to the conversation, remembered; up to 1100 px it is a drawer from that
|
|
504
505
|
button; on a phone (760 px and less) it is the Loops tab.
|
|
505
|
-
- [x]
|
|
506
|
+
- [x] Twelve controls do not fit across a narrow conversation. Below 1000 px of conversation,
|
|
506
507
|
whatever is beside it, the ones you reach for mid-conversation (undo, find, compact and resume)
|
|
507
508
|
stay in the header on one row with ⋯, and the header stays two rows at a 1280 or 1440 px
|
|
508
|
-
window; reload, save, share,
|
|
509
|
+
window; reload, save, share, add device, theme and notifications go behind ⋯. The
|
|
509
510
|
sheet lists all of them, moved — not copied — into it. On a phone (760 px and less) every one is behind ⋯.
|
|
510
511
|
`test/web-page.test.ts`: *the header's secondary actions move into a sheet and back*, *below
|
|
511
512
|
1000 px of conversation, undo, find, compact and resume stay in the header and the rest go
|
|
@@ -591,8 +592,8 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
591
592
|
first; it replaces `new_trigger`'s confirm, so there is one question, never two. A scheduled
|
|
592
593
|
turn, a sub-agent and pi-loops in the background ask nobody. `test/scoping.test.ts`: *the answer
|
|
593
594
|
is promote_to_chat and fire_once for a rule, and stateful and verify for a schedule*, *a
|
|
594
|
-
sub-agent
|
|
595
|
-
host's confirm once and cron_create creates*; J8 in `test/journey/journeys/j08-watch.mjs`
|
|
595
|
+
sub-agent has no new_trigger; a scheduled turn and a pi with no dialog keep today's gate:
|
|
596
|
+
new_trigger asks the host's confirm once and cron_create creates*; J8 in `test/journey/journeys/j08-watch.mjs`
|
|
596
597
|
drives the selector in tmux.
|
|
597
598
|
- [x] At 390 px the box and the card fit: nothing scrolls sideways, the chips and buttons wrap, and
|
|
598
599
|
every button is at least 32 px tall (J8 in `test/journey/journeys/j08-watch.mjs`).
|
|
@@ -742,7 +743,7 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
742
743
|
whether pi-loops runs*.
|
|
743
744
|
- [x] **Starting over does not mean going back to a terminal.** The context is finished with far
|
|
744
745
|
more often than the window is: a new session and going back to an earlier one are both here,
|
|
745
|
-
by button (the list's + New
|
|
746
|
+
by button (the list's + New in this project) and by `/clear`, `/new` and `/resume` typed
|
|
746
747
|
in the composer — the habit comes from a
|
|
747
748
|
terminal and the muscle memory arrives with it. Neither deletes anything: the session being
|
|
748
749
|
left is a file that `resume` lists, labelled by what was said in it rather than by a filename.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alphacoder-v0/pi-loops",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.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/skills/pi-loops/SKILL.md
CHANGED
|
@@ -46,6 +46,8 @@ reported in your notes; report only new or newly closed issues". Findings should
|
|
|
46
46
|
(a reason is shown to the loop's next run, so a finding the user calls noise can say why).
|
|
47
47
|
`/cron` lists jobs, `/cron runs`, `/cron trace <job> [k]` shows what a run did. `/triggers rules`,
|
|
48
48
|
`/triggers audit` for dynamic triggers.
|
|
49
|
+
From a shell: `pi-loops inbox list --json`, `pi-loops inbox claim <id> --json`,
|
|
50
|
+
`pi-loops inbox dismiss <id> --reason <text> --json`.
|
|
49
51
|
|
|
50
52
|
## Confirmations
|
|
51
53
|
|
package/src/activity.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { InboxEntry } from "./inbox.ts";
|
|
|
8
8
|
import { ruleResultText, splitFinding } from "./inbox-card.ts";
|
|
9
9
|
import { failingJobs } from "./job-health.ts";
|
|
10
10
|
import { redact } from "./redact.ts";
|
|
11
|
-
import { runCardFor } from "./run-card.ts";
|
|
11
|
+
import { runCardFor, runCardsHere } from "./run-card.ts";
|
|
12
12
|
import type { LoopJob, RunRecord } from "./store.ts";
|
|
13
13
|
import type { AuditRecord } from "./triggers.ts";
|
|
14
14
|
|
|
@@ -45,7 +45,7 @@ export interface ActivityItem {
|
|
|
45
45
|
carded: boolean;
|
|
46
46
|
/** A rule check: its findings, so a row can point at the card. */
|
|
47
47
|
inboxIds: string[];
|
|
48
|
-
/** A rule check: the result also went to a conversation (`promote_to_chat`). */
|
|
48
|
+
/** A rule check: the result also went to a conversation (`promote_to_chat`). A run: its card is in this conversation. */
|
|
49
49
|
inChat: boolean;
|
|
50
50
|
/** The run's loop or the rule was made in the conversation asking. */
|
|
51
51
|
fromThisConversation: boolean;
|
|
@@ -123,7 +123,8 @@ export function activityFrom(input: ActivityInput): { items: ActivityItem[]; mor
|
|
|
123
123
|
...(!r.ok && r.error ? { error: clip(r.error) } : {}),
|
|
124
124
|
carded: runCardFor(r) === "card",
|
|
125
125
|
inboxIds: [],
|
|
126
|
-
|
|
126
|
+
// Where its card is: a run someone asked for cards where they asked, not where its loop was made.
|
|
127
|
+
inChat: runCardFor(r) === "card" && runCardsHere([r], { sessionId: input.sessionId, inProject: here }).length > 0,
|
|
127
128
|
fromThisConversation: !!input.sessionId && r.forSession === input.sessionId,
|
|
128
129
|
});
|
|
129
130
|
}
|
package/src/cli.ts
CHANGED
|
@@ -9,14 +9,13 @@
|
|
|
9
9
|
* pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
12
|
-
import { plural } from "./
|
|
12
|
+
import { plural } from "./plural.ts";
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import * as net from "node:net";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import * as readline from "node:readline";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
20
19
|
import { isRemoteTty, openFileTarget, openUrl, thisMachine } from "./browser-open.ts";
|
|
21
20
|
import { applyImport, automationOf, exportSession, defaultExportPath, importSession, inspectArchive, offerToEnable, parseActivateMode } from "./archive.ts";
|
|
22
21
|
import { API_KEY_REFUSAL, CONTROL_TIMEOUT_MS, COPY_KEY_MS, askHost, hostSocketPath, openConversations, renderHostSnapshot, requestLines, requestQueues, ringHost } from "./host-control-channel.ts";
|
|
@@ -31,13 +30,13 @@ import { crashPaused, crashPauseText, ensureHost, HOST_LOG, hostCommand, hostSer
|
|
|
31
30
|
import { loadConfig, pageBindPort } from "./config.ts";
|
|
32
31
|
import { writeUiPref } from "./ui-prefs.ts";
|
|
33
32
|
import { type UiPrefs, clearUiPref, readUiPrefs } from "./ui-prefs.ts";
|
|
34
|
-
import { JobStore, defaultLoopsDir, homeLoopsDir } from "./store.ts";
|
|
33
|
+
import { JobStore, defaultLoopsDir, homeLoopsDir, piAgentDir } from "./store.ts";
|
|
35
34
|
import { automationBadge, previewText, readSessionHead, shortSessionId } from "./session-head.ts";
|
|
36
35
|
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
|
|
37
36
|
import { TriggerStore } from "./triggers.ts";
|
|
38
37
|
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
39
38
|
import { formatLocal, stamp } from "./schedule.ts";
|
|
40
|
-
import { canonicalDir } from "./
|
|
39
|
+
import { canonicalDir } from "./paths.ts";
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
42
|
* Which front end `pi-loops` opens when you do not say. A browser is the better window when one is
|
|
@@ -73,7 +72,7 @@ export function noTerminalRefusal(input: { web: boolean; tui: boolean; interacti
|
|
|
73
72
|
* `allow-host` and `no-auth` are claimed only to be refused: the page outlives the command now, so
|
|
74
73
|
* its address lives in config.toml.
|
|
75
74
|
*/
|
|
76
|
-
const LAUNCH_FLAGS = new Set(["web", "tui", "port", "host", "allow-host", "
|
|
75
|
+
const LAUNCH_FLAGS = new Set(["web", "tui", "port", "host", "allow-host", "no-open", "no-auth", "loops-dir", "help"]);
|
|
77
76
|
/** The ones that take the next argument as their value. */
|
|
78
77
|
const LAUNCH_FLAGS_WITH_VALUE = new Set(["port", "host", "allow-host", "loops-dir"]);
|
|
79
78
|
|
|
@@ -128,7 +127,7 @@ const CLI_USAGE = [
|
|
|
128
127
|
"pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]",
|
|
129
128
|
" Restore an archive into a project.",
|
|
130
129
|
"",
|
|
131
|
-
"pi-loops sessions [--all] [--limit <n>]",
|
|
130
|
+
"pi-loops sessions [--all] [--limit <n>] [--cwd <dir>]",
|
|
132
131
|
" List sessions, newest first.",
|
|
133
132
|
"",
|
|
134
133
|
"pi-loops inspect <file>",
|
|
@@ -287,6 +286,10 @@ function runChild(command: string, args: string[], env?: NodeJS.ProcessEnv): Pro
|
|
|
287
286
|
export async function runCli(argv: string[], out: (line: string) => void = console.log): Promise<number> {
|
|
288
287
|
const route = cliRoute(argv);
|
|
289
288
|
if (route === "launch") return launch(argv, out);
|
|
289
|
+
if (route === "version") {
|
|
290
|
+
out(`pi-loops v${PI_LOOPS_VERSION}`);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
290
293
|
if (route === "help") {
|
|
291
294
|
out(usageFor(argv[0]));
|
|
292
295
|
return 0;
|
|
@@ -299,7 +302,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
299
302
|
const v = flags.get(name);
|
|
300
303
|
return typeof v === "string" ? v : undefined;
|
|
301
304
|
};
|
|
302
|
-
const agentDir =
|
|
305
|
+
const agentDir = piAgentDir();
|
|
303
306
|
const loopsDir = process.env.PI_LOOPS_DIR || defaultLoopsDir(agentDir);
|
|
304
307
|
const cwd = path.resolve(str("cwd") ?? process.cwd());
|
|
305
308
|
|
|
@@ -383,6 +386,8 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
383
386
|
const triggerStore = new TriggerStore(loopsDir);
|
|
384
387
|
// pi encodes a project as `--home-u-proj--` and `SessionManager.list()` reads only that one
|
|
385
388
|
// directory, with no fallback — a hand-rolled name here would restore a session pi never sees.
|
|
389
|
+
// Loaded here, not at the top: pi's SDK takes about a second, and only this verb needs it.
|
|
390
|
+
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
386
391
|
const sessionDir = SessionManager.create(cwd).getSessionDir();
|
|
387
392
|
const existingJobs = jobStore.load();
|
|
388
393
|
const existingRules = triggerStore.load();
|
|
@@ -751,7 +756,7 @@ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise
|
|
|
751
756
|
out("already up to date");
|
|
752
757
|
return 0;
|
|
753
758
|
}
|
|
754
|
-
const spec = upgradeSpec(packageRoot(), name ?? "",
|
|
759
|
+
const spec = upgradeSpec(packageRoot(), name ?? "", piAgentDir(), latest, configuredNpmSource(packageRoot(), name ?? ""));
|
|
755
760
|
if (!spec) {
|
|
756
761
|
// Nothing failed, so this is not an error: the newest release is out and the way to it is a
|
|
757
762
|
// pull, not an install that would leave a second copy beside the checkout.
|
|
@@ -779,11 +784,14 @@ async function upgrade(checkOnly: boolean, out: (line: string) => void): Promise
|
|
|
779
784
|
* `-h` or `--help` anywhere before a `--` asks how a subcommand works, and asking must not run it:
|
|
780
785
|
* `install-launcher --help` wrote the launcher and `upgrade --help` ran the upgrade. After `--`
|
|
781
786
|
* they are pi's.
|
|
787
|
+
*
|
|
788
|
+
* `--version` alone names this pi-loops; `pi-loops --tui -- --version` still asks pi.
|
|
782
789
|
*/
|
|
783
|
-
export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" {
|
|
790
|
+
export function cliRoute(argv: string[]): "launch" | "subcommand" | "help" | "version" {
|
|
784
791
|
const first = argv[0];
|
|
785
792
|
if (first === undefined) return "launch";
|
|
786
793
|
if (first === "--help" || first === "-h" || first === "help") return "help";
|
|
794
|
+
if (argv.length === 1 && (first === "--version" || first === "-v")) return "version";
|
|
787
795
|
if (first.startsWith("-")) return "launch";
|
|
788
796
|
const dashdash = argv.indexOf("--");
|
|
789
797
|
const before = dashdash === -1 ? argv.slice(1) : argv.slice(1, dashdash);
|
|
@@ -898,7 +906,7 @@ function launcherDirFlag(flags: ReadonlyMap<string, string | true>): string | un
|
|
|
898
906
|
* says whose it is. With nothing there, the state asked for is already the state on disk.
|
|
899
907
|
*/
|
|
900
908
|
export function uninstallLauncher(dir: string | undefined, out: (line: string) => void): number {
|
|
901
|
-
writeUiPref(defaultLoopsDir(
|
|
909
|
+
writeUiPref(defaultLoopsDir(piAgentDir()), "launcher", "declined");
|
|
902
910
|
const target = launcherTarget(dir);
|
|
903
911
|
const again = "no pi session writes it again; `/pi-loops install-launcher` in pi puts it back";
|
|
904
912
|
if (!target) {
|
|
@@ -980,8 +988,8 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
|
|
|
980
988
|
}
|
|
981
989
|
// Whatever a session start was told once, this is the answer now: a launcher exists because
|
|
982
990
|
// someone asked for it, so the offer is live again if the file ever goes.
|
|
983
|
-
clearUiPref(defaultLoopsDir(
|
|
984
|
-
clearUiPref(defaultLoopsDir(
|
|
991
|
+
clearUiPref(defaultLoopsDir(piAgentDir()), "launcher");
|
|
992
|
+
clearUiPref(defaultLoopsDir(piAgentDir()), "launcherFailedIn");
|
|
985
993
|
out(`wrote ${file}`);
|
|
986
994
|
out(` it runs ${path.join(entryDir, "cli-entry.mjs")}`);
|
|
987
995
|
out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
|
|
@@ -1026,7 +1034,7 @@ export function launcherScript(entryDir: string, node: string, agentDir: string)
|
|
|
1026
1034
|
|
|
1027
1035
|
/** What a launcher for *this* copy is written from, so a caller needs to know none of the three. */
|
|
1028
1036
|
export function launcherSource(): { entryDir: string; node: string; agentDir: string } {
|
|
1029
|
-
return { entryDir: path.dirname(fileURLToPath(import.meta.url)), node: process.execPath, agentDir:
|
|
1037
|
+
return { entryDir: path.dirname(fileURLToPath(import.meta.url)), node: process.execPath, agentDir: piAgentDir() };
|
|
1030
1038
|
}
|
|
1031
1039
|
|
|
1032
1040
|
/**
|
|
@@ -1036,7 +1044,7 @@ export function launcherSource(): { entryDir: string; node: string; agentDir: st
|
|
|
1036
1044
|
* for a command that was never put on their PATH.
|
|
1037
1045
|
*/
|
|
1038
1046
|
export function installedByPi(): boolean {
|
|
1039
|
-
return upgradeSpec(packageRoot(), manifest().name ?? "",
|
|
1047
|
+
return upgradeSpec(packageRoot(), manifest().name ?? "", piAgentDir(), "v0.0.0") !== undefined;
|
|
1040
1048
|
}
|
|
1041
1049
|
|
|
1042
1050
|
export type LauncherState = "absent" | "current" | "stale" | "other" | "foreign";
|
|
@@ -1214,7 +1222,8 @@ async function launch(argv: string[], out: (line: string) => void, cwd = process
|
|
|
1214
1222
|
const mode = resolveUiMode({ ...asked, remoteTty: isRemoteTty() });
|
|
1215
1223
|
const dir = loopsDir(ours);
|
|
1216
1224
|
const withDefaults = applyRememberedModel(pi, readUiPrefs(dir));
|
|
1217
|
-
|
|
1225
|
+
// The extension in that terminal reads its directory from the environment, not from our flags.
|
|
1226
|
+
if (mode === "terminal") return runChild(process.env.PI_BIN || "pi", withDefaults, has("loops-dir") ? { ...process.env, PI_LOOPS_DIR: dir } : undefined);
|
|
1218
1227
|
|
|
1219
1228
|
const refused = pageFlagRefusal(ours);
|
|
1220
1229
|
if (refused) {
|
|
@@ -1267,7 +1276,7 @@ export async function openPage(
|
|
|
1267
1276
|
// pi-loops is up; if it does not come up, the stop stays as it was.
|
|
1268
1277
|
const liftedStop = readUiPrefs(dir).host === "stopped";
|
|
1269
1278
|
if (liftedStop) clearUiPref(dir, "host");
|
|
1270
|
-
const ensured = await ensureHost(how.host);
|
|
1279
|
+
const ensured = await ensureHost({ ...how.host, onLeaveWait: () => process.stderr.write(`${RESTART_WAIT_LINE}\n`) });
|
|
1271
1280
|
// Running, started, or being started by a window right now: each of these has a page coming.
|
|
1272
1281
|
const why = ensured.error ?? (ensured.action.kind === "none" && ensured.action.reason !== "running" && ensured.action.reason !== IN_PROGRESS ? ensured.action.reason : undefined);
|
|
1273
1282
|
const rec = await waitForSocket(dir, why ? 0 : PAGE_WAIT_MS);
|
|
@@ -1383,7 +1392,7 @@ function socketOpen(socketPath: string): Promise<boolean> {
|
|
|
1383
1392
|
*/
|
|
1384
1393
|
export function pageAddress(dir: string): string {
|
|
1385
1394
|
const config = loadConfig(dir);
|
|
1386
|
-
const port = pageBindPort(config, { defaultDir: path.resolve(dir) === path.resolve(homeLoopsDir(
|
|
1395
|
+
const port = pageBindPort(config, { defaultDir: path.resolve(dir) === path.resolve(homeLoopsDir(piAgentDir())), env: process.env.PI_LOOPS_PAGE_PORT });
|
|
1387
1396
|
return port ? `${config.pageHost}:${port}` : `${config.pageHost} (on a free port; pi-loops status names it)`;
|
|
1388
1397
|
}
|
|
1389
1398
|
|
|
@@ -1427,7 +1436,7 @@ export function pageFlagRefusal(ours: string[]): string[] | undefined {
|
|
|
1427
1436
|
* already has, found the way `pi-loops export` finds it and taken out of pi's arguments; without
|
|
1428
1437
|
* either, a new one in `cwd` with the arguments as they are.
|
|
1429
1438
|
*/
|
|
1430
|
-
export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.join(
|
|
1439
|
+
export function pageTarget(piArgs: string[], cwd: string, sessionsRoot = path.join(piAgentDir(), "sessions")): { sessionId?: string; sessionFile?: string; piArgs: string[] } | { error: string } {
|
|
1431
1440
|
const rest: string[] = [];
|
|
1432
1441
|
let id: string | undefined;
|
|
1433
1442
|
let latest = false;
|
|
@@ -1538,7 +1547,7 @@ export function loopsDir(ours: string[]): string {
|
|
|
1538
1547
|
if (at !== -1 && ours[at + 1]) return ours[at + 1];
|
|
1539
1548
|
const eq = ours.find((a) => a.startsWith("--loops-dir="));
|
|
1540
1549
|
if (eq) return eq.slice("--loops-dir=".length);
|
|
1541
|
-
return process.env.PI_LOOPS_DIR || defaultLoopsDir(
|
|
1550
|
+
return process.env.PI_LOOPS_DIR || defaultLoopsDir(piAgentDir());
|
|
1542
1551
|
}
|
|
1543
1552
|
|
|
1544
1553
|
/**
|
package/src/engine.ts
CHANGED
|
@@ -1079,8 +1079,9 @@ export function deadLetterText(why: string, prompt: string): string {
|
|
|
1079
1079
|
return `could not be delivered (${why}): ${prompt}`;
|
|
1080
1080
|
}
|
|
1081
1081
|
|
|
1082
|
-
/** The parts of a
|
|
1083
|
-
export function deadLetterParts(text: string): { why: string; prompt: string } | undefined {
|
|
1082
|
+
/** The parts of a dead letter's text, or undefined for any other finding: the engine's mark decides, never the text. */
|
|
1083
|
+
export function deadLetterParts(entry: { text: string; undelivered?: true }, text = entry.text): { why: string; prompt: string } | undefined {
|
|
1084
|
+
if (!entry.undelivered) return undefined;
|
|
1084
1085
|
const m = /^could not be delivered \((.*?)\): ([\s\S]*)$/.exec(text);
|
|
1085
1086
|
return m ? { why: m[1], prompt: m[2] } : undefined;
|
|
1086
1087
|
}
|
|
@@ -1098,7 +1099,7 @@ async function deadLetter(store: RequestStore, sinks: EngineSinks, sessionId: st
|
|
|
1098
1099
|
if (rule !== undefined) {
|
|
1099
1100
|
if (!checkDecidedFinding(sinks.audit, row.id)) await sinks.inbox.appendOnce({ source: `trigger:${rule}`, run_id: row.id, job_id: rule, cwd, text: text || `rule result ${row.id}`, session_id: sessionId }, { run_id: row.id, source: `trigger:${rule}` });
|
|
1100
1101
|
}
|
|
1101
|
-
else await sinks.inbox.append({ source: row.source, run_id: row.id, cwd, text: deadLetterText(why, text.slice(0, DEAD_LETTER_CHARS) || `${row.kind} request ${row.id}`), session_id: sessionId });
|
|
1102
|
+
else await sinks.inbox.append({ source: row.source, run_id: row.id, cwd, text: deadLetterText(why, text.slice(0, DEAD_LETTER_CHARS) || `${row.kind} request ${row.id}`), session_id: sessionId, undelivered: true });
|
|
1102
1103
|
sinks.audit.appendAudit({ cwd, type: "request", traceId: row.id, state: "dead", sourceLabel: row.source, eventLabel: row.session_id, summary: text, details: { session_id: row.session_id, kind: row.kind, attempts: row.attempts, last_error: row.last_error, reason: why } });
|
|
1103
1104
|
store.remove(sessionId, [row.id]);
|
|
1104
1105
|
}
|
|
@@ -16,6 +16,7 @@ import * as os from "node:os";
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { DEAD_AFTER_ATTEMPTS, formatWait, heldTurnFor, stopRow } from "./engine.ts";
|
|
18
18
|
import { snapshotLoopLabel } from "./job-lines.ts";
|
|
19
|
+
import { plural } from "./plural.ts";
|
|
19
20
|
import { type LeaseStore, type ProcessProbe, holderAlive, sessionFileName } from "./lease.ts";
|
|
20
21
|
import { conversationLabel, shortSessionId } from "./session-head.ts";
|
|
21
22
|
import { previewRedacted, redact } from "./redact.ts";
|
|
@@ -205,7 +206,7 @@ export function requestLines(queues: readonly RequestQueue[], now: number, opts:
|
|
|
205
206
|
const dead = queues.reduce((n, q) => n + q.dead, 0);
|
|
206
207
|
const undelivered = (n: number) => `${n} could not be delivered`;
|
|
207
208
|
const where = (q: RequestQueue) => (q.holder.kind === "terminal" ? `open in pid ${line(q.holder.pid)}` : q.holder.kind === "host" ? "in the background" : "closed");
|
|
208
|
-
const head = ` ${total
|
|
209
|
+
const head = ` ${plural(total, "message")} waiting for ${plural(queues.length, "conversation")}${dead ? `, ${undelivered(dead)}` : ""}`;
|
|
209
210
|
return [head, ...queues.map((q) => ` ${shortSessionId(line(q.sessionId))}: ${line(q.count)} waiting · oldest ${formatWait(now - Date.parse(q.oldest))}${q.dead ? ` · ${undelivered(q.dead)}` : ""}${opts.where === false ? "" : ` · ${where(q)}`}`)];
|
|
210
211
|
}
|
|
211
212
|
|
package/src/host-control.ts
CHANGED
|
@@ -604,6 +604,12 @@ export interface EnsureHostOptions extends SpawnHostOptions {
|
|
|
604
604
|
page?: true;
|
|
605
605
|
/** How long an upgrade waits for the old process to leave; `LEAVE_WAIT_MS` unless a test says. */
|
|
606
606
|
leaveWaitMs?: number;
|
|
607
|
+
/**
|
|
608
|
+
* Called once when an upgrade is still waiting for the old process after `leaveNoticeMs` (2 s):
|
|
609
|
+
* it is letting a turn in the page finish, and a person waiting on this call is told why.
|
|
610
|
+
*/
|
|
611
|
+
onLeaveWait?: () => void;
|
|
612
|
+
leaveNoticeMs?: number;
|
|
607
613
|
/**
|
|
608
614
|
* Whether a start waits for the process to record itself (up to 10 s). Every caller waits but a
|
|
609
615
|
* window that is quitting: it has nothing to do with the answer, and waiting made quitting slow.
|
|
@@ -645,13 +651,22 @@ export async function ensureHost(opts: EnsureHostOptions): Promise<{ action: Ens
|
|
|
645
651
|
if (action.kind === "upgrade") {
|
|
646
652
|
recordUpgrader(opts.dir, { pid: process.pid, version: opts.myVersion, procStart: procStart(process.pid) });
|
|
647
653
|
await askHost(opts.dir, { op: "upgrade" });
|
|
654
|
+
// A quitting window asks and goes. The claim above holds older windows back; the next window,
|
|
655
|
+
// or the next `pi-loops`, starts this version once the old process has left.
|
|
656
|
+
if (opts.wait === false) return { action };
|
|
648
657
|
// Wait for it to go and start this version at once: the gap until the next tick is where an
|
|
649
658
|
// older window used to start the old code again. It may first let a turn in the page finish.
|
|
650
659
|
const old = rec!.pid;
|
|
651
|
-
const
|
|
660
|
+
const began = Date.now();
|
|
661
|
+
const deadline = began + (opts.leaveWaitMs ?? LEAVE_WAIT_MS);
|
|
662
|
+
let told = false;
|
|
652
663
|
while (Date.now() < deadline) {
|
|
653
664
|
const now = readHostService(opts.dir);
|
|
654
665
|
if (!now || now.pid !== old || !hostServiceAlive(now)) break;
|
|
666
|
+
if (!told && Date.now() - began >= (opts.leaveNoticeMs ?? 2_000)) {
|
|
667
|
+
told = true;
|
|
668
|
+
opts.onLeaveWait?.();
|
|
669
|
+
}
|
|
655
670
|
await new Promise((r) => setTimeout(r, 100));
|
|
656
671
|
}
|
|
657
672
|
const still = readHostService(opts.dir);
|