@alphacoder-v0/pi-loops 0.22.3 → 0.22.5
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 +91 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -1
- package/docs/cli.md +8 -1
- package/docs/configuration.md +1 -0
- package/docs/design.md +7 -5
- package/docs/loops.md +8 -2
- package/docs/recipes.md +2 -1
- package/docs/web-ui-parity.md +17 -6
- package/package.json +1 -1
- package/src/cli.ts +10 -4
- package/src/goal.ts +5 -1
- package/src/job-lines.ts +68 -0
- package/src/pi-loops.ts +54 -55
- package/src/recipe.ts +26 -6
- package/src/scheduler.ts +97 -27
- package/src/session-head.ts +6 -2
- package/src/store.ts +55 -8
- package/src/tools.ts +3 -2
- package/src/triggers.ts +24 -0
- package/src/web.mjs +61 -22
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,97 @@ All notable changes to pi-loops are documented here. The format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.22.5] - 2026-09-28
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Both READMEs say what `/cron remove` reaches.** The command tables put `remove` in one row
|
|
12
|
+
with `enable` and `disable`, which resolve a name across the machine; removal stopped doing
|
|
13
|
+
that in 0.22.4 and the row never said so. It now states the rule: a name of this project, or
|
|
14
|
+
the exact id of a job anywhere.
|
|
15
|
+
- **The design principles say when a job disappears.** Principle 11 said a job goes when you
|
|
16
|
+
remove it "and not before", which a one-shot that has fired and a job `/cron gc` collects
|
|
17
|
+
both contradict. It now names all three, and says none of them is time passing.
|
|
18
|
+
- **`/cron` says `skipped overlaps`.** The listing called the counter `overlap skips`, while the
|
|
19
|
+
terminal panel and docs/loops.md call it `skipped overlaps`. The listing now uses the same name.
|
|
20
|
+
- **The `/cron` listing is in `src/job-lines.ts`.** It was composed inside the extension's default
|
|
21
|
+
export, where no test can reach it. The lines are unchanged; each marker and column now has a test.
|
|
22
|
+
- **docs/design.md counts eight recipes** and names `deps-sweeper`, which ships but was not listed.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **A long session with non-ASCII text is listed as truncated again.** The session-list window is
|
|
26
|
+
64 KB of *bytes*, but the check asked whether the decoded string was that many UTF-16 code units
|
|
27
|
+
long. With CJK text the decoded length is smaller, so a session well past the window read as
|
|
28
|
+
complete and the resume picker printed a floor (`314 message(s)`) as an exact count. Both copies —
|
|
29
|
+
`src/session-head.ts` and the one inside `src/web.mjs` — now compare the bytes actually read.
|
|
30
|
+
- **`pi-loops sessions --limit` refuses a value it cannot use.** `--limit abc` and `--limit 0`
|
|
31
|
+
printed nothing and exited 0, the same output as a project with no sessions, and `--limit -1`
|
|
32
|
+
printed all but the oldest. The value must now be a whole number of at least 1.
|
|
33
|
+
- **`/triggers audit` refuses a limit it cannot read.** `alll`, `0` and `25x` quietly became the
|
|
34
|
+
default of 10 or 25, and `-5` printed almost the whole audit file. Anything other than one whole
|
|
35
|
+
number of at least 1 and `--all` now prints the usage line.
|
|
36
|
+
- **The `/goal` evaluator gets the 40 000 characters docs/goal.md promises.** The cap counted UTF-16
|
|
37
|
+
code units, so an emoji-heavy transcript was cut to about half and could begin on half a character.
|
|
38
|
+
- **The browser shows pi's last words with the cut marked.** When pi exits, the server cut its
|
|
39
|
+
stderr to 4000 UTF-16 code units and said nothing, so the tail looked whole and could begin on half
|
|
40
|
+
a character. The cut now counts characters and starts with `… (N chars dropped)`.
|
|
41
|
+
- **The browser panel shows an MCP server's last error whole.** The Runtime box cut it at 120
|
|
42
|
+
characters with no ellipsis, although the error it receives is already capped at 160 and marked.
|
|
43
|
+
- **The browser job card shows a loop's skipped overlaps,** as `/cron` and the terminal panel do.
|
|
44
|
+
- **The browser panel keeps a job whose checkout is gone in this project.** It resolved a missing
|
|
45
|
+
path only one directory up, so a removed worktree two levels down counted as another project's,
|
|
46
|
+
while `/cron` still listed it with `[orphan: cwd missing]`. It now resolves the deepest ancestor
|
|
47
|
+
that exists, as `src/paths.ts` does.
|
|
48
|
+
- **docs/web-ui-parity.md names a test that exists.** One line of the front-end gate named a test
|
|
49
|
+
that had been renamed. A new test checks every test name that a document gives.
|
|
50
|
+
|
|
51
|
+
## [0.22.4] - 2026-09-20
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- **Removing a running job aborts the run, and the run leaves nothing behind.** `/cron remove`,
|
|
55
|
+
`cron_remove` and `/recipe remove` deleted the job and left its sub-agent going; the run then
|
|
56
|
+
wrote its state, filed a finding for a job that no longer existed and recreated `sessions/<id>/`.
|
|
57
|
+
Removal now aborts an in-flight run and gives it up to two seconds to write its record — enough for
|
|
58
|
+
a normal abort, not a wait that outlasts a wedged sub-agent — and a run that finds its job gone
|
|
59
|
+
writes the record only, discarding the state and the findings.
|
|
60
|
+
- **`/cron gc --purge` also collects the transcripts of jobs that are gone.** A removed job that
|
|
61
|
+
never wrote state but left `sessions/<id>/` behind was invisible to `orphanStates()`, so its
|
|
62
|
+
transcripts stayed for ever. The orphan scan now unions the state files with the transcript
|
|
63
|
+
directories and counts both in the size it reports.
|
|
64
|
+
- **`/recipe remove --purge` keeps a playbook you edited.** `purgeInstall` deleted every file in the
|
|
65
|
+
record without comparing it with its untouched copy under `.orig/`, so an edit that `/recipe
|
|
66
|
+
update` would have preserved was destroyed without a word. It now removes a file only while it
|
|
67
|
+
still matches its untouched copy, keeps and names the edited ones, and leaves the directory in
|
|
68
|
+
place when one remains.
|
|
69
|
+
- **A run whose `jobs.json` is torn mid-flight no longer disappears.** A run re-read the store to ask
|
|
70
|
+
whether its job was still there, and an empty or too-new file made that read throw: the record, the
|
|
71
|
+
findings and the run-finished callback were all skipped, so a run that had completed left no trace.
|
|
72
|
+
A store that cannot be read now counts as "the job is still there" — the record and the findings are
|
|
73
|
+
written, and only the job's own bookkeeping is skipped.
|
|
74
|
+
- **`/recipe remove --purge` keeps a setup script you edited.** The install copied the setup script
|
|
75
|
+
but kept no untouched copy of it, and purge deleted it unconditionally, so an edited script was
|
|
76
|
+
destroyed without a word. The install now writes the `.orig` copy and purge compares it like any
|
|
77
|
+
other file, keeping and naming the edited one.
|
|
78
|
+
- **The collection of a removed job's leftovers says what it does, and counts all of it.**
|
|
79
|
+
`removeWhere` deleted state and transcripts with no flag, so the next caller would have thrown away
|
|
80
|
+
a loop's notes unasked; it now keeps them unless the caller passes `purge`, which is what gc does.
|
|
81
|
+
The orphan scan also walks the whole transcript tree, so a nested file that `purgeState` deletes is
|
|
82
|
+
counted in the size `/cron gc` reports.
|
|
83
|
+
- **The command line finds a project's sessions and recipe jobs from a subdirectory.** Three listings
|
|
84
|
+
compared the recorded directory with `===`, so run from a subdirectory of the project they answered
|
|
85
|
+
"nothing here" or "0 job(s)". They now use the same realpath-and-containment match the rest of
|
|
86
|
+
pi-loops uses.
|
|
87
|
+
- **The docs say the command line does not delete.** There is no `cron remove`, `recipe remove` or
|
|
88
|
+
`inbox clear` on it: removal asks a person to confirm and records what it did in the session's
|
|
89
|
+
control-plane audit, so it stays in the slash commands.
|
|
90
|
+
- **`/cron remove <name>` no longer reaches into another project.** A name resolved machine-wide, so
|
|
91
|
+
a name this project does not have deleted another checkout's job and said so in the same words as
|
|
92
|
+
any other removal. It now follows the rule `cron_remove` already had — a name resolves here, and
|
|
93
|
+
another project's job needs its exact id — and a removal that did reach outside names the project
|
|
94
|
+
it reached into.
|
|
95
|
+
- **A one-shot that retires itself leaves a control-plane audit entry.** A one-shot was removed after
|
|
96
|
+
it fired or failed for the second time with nothing in the trail; the self-removal now goes through
|
|
97
|
+
the same audit as a removal a person asked for.
|
|
98
|
+
|
|
8
99
|
## [0.22.3] - 2026-09-16
|
|
9
100
|
|
|
10
101
|
### Changed
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ the first sign of credentials that do not work should not be an empty inbox tomo
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
pi install npm:@alphacoder-v0/pi-loops # from npm, following new releases
|
|
41
|
-
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.
|
|
41
|
+
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.5 # 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
|
```
|
|
@@ -276,7 +276,7 @@ State stays in `~/.pi/agent/loops` until you delete it.
|
|
|
276
276
|
|---|---|
|
|
277
277
|
| `/cron add [--stateful] [--verify] "<schedule>" <prompt>` | Schedule a job: a plain job belongs to this chat and injects its result here (it sleeps while this session is not open), `--stateful` makes a loop that runs wherever the clock is, with memory and inbox routing, `--verify` adds the checker |
|
|
278
278
|
| `/cron`, `/cron all` | This project's jobs, or every project on this machine |
|
|
279
|
-
| `/cron enable\|disable\|remove <ref>` | Pause, resume or delete one job — and write an audit entry into the session, as `add` does |
|
|
279
|
+
| `/cron enable\|disable\|remove <ref>` | Pause, resume or delete one job — and write an audit entry into the session, as `add` does. `remove` takes a name of this project or the exact id of a job anywhere, so a name never reaches a checkout you are not in |
|
|
280
280
|
| `/cron disable --all` | Pause every job in this project (`--all-projects` for the machine); `/cron enable --all` resumes |
|
|
281
281
|
| `/cron run <ref>` | Fire one job now instead of waiting for its schedule |
|
|
282
282
|
| `/cron state <ref>` | The loop's notes — the Markdown it carries from one run to the next |
|
package/README.zh-CN.md
CHANGED
|
@@ -45,7 +45,7 @@ pi-loops 自己没有任何运行时依赖。
|
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
pi install npm:@alphacoder-v0/pi-loops # 从 npm 装,跟着新版本走
|
|
48
|
-
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.
|
|
48
|
+
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.5 # 或者从 GitHub 装,钉住一个 tag
|
|
49
49
|
pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -242,6 +242,7 @@ playbook 拷到 `.agents/skills/<name>/`,通过 `.git/info/exclude` 排除在
|
|
|
242
242
|
/cron add in 10m 提醒我看一下测试结果 # 会话级闹钟
|
|
243
243
|
/cron · /cron list|ls|status 本项目的任务,[stateful] 标记;/cron all 看整台机器
|
|
244
244
|
/cron enable|resume|disable|pause|remove <n|id|name>
|
|
245
|
+
remove 只认本项目的名字和编号,别的项目的任务要给完整 id(删除撤不回来)
|
|
245
246
|
/cron set <ref> … 改已有任务而不换 id(笔记因此留着):--prompt、--schedule,
|
|
246
247
|
以及 --model、--thinking、--timeout、--name 这几个钉子(`-` 解掉)
|
|
247
248
|
/cron run 1 立刻跑一次(once 跑完即删,every 的间隔从现在重算,cron 的下次不变;停用的普通任务会被拒绝)
|
package/docs/cli.md
CHANGED
|
@@ -227,13 +227,20 @@ pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level
|
|
|
227
227
|
pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason <text>] [--json]
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
+
The command line reads, triages one finding at a time and moves sessions. It never creates or
|
|
231
|
+
removes: there is no `cron remove`, no `recipe remove` and no `inbox clear`. Creating and removing
|
|
232
|
+
jobs, rules and recipes are slash commands, because each asks a person first and records what it did
|
|
233
|
+
in the session's control-plane audit.
|
|
234
|
+
|
|
230
235
|
## sessions, inspect
|
|
231
236
|
|
|
232
237
|
`sessions` lists this project's sessions newest first, one line each: the first sixteen
|
|
233
238
|
characters of the id, when the session started, the automation it
|
|
234
239
|
has (`[2 cron, 1 trigger]`, or `[automation off]` when all of it is disabled), and what was first
|
|
235
240
|
said in it — the session's name if it was given one, otherwise the first message, cut at eighty
|
|
236
|
-
characters. `--all` lists every project and puts the cwd after the id.
|
|
241
|
+
characters. `--all` lists every project and puts the cwd after the id. `--limit <n>` shows at most
|
|
242
|
+
`n` (default 20); a whole number of at least 1 is required, and anything else is refused rather than
|
|
243
|
+
read as an offset. The id is what
|
|
237
244
|
`export --session` accepts; a unique prefix will do. `inspect` prints an archive's schedules, prompts and rules without
|
|
238
245
|
writing anything, which is what you want before restoring on a machine you care about.
|
|
239
246
|
|
package/docs/configuration.md
CHANGED
|
@@ -16,6 +16,7 @@ Everything lives under `~/.pi/agent/loops/` (override: `PI_LOOPS_DIR`).
|
|
|
16
16
|
| `spend.json` | per-day totals of what rotation dropped from the run log, so a daily budget still counts it |
|
|
17
17
|
| `sessions/<job-id>/`, `sessions/triggers-<project>-<hash>/` | sub-agent transcripts (20 per job, 40 per project's checks; the hash of the project's path keeps two projects named alike apart) |
|
|
18
18
|
| `scheduler.json` | timer owner on this machine: pid, heartbeat |
|
|
19
|
+
| `next-runs.json` | job id → when it runs next, as the timer owner computed it (`{ at, next }`). Written only when an answer changes, so it can be older than the last tick; the browser panel reads it rather than parsing schedules a second time |
|
|
19
20
|
| `presence/` | one file per live pi process: pid, session, cwd (who acts for which project) |
|
|
20
21
|
| `host.json`, `host.log` | the headless host that keeps the clock while no pi is open: pid, and its log |
|
|
21
22
|
| `polls.json` | last dynamic check per project (shared, so a hand-over never double-checks) |
|
package/docs/design.md
CHANGED
|
@@ -102,7 +102,9 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
102
102
|
not otherwise need.
|
|
103
103
|
|
|
104
104
|
11. **Bounded everywhere, and no expiry.** Loop state ≤ 2000 characters, a finding ≤ 500, at most
|
|
105
|
-
16 per run, prompts ≤ 8 KB. A job disappears when you remove it
|
|
105
|
+
16 per run, prompts ≤ 8 KB. A job disappears when you remove it, when a one-shot has fired, or
|
|
106
|
+
when `/cron gc` collects one whose session is gone — never because time passed. Each of the
|
|
107
|
+
three is recorded in the control-plane audit, the self-removals included.
|
|
106
108
|
|
|
107
109
|
12. **Cycle suppression by hop count.** Sub-agents run at hop 1 and keep the cron and trigger tools
|
|
108
110
|
(`cron_create`, `cron_remove`, listing, disabling), so a loop can manage automation; the
|
|
@@ -122,10 +124,10 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
122
124
|
15. **Recipes are files, not code.** A recipe (`CONTEXT.md` has the vocabulary) is a directory:
|
|
123
125
|
a `recipe.toml` manifest whose `[[job]]` fields are the arguments of `/cron add` and nothing
|
|
124
126
|
else, one playbook per job, an optional setup script. Anything the wizard does with one is
|
|
125
|
-
something a person could have done by hand with `/cron add` and `cp`. The
|
|
126
|
-
|
|
127
|
-
ecosystem —
|
|
128
|
-
person claims before it is said.
|
|
127
|
+
something a person could have done by hand with `/cron add` and `cp`. The catalogue is eight:
|
|
128
|
+
issue-loop, daily-digest, changelog-draft, pr-watch, ci-sweeper, autoresearch, deps-sweeper,
|
|
129
|
+
and ecosystem — `ecosystem` capped at `propose`, because every outward word it drafts is a
|
|
130
|
+
finding a person claims before it is said.
|
|
129
131
|
|
|
130
132
|
The cost: a project cannot add a recipe by writing TypeScript, only by writing a directory,
|
|
131
133
|
and the wizard can only ask what a manifest can declare.
|
package/docs/loops.md
CHANGED
|
@@ -240,7 +240,8 @@ cron job's clock restarts at the edit, so slots that only exist retroactively un
|
|
|
240
240
|
expression are not owed. An `every <dur>` job is still measured from its last run — if it last ran
|
|
241
241
|
longer ago than the new interval, it is genuinely overdue and the confirmation says `due now`
|
|
242
242
|
instead of promising a later time. One-shots (`in 10m`, `at <ISO>`) are refused here: the scheduler
|
|
243
|
-
deletes a `once` job after it fires, notes included
|
|
243
|
+
deletes a `once` job after it fires, notes included, and records the removal in the control-plane
|
|
244
|
+
audit like any other.
|
|
244
245
|
|
|
245
246
|
`/cron run <ref>` fires a job now instead of at its next due time, and that run counts as a run: a
|
|
246
247
|
`once` job is fired and then removed (an enabled job with nothing left to fire is worse than none),
|
|
@@ -300,9 +301,14 @@ anything, so this is how you actually go quiet.
|
|
|
300
301
|
If a loop is stuck showing `running` after a process was killed and its pid reused, `/cron clear
|
|
301
302
|
<ref>` releases the marker (it asks first).
|
|
302
303
|
|
|
304
|
+
`/cron remove` takes a name of this project, a number from its listing, or the exact id of a job
|
|
305
|
+
anywhere — the rule `cron_remove` follows, because a removal cannot be undone and a name this
|
|
306
|
+
project does not have should not reach another checkout. A job removed by its exact id from outside
|
|
307
|
+
this project is named with the project it was in.
|
|
308
|
+
|
|
303
309
|
`/cron remove` keeps the loop's notes (to change a prompt or a schedule, edit the job in place with
|
|
304
310
|
`/cron set` instead); `--purge` deletes them. `/cron gc` collects this project's jobs whose session
|
|
305
|
-
is gone — `--all` every project's, `--purge` also the loop state left behind by jobs that are gone.
|
|
311
|
+
is gone — `--all` every project's, `--purge` also the loop state and transcripts left behind by jobs that are gone.
|
|
306
312
|
|
|
307
313
|
A job that fails three times in a row is retried on a widening gap (5 minutes, doubling, up to six
|
|
308
314
|
hours) instead of at every due tick, and says so; one success clears the streak.
|
package/docs/recipes.md
CHANGED
|
@@ -141,7 +141,8 @@ A playbook installed by hand, with no untouched copy, is left alone and named.
|
|
|
141
141
|
|
|
142
142
|
`/recipe remove <name>` removes the recipe's jobs and keeps the files and the loops' notes;
|
|
143
143
|
`--purge` deletes both — of the files, only what the install wrote, since `.agents/skills/` may
|
|
144
|
-
hold the project's own
|
|
144
|
+
hold the project's own, and only a file that still matches its untouched copy: a playbook you
|
|
145
|
+
edited is kept and named. `/cron` shows recipe jobs like any other, and `/cron set` edits them like
|
|
145
146
|
any other — a recipe is how they were made, not what they are.
|
|
146
147
|
|
|
147
148
|
## Writing a recipe
|
package/docs/web-ui-parity.md
CHANGED
|
@@ -25,13 +25,15 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
25
25
|
- [x] Busy, queued count, and "pi exited" are all visible without opening a terminal.
|
|
26
26
|
`test/web-page.test.ts`: *a tool call and a dead pi both reach the page*.
|
|
27
27
|
- [x] Prompt history: ArrowUp and ArrowDown, but only while the caret is on the first or last line,
|
|
28
|
-
so they still navigate a multi-line draft.
|
|
28
|
+
so they still navigate a multi-line draft. `test/web-page.test.ts`: *prompt history recalls what
|
|
29
|
+
was sent, and steps aside on a middle line*.
|
|
29
30
|
|
|
30
31
|
## Composing
|
|
31
32
|
|
|
32
33
|
- [x] `/` completes slash commands; `@` completes paths, and the mention is expanded before the
|
|
33
34
|
prompt is sent — the expansion is anchored to the session's directory, not to anything the
|
|
34
|
-
browser supplies.
|
|
35
|
+
browser supplies. `test/web.test.ts`: *an @ mention is expanded from the session's directory,
|
|
36
|
+
and one outside it is skipped*.
|
|
35
37
|
- [x] A command with an argument can be typed straight through. The completion list closes on the
|
|
36
38
|
space, and an answer still in flight does not put it back — which it did, so Enter accepted
|
|
37
39
|
the completion instead of sending the line and anything taking an argument was mouse-only.
|
|
@@ -85,6 +87,8 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
85
87
|
`test/web-page.test.ts`: *a gap in the event stream reloads the conversation*.
|
|
86
88
|
- [x] While you are reading back through the conversation, an update never yanks the page — and
|
|
87
89
|
never arrives silently either: there is a way back to the newest.
|
|
90
|
+
`test/web-page.test.ts`: *an update while you are reading back does not yank the page, and the
|
|
91
|
+
pill is the way back*.
|
|
88
92
|
- [x] Every block says when it happened.
|
|
89
93
|
- [x] Every message, tool call and result can be copied, including over plain http where the
|
|
90
94
|
clipboard API is unavailable. The button waits for a hover on a mouse and is simply always
|
|
@@ -92,7 +96,10 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
92
96
|
- [x] Tool results, an extension's messages and pi's dying words are capped, and every cap says how
|
|
93
97
|
much was dropped — one helper for the three of them, because they used to report it three
|
|
94
98
|
different ways and one of them not at all, so a result that stopped at 8000 characters looked
|
|
95
|
-
like a result that ended there.
|
|
99
|
+
like a result that ended there. The count and the cut are characters, not UTF-16 code units, so
|
|
100
|
+
an astral character is counted once and a cut cannot end on half of one. `test/web-page.test.ts`:
|
|
101
|
+
*a capped result counts characters, not the units a string is counted in*; `test/web.test.ts`:
|
|
102
|
+
*pi's dying words are cut by characters, and the cut says how much it dropped*.
|
|
96
103
|
- [x] **You can see what the session made, not only read about it.** A picture in a reply is a
|
|
97
104
|
picture — including one named in ordinary prose rather than as a link; a path is something to
|
|
98
105
|
open; a page written into the reply has a preview; an image a tool returned is shown rather
|
|
@@ -130,7 +137,7 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
130
137
|
What counts as "this project" is what the extension says it is — symlinks resolved, `$HOME`
|
|
131
138
|
too broad to be one — because a panel that disagrees with the command is worse than either.
|
|
132
139
|
`test/web.test.ts`: *the panel and /cron agree about what this project is*.
|
|
133
|
-
`test/web-page.test.ts`: *
|
|
140
|
+
`test/web-page.test.ts`: *what the machine has that this project does not is counted, apart*.
|
|
134
141
|
- [x] **When each job runs next, whatever its schedule.** The page used to work this out itself and
|
|
135
142
|
understood only `every <interval>`, so a job on `0 9 * * *` — the first example in the
|
|
136
143
|
README — showed nothing. A second cron parser in a page with no dependencies was the wrong
|
|
@@ -188,11 +195,14 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
188
195
|
browser. A session whose only question is on a terminal you closed is a stuck session. One
|
|
189
196
|
asked while no browser was attached is held for a browser that turns up, and the list of those
|
|
190
197
|
is bounded the way the event backlog is: an abandoned dialog was otherwise re-offered every
|
|
191
|
-
eight seconds for as long as the process lived.
|
|
198
|
+
eight seconds for as long as the process lived. `test/web.test.ts`: *dialogs held for a browser
|
|
199
|
+
that turns up are bounded, oldest first*.
|
|
192
200
|
- [x] A confirmation shows what is about to happen apart from the reasoning about it, so it cannot
|
|
193
201
|
be read as prose and waved through. `test/web-page.test.ts`: *a confirmation shows what is
|
|
194
202
|
about to run*.
|
|
195
|
-
- [x] Enter does not approve: the focus starts on cancel
|
|
203
|
+
- [x] Enter does not approve: the focus starts on cancel, which is written before approve so the
|
|
204
|
+
first menu button is the one Enter hits. `test/web-page.test.ts`: *Enter in a confirmation
|
|
205
|
+
dialog hits cancel, not approve*.
|
|
196
206
|
- [x] **A tab in the background does not hold one of the browser's six connections.** Every tab
|
|
197
207
|
used to keep an event stream open for as long as it existed, so the sixth tab — or a few
|
|
198
208
|
forgotten behind the first — left nothing for the request that sends what you typed, and the
|
|
@@ -335,6 +345,7 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
335
345
|
- [x] Pairing survives an upgrade: the token is a file outside the package, so a device stays signed
|
|
336
346
|
in across restarts and new versions. `test/web.test.ts`: *the token outlives the process*.
|
|
337
347
|
- [x] Events are incremental. The backlog a late-joining browser replays is bounded.
|
|
348
|
+
`test/web.test.ts`: *the event backlog a late-joining browser replays is bounded*.
|
|
338
349
|
- [x] Events carry no credential and no oversized tool payload; the image bytes a message already
|
|
339
350
|
contains travel with it, and the page renders them rather than fetching them. The page itself
|
|
340
351
|
is handed the token — in the cookie, and in the URL on a first visit — and nothing else is. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alphacoder-v0/pi-loops",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.5",
|
|
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
|
@@ -184,7 +184,7 @@ export function pickSession(sessions: SessionFile[], opts: { id?: string; cwd: s
|
|
|
184
184
|
if (byPrefix.length > 1) throw new Error(`session id ${opts.id} is ambiguous (${byPrefix.length} matches)`);
|
|
185
185
|
throw new Error(`no session with id ${opts.id}`);
|
|
186
186
|
}
|
|
187
|
-
const here = sessions.filter((s) => s.cwd
|
|
187
|
+
const here = sessions.filter((s) => sameProject(s.cwd, opts.cwd));
|
|
188
188
|
if (!here.length) throw new Error(`no sessions recorded for ${opts.cwd}; pass --session <id>`);
|
|
189
189
|
return here[0];
|
|
190
190
|
}
|
|
@@ -266,9 +266,15 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
266
266
|
if (command === "inbox") return inboxCommand(positional, { all: flags.has("all"), json: flags.has("json"), reason: flags.get("reason") }, cwd, loopsDir, out);
|
|
267
267
|
|
|
268
268
|
if (command === "sessions") {
|
|
269
|
+
// A count, so anything that is not a whole number ≥ 1 would reach `slice` as NaN, a
|
|
270
|
+
// negative offset or a fraction, and change what is listed without ever saying so.
|
|
271
|
+
const limitFlag = flags.get("limit");
|
|
272
|
+
if (limitFlag !== undefined && (typeof limitFlag !== "string" || !/^\d+$/.test(limitFlag) || Number(limitFlag) < 1))
|
|
273
|
+
throw new Error(`--limit takes a whole number of sessions ≥ 1; got ${limitFlag === true ? "nothing" : JSON.stringify(limitFlag)}`);
|
|
274
|
+
const limit = limitFlag === undefined ? 20 : Number(limitFlag);
|
|
269
275
|
// `pickSession` refuses an unknown id, and there was no way to discover one.
|
|
270
276
|
const sessions = listSessions(path.join(agentDir, "sessions"));
|
|
271
|
-
const here = flags.has("all") ? sessions : sessions.filter((s) => s.cwd
|
|
277
|
+
const here = flags.has("all") ? sessions : sessions.filter((s) => sameProject(s.cwd, cwd));
|
|
272
278
|
if (!here.length) {
|
|
273
279
|
out(flags.has("all") ? "no sessions recorded" : `no sessions recorded for ${cwd} (use --all)`);
|
|
274
280
|
return 1;
|
|
@@ -277,7 +283,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
277
283
|
// session has, what was first said in it. With --all the cwd sits after the id.
|
|
278
284
|
const jobs = new JobStore(loopsDir).load();
|
|
279
285
|
const rules = new TriggerStore(loopsDir).load();
|
|
280
|
-
for (const s of here.slice(0,
|
|
286
|
+
for (const s of here.slice(0, limit)) {
|
|
281
287
|
const badge = automationBadge(s.id, jobs, rules);
|
|
282
288
|
out(`${s.id.slice(0, 16)} ${flags.has("all") ? `${s.cwd} ` : ""}${(s.startedAt ?? stamp(s.mtimeMs)).slice(0, 16)}${badge ? ` [${badge}]` : ""} ${s.preview ? previewText(s.preview) : ""}`.trimEnd());
|
|
283
289
|
}
|
|
@@ -493,7 +499,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
|
|
|
493
499
|
if (sub === "list") {
|
|
494
500
|
const packaged = listRecipes();
|
|
495
501
|
const installed = new Map(installedRecipes(cwd).map((r) => [r.recipe, r]));
|
|
496
|
-
const jobs = new JobStore(loopsDir).load().filter((j) => j.recipe && j.cwd
|
|
502
|
+
const jobs = new JobStore(loopsDir).load().filter((j) => j.recipe && sameProject(j.cwd, cwd));
|
|
497
503
|
for (const r of packaged) {
|
|
498
504
|
const rec = installed.get(r.manifest.name);
|
|
499
505
|
const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
|
package/src/goal.ts
CHANGED
|
@@ -69,7 +69,11 @@ export function transcriptFromMessages(messages: Array<{ role?: string; content?
|
|
|
69
69
|
if (text) lines.push(`${role}: ${text}`);
|
|
70
70
|
}
|
|
71
71
|
const joined = lines.join("\n");
|
|
72
|
-
|
|
72
|
+
// Characters, not UTF-16 code units: one emoji is two units, so counting units cut an emoji-heavy
|
|
73
|
+
// transcript to about half of the 40 000 docs/goal.md promises and could land between the halves
|
|
74
|
+
// of one character. `tailChars` is the character-correct tail already used a few lines below.
|
|
75
|
+
const tail = tailChars(joined, limit);
|
|
76
|
+
return tail === joined ? joined : `…\n${tail}`;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
function renderContent(content: unknown): string {
|
package/src/job-lines.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/cron` listing, as lines.
|
|
3
|
+
*
|
|
4
|
+
* It lives here rather than in `src/pi-loops.ts` — the extension's default export, which nothing can
|
|
5
|
+
* import — because docs/loops.md states these markers and columns as the behaviour, and until this
|
|
6
|
+
* was importable no test could see one go. Everything it reads is a parameter, so a test can hand it
|
|
7
|
+
* a job, a run log and an inbox and read the lines a person reads.
|
|
8
|
+
*/
|
|
9
|
+
import type { InboxEntry } from "./inbox.ts";
|
|
10
|
+
import { asleepNote, runsIn } from "./job-owner.ts";
|
|
11
|
+
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal, signalSummary } from "./job-signal.ts";
|
|
12
|
+
import { previewRedacted } from "./redact.ts";
|
|
13
|
+
import { computeNext, formatLocal, formatSchedule } from "./schedule.ts";
|
|
14
|
+
import type { LoopJob, RunRecord } from "./store.ts";
|
|
15
|
+
|
|
16
|
+
export interface JobLinesInput {
|
|
17
|
+
/** The whole run log, read once by the caller; only this listing's loops are filtered out of it. */
|
|
18
|
+
runs: RunRecord[];
|
|
19
|
+
/** The whole inbox, read once by the caller. */
|
|
20
|
+
inbox: InboxEntry[];
|
|
21
|
+
/** The session reading the list: a plain job of another session has no next run where it is. */
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
now: number;
|
|
24
|
+
/** `$HOME`, for `~` shortening; passed rather than read so a test's paths are its own. */
|
|
25
|
+
home: string;
|
|
26
|
+
/** Injected because the answer decides `[orphan: cwd missing]`, and a test should not make directories. */
|
|
27
|
+
cwdExists: (cwd: string) => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The caller's `homeRel`, with `home` passed in: this module has no `process.env`. */
|
|
31
|
+
const homeRel = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p);
|
|
32
|
+
|
|
33
|
+
export function jobLines(jobs: LoopJob[], opts: JobLinesInput): string[] {
|
|
34
|
+
const { runs, inbox, sessionId, now, home, cwdExists } = opts;
|
|
35
|
+
return jobs
|
|
36
|
+
.map((job, i) => {
|
|
37
|
+
// A next run is shown where it will happen: a plain job of a session not open here has none.
|
|
38
|
+
const next =
|
|
39
|
+
job.enabled && runsIn(job, sessionId)
|
|
40
|
+
? computeNext(
|
|
41
|
+
{
|
|
42
|
+
schedule: job.schedule,
|
|
43
|
+
createdAt: Date.parse(job.createdAt),
|
|
44
|
+
lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined,
|
|
45
|
+
},
|
|
46
|
+
now,
|
|
47
|
+
)
|
|
48
|
+
: undefined;
|
|
49
|
+
const asleepMark = asleepNote(job, sessionId);
|
|
50
|
+
const dormant = asleepMark ? `[${asleepMark}]` : undefined;
|
|
51
|
+
const orphan = job.stateful && !cwdExists(job.cwd) ? "[orphan: cwd missing]" : undefined;
|
|
52
|
+
// What the loop's runs came to (src/job-signal.ts): `quiet ×N` in the marks once N empty
|
|
53
|
+
// runs are the newest, and the findings line — filed, claimed, dismissed — over 30 days.
|
|
54
|
+
// A loop was, until now, judged by whether it ran; this is whether it was worth running.
|
|
55
|
+
const signal = job.stateful ? loopSignal(job.id, runs, inbox, now - SIGNAL_WINDOW_MS) : undefined;
|
|
56
|
+
const quiet = signal && signal.quiet >= QUIET_MARK_AFTER ? `[quiet ×${signal.quiet}]` : undefined;
|
|
57
|
+
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, quiet, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join(" ");
|
|
60
|
+
const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
|
|
61
|
+
const action = ` action: ${previewRedacted(job.prompt, 120)}`;
|
|
62
|
+
const worth = signal ? signalSummary(signal) : undefined;
|
|
63
|
+
const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · skipped overlaps ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""} · ${homeRel(job.cwd, home)}`;
|
|
64
|
+
const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
|
|
65
|
+
return [head, action, meta, err].filter((l): l is string => !!l);
|
|
66
|
+
})
|
|
67
|
+
.flat();
|
|
68
|
+
}
|