@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/triggers.md
ADDED
|
@@ -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
|