@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/cli.md ADDED
@@ -0,0 +1,252 @@
1
+ # The `pi-loops` command line
2
+
3
+ `pi-loops` starts a session. With no arguments it opens the browser front end when you are at a
4
+ local terminal, and pi itself when you are not — over ssh, or with no terminal at all, where a
5
+ browser on this machine would help nobody. `--web` and `--tui` say which when the guess is wrong.
6
+
7
+ ```
8
+ pi-loops # start a session, in whichever window makes sense here
9
+ pi-loops --tui # the terminal one
10
+ pi-loops --web --port 4200 # the browser one, on a port you chose
11
+ pi-loops --model anthropic/claude-opus-5 -e . # anything it does not recognise goes to pi
12
+ ```
13
+
14
+ Both are complete pi sessions: the browser one runs `pi --mode rpc` behind a page ([src/web.mjs](../src/web.mjs)),
15
+ so the session file, `--resume`, your models, tools and extensions are the same either way. What
16
+ the browser cannot do is pi's own built-in slash commands, which do not exist in that mode, and
17
+ `/login`, whose OAuth flow has no equivalent — log in once with `pi` and the rest follows.
18
+
19
+ Ctrl-C in the terminal you started it from ends the session the way `/quit` does: pi is asked to
20
+ shut down rather than killed, so it still hands the clock to a headless host when there are loops,
21
+ rules or MCP pushes to keep running, and the line saying so is printed before the window closes. That
22
+ line is pi's own when it arrives in time — pi addresses it to the window rather than to the terminal,
23
+ so when it does not, the front end reads `host.json` itself and says either `automation handed to a
24
+ background host (pid N); pi-loops host status | stop` or `no background host started; automation is
25
+ not running — see <loops dir>/host.log`.
26
+
27
+ ### The address
28
+
29
+ Always `http://127.0.0.1:4173/`. Bookmark it. The port is fixed rather than "whatever was free",
30
+ and the token that guards it lives in `~/.pi/agent/loops/web-token` (mode 0600) rather than being
31
+ made fresh every launch — so the address is the same one tomorrow, and the first visit leaves a
32
+ cookie that means you never see the token again. `--port <n>` moves it if 4173 is spoken for.
33
+
34
+ There is a token at all because anything that reaches this server gets your whole session, and
35
+ "anything" includes a website you have open in another tab: it cannot read the answers, but
36
+ without a check it could still tell your agent what to do. The cookie is `SameSite=Strict`, which
37
+ is a browser's promise not to send it on anything another site started, so the cost of this to you
38
+ is one visit and then nothing.
39
+
40
+ Running `pi-loops` a second time while one is up does not fail on the busy port — it opens the
41
+ window that is already there and leaves.
42
+
43
+ ### From a phone
44
+
45
+ The front end binds loopback, which a phone cannot reach. Two ways round it, and the first is
46
+ better:
47
+
48
+ **Tailscale.** Leave the server where it is and put Tailscale in front:
49
+
50
+ ```
51
+ tailscale serve --bg 4173
52
+ ```
53
+
54
+ Your phone then opens `https://<machine>.<tailnet>.ts.net/` — TLS terminated on the tailnet, only
55
+ your own devices, and this server still bound to 127.0.0.1. It arrives here looking local and
56
+ carrying the tailnet name as its `Host`, which is accepted for that reason.
57
+
58
+ **The local network.** `pi-loops --host 0.0.0.0` binds every interface and prints the addresses a
59
+ phone on the same wifi can use. Anything that can open the port is then talking to your session, so
60
+ the token is not optional here — `--no-auth` is refused outright in this mode.
61
+
62
+ `tailscale funnel` is the same shape and puts it on the open internet; the token is what stands
63
+ between that and anyone, so `--no-auth` refuses to serve anything but loopback no matter which
64
+ route a request took to arrive.
65
+
66
+ Either way the phone has to get in once, and the token is 32 hex characters, which is fine to click
67
+ and miserable to type. So: press **add device** in the browser you are already signed in on. It
68
+ shows a **QR code** — point the phone at it and it is in, having typed nothing — and the same six
69
+ digits underneath for when a camera is not the thing you want to use. The terminal prints a code at
70
+ startup too.
71
+
72
+ The QR encodes the address *this browser reached the server on*, which under `tailscale serve` is
73
+ the tailnet name and is the one that works from anywhere on your tailnet. If the window you press it
74
+ in is on `127.0.0.1`, there is no address to put in a QR that would work from a phone, and it says
75
+ so instead of showing you one that does not.
76
+
77
+ A code is good for one use and lasts ten minutes; twenty wrong guesses disable it until you ask
78
+ for another.
79
+
80
+ On a network you do not own, remember that `--host` is plain http: the cookie it hands out carries
81
+ a token that outlives the process, and anyone on the wire can read it. That is the case
82
+ `tailscale serve` exists for, and the startup output says so.
83
+
84
+ Add it to the home screen and it opens without browser chrome: there is a web app manifest, and no
85
+ service worker — nothing here is worth caching, and a stale copy of a live front end is worse than
86
+ no copy.
87
+
88
+ ### Dropping the token
89
+
90
+ `pi-loops --no-auth` drops the token entirely: no cookie, no query string, nothing to carry, and
91
+ anything on this machine that can open port 4173 has your session. What is left is the check that
92
+ the request did not come from another site — a browser tells the truth about that, and it is what
93
+ keeps a page on `http://localhost:5173` from posting into your agent — but any *program* running as
94
+ any user on this machine is then in. It is the right trade on a machine only you use, and the wrong
95
+ one on a shared host.
96
+
97
+ A browser that has never been here (a different one, or after clearing cookies) gets a page saying
98
+ so. Start a session from a terminal on this machine and it will open a window that works from then
99
+ on, or use the `?token=…` address the terminal printed.
100
+
101
+ ### It starts a session; it does not attach to one
102
+
103
+ The browser front end starts its own `pi --mode rpc`. It does not join a pi you already have open
104
+ in a terminal — two front ends driving one agent is not something pi offers, and pretending
105
+ otherwise would mean two windows disagreeing about whose turn it is.
106
+
107
+ Picking up where you left off is a different thing, and pi already has it. The flags reach pi
108
+ unchanged, so:
109
+
110
+ ```
111
+ pi-loops --continue # the newest session in this directory, in the browser
112
+ pi-loops --resume # pick one
113
+ pi-loops --session <id-or-path> # a particular one
114
+ ```
115
+
116
+ That also means you can hand a session between windows: quit the terminal one, `pi-loops --continue`,
117
+ and carry on in the browser with the same transcript.
118
+
119
+ ### Starting over, without leaving the window
120
+
121
+ The context is finished with far more often than the window is, so the things you do between turns
122
+ are in the page rather than in the terminal that launched it:
123
+
124
+ | | |
125
+ |---|---|
126
+ | **clear** / `/clear`, `/new` | start a new session here |
127
+ | **resume** / `/resume` | go back to an earlier session in this project |
128
+ | **compact** / `/compact <what to keep>` | summarise what is there and carry on |
129
+
130
+ Nothing is deleted by any of them. The session you leave is a file on disk, and **resume** lists
131
+ this project's sessions newest first, each labelled by what was said in it rather than by a
132
+ filename. Compaction reports what the context was, what it became, and what the summary cost.
133
+
134
+ Underneath, **clear** and **resume** are pi's `switch_session`: the session is replaced inside the
135
+ process that is already running, so the model, the MCP connections and the extensions are the same
136
+ ones, rebuilt against the new session. That is what `/new` and `/resume` do in the terminal, and
137
+ automation follows it the same way — the scheduler stops and starts with the session rather than
138
+ handing the clock to the headless host, and a scheduled run the swap interrupts gives its slot back
139
+ and fires again on the next tick.
140
+
141
+ One thing the front end adds on top of the swap: **resume** puts the session back on the model it
142
+ was last using. pi re-resolves the launch command line's `--model` on every swap — and the launcher
143
+ supplies one from the model you last chose ([configuration.md](configuration.md#what-is-remembered-between-sessions))
144
+ — so without this, going back to a conversation had with one model landed it on a different one. If
145
+ the model it used is not available any more (credentials gone), the session stays on the one it has
146
+ and the terminal says which model it could not go back to.
147
+
148
+ Both are refused while a turn is running, because the swap would abort it: stop the turn first. A
149
+ session started this way is named by the front end rather than by pi, which is invisible everywhere
150
+ except that its filename does not contain its session id — pi and pi-loops both identify a session
151
+ by the id in its header.
152
+
153
+ ## Getting the command onto your PATH
154
+
155
+ `pi install` puts this package under pi's managed directory rather than on your `PATH`, so the
156
+ command that is meant to start your sessions is at first reachable only by absolute path — and
157
+ putting itself there is the one thing it cannot do for itself. Run it from inside pi, where the
158
+ extension is already loaded:
159
+
160
+ ```text
161
+ /pi-loops install-launcher
162
+ ```
163
+
164
+ or from the directory pi installed the package into — `~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops`
165
+ for a `pi install npm:` package, `~/.pi/agent/git/<host>/<owner>/<repo>` for a `pi install git:` one:
166
+
167
+ ```bash
168
+ node src/cli-entry.mjs install-launcher
169
+ node src/cli-entry.mjs install-launcher --dir ~/bin
170
+ ```
171
+
172
+ It writes a two-line `sh` script that names the node you ran it with and the package it lives in —
173
+ a launcher rather than a symlink, so it keeps working if either moves for the other's reason.
174
+
175
+ ## Upgrading
176
+
177
+ ```bash
178
+ pi-loops upgrade # install the newest release
179
+ pi-loops upgrade --check # say whether there is one, and stop
180
+ ```
181
+
182
+ It reads release tags from the repository this copy came from (`repository.url` in its
183
+ `package.json`, so a fork upgrades from the fork), takes the highest `vN.N.N` — comparing
184
+ numerically, so `v0.10.0` beats `v0.9.0` — and runs `pi install` for it. Release candidates and
185
+ branch-shaped tags are ignored: those are not things to move someone onto without being asked.
186
+
187
+ `pi update --extensions` does something different and both are useful: it reconciles every package
188
+ to the ref already pinned in your settings, which is how you repair a clone, not how you take a new
189
+ version.
190
+
191
+ ## The tools
192
+
193
+ The rest of the command line does not need a pi session and has no build step; it resolves pi's
194
+ packages the way the headless host does. `PI_LOOPS_DIR` selects the loops directory, as everywhere.
195
+
196
+ ```
197
+ pi-loops sessions [--all] [--limit <n>]
198
+ pi-loops inspect <file>
199
+ pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]
200
+ pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
201
+ pi-loops host status | abort <run-id|trace-id> | stop
202
+ ```
203
+
204
+ ## sessions, inspect
205
+
206
+ `sessions` lists the ids `export --session` accepts, newest first — without it an unknown id was
207
+ the only error you could get. `inspect` prints an archive's schedules, prompts and rules without
208
+ writing anything, which is what you want before restoring on a machine you care about.
209
+
210
+ ## export
211
+
212
+ Bundles a session's transcript with the automation that session created — its cron jobs, trigger
213
+ rules and loop state — into one `.pisession` archive.
214
+
215
+ With no `--session`, the newest session recorded for `--cwd` (default: the current directory) is
216
+ taken. `--session` accepts a full id or a unique prefix. `--exclude-triggers` drops every automation
217
+ sidecar.
218
+
219
+ ```
220
+ pi-loops export --session 01a084a0 --output ~/backups/api.pisession
221
+ ```
222
+
223
+ ## import
224
+
225
+ Restores an archive into `--cwd` (default: the current directory), rewriting ids, project and
226
+ machine so the automation runs where it landed.
227
+
228
+ Imported automation stays disabled unless `--activate-triggers=on`; `ask` prompts on a terminal.
229
+ Importing the same archive twice adds nothing the second time.
230
+
231
+ ## host
232
+
233
+ The headless host has no chat, so `host status` is how you see what it is doing while no pi is
234
+ open: the loop runs and trigger checks in flight, what is enabled, the inbox count, and each MCP
235
+ server's state.
236
+
237
+ ```
238
+ $ pi-loops host status
239
+ background host
240
+ pid 2949521 on box, started 2026-09-09T13:33:16.652+08:00, model openai-codex/gpt-5.5
241
+ owns the clock · 1/1 loop(s), 0/0 rule(s) enabled · inbox: 0 new
242
+ running nightly (run-97ffb250) since 2026-09-09T13:34:16.676+08:00: check the repo issues
243
+ ```
244
+
245
+ The stamps carry the offset of the machine the host runs on ([loops.md](loops.md#time-and-which-clock-it-is));
246
+ these are a `+08:00` machine's.
247
+
248
+ `host abort <id>` interrupts one run or trigger check (the shortened ids the status prints are
249
+ accepted). `host stop` ends the host; the next pi to open would have taken the clock back anyway.
250
+
251
+ The channel is a unix socket at `host.sock` in the loops directory, created 0600, and it is
252
+ read-mostly on purpose: a host you could prompt would be a second chat.
@@ -0,0 +1,110 @@
1
+ # Configuration
2
+
3
+ Everything lives under `~/.pi/agent/loops/` (override: `PI_LOOPS_DIR`).
4
+
5
+ | File | Purpose |
6
+ |---|---|
7
+ | `config.toml` | `allow_project_hooks`, `[triggers] poll_interval_secs = 600` / `run_timeout_secs = 900`, `[cron] catch_up = true` / `max_concurrent_runs = 3`, `[hooks] mode = "sync"`, `[host] auto = true`, `[limits] daily_budget_usd = 0`, `[danger] allow = []` |
8
+ | `logs/pi-<pid>.log` | what each pi process diagnosed: jobs disabled, writes that failed, sub-agent warnings. Rotated at 2 MB, newest 5 processes kept |
9
+ | `mcp.toml` | MCP servers — see [mcp.md](mcp.md) |
10
+ | `hooks.toml` | lifecycle hooks — see [hooks.md](hooks.md) |
11
+ | `ui.json` | preferences that outlive a session: `panel` (written by `/cron panel on\|off`), and `model` / `thinking` — the last ones you chose in the browser front end, applied to the next session that does not say otherwise |
12
+ | `jobs.json`, `triggers.json` | cron jobs and trigger rules (machine-global, each with a `cwd`) |
13
+ | `state/<id>.md` | loop notes; plain Markdown, editable |
14
+ | `inbox.jsonl` | the inbox (past 1 MB the oldest triaged findings are dropped; nothing still new) |
15
+ | `runs.jsonl`, `triggers-audit.jsonl` | run log (halved past 1 MB) and trigger audit (2 MB) |
16
+ | `spend.json` | per-day totals of what rotation dropped from the run log, so a daily budget still counts it |
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 |
19
+ | `presence/` | one file per live pi process: pid, session, cwd (who acts for which project) |
20
+ | `host.json`, `host.log` | the headless host that keeps the clock while no pi is open: pid, and its log |
21
+ | `polls.json` | last dynamic check per project (shared, so a hand-over never double-checks) |
22
+ | `dedup.json` | machine-wide trigger dedup window (5 minutes) |
23
+ | `web-token` | the browser front end's token, mode 0600. It lives in a file rather than being made per launch so the address stays the same one and a signed-in device stays signed in across restarts and upgrades. Delete it to sign every device out |
24
+
25
+ Project-level: `<project>/.pi/mcp.toml` (trusted projects only) and `<project>/.pi/hooks.toml`
26
+ (when allowed).
27
+ An invalid `[triggers] poll_interval_secs` (or `--trigger-poll-secs`) is reported at startup
28
+ and ignored.
29
+
30
+ ## `[cron] max_concurrent_runs`
31
+
32
+ How many **sub-agents** this pi process may have in flight at once — not how many loop runs. Loop
33
+ runs, dynamic-trigger checks and trigger actions all draw on the same counter, because they are the
34
+ same thing to the machine: an in-process pi session, on your model, on your bill. With
35
+ `max_concurrent_runs = 3` you get at most three of them together, in any mix.
36
+
37
+ The count is per process. Two pi windows are two processes and two counters; nothing is
38
+ coordinated across them, because what the cap protects — this process's memory, sockets and
39
+ in-flight requests — is not shared either.
40
+
41
+ What a refusal means differs by pipeline, on purpose:
42
+
43
+ - a **loop run** is deferred, not skipped: the tick stays owed, `/cron list` shows
44
+ `deferred: N sub-agent(s) already in flight`, and the next tick tries again;
45
+ - an **MCP push** is held (up to 32) and retried on the next tick, because no server sends the same
46
+ event twice;
47
+ - a **periodic trigger check** is dropped, because the next poll asks the same question of a world
48
+ that has moved on.
49
+
50
+ Two kinds of sub-agent **count against the cap but are never refused a slot**: `/cron run`, and the
51
+ `/goal` evaluator. Both are things you asked for directly — the evaluator runs after every turn of a
52
+ session that has a goal, one at a time, and a busy machine quietly declining to evaluate would be
53
+ indistinguishable from a goal that was never set. They can therefore take the total past the limit,
54
+ which is why the in-flight count is reported against it (`4 of 3 sub-agent slot(s) in use`) rather than assumed
55
+ to be under it. If you have a goal running and want headroom for it, size the setting one higher.
56
+
57
+ ## Flags and environment
58
+
59
+ | | |
60
+ |---|---|
61
+ | `--trigger-poll-secs <n>` | dynamic trigger poll interval for this run |
62
+ | `PI_LOOPS_DIR` | relocate the data directory |
63
+ | `PI_WEB_TOKEN` | use this instead of the token in `web-token`. Letters, digits, `-` and `_`, at least 8 of them: it is substituted into a JavaScript string in the page, and a quote there would end the string early |
64
+ | `PI_ALLOW_PROJECT_HOOKS=1` | allow project hooks. `true` works too; anything else is off |
65
+ | `PI_LOOPS_HOST=1` | let a `pi -p` run host the timer for as long as it lives (the headless host below is the normal answer) |
66
+ | `PI_LOOPS_DEBUG=1` | add a line per provider retry, compaction and tool call of a sub-agent run to `logs/pi-<pid>.log` |
67
+ | `PI_BIN` | the pi executable to start, when it is not the `pi` on your `PATH` |
68
+
69
+ ## What is remembered between sessions
70
+
71
+ A session opens on pi's default model, so choosing the same one every morning was the first thing
72
+ the browser front end asked of anybody. Changing the model or the thinking level there records it in
73
+ `ui.json`, and `pi-loops` applies it when it starts the next session — the terminal window too,
74
+ since the launcher is what applies it.
75
+
76
+ It is not applied when you said which model yourself (`pi-loops --model …`), and not when the
77
+ session already has one: `--continue`, `--resume`, `--session` and `--session-id` keep the model
78
+ their conversation was had with. Delete the keys from `ui.json` to go back to pi's default.
79
+
80
+ A `--model` — yours or the remembered one — lasts as long as the process, not as long as the
81
+ session: pi re-resolves it every time the session inside the process is replaced. So **resume** in
82
+ the browser front end re-applies the model the session it is going back to was last using, which is
83
+ what a fresh `pi --resume` would have done.
84
+
85
+ ## The browser front end
86
+
87
+ `pi-loops` takes these for itself and passes everything else to pi. Full explanation in
88
+ [cli.md](cli.md); this is the list.
89
+
90
+ | | |
91
+ |---|---|
92
+ | `--web` / `--tui` | which window, when the guess is wrong |
93
+ | `--port <n>` | default 4173, fixed on purpose so the address is worth bookmarking |
94
+ | `--host <addr>` | bind somewhere other than loopback, so a phone on the same network can reach it. Prints the addresses it can be reached on, and says that they are unencrypted |
95
+ | `--allow-host <name,…>` | accept these values in the `Host` header, for a reverse proxy in front. A name resolved by public DNS puts the token back in charge of keeping strangers out |
96
+ | `--no-auth` | no token and no cookie. Loopback only, whatever route a request took, and refused outright with `--host` |
97
+ | `--no-open` | do not open a browser |
98
+ | `--loops-dir <dir>` | the same thing `PI_LOOPS_DIR` does |
99
+
100
+ Tailnet names (`*.ts.net`) are accepted from a tailnet connection without `--allow-host`, which is
101
+ what makes `tailscale serve --bg 4173` work with the server still on loopback.
102
+
103
+ ## Per-job options (`/cron add`)
104
+
105
+ `--name`, `--cwd <dir>`, `--model provider/id`, `--thinking <level>`, `--tools a,b`
106
+ (allowlist for the sub-agent), `--timeout 20m` (default 15m), `--catchup` / `--no-catchup` (default: loops on, plain jobs off), `--stateful` (or `--loop`),
107
+ `--inject` (a plain job — the default, spelled out), `--verify`, `--checker-model provider/id`. Change model, thinking, timeout or name later with
108
+ `/cron set <id> …` (`-` = follow the running session). Sub-agents run inside the interactive pi
109
+ (pi's SDK) and share its live MCP servers, its `-e` extensions, system-prompt and skill flags, its
110
+ model unless the job pins one, and the project's trust when they run in the same project.
package/docs/design.md ADDED
@@ -0,0 +1,155 @@
1
+ # Design
2
+
3
+ ## What each piece is built out of
4
+
5
+ pi-loops is a plain extension. Everything below is a public pi API doing a job the runtime does not
6
+ do for you.
7
+
8
+ | the job | what does it |
9
+ |---|---|
10
+ | a clock | `setInterval` started in `session_start`, cleared in `session_shutdown` |
11
+ | a scheduled job that lands in your chat | `pi.sendUserMessage` (follow-up queue when busy), `[Trigger <id>] ` prefix |
12
+ | a scheduled job that must not touch your chat | an in-process `AgentSession` from pi's SDK (`src/sdk-runner.ts`): fresh context, own transcript, the parent's tools, MCP clients, extensions, model and trust |
13
+ | state that outlives a session | `jobs.json`, `triggers.json`, `state/<id>.md` under `~/.pi/agent/loops` |
14
+ | a record of who changed what | `pi.appendEntry` for cron control operations; `triggers-audit.jsonl` for trigger runs |
15
+ | something to look at | transcript cards via `pi.appendEntry` + an entry renderer; a widget above the editor; a footer badge via `ctx.ui.setStatus` |
16
+ | a decision only a person should make | `ctx.ui.confirm` inside the tool |
17
+ | notifications from outside | `src/mcp.ts`, a client that consumes them |
18
+ | a way to react to the session's own events | `src/hooks.ts` |
19
+ | a session you can carry to another machine | `src/archive.ts` |
20
+
21
+ ## The decisions, and what each costs
22
+
23
+ The premise is that **"pi was restarted" is the normal case**. Automation that dies with the window
24
+ it was configured in is automation you cannot rely on, so jobs and rules live on disk for the whole
25
+ machine. Everything here follows from that, and each one has a price.
26
+
27
+ 1. **Machine-global jobs and rules, each carrying a `cwd` and a `host`.** `/cron` and
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
31
+ 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.
34
+
35
+ The cost: a list has to be scoped, and a job you cannot see reads as a job that is gone. Hence
36
+ the counts, the markers, and `/cron all`.
37
+
38
+ 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
40
+ evaluations run in a pi that is *open in that project* — preferring the session that created the
41
+ rules, lowest pid otherwise (`presence/`) — so a promotion lands in the conversation it belongs
42
+ to. Only a project with no pi open falls to the leader, and its results go to the inbox
43
+ (`redirected`). The poll interval is enforced machine-wide (`polls.json`), so a hand-over never
44
+ double-checks.
45
+
46
+ 3. **Model and thinking level are recorded when a job is created** and editable afterwards
47
+ (`/cron set`, `/triggers set`; `--model -` follows the running session). A loop that was set up
48
+ against a capable model does not quietly start running on whatever the newest session happens to
49
+ be using.
50
+
51
+ 4. **MCP pushes.** A push that injects into the chat reaches every window that has the server. A
52
+ push evaluated against rules is evaluated once per project, by that project's owner. Sub-agents
53
+ ignore pushes; anything that reaches hop ≥ 1 is audited as `cycle_suppressed`.
54
+
55
+ 5. **Sub-agents are sessions, not processes** (`createAgentSession`). They share the parent's live
56
+ MCP client instances — the browser tab or database session opened in the chat is the one a loop
57
+ sees — its `-e` extensions, system prompt and skill flags, its model unless the job pins one, the
58
+ project's trust when the run is in a project this session or an earlier `/trust` decision
59
+ trusted (never by default), and the parent session's id, so plain jobs they schedule bind to it.
60
+ The parent's extensions get their `session_start` / `session_shutdown` in every sub-session.
61
+ Each run gets its own transcript file and a configurable cap (`[triggers] run_timeout_secs`,
62
+ per-rule `--timeout`). Nothing is re-spawned per run.
63
+
64
+ The cost: a sub-agent has no UI, so a tool that would ask for confirmation is denied
65
+ fail-closed there. Nobody can say yes, so the answer has to be no.
66
+
67
+ 6. **Catch-up.** A loop tick missed while no pi was running is fired once at startup, collapsed
68
+ rather than replayed (`[cron] catch_up = false` or `--no-catchup` turns it off). Plain jobs do
69
+ not catch up unless `--catchup`: they land in a conversation, and a conversation that opens to
70
+ four hours of backfill is worse than one that opens to nothing. A run that died with its process
71
+ is retried. `[cron] max_concurrent_runs` (3) bounds the burst.
72
+
73
+ 7. **Audit is both a file and part of the session.** A machine-wide JSONL
74
+ (`triggers-audit.jsonl`, rotated at 2 MB) *and* session custom entries (`trigger`,
75
+ `trigger_result`, `trigger_promotion`), so it resumes and exports with the session it belongs
76
+ to. `/triggers audit` shows this project's rows (`--all` for every project).
77
+
78
+ 8. **Hooks are awaited inline**, so a hook that is slow is visibly slow rather than silently
79
+ racing the turn that triggered it. `[hooks] mode = "async"` queues them off the turn instead.
80
+
81
+ 9. **A promotion that arrives while the agent is busy** goes to pi's follow-up queue and runs a
82
+ turn after the current one; when the agent is idle it is inserted without a model call.
83
+
84
+ 10. **Nobody around.** When the last interactive pi on the machine quits with loops, rules or MCP
85
+ servers configured, it starts a headless host (`src/host.ts`, a plain `node` process using the
86
+ same stores, the same in-process runner and its own MCP clients) that keeps the clock: loops,
87
+ trigger checks for every project, pushes, catch-up. Chat-bound output goes to the inbox. The
88
+ first interactive pi to open takes the clock back — its scheduler preempts a `host` leader —
89
+ and the host exits. `/cron host [start|stop]`, `[host] auto = false` to opt out, `host.log` for
90
+ its output.
91
+
92
+ What triggers it is pi shutting a session down cleanly with reason `quit`: `/quit` in a terminal,
93
+ or SIGTERM/SIGHUP, which are the signals pi handles. Nothing else does — so the browser front end
94
+ starts its pi in a process group of its own and turns a Ctrl-C into that SIGTERM itself
95
+ (`src/web.mjs`). A pi that shares the terminal's group is killed by the group's SIGINT, which pi
96
+ has no handler for, and the hand-off never happens.
97
+
98
+ The cost: nothing restarts it after a machine reboot until a pi opens. It is a hand-off between
99
+ processes, not a system service, and installing one would mean asking for privileges this does
100
+ not otherwise need.
101
+
102
+ 11. **Bounded everywhere, and no expiry.** Loop state ≤ 2000 characters, a finding ≤ 500, at most
103
+ 16 per run, prompts ≤ 8 KB. A job disappears when you remove it and not before.
104
+
105
+ 12. **Cycle suppression by hop count.** Sub-agents run at hop 1 and keep the cron and trigger tools
106
+ (`cron_create`, `cron_remove`, listing, disabling), so a loop can manage automation; the
107
+ operations that would need a human's yes are denied there, and sub-sessions never run the
108
+ trigger runtime, so nothing nests.
109
+
110
+ 13. **Stdio MCP servers reconnect** with backoff (20 attempts by default), each distinct error
111
+ reported once rather than on every retry.
112
+
113
+ 14. **A bill, and a cap on it.** A headless host can run for days, so cost cannot be something you
114
+ discover at the end of the month: `[limits] daily_budget_usd` gates every dispatch, `/cron cost`
115
+ adds up the run log, and what log rotation drops is folded into `spend.json` first so the cap
116
+ does not quietly stop capping. A run stopped by the budget is recorded as aborted rather than
117
+ failed — the slot is still owed and the failure streak does not advance. Diagnostics go to
118
+ `logs/pi-<pid>.log` rather than only to a chat notification that a new session erases.
119
+
120
+ ## Where the browser front end came from
121
+
122
+ pi owns the `pi` command and its terminal, so a second front end cannot replace the first one. It
123
+ goes through the door pi already provides: `pi --mode rpc` — pi with no terminal UI, commands in
124
+ and events out as JSON lines — with [src/web.mjs](../src/web.mjs), a browser front end in one
125
+ dependency-free file, passing that protocol through to a page. The session is a real pi session,
126
+ and `pi --resume` picks it up afterwards. What only the process knows — which MCP servers connected,
127
+ what they exposed, the active tools, who owns the clock — reaches the page as the
128
+ `pi_loops_snapshot` session entry.
129
+
130
+ `pi-loops` with no arguments starts a session and picks the window: the browser at a local
131
+ terminal, pi itself over ssh or with no terminal at all, because a browser on the far machine helps
132
+ nobody. `--web` and `--tui` say which when the guess is wrong.
133
+
134
+ Two things stay in the terminal. `/login` is one: OAuth has no rpc command, so a provider is logged
135
+ in once with `pi` and the browser front end started afterwards. pi's built-in slash commands are
136
+ the other — they do not exist in rpc mode, and the front end implements the ones that matter (cost,
137
+ find, undo, save, compact, model, thinking, clear, resume) from rpc primitives rather than
138
+ pretending. What the page owes you is kept as a gate rather than a wish list in
139
+ [web-ui-parity.md](web-ui-parity.md).
140
+
141
+ Reaching that page from a phone is solved without putting a broker in the middle: `tailscale serve`
142
+ terminates TLS on your tailnet and proxies to this server on loopback, so the page is on your phone
143
+ without anything of yours passing through a third party, and `--host` does the same over a local
144
+ network for people who would rather not run a tailnet. What both need is a way in that a phone can
145
+ manage — the pairing code and the QR ([cli.md](cli.md)). A phone on neither network is the case
146
+ this does not cover; that one would need a relay.
147
+
148
+ While nobody is at the terminal, the host's control channel is what answers "what is it doing":
149
+ `pi-loops host status|abort|stop` ([cli.md](cli.md)).
150
+
151
+ ## What this is not
152
+
153
+ An agent. Writing code, reading the web, managing skills — those are pi's, and this does not
154
+ duplicate them. What it decides is *when* work happens, *where* it runs, and *what context it
155
+ carries*; the work itself is done by the agent you already have.
package/docs/goal.md ADDED
@@ -0,0 +1,59 @@
1
+ # Goals: holding a session to a stop condition
2
+
3
+ `/goal` is a stop condition the session is held to, and the only mechanism
4
+ here that asks "am I done yet". Cron jobs, loops and triggers all run on a clock or an event; a goal
5
+ runs on a judgement.
6
+
7
+ ```
8
+ /goal the test suite passes and the changes are committed
9
+ ```
10
+
11
+ After every settled turn an evaluator — a model call with no tools, reading only a bounded
12
+ transcript of the whole conversation on the active branch — answers one question: is the condition
13
+ satisfied? Evidence from an earlier turn counts: the evaluator sees the session, not just the run
14
+ that happened to end. It must reply in a fixed shape,
15
+ quoting the transcript:
16
+
17
+ ```json
18
+ {"ok": true, "reason": "npm test reported 0 failures"}
19
+ {"ok": false, "reason": "3 tests still fail in parser.test.ts"}
20
+ ```
21
+
22
+ `ok: false` sends the agent back to work with what is missing, as a normal user turn. `ok: true`
23
+ stops and shows the evidence. Missing evidence is not success: an evaluator with nothing to point at
24
+ is told to answer `{"ok": false, "reason": "insufficient evidence in transcript"}`.
25
+
26
+ ## What bounds it
27
+
28
+ | Guard | Behaviour |
29
+ |---|---|
30
+ | Continuations | At most 8, then the goal is `budget_limited` and pauses |
31
+ | Evaluator failure | Pauses with the reason; it never loops on an evaluator that cannot decide |
32
+ | Transcript | 40 000 characters, truncated from the front so the newest evidence always survives |
33
+ | Tools | None. The evaluator reads, it does not act |
34
+ | Interruption | A turn you aborted is not judged, and Esc during an evaluation stops it |
35
+ | Its own timeout | 2 minutes; it is one read, not a piece of work |
36
+
37
+ `/goal resume` after a budget limit starts the allowance again. `pause`, `resume` and `clear` are
38
+ matched as whole words, so `/goal clear the type errors first` sets that as the condition rather
39
+ than dropping the goal.
40
+
41
+ ## Commands
42
+
43
+ ```
44
+ /goal <condition> hold this session to a condition (then send a prompt to begin)
45
+ /goal show the condition, the status, and what the evaluator last said
46
+ /goal pause stop evaluating, keep the condition
47
+ /goal resume start evaluating again
48
+ /goal clear drop the goal
49
+ ```
50
+
51
+ ## Where it lives
52
+
53
+ Each state change is appended to the session as a `goal_state` entry, so `--resume` picks the goal
54
+ up where it left off. The status line shows `goal: <status>`; the
55
+ statuses are `pursuing`, `paused`, `achieved`, `budget_limited` and `cleared` (what `/goal clear`
56
+ leaves behind).
57
+
58
+ The evaluator runs as a sub-agent with the session's model, so it is billed like any other run and
59
+ its transcript is kept with them.