@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
@@ -0,0 +1,140 @@
1
+ # Dynamic triggers
2
+
3
+ ## Rules
4
+
5
+ A rule is a natural-language **condition** and **action**:
6
+
7
+ ```text
8
+ /new-trigger when ~/build.done exists, run cargo test and show me the result
9
+ /new-trigger 当 $HOME/helloworld 存在的时候,打印它的内容
10
+ ```
11
+
12
+ `/new-trigger` asks the agent to extract condition and action and call the `new_trigger` tool
13
+ (the agent may also do this from ordinary chat). Rules **fire once** by default (`fire_once=false`
14
+ for repeating), and their output stays in the TUI and audit unless `promote_to_chat` is set.
15
+ Time-based requests ("every hour", "daily", "定时任务") are refused and routed to `cron_create`.
16
+
17
+ Tools: `new_trigger` (condition, action, spec, fire_once, promote_to_chat), `list_triggers`,
18
+ `remove_trigger` (id | all), `set_trigger_state`. Creating, removing and re-enabling ask the user to
19
+ confirm, with `ctx.ui.confirm`.
20
+
21
+ The model-facing tools see the calling project only: `list_triggers` takes `all_projects: true`
22
+ when the user asks about the rest, and `remove_trigger { all: true }` clears this project's rules,
23
+ never the machine's.
24
+
25
+ ## Evaluation
26
+
27
+ While at least one enabled rule exists, every `poll_interval_secs` (default 600; `--trigger-poll-secs`
28
+ or `config.toml`) one sub-agent per project that has rules is started — by a pi open in that project
29
+ (the session that created the rules if it is open, otherwise the lowest pid there), or by the
30
+ machine leader when no pi is open in the project. The check receives the
31
+ event JSON and every enabled rule and is told to inspect the filesystem, commands, clock or network
32
+ as needed, execute the actions of matching rules, and reply `matched dyn-…` or exactly
33
+ `no dynamic trigger rule matched`. Matched fire-once rules are disabled with `fired_at`.
34
+
35
+ Pushed MCP notifications (see [mcp.md](mcp.md)) enter the same runtime and are evaluated against
36
+ the rules unless the server is configured as an `inject_summary` / `inject_and_run` feed.
37
+
38
+ A rule whose check keeps failing is polled with a widening gap instead of at every interval: after
39
+ three failed checks in a row the rule waits 5 minutes, then 10, 20, … up to 6 hours (the scheduler's
40
+ job backoff, same numbers — literally the same function, `backoffWaitMs` in `src/schedule.ts`).
41
+ The count lives on the rule (`consecutive_failures`,
42
+ `last_check_failed_at`), a check that completes clears it — including `/triggers run <id>`, which is
43
+ the way to retry a backed-off rule now — and entering the backoff is audited as `backoff` with the
44
+ rule ids and the failure count. Pushes are unaffected: an event is worth one attempt.
45
+
46
+ ## What happens when the machine is busy or over budget
47
+
48
+ A trigger that finds every check slot taken (`[cron] max_concurrent_runs`, default 3) or the day over
49
+ `[limits] daily_budget_usd` is refused before it claims the dedup key, and what happens next depends
50
+ on what was refused:
51
+
52
+ - A **periodic check** is dropped (`deferred`). The next poll looks at the world again and reaches
53
+ the same conclusion, so re-running the held one would only check twice.
54
+ - A **push** refused for want of a slot is held in a bounded list (32 events, keyed by idempotency
55
+ key and project) and retried oldest first on the next scheduler tick — a push is an event that
56
+ happened once and no server re-sends it. Its audit row says `deferred` with `queued` (`queued`,
57
+ `replaced` when the envelope's `latest_replaces` policy supersedes one already waiting,
58
+ `collapsed` when it does not) and the current `pending` depth. The retry goes through dedup,
59
+ audit and the normal delivery, and its check prompt carries the event's original `received_at`
60
+ plus `deferred_ms` and a line saying how long it was held, so a time-sensitive rule can re-check
61
+ before acting. When 32 are already waiting, the **oldest** is dropped with its own `dropped` row.
62
+ - A push refused because the day is **over budget** is dropped (`budget_exceeded`), not held: too
63
+ busy clears in minutes, but the cap can last until midnight, and acting on the morning's event at
64
+ 23:59 is worse than not acting. The same applies to a held push whose retry finds the cap
65
+ exceeded. Held pushes live in memory, so they do not survive the process quitting.
66
+
67
+ ## Promotion and audit
68
+
69
+ A matched rule with `promote_to_chat` inserts `[Trigger <trace>] <result>` into the chat context
70
+ of the pi that ran the check (visible to future turns; no model call when idle, a follow-up turn
71
+ when the agent was busy). Because checks run in the rule's project, that is the right chat; only
72
+ when no pi is open there does the result go to the inbox (`redirected`). One check evaluates every
73
+ rule of the project at once, so it runs under **one** model: the first rule that has one recorded
74
+ (`/triggers set <id> --model … | -`), and that rule's thinking level with it — a thinking level
75
+ belongs to the model it was chosen for, and a rule with a model but no level falls back to the
76
+ running session's. Pin the model on the rule you want to decide it, or on all of them. The timeout is
77
+ the longest of the project's rules (`--timeout`), else `[triggers] run_timeout_secs`. Every trigger leaves audit records in `triggers-audit.jsonl` and as
78
+ session entries (`trigger`, `trigger_result`, `trigger_promotion`); `/triggers audit [N] [--all]`
79
+ shows this project's rows with decisions and transcript paths. The complete set of states:
80
+
81
+ - admission — `accepted`, `deduped`, `deferred` (no slot, or handed to the pi that owns the rules),
82
+ `taken_over` (the owner did not claim it), `dropped` (the held list was full), `budget_exceeded`,
83
+ `cycle_suppressed` (it reached a sub-agent), `backoff`, `disabled` (the rule's project is gone);
84
+ - the run — `running`, then `completed` / `failed` / `aborted`, or `no_rules`;
85
+ - promotion — `promoted`, `redirected` (to the inbox: no pi open in that project), `skipped` (no
86
+ matched rule has `promote_to_chat`).
87
+
88
+ Rows of type `cron_control_plane` use the operation as the state instead: `add`, `enable`,
89
+ `disable`, `remove`.
90
+
91
+ A 5-minute dedup window collapses repeated events with the same idempotency key (per project for
92
+ rule evaluation, per window for injected pushes).
93
+
94
+ ```text
95
+ /triggers status rule counts, checker ownership, last check, push sources
96
+ /triggers rules this project's rules (--all for every project)
97
+ /triggers sources local checker + each MCP server: state, queued/dropped/deduped, tools
98
+ (/triggers hooks is the same view)
99
+ /triggers enable <id> /triggers disable <id> /triggers remove <id> also --all | --all-projects
100
+ /triggers set <id> --model | --thinking | --timeout | --host: what the action runs with
101
+ /triggers run <id> check one rule now, without waiting for its poll slot
102
+ /triggers running sub-agents in flight (dynamic checks and cron runs) /triggers abort <trace>|--all
103
+ /triggers audit [N]
104
+ /triggers panel on|off
105
+ /triggers help all of the above, in the terminal
106
+ ```
107
+
108
+ Cron runs share this runtime's views: they appear in `/triggers running` and `/triggers audit`
109
+ and can be aborted by run id.
110
+
111
+ `/triggers run <id>` takes the same path a periodic check takes — dedup, audit, the check
112
+ sub-agent, promotion — and skips only the poll ledger, which is what running it now means. The
113
+ poll interval is unaffected: the next scheduled check happens when it would have anyway.
114
+
115
+ ## Cycle safety
116
+
117
+ Cycles are bounded by a hop count: sub-agents run at hop 1 and still have the
118
+ cron/trigger tools, so a trigger action can schedule a job; they never run the trigger runtime, so
119
+ nothing nests. Prompt-class operations — creating or removing a trigger,
120
+ re-enabling a trigger or a cron job — are denied fail-closed in sub-agents (no control-plane
121
+ prompt channel there); `cron_create` and `cron_remove` work and the control-plane audit records
122
+ `actor: sub-agent`. Sub-agents never handle triggers themselves: they ignore MCP pushes, and the
123
+ runtime audits anything reaching hop ≥ 1 as `cycle_suppressed` (hops are counted per trace, but the
124
+ bound that matters is structural: a sub-agent session never acts on a trigger at all).
125
+
126
+ ## Command output, approvals, promotion
127
+
128
+ `/triggers enable|disable <id>` prints the rule's state, condition and action (and the
129
+ fire-once note); `/triggers sources` lists MCP servers, the cron hook and the dynamic
130
+ checker in registration order with a `sources: N total, M connected, K require
131
+ attention` summary in `/triggers status`. Errors are worded once and kept that way (`unknown /triggers command:
132
+ …`, `usage: /triggers remove <id>|--all`, `/new-trigger` parse messages). `/triggers remove --all`
133
+ clears this project's rules; `--all-projects` is the explicit machine-wide sweep.
134
+
135
+ Prompt-class tool calls (`new_trigger`, `remove_trigger`, re-enabling a trigger or a cron job)
136
+ show an approval card — Action, Tool, a value-free Reason, an args hash and a redacted
137
+ Preview — and leave `approval required` / `approved` / `denied` lines in the feed. Promoted
138
+ results and injected summaries are inserted as `[Trigger <trace>] <text>`, with no extra
139
+ wrapper; an inject-and-run turn announces itself with
140
+ `running triggered turn (trace …)`.
@@ -0,0 +1,111 @@
1
+ # Troubleshooting
2
+
3
+ **Nothing fires.** `/cron scheduler`: is this process the timer owner or standby? If the owner died
4
+ without cleanup, a standby takes over within 90 seconds. Jobs show `next …` when enabled. A plain
5
+ job marked `[dormant …]` only fires in the session that created it (`--resume` it); a loop marked
6
+ `[orphan: cwd missing]` was disabled because its checkout is gone.
7
+
8
+ **A run failed.** `/cron runs` shows the error; `/cron trace <job> 1` shows the sub-agent's
9
+ transcript; `pi --session <file>` resumes it. Sub-agents run inside your interactive pi with its
10
+ credentials, tools and MCP servers; they cannot answer permission prompts (use `--tools` to restrict).
11
+
12
+ **Findings never arrive.** The loop must end its reply with `<inbox>…</inbox>` tags; check
13
+ `/cron trace`. Quiet runs are normal. With `--verify`, dropped findings and reasons are on the run
14
+ card and in `/cron trace <job> 1 checker`.
15
+
16
+ **`Tool "cron_create" conflicts with …`, and pi exits.** The package is installed twice, and this
17
+ is fatal rather than cosmetic: the second copy fails to load and pi stops. It happens easily —
18
+ a local checkout you are working on plus `pi install git:…` of the published one both sit in
19
+ `packages` in `~/.pi/agent/settings.json`, and both register the same tools. (An older, milder
20
+ symptom of the same thing was commands appearing as `/cron:1`.)
21
+
22
+ Keep one:
23
+
24
+ ```bash
25
+ pi remove git:github.com/alphacoder-v0/pi-loops # keep the checkout you are working on
26
+ pi remove /path/to/your/checkout # or keep the installed one
27
+ ```
28
+
29
+ Then re-run the launcher install from the copy you kept: `install-launcher` writes the path of
30
+ whichever copy ran it, so removing that one leaves `pi-loops` pointing at a package that is no
31
+ longer loaded.
32
+
33
+ **The browser window says "This browser has not been here before."** It has no cookie for this
34
+ address, which is normal for a new device, a new browser, a private window, or after clearing site
35
+ data. Press **add device** in a browser that is already signed in and scan the QR, or type the six
36
+ digits into the box on that page. The terminal that started the session printed a code too.
37
+
38
+ **The phone cannot reach it at all.** The server binds loopback by default, and a phone has no
39
+ route to that. Either `tailscale serve --bg 4173` (the server stays on loopback; the tailnet does
40
+ TLS) or start with `pi-loops --host 0.0.0.0` for a phone on the same wifi. See
41
+ [cli.md](cli.md#from-a-phone).
42
+
43
+ **The QR is offered but scanning it goes nowhere.** The QR encodes the address *the browser you
44
+ pressed the button in* is using. Press it in a window that is on `127.0.0.1` and there is no
45
+ address a phone could use — it says so instead of drawing one. Open the tailnet or LAN address on
46
+ the desktop first, then press it there. Where the machine has several addresses, the dialog lists
47
+ them with the interface each belongs to; a docker or libvirt bridge is not the one.
48
+
49
+ **"port 4173 is already in use" — or a second `pi-loops` opened the first one's window.** Starting
50
+ a second session while one is up hands you the window that is already there rather than failing.
51
+ That is deliberate. `--port <n>` starts a genuinely separate one, and the browser stays signed in:
52
+ the token is the one file both of them read, and a cookie is not scoped to a port — `localhost:4173`
53
+ and `localhost:4180` are the same site to a browser, which is the fact the `Sec-Fetch-Site` check
54
+ here is built around. A `--port` that is not a number is refused rather than quietly served on 4173.
55
+
56
+ **A pairing code stopped working.** They last ten minutes, are good for one use, and closing the
57
+ dialog retires the one it was showing. Twenty wrong guesses disable pairing until another code is
58
+ made. Press **add device** again.
59
+
60
+ **Every device was signed out at once.** Something removed `~/.pi/agent/loops/web-token`, or
61
+ `PI_LOOPS_DIR` points somewhere new — the token is that file, and the cookie every device holds is
62
+ its contents.
63
+
64
+ **The window stops answering — you type, your message appears, no reply ever does.** The event
65
+ stream is numbered per run of the server process, so a page that was open across a restart can end
66
+ up counting against numbering that no longer exists and skip everything the new process sends,
67
+ while still polling happily and looking alive. From 0.12.2 the page notices this by itself, within
68
+ a few seconds, and says "the conversation above was reloaded". A page older than that has to be
69
+ reloaded by hand — the fix cannot reach a tab that was already open when it was installed.
70
+
71
+ Reloading the plain address is enough: the page is served `no-store`, so a normal refresh always
72
+ fetches the current one, and the token in the URL is only needed by a browser that has never been
73
+ here. From 0.12.4 a window that is older than what is installed says so in a bar across the top.
74
+
75
+ **MCP server shows `disconnected` / `auth_failed`.** `/triggers sources` has the last error.
76
+ Bearer tokens come from `$TOKEN_REF` or pi's credential store; endpoints must be https except
77
+ 127.0.0.1. Custom notifications without `_meta.pi_dedup_key` are dropped and counted.
78
+
79
+ **Hooks do not run.** Startup warnings list malformed rules. Project hooks need
80
+ `allow_project_hooks = true`. Hooks fire only in the interactive pi, not in sub-agents.
81
+
82
+ **`/session-export` says the session is ephemeral.** pi writes the session file after the first
83
+ message; `--no-session` sessions cannot be exported.
84
+
85
+ **Costs.** A stateful run is one sub-agent call (~$0.04 with gpt-5.5); `--verify` adds a second;
86
+ a dynamic check runs only while enabled rules exist. `every 1m` loops add up — prefer hourly or
87
+ daily schedules for anything that is not a test. `/cron cost` adds up the run log;
88
+ `[limits] daily_budget_usd` stops dispatching once the day reaches it, and says so on the job. It
89
+ also stops a run that is already going when its own cost would carry the day past the cap — that
90
+ run is recorded as aborted rather than failed, so the slot is still owed and the job's failure
91
+ streak is untouched; it simply will not be dispatched again until the day rolls over or the cap is
92
+ raised.
93
+
94
+ **`pi-loops host status` says the host "is not answering" but it is running.** Before 0.4.0 this
95
+ happened whenever `PI_LOOPS_DIR` was deep: a unix socket path is capped at 108 bytes, so
96
+ `<dir>/host.sock` failed to bind and the control channel silently did not exist. It now falls back
97
+ to a short path under a per-user directory in the temp directory. If you still see it, the host is
98
+ genuinely wedged — `host.log` has its last line, and `pi-loops host stop` escalates to SIGTERM.
99
+
100
+ **A host is running that you did not start.** That is the design: the last interactive pi to quit
101
+ hands the clock to a headless host when there is work for it (an enabled loop or rule for this
102
+ machine, or an MCP server that pushes). `pi-loops host status` says what it is doing and what it
103
+ has spent; `pi-loops host stop` ends it; `[host] auto = false` stops the hand-off happening at all.
104
+ After testing with a throwaway `PI_LOOPS_DIR`, remember that the host it spawned outlives the pi
105
+ that spawned it — it is polling and billing against *that* directory until stopped.
106
+
107
+ **The browser front end refuses a slash command.** It drives `pi --mode rpc`,
108
+ where pi's own built-in commands do not exist — only extension commands and skills do. Typing one
109
+ is refused with a pointer to the button that does the same thing rather than being passed to the
110
+ model as text. `/login` is the one with no equivalent at all: log in once with `pi` in a terminal,
111
+ then start the front end.
@@ -0,0 +1,305 @@
1
+ # What the browser front end owes you
2
+
3
+ `pi-loops` opens a browser window instead of a terminal one when you are at a local terminal. That
4
+ makes the page an alternative to the TUI, not a lesser chat box bolted onto the side of it — and the
5
+ difference between those two things is a list, kept here, of what a person can still do after the
6
+ window changed.
7
+
8
+ This is a gate, not a wish list. Anything below is either implemented, or marked as held with the
9
+ reason. A release that drops one of these silently is the failure this file exists to prevent.
10
+
11
+ Where a line is enforced by a test, the test is named. The rest are checked by reading, which is
12
+ weaker, and worth converting whenever one of them breaks.
13
+
14
+ ## Sending, and what happens while it runs
15
+
16
+ - [x] Enter sends. Shift+Enter inserts a newline.
17
+ - [x] Enter does nothing while an input method is composing — typing Chinese, Japanese or Korean
18
+ means Enter picks a candidate, not "send". `test/web-page.test.ts`: *Enter while an input
19
+ method is mid-word does not send*.
20
+ - [x] Submitting while a turn is running queues instead of racing it, the way the TUI does.
21
+ - [x] The queue is visible and can be cleared.
22
+ - [x] Abort stops the current turn.
23
+ - [x] Busy, queued count, and "pi exited" are all visible without opening a terminal.
24
+ `test/web-page.test.ts`: *a tool call and a dead pi both reach the page*.
25
+ - [x] Prompt history: ArrowUp and ArrowDown, but only while the caret is on the first or last line,
26
+ so they still navigate a multi-line draft.
27
+
28
+ ## Composing
29
+
30
+ - [x] `/` completes slash commands; `@` completes paths, and the mention is expanded before the
31
+ prompt is sent — the expansion is anchored to the session's directory, not to anything the
32
+ browser supplies.
33
+ - [x] A command with an argument can be typed straight through. The completion list closes on the
34
+ space, and an answer still in flight does not put it back — which it did, so Enter accepted
35
+ the completion instead of sending the line and anything taking an argument was mouse-only.
36
+ `test/web-page.test.ts`: *a completion answer in flight does not reopen a list the space just
37
+ closed*.
38
+ - [x] A command that takes no argument runs on one Enter, the way it does in a terminal. When what
39
+ you have typed is already the whole of the highlighted completion there is nothing left to
40
+ accept, so Enter sends the line; a shorter prefix still gets completed, and Tab only ever
41
+ inserts. `test/web-page.test.ts`: *Enter on a command already typed in full sends it instead of
42
+ completing it again*.
43
+ - [x] Images: attach, paste, and a strip showing what is attached, each with a visible way to take
44
+ it off again. An image-only prompt is valid; ten per message is the cap, and it says so. PNG,
45
+ JPEG, GIF, WebP and AVIF, the same set however it arrived — anything else is refused with the
46
+ same visible notice rather than sent under a type it is not.
47
+ `test/web-page.test.ts`: *a message carries at most ten images*, *an image type the page cannot
48
+ render is refused rather than sent as a broken PNG*.
49
+ - [x] A tap on a composer button lands on that button, even with a soft keyboard open — tapping it
50
+ blurs the box, which dismisses the keyboard, which moves everything.
51
+ `test/web-page.test.ts`: *a phone's soft keyboard cannot steal the tap on send*.
52
+
53
+ ## What the feed shows
54
+
55
+ - [x] Assistant text, thinking, tool calls, tool results, errors, and the end of a turn.
56
+ - [x] The conversation is what the conversation is for. A whole stretch of work — thinking and tool
57
+ calls, interleaved as they arrive — is **one** row, closed: what is happening while it happens,
58
+ what happened afterwards. One button in the header opens or closes every one of them.
59
+ `test/web-page.test.ts`: *a stretch of work is one row, and one button opens every one of
60
+ them*.
61
+ - [x] Replies render as Markdown — headings, lists, quotes, rules, tables, inline code, fenced
62
+ code, and http(s) links. Everything is escaped first: a reply is not trusted input, and a
63
+ tool result quoted inside one is whatever some web page said. `test/web-page.test.ts`: *a
64
+ reply is rendered as Markdown, and cannot smuggle markup through it*.
65
+ - [x] Terminal escape sequences are stripped rather than shown. Plenty of what reaches a session
66
+ was written for a terminal. `test/web-page.test.ts`: *terminal escape codes do not reach the
67
+ screen as text*.
68
+ - [x] Your own message appears once, whether the page drew it or pi echoed it back.
69
+ `test/web-page.test.ts`: *your own message is drawn once*.
70
+ - [x] Text selection works, and is never destroyed by an update: the feed is appended to, never
71
+ rebuilt. The cost of appending is that a missed event leaves a hole, so events are numbered
72
+ and a gap reloads the transcript rather than drawing on top of one. A page that stops being
73
+ told anything at all notices that from its own polling, rather than sitting there looking
74
+ alive. `test/web-page.test.ts`: *a page that stops receiving events notices by itself*.
75
+ `test/web-page.test.ts`: *a gap in the event stream reloads the conversation*.
76
+ - [x] While you are reading back through the conversation, an update never yanks the page — and
77
+ never arrives silently either: there is a way back to the newest.
78
+ - [x] Every block says when it happened.
79
+ - [x] Every message, tool call and result can be copied, including over plain http where the
80
+ clipboard API is unavailable. The button waits for a hover on a mouse and is simply always
81
+ there on a touch screen, which has no hover to wait for.
82
+ - [x] Tool results, an extension's messages and pi's dying words are capped, and every cap says how
83
+ much was dropped — one helper for the three of them, because they used to report it three
84
+ different ways and one of them not at all, so a result that stopped at 8000 characters looked
85
+ like a result that ended there.
86
+ - [x] **You can see what the session made, not only read about it.** A picture in a reply is a
87
+ picture — including one named in ordinary prose rather than as a link; a path is something to
88
+ open; a page written into the reply has a preview; an image a tool returned is shown rather
89
+ than dropped. `test/web-page.test.ts`: *a path written in prose becomes something to open*.
90
+ Everything served that way is anchored inside the session's directory — and inside `$HOME`,
91
+ because an agent asked to make something for a person leaves it where a person keeps things,
92
+ but there only for the types one *looks* at, so a service-account key named like ordinary JSON
93
+ is not previewed. Both roots exclude every dot segment and the directory holding the token.
94
+ Restricted to file types worth showing, and sandboxed into an opaque origin — a page the model
95
+ wrote can be looked at and cannot act.
96
+ `test/web.test.ts`: *a file the session made can be looked at, and nothing else can*.
97
+ `test/web-page.test.ts`: *a reply can show a picture*, *an image that came back from a tool*.
98
+ - [x] A tool call and what it returned are one block, and it starts closed. A tool that prints two
99
+ hundred lines must not push the conversation off the screen to do it.
100
+ `test/web-page.test.ts`: *a run of tool calls is one row, and the next run is a new one*.
101
+ - [x] You are on the right in a bubble and the model is full-width prose: the shape says who is
102
+ speaking before a word is read. Status lines are small, monospace and quiet — context, not
103
+ conversation.
104
+ - [x] The conversation has a reading width. A line the width of a 27-inch monitor is not readable.
105
+ - [x] An empty session says what it is and what to type, rather than being a blank rectangle —
106
+ which is also the first thing a newly paired phone shows, and what is left when the last
107
+ message is undone: a line the page wrote about itself is not a conversation.
108
+ `test/web-page.test.ts`: *an empty session says what it is*, *undoing the only message shows
109
+ the empty state*.
110
+
111
+ ## Automation, which is the reason this project exists
112
+
113
+ - [x] Jobs, loops and rules: what they are, whether they are enabled, and what the last error was.
114
+ - [x] **What this project has is what this project has, and the rest is counted rather than
115
+ dropped.** The store is machine-wide and this list is not, so a job made in another directory
116
+ says so as a count — without it, a job somewhere else is indistinguishable from a job that is
117
+ gone. Jobs and rules are counted and labelled apart, each line naming the command that lists
118
+ them (`/cron all`, `/triggers rules --all`): one number covering both agrees with neither
119
+ command, and the line exists to send you to the command. A job stamped with a hostname this
120
+ machine no longer has (a rebuilt container, a rename) is listed and marked, the way `/cron`
121
+ lists it, rather than filtered out of sight.
122
+ What counts as "this project" is what the extension says it is — symlinks resolved, `$HOME`
123
+ too broad to be one — because a panel that disagrees with the command is worse than either.
124
+ `test/web.test.ts`: *the panel and /cron agree about what this project is*.
125
+ `test/web-page.test.ts`: *a job this machine no longer owns is listed, not hidden*.
126
+ - [x] **When each job runs next, whatever its schedule.** The page used to work this out itself and
127
+ understood only `every <interval>`, so a job on `0 9 * * *` — the first example in the
128
+ README — showed nothing. A second cron parser in a page with no dependencies was the wrong
129
+ fix: the process that owns the clock has the evaluator, and writes the answers to
130
+ `next-runs.json` beside the store when one of them changes. A time that has already passed is
131
+ shown as no next run rather than as a past one, a job that is disabled or belongs to another
132
+ machine is not promised one, and a next run that is not today says which day it is.
133
+ `test/scheduler.test.ts`: *the leader writes when each job runs next*.
134
+ `test/web.test.ts`: *the panel shows the next run of a cron-expression job*.
135
+ `test/web-page.test.ts`: *a next run in April does not render as a time of day*.
136
+ - [x] The name of a job is never abbreviated: it is what `/cron set <name> …` takes, so a clipped
137
+ one is a name you cannot act on. It wraps; the schedule beside it does not break in half.
138
+ - [x] Run a job now.
139
+ - [x] Inbox count.
140
+ - [x] Runtime: scheduler state and whether this pi owns the clock, MCP servers and their state,
141
+ hook count and events, tool count, last poll, snapshot age and version.
142
+ - [x] A count leads to the list behind it — "24 tools" answers the wrong question on its own.
143
+ `test/web-page.test.ts`: *a count in the panel leads to the list behind it*.
144
+ - [x] Goal state and the session's own metadata.
145
+ - [x] A malformed job in a store file does not blank the panel. `test/web-page.test.ts`: *a job
146
+ with the wrong types in it does not blank the sidebar*.
147
+ - [x] On a narrow screen the panel is a drawer, not something that disappears: what a loop is doing
148
+ is the reason to open this on a phone.
149
+ - [x] Thirteen controls do not fit across a phone. The ones you reach for mid-conversation stay in
150
+ the header; the rest move — not copy — into a sheet behind one button.
151
+ `test/web-page.test.ts`: *the header's secondary actions move into a sheet and back*.
152
+
153
+ ## How it looks
154
+
155
+ - [x] Light and dark are both designed: every surface is a named colour, not a translucent grey
156
+ over whatever the browser paints, so the page looks the same on a machine whose default
157
+ background is not white.
158
+ - [x] The stored theme is applied before the first paint. Applied later, a dark page renders light
159
+ and then blinks. `test/web-page.test.ts`: *the stored theme is applied before the first
160
+ paint*.
161
+ - [x] Prose is proportional and anything from a terminal is monospace — with CJK faces named in
162
+ both stacks, because the default fallback is neither the right shape for reading nor the
163
+ right width for a box someone drew with line characters.
164
+
165
+ ## Confirmations
166
+
167
+ - [x] Anything an extension asks — select, confirm, input, editor, notify — is answerable in the
168
+ browser. A session whose only question is on a terminal you closed is a stuck session. One
169
+ asked while no browser was attached is held for a browser that turns up, and the list of those
170
+ is bounded the way the event backlog is: an abandoned dialog was otherwise re-offered every
171
+ eight seconds for as long as the process lived.
172
+ - [x] A confirmation shows what is about to happen apart from the reasoning about it, so it cannot
173
+ be read as prose and waved through. `test/web-page.test.ts`: *a confirmation shows what is
174
+ about to run*.
175
+ - [x] Enter does not approve: the focus starts on cancel.
176
+ - [x] Clicking away from any dialog closes it. `test/web-page.test.ts`: *clicking outside a dialog
177
+ closes it*.
178
+
179
+ ## Session
180
+
181
+ - [x] Model and thinking level, switchable, with the failure reported when credentials are missing,
182
+ and **remembered**: the next session starts on the model you last chose.
183
+ `test/cli.test.ts`: *the model you chose last time starts the next session*.
184
+ The picker is grouped by provider and says what decides the choice — the model's own name, its
185
+ context window, whether it takes images — with the ones you have used recently at the top. pi
186
+ only offers models from providers you have configured, so everything in it is usable.
187
+ - [x] The model in use is in the picker even when pi's catalog has never heard of it. A session can
188
+ be running on a provider this machine has no credentials for, and pi lists only the ones it
189
+ does — so the picker had nothing to select and showed an empty control beside a panel naming
190
+ the model, which reads as "no model". It is offered under its own provider, marked as not in
191
+ the catalog so the reason is on the screen.
192
+ `test/web-page.test.ts`: *the model in use is in the picker even when the catalog has never
193
+ heard of it*.
194
+ - [x] The thinking levels offered are the ones this model has — asked for per model rather than
195
+ drawn from one fixed list, because picking a level a model does not implement does nothing at
196
+ all.
197
+ - [x] Attaching an image is refused, with the reason, by a model that cannot see one.
198
+ - [x] Cost and token counts.
199
+ - [x] Compact, undo (fork from your last message), find across the whole session including
200
+ abandoned branches, export to HTML, and share as a redacted gist. Undo reloads the
201
+ conversation through the one path every other thing that empties the feed uses, so an undone
202
+ session is as empty as a new one rather than a blank rectangle with a notice on it.
203
+ `test/web-page.test.ts`: *undoing the only message shows the empty state*.
204
+ - [x] Compaction says what it did — what the context was and what it became, and what the summary
205
+ cost — and what it keeps can be steered: `/compact keep the API shapes`, as in the terminal.
206
+ A line that reports neither is a line asking to be taken on faith.
207
+ `test/web.test.ts`: *the compact button can steer what the summary keeps*.
208
+ `test/web-page.test.ts`: *clear, resume and compact are typed as well as clicked*.
209
+ - [x] **Starting over does not mean going back to a terminal.** The context is finished with far
210
+ more often than the window is: a new session and going back to an earlier one are both here,
211
+ by button and by `/clear`, `/new` and `/resume` typed in the composer — the habit comes from a
212
+ terminal and the muscle memory arrives with it. Neither deletes anything: the session being
213
+ left is a file that `resume` lists, labelled by what was said in it rather than by a filename.
214
+ `test/web.test.ts`: *a new session is a path pi has not written yet, and going back is one it
215
+ has*. `test/web-page.test.ts`: *the session you are in is not offered as one to go back to*.
216
+ - [x] A session started here is a session pi-loops recognises. pi names the sessions it starts after
217
+ their id and the front end cannot know that id in time, so anything identifying a session by
218
+ its file name is wrong about this one — which parked every inject-and-run cron job made in a
219
+ session started from the page. `test/store.test.ts`: *sessionExists scans pi's sessions root*.
220
+ - [x] Nothing read off the disk can make a line say something other than what it is: terminal
221
+ escapes and the bidi overrides that reorder text are stripped from every name and message
222
+ shown, and the picker is where that matters most.
223
+ `test/web-page.test.ts`: *a name cannot reorder the line it is drawn on*.
224
+ - [x] A session is not swapped out from under a turn that is running — the swap would abort it, and
225
+ losing a reply you are waiting for is not something to find out afterwards. The refusal is on
226
+ the server, so a tab left open across an upgrade cannot skip it.
227
+ `test/web.test.ts`: *a session is not swapped out from under a turn that is running*.
228
+ - [x] Going back to a session puts it back on the model it was last using. A `--model` on the launch
229
+ command line — yours, or the remembered one the launcher adds — lasts as long as the process
230
+ rather than the session, so pi re-applies it on every swap and a resumed conversation landed on
231
+ a model it was never had with. A model whose credentials are gone is reported rather than
232
+ forced. `test/web.test.ts`: *going back to an earlier session puts it back on the model it was
233
+ last using*.
234
+ - [x] `--continue`, `--resume` and `--session` reach pi unchanged, so a session moves between the
235
+ two windows.
236
+ - [x] **Ending the window ends the session, properly.** Ctrl-C in the terminal that started it means
237
+ `/quit`, not a kill: pi is started in a process group of its own so the terminal's SIGINT
238
+ cannot reach it, is asked to shut down with the signal it does handle, and is waited for — which
239
+ is what hands the clock to the headless host and what puts the line saying so in front of you
240
+ before the window closes.
241
+ `test/web.test.ts`: *the pi behind the page runs in a process group of its own*, *Ctrl-C in that
242
+ terminal ends the session the way /quit does, and waits for it*.
243
+ - [x] **And the terminal is told what became of the automation.** pi-loops announces the hand-off
244
+ through `ctx.ui.notify`, and `ctx.hasUI` is true in rpc mode — so the note is an event addressed
245
+ to a page whose server is the process on its way out, and the person who pressed Ctrl-C saw only
246
+ `pi exited (143)` while their loops kept running somewhere nobody had told them about. That note is
247
+ relayed to the terminal when it arrives; when it does not, the loops directory is read the way
248
+ `pi-loops host status` reads it — the live host and how to see or stop it, or that none started
249
+ and which log says why, or nothing at all when nothing was going to run.
250
+ `test/web.test.ts`: *Ctrl-C says where the automation went, when pi's own note never arrives*,
251
+ *pi's own word on the hand-off reaches the terminal when it arrives in time*.
252
+ - [x] **A window older than what is installed says so, across the top, with the button that fixes
253
+ it.** A tab left open across an upgrade looks exactly like a current one — the panel even
254
+ shows a version, but that is the server's, read live — and three rounds of "no reply appears"
255
+ were spent on a page that could not have received one.
256
+ `test/web-page.test.ts`: *a page left open across an upgrade says so*.
257
+
258
+ ### Held, with the reason
259
+
260
+ - **pi's own built-in slash commands.** They do not exist in `pi --mode rpc`; there is nothing to
261
+ call. The extension's own commands work.
262
+ - **`/login`.** Its OAuth flow has no rpc equivalent. Log in once with `pi` and the rest follows.
263
+
264
+ ## Transport
265
+
266
+ - [x] Loopback by default. `--host` binds elsewhere, and refuses `--no-auth` when it does.
267
+ `test/web.test.ts`: *--no-auth is refused when the front end is put on the network*.
268
+ - [x] A name you put in front of this — a reverse proxy, a hostname on your own network — is named
269
+ with `--allow-host`, nothing else is accepted under it, and the flag is in `--help`: a flag
270
+ that relaxes a security check and is not documented is a flag nobody can audit.
271
+ `test/web.test.ts`: *--allow-host admits the name you put in front of it, and no other*.
272
+ - [x] A `--port` that is not a port is refused out loud rather than served somewhere else.
273
+ `test/web.test.ts`: *a --port that is not a number is refused rather than quietly served
274
+ somewhere else*.
275
+ - [x] **Every route keeps its own guards, the escape hatch included.** `/rpc` carries anything in
276
+ pi's protocol this front end has not grown a button for, and refuses the commands that do have
277
+ a route here — reaching pi through it skipped the mid-turn refusal, the "one of this project's
278
+ sessions" check, and the reset the attached browsers are owed when a session is swapped. It
279
+ also refuses `new_session` and `clone`, which replace that session with no route to do the
280
+ resetting: opening a fresh session is `/switch_session`-shaped work.
281
+ `test/web.test.ts`: *the escape hatch cannot be used to skip a route's own guards*.
282
+ - [x] The routes that only read are only read from: `/state`, `/history` and `/stats` answer GET and
283
+ nothing else. `test/web.test.ts`: *the routes that only read are only read from*.
284
+ - [x] A request another site started is refused on every route, whatever cookie the browser
285
+ attached. `test/web.test.ts`: *a request another site started is refused*.
286
+ - [x] One address that does not change between launches, and a device is added by pointing its
287
+ camera at a QR — with the six digits as the fallback, never a curl command.
288
+ `test/web.test.ts`: *the token outlives the process*, *a phone gets in with the six-digit
289
+ code*. `test/web-page.test.ts`: *adding a device shows a code and something to point a camera
290
+ at*, *the QR encoder still produces the matrix a scanner was shown*. A guess spends one of
291
+ twenty tries, so a browser that is already signed in is recognised as signed in first —
292
+ otherwise reloading a bookmark with a stale code on it burns the budget the phone needs.
293
+ `test/web.test.ts`: *a signed-in browser reloading a stale pairing code does not spend a
294
+ guess*.
295
+ - [x] Pairing survives an upgrade: the token is a file outside the package, so a device stays signed
296
+ in across restarts and new versions. `test/web.test.ts`: *the token outlives the process*.
297
+ - [x] Events are incremental. The backlog a late-joining browser replays is bounded.
298
+ - [x] Events carry no credential and no oversized tool payload; the image bytes a message already
299
+ contains travel with it, and the page renders them rather than fetching them. The page itself
300
+ is handed the token — in the cookie, and in the URL on a first visit — and nothing else is. The
301
+ one event that could have carried a credential was pi's stderr, broadcast when it exits to say
302
+ why: a provider that refuses to authenticate prints the key it was refused with, so that tail
303
+ is capped and redacted before it leaves this process. The terminal that started the session
304
+ still has the whole of it.
305
+ `test/web.test.ts`: *a credential in pi's dying words does not reach the page*.
@@ -0,0 +1,18 @@
1
+ # Examples
2
+
3
+ ## mcp-notify-server.mjs — an MCP server that pushes into pi-loops
4
+
5
+ An MCP server in Node with no dependencies, small enough to read in one sitting.
6
+ The server registers one tool (`demo_status`) and pushes a `notifications/pi/demo/heartbeat`
7
+ event every 10 seconds. Each push carries `_meta.pi_dedup_key` (unique per beat, so nothing is
8
+ deduplicated away) and `_meta.pi_summary` (the text the audit, the panel and — with
9
+ `inject_summary` — the chat will show).
10
+
11
+ ```sh
12
+ cp examples/mcp.toml ~/.pi/agent/loops/mcp.toml # or merge the [[server]] block
13
+ pi # then: /triggers sources
14
+ ```
15
+
16
+ Without an `inject_*` flag each push is evaluated by the dynamic-rule sub-agent against your
17
+ `/new-trigger` rules; with `inject_summary = true` the summary lands in the chat directly;
18
+ with `inject_and_run = true` the agent also reacts to it. See [docs/mcp.md](../docs/mcp.md).
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // Minimal MCP stdio server that pushes notifications into pi-loops' trigger runtime.
3
+ // Node built-ins only. JSON-RPC 2.0, one message per line. Registers one tool and emits a
4
+ // `notifications/pi/demo/heartbeat` event every 10 seconds; each carries a unique
5
+ // `_meta.pi_dedup_key` (so every beat counts as a distinct trigger) and a human-readable
6
+ // `_meta.pi_summary` (what the audit and the chat see).
7
+ import readline from "node:readline";
8
+
9
+ const send = (m) => process.stdout.write(`${JSON.stringify(m)}\n`);
10
+ let beats = 0;
11
+ const rl = readline.createInterface({ input: process.stdin });
12
+ rl.on("line", (line) => {
13
+ let msg;
14
+ try {
15
+ msg = JSON.parse(line);
16
+ } catch {
17
+ return;
18
+ }
19
+ if (msg.method === "initialize") {
20
+ send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "pi-loops-notify-demo", version: "0.1.0" } } });
21
+ } else if (msg.method === "notifications/initialized") {
22
+ setInterval(() => {
23
+ beats++;
24
+ send({ jsonrpc: "2.0", method: "notifications/pi/demo/heartbeat", params: { _meta: { pi_dedup_key: `beat-${process.pid}-${beats}`, pi_summary: `demo heartbeat #${beats} at ${new Date().toISOString()}` } } });
25
+ }, 10_000).unref?.();
26
+ } else if (msg.method === "tools/list") {
27
+ send({ jsonrpc: "2.0", id: msg.id, result: { tools: [{ name: "demo_status", description: "How many heartbeats this demo server has pushed", inputSchema: { type: "object", properties: {} } }] } });
28
+ } else if (msg.method === "tools/call") {
29
+ send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: `pushed ${beats} heartbeat(s) so far` }], isError: false } });
30
+ } else if (msg.method === "ping") {
31
+ send({ jsonrpc: "2.0", id: msg.id, result: {} });
32
+ } else if (msg.id !== undefined) {
33
+ send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `unsupported method ${msg.method}` } });
34
+ }
35
+ });
@@ -0,0 +1,8 @@
1
+ # Copy into ~/.pi/agent/loops/mcp.toml (or <project>/.pi/mcp.toml in a trusted project).
2
+ # `args` is relative to the directory pi runs in; use an absolute path to make it work anywhere.
3
+ [[server]]
4
+ name = "notify-demo"
5
+ command = "node"
6
+ args = ["examples/mcp-notify-server.mjs"]
7
+ # inject_summary = true # push the summary straight into the chat, no model call
8
+ # inject_and_run = true # push it and run one model turn on it