@alphacoder-v0/pi-loops 0.17.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
package/docs/hooks.md ADDED
@@ -0,0 +1,110 @@
1
+ # Lifecycle hooks
2
+
3
+ `~/.pi/agent/loops/hooks.toml`; a
4
+ `hooks.toml` works verbatim.
5
+
6
+ ```toml
7
+ allow_project_hooks = false # also: config.toml, or PI_ALLOW_PROJECT_HOOKS=1
8
+
9
+ [[hook]]
10
+ event = "tool_end" # agent_start agent_end run_start run_end turn_start turn_end
11
+ tool = "bash" # message_start message_update message_end tool_start tool_update tool_end compaction
12
+ command = "echo \"$PI_TOOL_NAME error=$PI_TOOL_IS_ERROR\" >> ~/tool-hooks.log"
13
+ timeout_ms = 3000 # default 5000
14
+ cwd = "project" # project | loops | home
15
+ on_failure = "warn" # warn | ignore
16
+
17
+ [[hook]]
18
+ event = "turn_end"
19
+ webhook = "https://example.com/hooks"
20
+ [hook.headers]
21
+ Authorization = "Bearer your-token"
22
+ ```
23
+
24
+ - Commands run through `sh -c` with `PI_*` variables (`HOOK_EVENT`, `HOOK_PAYLOAD`
25
+ → path of a JSON file, `SESSION_ID`, `CWD`, `MODEL_PROVIDER`, `MODEL_ID`, `THINKING_LEVEL`,
26
+ `MESSAGE_KIND`, `ASSISTANT_EVENT`, `TOOL_CALL_ID`, `TOOL_NAME`, `TOOL_IS_ERROR`,
27
+ `COMPACTION_TRIGGER`, `COMPACTION_TOKENS_BEFORE`, `COMPACTION_FAILED`), set only when they have
28
+ a value. `cwd = "loops"` runs the command in `~/.pi/agent/loops`.
29
+ - What a hook prints on **stdout** is written to this process's log, `~/.pi/agent/loops/logs/pi-<pid>.log`,
30
+ as `hook <source> <event>: <output>` — redacted and rotated like everything else there, and cut
31
+ off after 4000 characters so a chatty hook cannot rotate away the night's history. `echo` and
32
+ read the file is the usual way to find out what a hook did. stderr is still kept for the failure
33
+ message only. The headless host does the same into `host.log`, which is where the question is
34
+ usually asked.
35
+ - Webhooks receive `Content-Type: application/json` with a payload whose every field is always
36
+ present, `null` when it does not apply. `message_kind` is `user` | `assistant` | `tool_result`
37
+ | the custom message's type. Summaries are truncated to 2000 characters and not redacted (they
38
+ are your own scripts).
39
+
40
+ ```json
41
+ {"event": "tool_end", "session_id": "…", "cwd": "/path/to/repo", "model_provider": "openai",
42
+ "model_id": "gpt-5.5", "thinking_level": "off", "source": "user", "message_kind": null,
43
+ "message_summary": null, "assistant_event": null, "tool_call_id": "call_…", "tool_name": "bash",
44
+ "tool_is_error": false, "tool_args": {"command": "ls"}, "tool_result_summary": "…",
45
+ "compaction_trigger": null, "compaction_tokens_before": null, "compaction_summary": null,
46
+ "compaction_failed": null}
47
+ ```
48
+
49
+ - `message_update` fires for every streamed delta; use it only when you really need
50
+ streaming-level callbacks. `compaction` carries a truncated summary of your conversation
51
+ (`compaction_trigger` = `auto` | `manual`, `compaction_tokens_before`); send it only to
52
+ destinations you trust.
53
+ - `compaction` also fires when a compaction **failed or was cancelled**, with
54
+ `compaction_failed: true` (`$PI_COMPACTION_FAILED`) and no summary or token count — nothing was
55
+ written. This is the case a watcher most wants: a session
56
+ that cannot compact is a session about to fail on context length. A hook that only cares about
57
+ successful compactions should test it:
58
+
59
+ ```toml
60
+ [[hook]]
61
+ event = "compaction"
62
+ command = 'if [ "$PI_COMPACTION_FAILED" = true ]; then notify-send "compaction failed" "$PI_SESSION_ID"; fi'
63
+ ```
64
+ - Rules for one event run sequentially in file order and are awaited inline, so
65
+ a hook always finishes before the agent moves on and nothing is lost at exit. `[hooks] mode =
66
+ "async"` in `config.toml` queues them off the turn instead (then shutdown waits up to 3 seconds).
67
+ A timeout or Ctrl-C kills the whole process tree. Failures warn (or are ignored per rule) and never
68
+ fail a turn.
69
+ - A malformed rule is skipped with a diagnostic; the rest of the file still loads. Project hooks
70
+ (`<project>/.pi/hooks.toml`) are ignored unless allowed.
71
+ Without a UI (`pi -p`) hook failures go to stderr.
72
+
73
+ ## Exactly when hooks fire
74
+
75
+ - **`agent_*`, `turn_*`, `message_*`, `tool_*` and `compaction` are about a conversation.** The pi
76
+ you are talking to fires them. Sub-agent sessions — loop runs, checkers, trigger checks — fire
77
+ nothing: they are not the session you are in.
78
+ - **`run_start` and `run_end` are about a scheduled run**, and they fire wherever the run happens:
79
+ in the pi you are talking to, and in the headless host ([loops.md](loops.md)). Whether a 3am job
80
+ ran under a host or under a pi you left open is an accident of who held the clock, and a hook rule
81
+ should not be able to tell.
82
+
83
+ They exist because a scheduled run is not a turn. It happens with no conversation at all, or
84
+ beside one — and overloading `agent_*` would mean a rule you wrote about your own turns quietly
85
+ started firing for automation.
86
+
87
+ Dynamic trigger checks and MCP pushes fire no hooks: they poll, and a hook on every poll is noise,
88
+ not news.
89
+ - The `run_*` payload describes the run. Alongside the usual fields it carries `run_job` (the job's
90
+ name or id), `run_id`, and on `run_end` also `run_ok`, `run_findings`, `run_error` and
91
+ `run_cost_usd` — each as `$PI_RUN_JOB`, `$PI_RUN_OK` and so on. So "tell me when a loop fails" is:
92
+
93
+ ```toml
94
+ [[hook]]
95
+ event = "run_end"
96
+ command = '[ "$PI_RUN_OK" = false ] && notify-send "loop $PI_RUN_JOB failed" "$PI_RUN_ERROR"'
97
+ ```
98
+
99
+ In the host, where there is no conversation, `session_id` is the run id and `cwd` — including any
100
+ `cwd = "project"` rule — is the job's directory.
101
+
102
+ - `run_*` hooks are always queued off the run, whatever `[hooks] mode` says, in both processes — a
103
+ webhook that hangs must not hold up the clock, and the run it announces has already started or
104
+ already finished. `mode = "sync"` is about ordering within a conversation turn, and a run is not
105
+ one. The host drains for up to 3 seconds when it exits. Failures go to the host log,
106
+ `~/.pi/agent/loops/host.log`.
107
+
108
+ - Project hooks in the host additionally need the job's cwd to be a directory you trusted in pi
109
+ (that exact directory, not an ancestor): a job's cwd can be chosen by a model, and nobody is
110
+ there to answer a trust prompt.
package/docs/loops.md ADDED
@@ -0,0 +1,315 @@
1
+ # Cron jobs, stateful loops, the inbox, maker/checker
2
+
3
+ Everything here is `/cron`; `/crontab` and `/loop` are the same command under other names.
4
+
5
+ ## Plain cron jobs
6
+
7
+ ```text
8
+ /cron add "*/30 * * * *" summarize the repo state
9
+ ```
10
+
11
+ When due, the prompt is injected into the session that created the job as a user message with the
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.
15
+
16
+ Schedules are local time: 5-field cron (`*/n`, ranges, lists, `mon-fri`, `jan`), the aliases
17
+ (`hourly` / `every hour` / `once an hour` → `0 * * * *`; `daily` / `every day` → `0 9 * * *`;
18
+ `weekly` → `0 9 * * 1`; `每小时`, `每天`, `每周`), and crontab's `@hourly` / `@daily` / `@weekly` /
19
+ `@monthly`. `daily` means 09:00 local; `@daily` means midnight. Also `every 30m`, `in 10m` and
20
+ `at 2026-09-08T18:00` (one-shot jobs are removed after they fire).
21
+
22
+ ## Stateful loops
23
+
24
+ ```text
25
+ /cron add --stateful "0 9 * * *" check the repo issues and report anything new since the last run
26
+ ```
27
+
28
+ Each run is a fresh sub-session inside the interactive pi, through pi's SDK: full tools including the parent's live MCP servers, the session's model and thinking
29
+ level, no conversation history, its own transcript file. Every run gets the same prompt: the output
30
+ protocol, preceded by one line of context (the job's name, when the run started, whether it is a
31
+ catch-up):
32
+
33
+ ```text
34
+ You are running the recurring loop "<name>" (current run started <when it started, on the clock of the machine running it, with that machine's offset>; write any time in your notes with its offset, as that one has). This is a background run: nobody is watching, and your final reply is parsed by a program.
35
+
36
+ [loop-state] (your notes from the previous run of this recurring job)
37
+ <contents of the state file, or "(first run)">
38
+ [/loop-state]
39
+
40
+ <your prompt>
41
+
42
+ Output protocol (mandatory):
43
+ - End your reply with <loop-state>notes for the next run</loop-state> — it REPLACES the saved state; keep it under 2000 characters and make it the information your next run needs (baselines, ids already seen, watermarks).
44
+ - For each finding a human should act on, emit <inbox>one concise line</inbox>. No findings → no inbox tags; do not invent work.
45
+ - Keep everything after the last tool call short so the tags are not truncated.
46
+ ```
47
+
48
+ Then:
49
+
50
+ - the last `<loop-state>` block replaces `state/<id>.md` (capped at 2000 characters);
51
+ - up to 16 `<inbox>` tags (each ≤500 characters) are appended to the inbox; extras are counted as dropped
52
+ — the count is exact up to the first 1000 tags, past which a reply is a runaway rather than a report;
53
+ - missing or malformed tags never fail a run: the state stays untouched, nothing enters the inbox;
54
+ - a run that is still going when the next tick arrives is skipped and counted (`skipped overlaps`).
55
+
56
+ Every run leaves a trail: the sub-agent's full transcript (`/cron trace <job> [k]`, 20 per job), a
57
+ run log with exit code, duration, cost and finding counts (`/cron runs`), and a card in the
58
+ transcript when the run finishes. Prompts are capped at 8 KB.
59
+
60
+ ## The inbox
61
+
62
+ Global JSONL, shared by every session and project, with a stable record shape (`id` = `inb-<32 hex>`,
63
+ `created_at`, `source` = `cron:<job>`, `text`, `trace_id` = the run id, `session_id`, `status`
64
+ `new → claimed | dismissed`) plus pi-loops' `job_id`, `cwd`, `claimed_by`, `verified`,
65
+ `verified_reason`. Job ids are `cron-<32 hex>`; prefixes, names and list numbers resolve.
66
+
67
+ ```text
68
+ /inbox Inbox (<project>, N new, times <offset>): "<n>. [<id prefix>] <finding> (<project>, <source>, <created_at>)"
69
+ /inbox --all the same, every project on this machine
70
+ /inbox all [--all] history including claimed and dismissed
71
+ /inbox claim <n|id> mark claimed and start a real agent turn:
72
+ "A recurring loop (<source>, running in <cwd>) reported this finding — investigate and address it: …"
73
+ (the loop's cwd is part of the line; a checker-kept finding adds a line saying so)
74
+ /inbox dismiss <n|id> /inbox clear [--all]
75
+ ```
76
+
77
+ The file is machine-wide because loops are; triage is not. `/inbox` lists this project's findings
78
+ the way `/cron` lists its jobs, names the project on every line, and says how many are waiting
79
+ elsewhere; `--all` lifts the filter. Numbers are the numbers on screen — `/inbox claim 3` claims
80
+ the third line of *this* project's list, never another repository's finding run in this directory —
81
+ while an id or an id prefix still resolves machine-wide. `/inbox clear` dismisses what it listed,
82
+ not the unread findings of four other projects. A finding stored without a cwd belongs to no
83
+ project and is listed in all of them.
84
+
85
+ Corrupt lines are skipped on read and never deleted. The footer shows `Inbox: N new` (the whole
86
+ machine, as the badge always has) and `N job(s) failing (<worst> ×<count>)` once a loop has failed
87
+ often enough for the scheduler to start backing off; the same clause is on the `[cron] … active
88
+ here` line at session start, for this project. Until then a job that has failed forty nights in a
89
+ row looked exactly like a healthy one. The side panel shows the inbox count too. Entries kept by
90
+ the checker carry a `✓`.
91
+
92
+ ## Maker/checker (`--verify`)
93
+
94
+ With `--verify` (implies
95
+ `--stateful`; `--checker-model provider/id` picks another model) the maker's findings do not go to
96
+ the inbox directly. A second sub-agent receives the loop goal, the maker's notes and the numbered
97
+ findings and is told to assume each may be wrong, stale, duplicated or trivial, verify with tools,
98
+ and answer with one `<verdict n="i">keep|drop — reason</verdict>` per finding (plus an optional
99
+ `<rewrite n="i">…</rewrite>`).
100
+
101
+ - Kept findings enter the inbox marked verified; the checker's reason travels with the claim.
102
+ - Dropped findings and their reasons go to the run log and the run card; `/cron trace <job> <k> checker` shows the checker's transcript.
103
+ - Findings without a verdict enter unverified.
104
+ - **Fail-open:** if the checker fails or times out, every finding enters the inbox marked unverified. A broken checker must not silence the loop.
105
+ - The checker never touches the state spine.
106
+
107
+ ## Restart behavior
108
+
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
111
+ or dies another open pi takes over on its next tick. A tick that was missed while no pi was running
112
+ is fired once at startup (collapsed, not replayed) for stateful loops unless `[cron] catch_up = false`;
113
+ plain inject jobs do not catch up unless created with `--catchup` (`--no-catchup` turns it off for
114
+ loops). A run that died with its process is retried on the next tick; at most
115
+ `[cron] max_concurrent_runs` (3) run at once. Trigger checks share `[cron] max_concurrent_runs`, so
116
+ a server pushing many distinct events cannot open one sub-agent per event.
117
+ Jobs run with the model and thinking level recorded
118
+ 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
+
126
+ ## Time, and which clock it is
127
+
128
+ **Everything is this machine's clock.** Cron expressions are matched against local time — `0 9 * * *`
129
+ is nine in the morning where the machine is, not 09:00 UTC — and there is no per-job timezone. Move
130
+ the machine, or change its `TZ`, and the jobs move with it.
131
+
132
+ Timestamps are written the same way, with the offset that makes them unambiguous — this is what a
133
+ machine at `+08:00` writes; yours writes its own:
134
+
135
+ ```json
136
+ {"startedAt": "2026-09-11T20:37:59.405+08:00", "finishedAt": "2026-09-11T20:38:12.880+08:00"}
137
+ ```
138
+
139
+ That is the same instant `2026-09-11T12:37:59.405Z` names, and anything that parsed one parses the
140
+ other — including what earlier versions wrote. The difference is that opening
141
+ `runs.jsonl` shows the hour you were at your desk, and it agrees with the `next` on the `/cron` line
142
+ that sent you there. `/cron` and `/inbox` say the offset in their header; a timestamp that travels
143
+ somewhere without one — into a sub-agent's prompt, into a tool result a model reads — carries its
144
+ own.
145
+
146
+ Two things are deliberately still UTC, because neither is a time anybody reads: the name of a
147
+ session file, which cannot hold the `+` and `:` an offset brings, and pi's own session header, whose
148
+ format is pi's to decide.
149
+
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,
154
+ "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>`.
158
+
159
+ ### Daylight saving
160
+
161
+ Local time means the clock does what the clock does, and twice a year it does something strange.
162
+ Measured, not assumed (`America/New_York`, 2026):
163
+
164
+ | | what happens |
165
+ |---|---|
166
+ | Spring forward — `0 2 * * *` on 8 March | 02:00 does not exist that day, so the job **does not run**. It runs again the next day. |
167
+ | Fall back — `0 1 * * *` on 1 November | 01:00 happens twice, so the job **runs twice**. |
168
+
169
+ Vixie cron special-cases both (it runs a skipped job once, and a repeated one once). pi-loops does
170
+ not: it matches the wall clock, and the wall clock is what it is. If a job must run exactly once a
171
+ day whatever the clock does, `every 24h` is immune — it counts elapsed time and never consults a
172
+ calendar.
173
+
174
+ ### `in` and `at`
175
+
176
+ `every 30m` is an interval: no timezone, no DST, no wall clock at all.
177
+
178
+ `in 10m` and `at <ISO time>` are resolved **once, when the job is created**, and stored as the
179
+ instant they landed on. Changing the machine's timezone afterwards does not move them. Note what
180
+ JavaScript does with the text you give `at`, which is the standard's rule rather than ours:
181
+
182
+ ```text
183
+ at 2026-09-08T18:00 → 18:00 local
184
+ at 2026-09-08T18:00Z → 18:00 UTC
185
+ at 2026-09-08 → midnight UTC ← a date with no time is UTC, not local
186
+ ```
187
+
188
+ Write the time if you mean a time.
189
+
190
+ ## Changing a job in place
191
+
192
+ ```text
193
+ /cron set <ref> --prompt "check the CI and report only what changed"
194
+ /cron set <ref> --schedule "0 9 * * 1-5"
195
+ ```
196
+
197
+ The first version of a loop's prompt is always slightly wrong, and the fix used to be
198
+ remove-and-re-add — which mints a new job id and leaves the loop's notes behind at the old
199
+ `state/<id>.md`. `--prompt` and `--schedule` edit the job in place, so the id, the notes and the
200
+ run history stay. Both values are one token: quote anything with spaces in it. Unlike the pins,
201
+ neither takes `-` — a job always has a prompt and a schedule, so `-` would have nothing to fall
202
+ back to and is refused rather than silently emptying the prompt (`--prompt "-"`, quoted, is the
203
+ literal text). The previous wording goes to the loops log, so a loop that starts behaving
204
+ differently can be traced to the edit that caused it.
205
+
206
+ The new schedule is parsed before it is stored: a typo leaves the job untouched, and so does an
207
+ expression that can never match (`0 0 30 2 *`). The confirmation prints the next run time, which is
208
+ where a plausible-but-wrong expression shows itself. Changing the schedule does not backfill: a
209
+ cron job's clock restarts at the edit, so slots that only exist retroactively under the new
210
+ expression are not owed. An `every <dur>` job is still measured from its last run — if it last ran
211
+ longer ago than the new interval, it is genuinely overdue and the confirmation says `due now`
212
+ instead of promising a later time. One-shots (`in 10m`, `at <ISO>`) are refused here: the scheduler
213
+ deletes a `once` job after it fires, notes included.
214
+
215
+ `/cron run <ref>` fires a job now instead of at its next due time, and that run counts as a run: a
216
+ `once` job is fired and then removed (an enabled job with nothing left to fire is worse than none),
217
+ an `every <dur>` job's interval restarts from now, and a `cron` job's next run is unchanged — the
218
+ expression says when it runs, not the last run. A disabled plain job is refused rather than run: its
219
+ prompt would land in whichever chat is open now rather than the one it was written for, and a job the
220
+ dead-session sweep parked has to keep the marker `/cron gc` collects it by.
221
+
222
+ A project is matched by realpath and containment, so a pi opened in a subdirectory, a worktree or
223
+ through a symlink sees and runs that project's automation.
224
+
225
+ ## What it costs, and capping it
226
+
227
+ `/cron cost [today|7d|all]` adds up the run log by job, and shows today's spend against the budget
228
+ if one is set. Every loop run, its checker and every trigger check is counted; so is the `/goal`
229
+ evaluator.
230
+
231
+ ```toml
232
+ [limits]
233
+ daily_budget_usd = 5.0 # 0 (the default) means no cap
234
+ ```
235
+
236
+ Once today's automation has cost that much, nothing more is dispatched: loop runs, plain jobs and
237
+ trigger checks stop, the job's `last_error` says so, and the slot stays owed rather than being
238
+ skipped, so work resumes when the day rolls over or the cap is raised. The run log is rotated by
239
+ size, so what it drops is folded into a small per-day ledger first — a cap that forgot yesterday's
240
+ busy morning would stop capping halfway through the day.
241
+
242
+ The cap is also checked **while a run is in flight**, not only before it is dispatched: a run
243
+ admitted at $4.99 of a $5.00 budget would otherwise be free to spend any amount, and three runs
244
+ admitted in the same tick would each be free to. A run is measured once per model turn against
245
+ today's recorded spend plus everything this process has in flight — its own cost so far, the runs
246
+ beside it, and, with `verify = true`, the maker whose findings its checker is reviewing. When that
247
+ total reaches the cap the run is stopped the way the run timeout stops one: pi aborts the
248
+ sub-session and the run is recorded with what it had spent. It is treated as an interruption rather
249
+ than as a job that failed — the concurrency slot is released, the tick stays owed, the job's
250
+ failure streak is untouched and a one-shot is not retired — so the loop simply resumes when the day
251
+ rolls over or the cap is raised, held back until then by the check at dispatch.
252
+
253
+ A stopped run is a failed run in `/cron runs` and `/cron trace`, but never an unexplained one — its
254
+ error is the reason, and the reason leads with the cap:
255
+
256
+ ```
257
+ FAILED: stopped by today's $5.00 daily budget ($4.62 already spent, $0.41 in flight); raise…
258
+ ```
259
+
260
+ The same line lands in the job's `last_error` and in the diagnostics log. A run stopped this way
261
+ keeps its transcript, and its findings up to that point are not written to the inbox — the run did
262
+ not finish, so its `<loop-state>` and `<inbox>` tags are not trusted.
263
+
264
+ ## Stopping, removing, and jobs that keep failing
265
+
266
+ `/cron disable --all` pauses every job in this project (`--all-projects` for the machine) and
267
+ `/cron enable --all` resumes them — quitting pi hands the clock to the host rather than stopping
268
+ anything, so this is how you actually go quiet.
269
+
270
+ If a loop is stuck showing `running` after a process was killed and its pid reused, `/cron clear
271
+ <ref>` releases the marker (it asks first).
272
+
273
+ `/cron remove` keeps the loop's notes (to change a prompt or a schedule, edit the job in place with
274
+ `/cron set` instead); `--purge` deletes them. `/cron gc` collects this project's jobs whose session
275
+ is gone — `--all` every project's, `--purge` also the loop state left behind by jobs that are gone.
276
+
277
+ A job that fails three times in a row is retried on a widening gap (5 minutes, doubling, up to six
278
+ hours) instead of at every due tick, and says so; one success clears the streak.
279
+
280
+ ## When something looks wrong
281
+
282
+ Every diagnostic a pi process produces is written to `logs/pi-<pid>.log` in the loops directory —
283
+ jobs that were disabled, writes that failed, sub-agent warnings — so a loop that failed at 03:00
284
+ still has something to read at 09:00. `/cron scheduler` prints the path. The headless host writes
285
+ `host.log` the same way; both are rotated.
286
+
287
+ `PI_LOOPS_DEBUG=1` adds a line per tool call, provider retry and compaction to that log, which is
288
+ the quickest way to tell a stuck run from a busy one.
289
+
290
+ `/cron snapshot` writes a `pi_loops_snapshot` entry into the session: which MCP servers connected
291
+ and what they exposed, the active tools, the hooks, whether this pi owns the clock, the last
292
+ check. One is written automatically whenever that state changes. The TUI panel shows the same
293
+ thing; the entry is for a front end that is not a terminal — the browser one in
294
+ [src/web.mjs](../src/web.mjs) reads it, and so can anything else that speaks to a session.
295
+
296
+ `/triggers running` shows how long each run has been going and, for loop runs, the transcript that
297
+ is being written right now (`pi --session <file>`), so "is it stuck or is it working" is answerable
298
+ before the run ends. A run gets the tools the
299
+ session that owns the clock has active (a job's `--tools` narrows that, never widens it), plus its
300
+ own project's MCP servers, and the automation tools it calls act in its own project.
301
+
302
+ An unattended run refuses a corpus of dangerous commands: sudo, `curl … | sh`, writing to a block
303
+ device, `mkfs`, `chmod 777 /`, shutdown/reboot, `git push --force` on main/master, pipes into
304
+ `eval`, the fork bomb, and `rm -r -f` aimed at `/`, an absolute path or `$HOME`. The model is told
305
+ why and can do the safe part.
306
+
307
+ When the last pi on the machine quits, it hands the clock to a headless host (`/cron host`): a
308
+ small `node` process with the same stores and runner that keeps loops, trigger checks and MCP
309
+ pushes going — findings still reach the inbox — until the next pi opens and takes the clock back.
310
+ It is started only when there is work for it: an enabled loop or rule for this machine, or an MCP
311
+ server whose pushes inject or have rules to match. `pi-loops host status` shows what it is doing
312
+ while it runs, and `pi-loops host abort <id>` interrupts one run (see [cli.md](cli.md)). `[host] auto = false` turns that off;
313
+ `/cron host start` hands off on quit anyway (this pi only), `/cron host stop` ends a running host
314
+ and cancels the hand-off. `host.log` shows what it did; a host that died is reported by the next
315
+ pi to open. After a reboot nothing runs until a pi opens (which hands off again when it quits).
package/docs/mcp.md ADDED
@@ -0,0 +1,90 @@
1
+ # MCP: notification sources and tools
2
+
3
+ pi has no built-in MCP client; pi-loops carries a small one (stdio and streamable HTTP) that consumes
4
+ notifications and registers the servers' tools with pi.
5
+
6
+ ## Configuration
7
+
8
+ `~/.pi/agent/loops/mcp.toml`, plus `<project>/.pi/mcp.toml` for trusted projects (same server
9
+ name → project wins). The schema:
10
+
11
+ ```toml
12
+ [[server]]
13
+ name = "filesystem" # kind defaults to stdio
14
+ command = "npx"
15
+ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
16
+ # env = { FOO = "bar" } # extra environment for the server process
17
+
18
+ [[server]]
19
+ name = "hub"
20
+ kind = "streamable_http"
21
+ endpoint = "https://example.com/mcp" # https required, 127.0.0.1 excepted
22
+ auth = { kind = "bearer", token_keychain_ref = "PI_MCP_TOKEN_HUB" } # pi's credential store, or a PI_MCP_TOKEN_* env var
23
+ request_timeout_ms = 30000
24
+ sse_idle_timeout_ms = 60000 # reconnect when the event stream goes silent
25
+ body_cap_bytes = 1048576
26
+ reconnect = { initial_ms = 500, max_ms = 30000, max_attempts = 10 }
27
+ inject_summary = true # summary straight into the chat, no model call
28
+ # inject_and_run = true # summary into the chat + one agent turn
29
+ ```
30
+
31
+ A misconfigured server is reported as `mcp server '<name>' failed: …`; the others still connect.
32
+ stdio servers must not set `endpoint`/`auth`; timeouts, caps and reconnect delays must be positive;
33
+ only bearer auth is supported.
34
+
35
+ ## Notifications → triggers
36
+
37
+ Server pushes are mapped: `tools/resources/prompts listChanged` use stable
38
+ keys and collapse to the latest event; `resources/updated` is keyed per URI; custom notifications
39
+ need `_meta.pi_dedup_key` or are dropped at the source and counted. Summaries contain only the
40
+ method name plus bounded, redacted metadata (`notifications/resources/updated uri=…`,
41
+ `_meta.pi_summary` capped at 200 chars) — never raw params.
42
+
43
+ Delivery per server: `inject_summary` (promotion only), `inject_and_run` (user message + one turn),
44
+ or the default: evaluation against the dynamic rules by a sub-agent.
45
+
46
+ ## Tools
47
+
48
+ After the handshake each server's `tools/list` is registered with pi:
49
+ original names (prefixed with `<server>_` on collision), schemas passed through, `tools/call` text /
50
+ image / resource content mapped to tool results, `isError` surfaced as a tool error, and
51
+ `notifications/cancelled` sent when the user interrupts. `/triggers sources` lists the tools per
52
+ server.
53
+
54
+ ## Processes
55
+
56
+ An unattended run refuses a corpus of dangerous commands (see [loops.md](loops.md)); a project that
57
+ legitimately needs one of those commands can list it:
58
+
59
+ ```toml
60
+ [danger]
61
+ allow = ["rm -rf /var/cache/mybuild"]
62
+ ```
63
+
64
+ An entry means that command and nothing else. `rm -rf /var/cache/mybuild/tmp` is covered — a path
65
+ strictly inside the one the entry names — but `rm -rf /var/cache/mybuild /`,
66
+ `rm -rf /var/cache/mybuild/../..` and `rm -rf /var/cache/mybuild; rm -rf /` are not: they lose the
67
+ exemption and are scanned like any other command. The commands reaching this gate are written by a
68
+ model that may have read something hostile, so an entry has to mean one command and not a foothold;
69
+ a run that needs two shapes of a command lists both.
70
+
71
+ A sub-agent shares this process's live clients rather than opening its own, so a browser tab or
72
+ database session opened in the chat is the one the loop sees. When a run's project is not this
73
+ process's own, that project's `.pi/mcp.toml` servers are connected on demand and lent to the run
74
+ (only if the user has trusted that project), so a loop is not at the mercy of which window owns
75
+ the clock; the headless host does the same. Notifications are consumed by interactive processes only; a
76
+ sub-agent ignores what its own connection pushes: sub-agents register no notification
77
+ hooks. A push that injects into the chat (`inject_summary` / `inject_and_run`) reaches every window
78
+ that has the server; a push evaluated against dynamic rules is
79
+ evaluated once per project, by the pi that owns that project's checks.
80
+
81
+ A repeated `[[server]]` name replaces the earlier entry (a diagnostic says so); the project file is
82
+ `<project>/.pi/mcp.toml`.
83
+ A machine-wide dedup window (`~/.pi/agent/loops/dedup.json`, 5 minutes) makes each push count once
84
+ no matter how many pi windows are open, and results are promoted only into a chat that belongs to
85
+ the rule's project (otherwise they go to the inbox). Crashed stdio servers are reconnected with
86
+ exponential backoff, 20 attempts by default, each distinct error reported once (rather than marking them
87
+ disconnected). `/triggers sources` lists MCP servers first, then the cron hook, then the dynamic
88
+ checker, in registration order; a stdio server's last stderr line is shown as `stderr:`
89
+ (diagnostic only — a successful push clears `last error`, stderr never sets it).
90
+ `examples/mcp-notify-server.mjs` is a dependency-free push server to try this with.
Binary file
@@ -0,0 +1,64 @@
1
+ # Session archives
2
+
3
+ pi's built-in `/export` and `/import` move the conversation. Neither moves the
4
+ conversation **and** its automation. pi-loops provides that as `/session-export` and
5
+ `/session-import` (pi already owns `/session`), as a `.pisession` archive — the transcript, the automation, and the loop state:
6
+
7
+ ```text
8
+ pi-session-<first 16 chars of the id>.pisession uncompressed ustar, mode 0600, never overwrites
9
+ manifest.json schema, timestamps, pi / pi-loops versions, source, sha256 of session.jsonl, sensitivity flags
10
+ session.jsonl pi's session file, verbatim
11
+ sidecars/cron.json this project's cron jobs (optional; dropped by --exclude-triggers)
12
+ sidecars/triggers.json this project's trigger rules (optional; dropped by --exclude-triggers)
13
+ loops/<job-id>.md loop state per stateful job (optional; follows the jobs)
14
+ ```
15
+
16
+ ```text
17
+ /session-export [path] [--exclude-triggers]
18
+ /session-import <path> [--activate-triggers=on|off] [--cwd <dir>] [--resume]
19
+ ```
20
+
21
+ Both commands print a sensitivity warning before doing anything, success or failure.
22
+ Import writes a new session file (fresh id, target cwd, `importedFrom` provenance in the header,
23
+ the source machine's parent-session pointer dropped)
24
+ into this project's session directory and rewrites the sidecars: automation disabled
25
+ unless `--activate-triggers=on`, running markers / errors / overlap counters cleared, ids
26
+ regenerated when they collide with existing ones (loop state follows the new id), non-stateful jobs
27
+ rebound to the imported session. Afterwards you are asked once whether to re-enable what was
28
+ enabled in the source. `--resume` switches to the imported session; otherwise `pi --session <path>`.
29
+
30
+ Validation: manifest schema, session checksum, path traversal, and size caps (session 50 MiB,
31
+ sidecars 2 MiB). Every sidecar is validated before the session file is written, and store writes
32
+ are rolled back if one fails, so a rejected archive leaves nothing behind (staged first, then committed). Archives contain the full transcript and tool history; treat them as sensitive.
33
+ They never contain credentials, MCP config or the inbox. Note that pi writes a session file only
34
+ after the first message, so an empty session has nothing to export.
35
+
36
+ ## /session-share — the transcript as a gist
37
+
38
+ `/session-share` renders the transcript to Markdown and runs `gh gist create`, borrowing the GitHub
39
+ CLI's credentials so nothing here ever has to hold one of yours:
40
+
41
+ ```text
42
+ /session-share secret gist (unlisted; anyone with the link can read it)
43
+ /session-share --public public gist
44
+ ```
45
+
46
+ pi has a built-in `/share` of its own, and it is not the same thing. It exports the raw session
47
+ JSONL and offers it to a hosted gateway first, falling back to a private gist, with no redaction
48
+ and nothing shown to you beforehand. This command renders the transcript as Markdown, runs it
49
+ through the redactor, writes a local copy, tells you what is in it, and only then asks — and it
50
+ only ever talks to `gh`.
51
+
52
+ Everything goes through the same redactor the rest of pi-loops uses (`src/redact.ts`), and the
53
+ command shows what it is about to publish — messages, tool results, size, how many secrets it
54
+ masked, and the path of the local copy in `<loops dir>/shares/` — then asks. Declining leaves the
55
+ rendered file on disk and uploads nothing. `gh` must be installed and logged in (`gh auth login`);
56
+ the gist is created by your account, and `gh gist delete <id>` removes it.
57
+
58
+ The redactor is a net, not a guarantee. It knows the common shapes — provider keys, bearer tokens,
59
+ JWTs, PEM blocks, `NAME=value` and `name: value` pairs whose name mentions a token, secret,
60
+ password or key — and it does not know your employer's key format. A secret wrapped across two
61
+ lines of tool output is two strings as far as it is concerned, and neither half matches. A
62
+ transcript contains every file the agent read and every command it ran, so "0 secrets masked"
63
+ means nothing matched, not that there is nothing in there. Read the local copy first — that is why
64
+ the command writes one before asking.