@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.
- package/CHANGELOG.md +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- 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.
|