@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 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.3 # or from GitHub, at a pinned tag
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.3 # 或者从 GitHub 装,钉住一个 tag
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. The id is what
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
 
@@ -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 and not before.
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 first catalogue is
126
- seven: issue-loop, daily-digest, changelog-draft, pr-watch, ci-sweeper, autoresearch, and
127
- ecosystem — the last capped at `propose`, because every outward word it drafts is a finding a
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. `/cron` shows recipe jobs like any other, and `/cron set` edits them like
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
@@ -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`: *a job this machine no longer owns is listed, not hidden*.
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",
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 === opts.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 === 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, Number(str("limit") ?? 20))) {
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 === 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
- return joined.length <= limit ? joined : `…\n${joined.slice(joined.length - limit)}`;
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 {
@@ -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
+ }