@alphacoder-v0/pi-loops 0.18.0 → 0.19.3

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
@@ -3,6 +3,71 @@
3
3
  All notable changes to pi-loops are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
5
5
 
6
+ ## [0.19.3] - 2026-09-14
7
+
8
+ ### Fixed
9
+ - **Six tabs of the browser front end and nothing you typed was sent** (#14). A browser gives one
10
+ address six connections, and every open tab held one for its event stream — so the sixth tab, or
11
+ a few left behind the first, left nothing for `POST /prompt`: the fetch queued, neither
12
+ succeeding nor failing, the bubble drawn and the message never delivered. A tab now closes its
13
+ stream fifteen seconds after going into the background (`paused in background`) and opens a new
14
+ one when looked at again, polling at once so the seq/epoch check reloads what it missed; the pi
15
+ process behind the page, and its jobs, are not involved either way. And a send that has not
16
+ settled in six seconds says `still sending — if it never lands, close the other tabs of this
17
+ address` instead of looking alive.
18
+
19
+ ## [0.19.2] - 2026-09-14
20
+
21
+ ### Fixed
22
+ - **A plain job of another session was promised a next run, could be run into the wrong chat, and
23
+ was called dormant as if that were a fault** (#15). A plain job belongs to the session that
24
+ created it — its result is a message in that conversation — and dispatch has always honoured
25
+ that; the lists did not. `cron_list` printed a `next_run` for a job this process would never
26
+ dispatch (the same class the `[other host]` fix closed for the machine half of the predicate),
27
+ the browser panel showed the time it read from `next-runs.json`, and `/cron run` injected the job
28
+ into whatever chat was open. On 2026-09-14 a daily digest missed its slot exactly this way, and
29
+ the only surface that knew was the terminal's `[dormant …]` marker. Now one predicate
30
+ (`src/job-owner.ts`) answers "does it run here" for dispatch, `/cron`, `cron_list`, the panel and
31
+ `/cron run`: another session lists it as `[session <id> — resume it to run]` with no next run,
32
+ `cron_list` says the same and sets `owner_session` / `asleep`, the panel says what would wake it,
33
+ and `/cron run` refuses it with the same sentence. "Asleep" replaces "dormant" in the vocabulary:
34
+ it is waiting for its conversation, not broken. The `catch_up` description now says when a
35
+ plain job's missed slot is honoured (when its session is next opened); the skill tells the model
36
+ that anything meant to run unattended is a loop. Parking stays what it was and is now documented
37
+ as such: a plain job is disabled once its session's file has been deleted, not before.
38
+
39
+ ## [0.19.1] - 2026-09-14
40
+
41
+ ### Changed
42
+ - **`pi-loops sessions` says what a session is, not only which.** It printed the id, the mtime and
43
+ the cwd, so telling two sessions apart meant opening them. It now prints one line a person can
44
+ tell them apart by: the short id, when the session started, the automation it has
45
+ (`[2 cron, 1 trigger]`, `[automation off]`), and what was first said in it — the name it was
46
+ given, or the first message cut at eighty characters, by characters and never inside one.
47
+ `--all` puts the cwd after the id. `src/session-head.ts` reads the head of a session file for
48
+ it; the browser front end's own copy of that reading is unchanged.
49
+ - **The session commands are two words in the browser front end.** `/sessions` lists this
50
+ project's sessions in the feed, and `/session export [path]` / `/session import <path>` are the
51
+ archive commands, delivered as `/session-export` / `/session-import`. pi's built-in `/session` is
52
+ a terminal command that does not exist over rpc — that is why the extension's commands are
53
+ hyphenated in the terminal, and why the page can spell them plainly.
54
+
55
+ ## [0.19.0] - 2026-09-14
56
+
57
+ ### Removed
58
+ - **The machine, as a concept.** Every job and rule used to carry the hostname that created it;
59
+ another machine sharing the `$HOME` ignored it and listed it as `[other host: <name>]`,
60
+ `/cron set <ref> --host here|-` and `/triggers set --host` re-homed it, `cron_list` printed
61
+ `other_host:`, the leader and next-runs files were named `scheduler.<host>.json` and
62
+ `next-runs.<host>.json`, and an imported archive was re-stamped for the importing machine. None
63
+ of it matches the way pi-loops is used: pi runs on one machine, the loops run
64
+ there, and a phone or a laptop reaches the browser front end over a tailnet without moving the
65
+ execution anywhere. All of it is gone. A `host` field an older build left in `jobs.json`,
66
+ `triggers.json` or a `.pisession` archive is ignored when read and dropped on the next write; the
67
+ leader file is `scheduler.json`, the next-runs file `next-runs.json`. Two machines syncing one
68
+ `$HOME` now both run every job — not supported, and said so rather than half-handled
69
+ (`docs/design.md` §19). "Host" means one thing here now: the headless process.
70
+
6
71
  ## [0.18.0] - 2026-09-14
7
72
 
8
73
  ### Added
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.18.0 # or from GitHub, at a pinned tag
41
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.19.3 # 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
  ```
@@ -268,7 +268,7 @@ State stays in `~/.pi/agent/loops` until you delete it.
268
268
 
269
269
  | Command | What it does |
270
270
  |---|---|
271
- | `/cron add [--stateful] [--verify] "<schedule>" <prompt>` | Schedule a job: plain jobs inject their result into this chat, `--stateful` makes a loop with memory and inbox routing, `--verify` adds the checker |
271
+ | `/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 |
272
272
  | `/cron`, `/cron all` | This project's jobs, or every project on this machine |
273
273
  | `/cron enable\|disable\|remove <ref>` | Pause, resume or delete one job — and write an audit entry into the session, as `add` does |
274
274
  | `/cron disable --all` | Pause every job in this project (`--all-projects` for the machine); `/cron enable --all` resumes |
@@ -289,10 +289,10 @@ State stays in `~/.pi/agent/loops` until you delete it.
289
289
  | `/new-trigger <natural language>` | Create a condition-based rule ("when ~/build.done exists, run cargo test") |
290
290
  | `/triggers [status\|rules\|enable\|disable\|remove\|running\|audit [N]\|abort]` | Dynamic rules: what exists, what is running, what happened |
291
291
  | `/triggers run <id>` | Check one rule now instead of waiting for its poll slot |
292
- | `/triggers set <id> --model\|--thinking\|--timeout\|--host` | Change what a rule runs with — the settings that decide how an unattended action behaves |
292
+ | `/triggers set <id> --model\|--thinking\|--timeout` | Change what a rule runs with — the settings that decide how an unattended action behaves |
293
293
  | `/triggers sources`, `/triggers hooks` | Every source feeding the trigger runtime: each connected MCP server, whose notifications can fire a rule; the local crontab; the dynamic checker |
294
294
  | `/triggers panel [on\|off]` | The same panel, toggled from the trigger side |
295
- | `/session-export [path]`, `/session-import <path>` | Portable `.pisession` archive: transcript + jobs + rules + loop state |
295
+ | `/session-export [path]`, `/session-import <path>` | Portable `.pisession` archive: transcript + jobs + rules + loop state (in the browser front end, `/session export` and `/session import` too; `/sessions` lists) |
296
296
  | `/session-share [--public]` | Upload a redacted transcript as a GitHub gist via `gh`, after showing you what it contains. (pi has its own `/share`, which sends the raw session elsewhere first — see [docs/session-archive.md](docs/session-archive.md)) |
297
297
  | `/recipe [list\|show\|add\|update\|remove <name>]` | Install a packaged way of running this project on loops — the issue tracker as a state machine, or one research experiment per run — with one question and one confirmation ([docs/recipes.md](docs/recipes.md)) |
298
298
  | `/pi-loops [install-launcher]` | Version and paths; `install-launcher` puts the `pi-loops` command on your `PATH` |
@@ -344,7 +344,7 @@ notifications, and lifecycle hooks — each has a page here.
344
344
  | `~/.pi/agent/loops/logs/pi-<pid>.log` | what each pi process's automation did |
345
345
  | `~/.pi/agent/loops/triggers.json`, `triggers-audit.jsonl` | dynamic rules and trigger audit |
346
346
  | `~/.pi/agent/loops/{config,mcp,hooks}.toml` | configuration |
347
- | `~/.pi/agent/loops/scheduler.<host>.json` | which pi process currently owns the timer |
347
+ | `~/.pi/agent/loops/scheduler.json` | which pi process currently owns the timer |
348
348
 
349
349
  Set `PI_LOOPS_DIR` to relocate all of it. The notes are yours to edit when the agent got something
350
350
  wrong, and `logs/pi-<pid>.log` is the file to read after an overnight failure.
@@ -354,7 +354,7 @@ wrong, and `logs/pi-<pid>.log` is the file to read after an overnight failure.
354
354
  Scheduled work is only worth trusting if it survives you closing the editor. So "pi was restarted"
355
355
  is treated here as the normal case rather than the exception.
356
356
 
357
- Jobs are machine-global, recorded per host, and never expire. Any open pi can own the timer:
357
+ Jobs are machine-global and never expire. Any open pi can own the timer:
358
358
  leadership is a file with a heartbeat, and when the process holding it exits or dies, the next tick
359
359
  in another window picks it up. A project's trigger checks run in a pi that is open in that project,
360
360
  so a result that belongs in a conversation lands in the right one. A tick a loop missed while
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.18.0 # 或者从 GitHub 装,钉住一个 tag
48
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.19.3 # 或者从 GitHub 装,钉住一个 tag
49
49
  pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
50
50
  ```
51
51
 
@@ -241,7 +241,7 @@ playbook 拷到 `.agents/skills/<name>/`,通过 `.git/info/exclude` 排除在
241
241
  /cron · /cron list|ls|status 本项目的任务,[stateful] 标记;/cron all 看整台机器
242
242
  /cron enable|resume|disable|pause|remove <n|id|name>
243
243
  /cron set <ref> … 改已有任务而不换 id(笔记因此留着):--prompt、--schedule,
244
- 以及 --model、--thinking、--timeout、--name、--host 这几个钉子(`-` 解掉)
244
+ 以及 --model、--thinking、--timeout、--name 这几个钉子(`-` 解掉)
245
245
  /cron run 1 立刻跑一次(once 跑完即删,every 的间隔从现在重算,cron 的下次不变;停用的普通任务会被拒绝)
246
246
  /cron state ci loop 的笔记(状态脊柱)
247
247
  /cron runs [ci] 最近运行,最新在前
@@ -312,7 +312,7 @@ at 2026-09-08 → UTC 零点 ← 只给日期按 UTC,在北京就
312
312
 
313
313
  要表示时间就把时间写出来。另外 `in 10m` / `at` 在**创建那一刻**就被算成一个绝对时刻存下来,之后改时区不影响它;`every 30m` 是纯间隔,和时区、夏令时都无关。
314
314
 
315
- **跨机器的 loop**:没有 `host` 的任务会在任何共享这个 `$HOME` 的机器上跑——`/cron set <ref> --host -` 干的就是把 `host` 这个键从任务上摘掉。stateful loop 的笔记是模型自己写的自由文本,里面常有 watermark(「看到这里为止」),所以 prompt 会要求模型写时间时带上偏移。0.14.1 之前写下的笔记没有这个保障,跨时区又带 watermark 的 loop 值得用 `/cron state <id>` 看一眼。
315
+ **一台机器**:pi-loops 在运行 pi 的那台机器上跑,没有跨机器的概念(0.19.0 去掉了任务上的主机名戳;两台机器同步一个 `$HOME` 会各跑一遍,不支持)。要在别处看它,走 tailnet 开浏览器前端。stateful loop 的笔记是模型自己写的自由文本,里面常有 watermark(「看到这里为止」),而机器的时区会变(设了 `TZ`、带着笔记本出差、容器重建成 UTC),所以 prompt 会要求模型写时间时带上偏移。0.14.1 之前写下的笔记没有这个保障,时区变过又带 watermark 的 loop 值得用 `/cron state <id>` 看一眼。
316
316
 
317
317
  ## Maker/checker:先质疑每条 finding,再给你看
318
318
 
@@ -486,8 +486,8 @@ Output protocol (mandatory):
486
486
 
487
487
  定时任务值不值得信,取决于你关掉编辑器之后会发生什么。所以这里把「pi 重启过」当成常态:
488
488
 
489
- 1. **任务是机器全局的(按主机)**,存在 `~/.pi/agent/loops/jobs.json`,不绑会话、不绑目录(每个任务记住自己的 `cwd` 与 `host`,子代理在那里跑;共享 $HOME 的另一台机器会忽略它)。任何目录里打开的任何 pi 都能看到并执行 stateful 任务;普通任务因为要注入对话,只在创建它的那个会话里触发(`--resume` 回来就继续),会话没了,leader 会把它停用,`/cron gc` 清掉;子代理创建的普通任务归它所服务的那个父会话。
490
- 2. **loop 归本机唯一的 leader 跑;动态检查归「开在那个项目里的那个 pi」跑。** `scheduler.<host>.json` 里放 pid 加心跳,30 秒一 tick;leader 退出或者崩了,其它 pi 在下一 tick 接手。
489
+ 1. **任务是机器全局的**,存在 `~/.pi/agent/loops/jobs.json`,不绑会话、不绑目录(每个任务记住自己的 `cwd`,子代理在那里跑)。任何目录里打开的任何 pi 都能看到并执行 stateful 任务;普通任务因为要注入对话,只在创建它的那个会话里触发(`--resume` 回来就继续),会话没了,leader 会把它停用,`/cron gc` 清掉;子代理创建的普通任务归它所服务的那个父会话。
490
+ 2. **loop 归本机唯一的 leader 跑;动态检查归「开在那个项目里的那个 pi」跑。** `scheduler.json` 里放 pid 加心跳,30 秒一 tick;leader 退出或者崩了,其它 pi 在下一 tick 接手。
491
491
 
492
492
  每个进程每 tick 往 `presence/` 登记自己的 pid、会话和 cwd。一个项目的规则检查和推送评估,就交给开在那个项目里的 pi——优先创建规则的那个会话,其次 pid 最小的那个——所以 promote_to_chat 一定落在对的对话里。项目里一个 pi 都没开,才轮到 leader 代跑,结果进 inbox。
493
493
 
@@ -516,7 +516,7 @@ Output protocol (mandatory):
516
516
  | `~/.pi/agent/loops/spend.json` | 轮转掉的那部分花费按天留一份,预算上限不会因为 run log 被截断而失效 |
517
517
  | `~/.pi/agent/loops/logs/pi-<pid>.log` | 每个 pi 进程的自动化诊断,超 2 MB 保留后半,只留最近五个进程(进程还活着的那份不算) |
518
518
  | `~/.pi/agent/loops/sessions/<id>/*.jsonl` | 子代理完整 transcript,每个 loop 保留最近 20 份 |
519
- | `~/.pi/agent/loops/scheduler.<host>.json` | 当前 leader 的 pid / 心跳(每台机器一份) |
519
+ | `~/.pi/agent/loops/scheduler.json` | 当前 leader 的 pid / 心跳 |
520
520
  | `~/.pi/agent/loops/triggers.json` | 动态 trigger 规则(全局,带 cwd) |
521
521
  | `~/.pi/agent/loops/triggers-audit.jsonl` | trigger audit,超 2 MB 保留后半 |
522
522
  | `~/.pi/agent/loops/sessions/triggers-<项目>-<哈希>/*.jsonl` | 动态检查子代理的 transcript,每个项目一个目录(目录名带项目路径的哈希,同名的两个项目不会共用),保留最近 40 份 |
package/docs/cli.md CHANGED
@@ -213,8 +213,12 @@ pi-loops host status | abort <run-id|trace-id> | stop
213
213
 
214
214
  ## sessions, inspect
215
215
 
216
- `sessions` lists the ids `export --session` accepts, newest first — without it an unknown id was
217
- the only error you could get. `inspect` prints an archive's schedules, prompts and rules without
216
+ `sessions` lists this project's sessions newest first, one line each: the first sixteen
217
+ characters of the id, when the session started, the automation it
218
+ has (`[2 cron, 1 trigger]`, or `[automation off]` when all of it is disabled), and what was first
219
+ said in it — the session's name if it was given one, otherwise the first message, cut at eighty
220
+ characters. `--all` lists every project and puts the cwd after the id. The id is what
221
+ `export --session` accepts; a unique prefix will do. `inspect` prints an archive's schedules, prompts and rules without
218
222
  writing anything, which is what you want before restoring on a machine you care about.
219
223
 
220
224
  ## export
@@ -15,7 +15,7 @@ Everything lives under `~/.pi/agent/loops/` (override: `PI_LOOPS_DIR`).
15
15
  | `runs.jsonl`, `triggers-audit.jsonl` | run log (halved past 1 MB) and trigger audit (2 MB) |
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
- | `scheduler.<host>.json` | timer owner on this host: pid, heartbeat |
18
+ | `scheduler.json` | timer owner on this machine: pid, heartbeat |
19
19
  | `presence/` | one file per live pi process: pid, session, cwd (who acts for which project) |
20
20
  | `host.json`, `host.log` | the headless host that keeps the clock while no pi is open: pid, and its log |
21
21
  | `polls.json` | last dynamic check per project (shared, so a hand-over never double-checks) |
package/docs/design.md CHANGED
@@ -24,19 +24,21 @@ The premise is that **"pi was restarted" is the normal case**. Automation that d
24
24
  it was configured in is automation you cannot rely on, so jobs and rules live on disk for the whole
25
25
  machine. Everything here follows from that, and each one has a price.
26
26
 
27
- 1. **Machine-global jobs and rules, each carrying a `cwd` and a `host`.** `/cron` and
27
+ 1. **Machine-global jobs and rules, each carrying a `cwd`.** `/cron` and
28
28
  `/triggers rules` show the current project by default, and say how many are elsewhere. A plain
29
- (inject) job belongs to the session that created it: listed as `[dormant …]` while that session
30
- is not open, parked as disabled once the session no longer exists (`/cron gc` removes it). A loop
29
+ (inject) job belongs to the session that created it — its result is a message in that
30
+ conversation — and everything that lists it agrees with dispatch about that: another session
31
+ shows it as `[session <id> — resume it to run]` with no next run, `cron_list` says the same to
32
+ the model, `/cron run` refuses it. Asleep is not broken. It is parked as disabled only once its
33
+ session's file has been deleted (`/cron gc` removes it). A loop
31
34
  whose `cwd` disappeared waits half an hour — a mount can be late at boot — and is disabled after
32
- that. Jobs and rules stamped with another host (a shared `$HOME`) are ignored here and listed
33
- with whose they are; leader election is per host.
35
+ that.
34
36
 
35
37
  The cost: a list has to be scoped, and a job you cannot see reads as a job that is gone. Hence
36
38
  the counts, the markers, and `/cron all`.
37
39
 
38
40
  2. **Who runs what.** Every process ticks. Loops run in the machine leader
39
- (`scheduler.<host>.json`, a pid and a heartbeat). A project's dynamic checks and push
41
+ (`scheduler.json`, a pid and a heartbeat). A project's dynamic checks and push
40
42
  evaluations run in a pi that is *open in that project* — preferring the session that created the
41
43
  rules, lowest pid otherwise (`presence/`) — so a promotion lands in the conversation it belongs
42
44
  to. Only a project with no pi open falls to the leader, and its results go to the inbox
@@ -160,6 +162,18 @@ machine. Everything here follows from that, and each one has a price.
160
162
  not a permission, and the permissions that exist (the danger policy, the daily budget, branch
161
163
  protection on the remote) are the ones that hold.
162
164
 
165
+ 19. **One machine.** Until 0.19.0 every job and rule carried the hostname that created it, another
166
+ machine sharing the `$HOME` ignored it and listed it as `[other host]`, `/cron set --host`
167
+ re-homed it, and the leader file was named per host. None of it matched the way pi-loops is
168
+ used: pi runs on one machine, the loops run there, and a phone or a
169
+ laptop reaches the browser front end over a tailnet (`tailscale serve`) without moving the
170
+ execution anywhere. So the concept is gone: no `host` on a job or a rule (a stamp an older
171
+ build left is dropped when the file is read), no `--host`, no marker, `scheduler.json` and
172
+ `next-runs.json` plain. "Host" now means one thing here, the headless process.
173
+
174
+ The cost: two machines syncing one `$HOME` both run every job. That is not supported, and it
175
+ is said rather than half-handled.
176
+
163
177
  ## Where the browser front end came from
164
178
 
165
179
  pi owns the `pi` command and its terminal, so a second front end cannot replace the first one. It
package/docs/loops.md CHANGED
@@ -10,8 +10,14 @@ Everything here is `/cron`; `/crontab` and `/loop` are the same command under ot
10
10
 
11
11
  When due, the prompt is injected into the session that created the job as a user message with the
12
12
  engine prefix `[Trigger <run-id>] `, and the agent runs one turn (an *inject-and-run* job). If the agent
13
- is busy the message is queued as a follow-up. Plain jobs fire only from the process whose current
14
- session created them; `--resume` brings that back.
13
+ is busy the message is queued as a follow-up. A plain job belongs to the session that created it:
14
+ its result is a message in that conversation, so it fires only in the process holding that session.
15
+ Open another session and `/cron` lists it as `[session <id> — resume it to run]` with no next run,
16
+ `cron_list` says the same to the model, and `/cron run` refuses it; `--resume` the session it
17
+ belongs to and it wakes, catching up a missed slot if it was created with `--catchup`. It is not
18
+ broken while it sleeps — it is waiting for its conversation. Something that should run whether or
19
+ not a window is open (a nightly digest, a watch) is a loop: `--stateful`, with the inbox as its
20
+ outlet.
15
21
 
16
22
  Schedules are local time: 5-field cron (`*/n`, ranges, lists, `mon-fri`, `jan`), the aliases
17
23
  (`hourly` / `every hour` / `once an hour` → `0 * * * *`; `daily` / `every day` → `0 9 * * *`;
@@ -106,8 +112,8 @@ and answer with one `<verdict n="i">keep|drop — reason</verdict>` per finding
106
112
 
107
113
  ## Restart behavior
108
114
 
109
- Jobs live in `~/.pi/agent/loops/jobs.json` and survive pi restarts. Exactly one pi process per
110
- host owns the timer (`scheduler.<host>.json`, 30-second ticks, 90-second heartbeat); when it exits
115
+ Jobs live in `~/.pi/agent/loops/jobs.json` and survive pi restarts. Exactly one pi process on the
116
+ machine owns the timer (`scheduler.json`, 30-second ticks, 90-second heartbeat); when it exits
111
117
  or dies another open pi takes over on its next tick. A tick that was missed while no pi was running
112
118
  is fired once at startup (collapsed, not replayed) for stateful loops unless `[cron] catch_up = false`;
113
119
  plain inject jobs do not catch up unless created with `--catchup` (`--no-catchup` turns it off for
@@ -116,12 +122,15 @@ loops). A run that died with its process is retried on the next tick; at most
116
122
  a server pushing many distinct events cannot open one sub-agent per event.
117
123
  Jobs run with the model and thinking level recorded
118
124
  on them (`/cron set <id> --model … --thinking … --timeout …`, `-` to follow the running session).
119
- `/cron scheduler` shows who owns the timer; `/cron` marks jobs as `[dormant …]` when their session
120
- is not open here, parks them as disabled once that session no longer exists (`/cron gc` removes
121
- them), and `[orphan: cwd missing]` when their checkout is gone — disabled half an hour later, since a
122
- mount can be late at boot. A job created by a sub-agent belongs to the session that ran it. A job
123
- stamped with another machine's hostname (a synced `$HOME`, a renamed machine, a rebuilt container) is listed as
124
- `[other host: <name>]` with no next run; `/cron set <ref> --host here` re-homes it.
125
+ `/cron scheduler` shows who owns the timer; `/cron` marks a plain job as `[session <id> — resume it
126
+ to run]` when its session is not open here, parks it as disabled once that session's file has been
127
+ deleted (`/cron gc` removes it), and marks a loop `[orphan: cwd missing]` when their checkout is gone — disabled half an hour later, since a
128
+ mount can be late at boot. A job created by a sub-agent belongs to the session that ran it.
129
+
130
+ pi-loops runs on one machine. Two machines syncing one `$HOME` would both run every job; that is
131
+ not supported (until 0.19.0 a hostname stamp on each job half-handled it, and is now ignored). To
132
+ reach a running pi from elsewhere, use the browser front end over your tailnet ([cli.md](cli.md));
133
+ the loops keep running where pi runs.
125
134
 
126
135
  ## Time, and which clock it is
127
136
 
@@ -147,14 +156,13 @@ Two things are deliberately still UTC, because neither is a time anybody reads:
147
156
  session file, which cannot hold the `+` and `:` an offset brings, and pi's own session header, whose
148
157
  format is pi's to decide.
149
158
 
150
- The offset in the run line above is the offset of **the machine that ran it**, not a fixed part of
151
- the prompt. That matters for one case in particular: a job with no `host` runs on any machine
152
- sharing the `$HOME` (`/cron set <ref> --host -` asks for that), so a stateful loop can alternate
153
- between machines in different timezones. Its notes are free text the model writes — watermarks,
159
+ The offset in the run line above is the offset of the machine **at the time it ran**, not a fixed
160
+ part of the prompt. A machine's clock is not a constant: `TZ` gets set, a laptop travels, a
161
+ container is rebuilt in UTC. A loop's notes are free text the model writes — watermarks,
154
162
  "everything up to here has been seen" — and a watermark without an offset is a watermark the next
155
- machine cannot read. The prompt asks for the offset for that reason. Notes written before this
156
- version do not have it; a loop that crosses timezones and keeps a watermark is worth one look at
157
- `/cron state <id>`.
163
+ run cannot read once the clock has moved. The prompt asks for the offset for that reason. Notes
164
+ written before 0.14.1 do not have it; a loop that keeps a watermark on a machine whose timezone has
165
+ changed is worth one look at `/cron state <id>`.
158
166
 
159
167
  ### Daylight saving
160
168
 
package/docs/triggers.md CHANGED
@@ -97,7 +97,7 @@ rule evaluation, per window for injected pushes).
97
97
  /triggers sources local checker + each MCP server: state, queued/dropped/deduped, tools
98
98
  (/triggers hooks is the same view)
99
99
  /triggers enable <id> /triggers disable <id> /triggers remove <id> also --all | --all-projects
100
- /triggers set <id> --model | --thinking | --timeout | --host: what the action runs with
100
+ /triggers set <id> --model | --thinking | --timeout: what the action runs with
101
101
  /triggers run <id> check one rule now, without waiting for its poll slot
102
102
  /triggers running sub-agents in flight (dynamic checks and cron runs) /triggers abort <trace>|--all
103
103
  /triggers audit [N]
@@ -1,8 +1,9 @@
1
1
  # Troubleshooting
2
2
 
3
3
  **Nothing fires.** `/cron scheduler`: is this process the timer owner or standby? If the owner died
4
- without cleanup, a standby takes over within 90 seconds. Jobs show `next …` when enabled. A plain
5
- job marked `[dormant …]` only fires in the session that created it (`--resume` it); a loop marked
4
+ without cleanup, a standby takes over within 90 seconds. A job shows `next …` when it will run
5
+ *here*. A plain job marked `[session <id> — resume it to run]` belongs to another session and fires
6
+ only there (`--resume` it, or `/cron add` it again in this chat); a loop marked
6
7
  `[orphan: cwd missing]` was disabled because its checkout is gone.
7
8
 
8
9
  **A run failed.** `/cron runs` shows the error; `/cron trace <job> 1` shows the sub-agent's
@@ -116,9 +116,7 @@ weaker, and worth converting whenever one of them breaks.
116
116
  says so as a count — without it, a job somewhere else is indistinguishable from a job that is
117
117
  gone. Jobs and rules are counted and labelled apart, each line naming the command that lists
118
118
  them (`/cron all`, `/triggers rules --all`): one number covering both agrees with neither
119
- command, and the line exists to send you to the command. A job stamped with a hostname this
120
- machine no longer has (a rebuilt container, a rename) is listed and marked, the way `/cron`
121
- lists it, rather than filtered out of sight.
119
+ command, and the line exists to send you to the command.
122
120
  What counts as "this project" is what the extension says it is — symlinks resolved, `$HOME`
123
121
  too broad to be one — because a panel that disagrees with the command is worse than either.
124
122
  `test/web.test.ts`: *the panel and /cron agree about what this project is*.
@@ -173,6 +171,18 @@ weaker, and worth converting whenever one of them breaks.
173
171
  be read as prose and waved through. `test/web-page.test.ts`: *a confirmation shows what is
174
172
  about to run*.
175
173
  - [x] Enter does not approve: the focus starts on cancel.
174
+ - [x] **A tab in the background does not hold one of the browser's six connections.** Every tab
175
+ used to keep an event stream open for as long as it existed, so the sixth tab — or a few
176
+ forgotten behind the first — left nothing for the request that sends what you typed, and the
177
+ message sat drawn and unsent (#14). A tab closes its stream fifteen seconds after going into the
178
+ background and says `paused in background`; looked at again, it subscribes anew and polls, and
179
+ the seq/epoch check reloads what it missed. The pi behind the page runs, and its jobs run, either
180
+ way. `test/web-page.test.ts`: *a tab in the background gives its connection back, and takes a
181
+ new one when looked at again*.
182
+ - [x] **A send that goes nowhere says so.** Six seconds without an answer to `/prompt` and the
183
+ status line reads `still sending — if it never lands, close the other tabs of this address`;
184
+ the request is not abandoned, and the line goes when it settles. `test/web-page.test.ts`: *a
185
+ send that goes nowhere says so instead of looking alive*.
176
186
  - [x] A slash command that waits in a dialog is not reported as timed out while the person reads:
177
187
  the browser waits up to fifteen minutes for a `/…` prompt, sixty seconds for anything else.
178
188
  - [x] Clicking away from any dialog closes it. `test/web-page.test.ts`: *clicking outside a dialog
@@ -215,6 +225,12 @@ weaker, and worth converting whenever one of them breaks.
215
225
  left is a file that `resume` lists, labelled by what was said in it rather than by a filename.
216
226
  `test/web.test.ts`: *a new session is a path pi has not written yet, and going back is one it
217
227
  has*. `test/web-page.test.ts`: *the session you are in is not offered as one to go back to*.
228
+ - [x] **The session commands are two words here.** `/sessions` prints this project's sessions into
229
+ the feed — short id, when, what was first said — and `/session export [path]` / `/session
230
+ import <path>` are the archive commands, sent as `/session-export` / `/session-import`. pi's
231
+ own `/session` is a terminal command that does not exist over rpc, which is why the extension
232
+ had to hyphenate its names in the terminal and why the page is free to spell them plainly.
233
+ `test/web-page.test.ts`: */sessions and /session export are typed here*.
218
234
  - [x] A session started here is a session pi-loops recognises. pi names the sessions it starts after
219
235
  their id and the front end cannot know that id in time, so anything identifying a session by
220
236
  its file name is wrong about this one — which parked every inject-and-run cron job made in a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacoder-v0/pi-loops",
3
- "version": "0.18.0",
3
+ "version": "0.19.3",
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",
@@ -11,7 +11,8 @@ The user's pi has pi-loops installed. Prefer its tools over ad-hoc `sleep` loops
11
11
 
12
12
  | The user wants… | Use |
13
13
  |---|---|
14
- | "every day at 9 / hourly / 每小时 …" run something, result in this chat | `cron_create` with `stateful: false` |
14
+ | "every day at 9 / hourly / 每小时 …" and the result belongs in this chat (a reminder, a question to answer here) | `cron_create` with `stateful: false` — it belongs to this session and runs only while this session is open; the user leaving the window means it sleeps |
15
+ | anything that must run whether or not a window is open — a nightly digest, a report, a watch | `cron_create` with `stateful: true`, even when the user did not say "loop": a plain job cannot run unattended |
15
16
  | recurring watch/triage: "check X and tell me what changed", "report new issues" | `cron_create` with `stateful: true` — the loop keeps notes between runs and reports findings to `/inbox` instead of interrupting the chat |
16
17
  | the findings must be double-checked before the user sees them | `cron_create` with `stateful: true, verify: true` (a second adversarial sub-agent reviews each finding) |
17
18
  | "when <condition> happens, do <action>" (a file appears, a PR merges, a build finishes) | `new_trigger` — condition + action, fires once unless the user asks for repeating |
package/src/archive.ts CHANGED
@@ -362,15 +362,14 @@ export function importSession(input: ImportInput): ImportSummary {
362
362
  }
363
363
  if (!SAFE_ID.test(raw.id)) throw new Error("cron sidecar contains an invalid job id");
364
364
  // Automation off unless activated, stale run bookkeeping cleared.
365
- // `host` is a hard run-time filter (scheduler.ts), so an archive restored on another
366
- // machine must be re-stamped or every job would look enabled and never fire.
365
+ // A `host` stamp from a pre-0.19.0 archive is dropped: pi-loops runs on one machine.
367
366
  // `createdBy` is left as the archive carries it: it names the session that created the job,
368
367
  // which is what lets a second import of the same archive recognise itself. Re-stamping it
369
368
  // with the fresh import session would make every import look new and double the automation.
369
+ const { host: _legacyHost, ...rawJob } = raw as typeof raw & { host?: unknown };
370
370
  const job: LoopJob = {
371
- ...raw,
371
+ ...rawJob,
372
372
  cwd: input.targetCwd,
373
- host: os.hostname(),
374
373
  enabled: raw.enabled && input.activate,
375
374
  running: undefined,
376
375
  lastDueAt: undefined,
@@ -396,7 +395,8 @@ export function importSession(input: ImportInput): ImportSummary {
396
395
  for (const raw of file.rules as DynamicTriggerRule[]) {
397
396
  if (!raw || typeof raw.id !== "string" || typeof raw.condition !== "string" || typeof raw.action !== "string") throw new Error("trigger sidecar contains an invalid rule");
398
397
  if (!SAFE_ID.test(raw.id)) throw new Error("trigger sidecar contains an invalid rule id");
399
- const rule: DynamicTriggerRule = { ...raw, cwd: input.targetCwd, host: os.hostname(), enabled: raw.enabled && input.activate, createdBy: raw.createdBy ?? { sessionId: parsed.header.id } };
398
+ const { host: _legacyRuleHost, ...rawRule } = raw as typeof raw & { host?: unknown };
399
+ const rule: DynamicTriggerRule = { ...rawRule, cwd: input.targetCwd, enabled: raw.enabled && input.activate, createdBy: raw.createdBy ?? { sessionId: parsed.header.id } };
400
400
  if (dedup.hasRule(rule)) continue;
401
401
  if (input.existingRuleIds.has(rule.id)) rule.id = newRuleId();
402
402
  if (raw.enabled) originallyEnabledRules.push(rule.id);
package/src/args.ts CHANGED
@@ -145,7 +145,6 @@ export interface SetArgs {
145
145
  timeoutMs?: number | null;
146
146
  name?: string | null;
147
147
  /** `"here"` = this machine; `null` = any machine. */
148
- host?: string | null;
149
148
  /** Jobs only: the new prompt, verbatim. A loop's notes live under its id, so rewording it here keeps them. */
150
149
  prompt?: string;
151
150
  /** Jobs only: already parsed, so a typo is rejected before it can be stored. */
@@ -163,7 +162,7 @@ export interface SetOptions {
163
162
  * `--model -` (or `current`) removes the pin.
164
163
  */
165
164
  export function parseSetArgs(input: string, opts: SetOptions = {}): SetArgs {
166
- const usage = `usage: /cron|/triggers set <id> [--model <provider/id>|-] [--thinking <level>|-] [--timeout <dur>|-] [--name <n>|-] [--host here|-]${opts.job ? ' [--prompt "<text>"] [--schedule "<expr>"]' : ""}`;
165
+ const usage = `usage: /cron|/triggers set <id> [--model <provider/id>|-] [--thinking <level>|-] [--timeout <dur>|-] [--name <n>|-]${opts.job ? ' [--prompt "<text>"] [--schedule "<expr>"]' : ""}`;
167
166
  const tokens = tokenize(input);
168
167
  const out: SetArgs = { ref: "" };
169
168
  let touched = 0;
@@ -186,12 +185,6 @@ export function parseSetArgs(input: string, opts: SetOptions = {}): SetArgs {
186
185
  else if (t === "--thinking") out.thinking = clear ? null : requireThinkingLevel(val);
187
186
  else if (t === "--timeout") out.timeoutMs = clear ? null : parseDuration(val);
188
187
  else if (t === "--name") out.name = clear ? null : val;
189
- // `--host here` re-homes a job stamped with a machine that no longer exists (a renamed box,
190
- // a rebuilt container, or one half of a synced $HOME); `-` unpins it for any machine.
191
- else if (t === "--host") {
192
- if (!clear && val !== "here") throw new Error("--host takes `here` (this machine) or `-` (any machine)");
193
- out.host = clear ? null : "here";
194
- }
195
188
  // Rewording a loop or moving it to another hour used to mean remove-and-re-add, which mints a
196
189
  // new id — and the notes a stateful loop has been accumulating live at `state/<id>.md`.
197
190
  else if (opts.job && (t === "--prompt" || t === "--schedule")) {
package/src/cli.ts CHANGED
@@ -20,6 +20,7 @@ import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
20
20
  import { liveHost, stopHost } from "./host-control.ts";
21
21
  import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
22
22
  import { JobStore, defaultLoopsDir } from "./store.ts";
23
+ import { automationBadge, previewText, readSessionHead } from "./session-head.ts";
23
24
  import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
24
25
  import { TriggerStore } from "./triggers.ts";
25
26
  import { PI_LOOPS_VERSION } from "./version.ts";
@@ -98,7 +99,7 @@ export const CLI_USAGE = [
98
99
  " Imported automation stays disabled unless --activate-triggers=on (ask: prompt on a terminal).",
99
100
  "",
100
101
  "pi-loops sessions [--all] [--limit <n>]",
101
- " List session ids you can export, newest first.",
102
+ " List sessions, newest first: short id, when it started, its automation, what was first said in it.",
102
103
  "",
103
104
  "pi-loops inspect <file>",
104
105
  " Show what an archive contains without writing anything.",
@@ -128,6 +129,10 @@ interface SessionFile {
128
129
  file: string;
129
130
  cwd: string;
130
131
  mtimeMs: number;
132
+ /** The header's timestamp. */
133
+ startedAt?: string;
134
+ /** `/name`, or the first thing the person said — what tells two sessions apart. */
135
+ preview?: string;
131
136
  }
132
137
 
133
138
  /** pi keeps `<agentDir>/sessions/<encoded cwd>/<timestamp>_<uuid>.jsonl`; the header names the cwd. */
@@ -151,9 +156,9 @@ export function listSessions(sessionsRoot: string): SessionFile[] {
151
156
  if (!name.endsWith(".jsonl")) continue;
152
157
  const file = path.join(dir, name);
153
158
  try {
154
- const header = JSON.parse(fs.readFileSync(file, "utf8").split("\n", 1)[0] ?? "{}");
155
- if (typeof header?.id !== "string") continue;
156
- out.push({ id: header.id, file, cwd: typeof header.cwd === "string" ? header.cwd : "", mtimeMs: fs.statSync(file).mtimeMs });
159
+ const head = readSessionHead(file);
160
+ if (!head) continue;
161
+ out.push({ id: head.id, file, cwd: head.cwd, mtimeMs: fs.statSync(file).mtimeMs, startedAt: head.startedAt, preview: head.name ?? head.first });
157
162
  } catch {
158
163
  /* not a readable session */
159
164
  }
@@ -254,7 +259,14 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
254
259
  out(flags.has("all") ? "no sessions recorded" : `no sessions recorded for ${cwd} (use --all)`);
255
260
  return 1;
256
261
  }
257
- for (const s of here.slice(0, Number(str("limit") ?? 20))) out(`${s.id} ${stamp(s.mtimeMs)} ${s.cwd}`);
262
+ // One line a person can tell sessions apart by: short id, when it started, the automation the
263
+ // session has, what was first said in it. With --all the cwd sits after the id.
264
+ const jobs = new JobStore(loopsDir).load();
265
+ const rules = new TriggerStore(loopsDir).load();
266
+ for (const s of here.slice(0, Number(str("limit") ?? 20))) {
267
+ const badge = automationBadge(s.id, jobs, rules);
268
+ 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());
269
+ }
258
270
  return 0;
259
271
  }
260
272
 
package/src/job-edit.ts CHANGED
@@ -20,7 +20,6 @@ export interface JobEdit {
20
20
  timeoutMs?: number | null;
21
21
  name?: string | null;
22
22
  /** "here" pins the job to this machine; null lets any machine run it. */
23
- host?: string | null;
24
23
  prompt?: string;
25
24
  schedule?: Schedule;
26
25
  }
@@ -38,7 +37,6 @@ export interface AppliedJobEdit {
38
37
 
39
38
  export interface JobEditContext {
40
39
  now: number;
41
- hostName: string;
42
40
  maxPromptBytes: number;
43
41
  /** Called with the requested name and the other jobs; throws if it is not usable as a reference. */
44
42
  checkName: (name: string, others: LoopJob[]) => void;
@@ -66,7 +64,6 @@ export function applyJobEdit(job: LoopJob, edit: JobEdit, ctx: JobEditContext):
66
64
  if (edit.thinking !== undefined) patch.thinking = edit.thinking ?? undefined;
67
65
  if (edit.timeoutMs !== undefined) patch.timeoutMs = edit.timeoutMs ?? undefined;
68
66
  if (edit.name !== undefined) patch.name = edit.name ?? undefined;
69
- if (edit.host !== undefined) patch.host = edit.host === "here" ? ctx.hostName : undefined;
70
67
  if (edit.prompt !== undefined) {
71
68
  patch.prompt = edit.prompt;
72
69
  // The old wording is otherwise unrecoverable, and it is what ties "this loop started
package/src/job-health.ts CHANGED
@@ -10,18 +10,17 @@ import { FAILURE_BACKOFF_AFTER } from "./scheduler.ts";
10
10
  import type { LoopJob } from "./store.ts";
11
11
 
12
12
  /**
13
- * Jobs failing on `hostName`, worst first. The threshold is the scheduler's backoff threshold, which
13
+ * Jobs failing, worst first. The threshold is the scheduler's backoff threshold, which
14
14
  * is the honest place to draw the line: below it a failure is a bad night, at it the scheduler has
15
- * already started widening the gap. A job pinned to another host (a shared $HOME) is that host's to
16
- * report, not ours.
15
+ * already started widening the gap.
17
16
  */
18
- export function failingJobs(jobs: LoopJob[], hostName: string): LoopJob[] {
19
- return jobs.filter((j) => j.enabled && (!j.host || j.host === hostName) && (j.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER).sort((a, b) => (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0));
17
+ export function failingJobs(jobs: LoopJob[]): LoopJob[] {
18
+ return jobs.filter((j) => j.enabled && (j.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER).sort((a, b) => (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0));
20
19
  }
21
20
 
22
21
  /** `2 job(s) failing (check-issues ×7)` — one clause, the worst one named, whatever the count. */
23
- export function failingSummary(jobs: LoopJob[], hostName: string): string | undefined {
24
- const failing = failingJobs(jobs, hostName);
22
+ export function failingSummary(jobs: LoopJob[]): string | undefined {
23
+ const failing = failingJobs(jobs);
25
24
  const worst = failing[0];
26
25
  if (!worst) return undefined;
27
26
  const label = worst.name ?? worst.id;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where a job runs, as one question the scheduler, the lists and `/cron run` all ask the same way.
3
+ *
4
+ * A stateful loop belongs to the machine: whichever process holds the clock runs it. A plain job
5
+ * belongs to the session that created it — its result is a message in that conversation, so it
6
+ * fires only in the process holding that session. Another session sees it, and sees that it is
7
+ * asleep; it is not broken, and nothing here promises it a next run it will not get.
8
+ */
9
+ import type { LoopJob } from "./store.ts";
10
+
11
+ /** Would the process holding `sessionId` dispatch this job? (For a loop, only if it is also the leader.) */
12
+ export function runsIn(job: Pick<LoopJob, "stateful" | "sessionId">, sessionId: string | undefined): boolean {
13
+ if (job.stateful) return true;
14
+ return !!sessionId && job.sessionId === sessionId;
15
+ }
16
+
17
+ /** The short form of the session a plain job belongs to. */
18
+ export function ownerSession(job: Pick<LoopJob, "stateful" | "sessionId">): string | undefined {
19
+ return job.stateful ? undefined : (job.sessionId ?? "?").slice(0, 8);
20
+ }
21
+
22
+ /**
23
+ * The line a list shows for a plain job that will not run here — worded as what to do, because
24
+ * "not open here" was read as an error. Undefined when the job runs here.
25
+ */
26
+ export function asleepNote(job: Pick<LoopJob, "stateful" | "sessionId">, sessionId: string | undefined): string | undefined {
27
+ if (runsIn(job, sessionId)) return undefined;
28
+ return `session ${ownerSession(job)} — resume it to run`;
29
+ }