@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/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.
|