@alphacoder-v0/pi-loops 0.22.7 → 0.24.0
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 +681 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -41
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +598 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/docs/design.md
CHANGED
|
@@ -8,8 +8,8 @@ do for you.
|
|
|
8
8
|
| the job | what does it |
|
|
9
9
|
|---|---|
|
|
10
10
|
| a clock | `setInterval` started in `session_start`, cleared in `session_shutdown` |
|
|
11
|
-
| a scheduled job that lands in your chat | `pi.sendUserMessage`
|
|
12
|
-
| a scheduled job that must not touch your chat | an in-process `AgentSession` from pi's SDK (`src/sdk-runner.ts`): fresh context, own transcript, the
|
|
11
|
+
| a scheduled job that lands in your chat | a row in `requests/<session>.jsonl`, handed to `pi.sendUserMessage` by the window holding the session once the agent is idle, `[Trigger <id>] ` prefix |
|
|
12
|
+
| a scheduled job that must not touch your chat | an in-process `AgentSession` from pi's SDK (`src/sdk-runner.ts`): fresh context, own transcript, and the tools, MCP clients, extensions and model of the process that runs it (pi-loops, for loops and checks) |
|
|
13
13
|
| state that outlives a session | `jobs.json`, `triggers.json`, `state/<id>.md` under `~/.pi/agent/loops` |
|
|
14
14
|
| a record of who changed what | `pi.appendEntry` for cron control operations; `triggers-audit.jsonl` for trigger runs |
|
|
15
15
|
| something to look at | transcript cards via `pi.appendEntry` + an entry renderer; a widget above the editor; a footer badge via `ctx.ui.setStatus` |
|
|
@@ -27,46 +27,77 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
27
27
|
1. **Machine-global jobs and rules, each carrying a `cwd`.** `/cron` and
|
|
28
28
|
`/triggers rules` show the current project by default, and say how many are elsewhere. A plain
|
|
29
29
|
(inject) job belongs to the session that created it — its result is a message in that
|
|
30
|
-
conversation — and everything that lists it agrees with dispatch about that
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
conversation — and everything that lists it agrees with dispatch about that. While no window
|
|
31
|
+
holds the conversation pi-loops fires it there, holding the conversation for that one turn
|
|
32
|
+
(`[session <id> — in the background]`); when pi-loops is not running it is paused (`paused: <why>`), and for a
|
|
33
|
+
conversation whose file pi-loops never saw it is asleep (`[session <id> — resume it to run]`, no
|
|
34
|
+
next run, `/cron run` refuses).
|
|
35
|
+
Asleep is not broken. It is parked as disabled only once its session's file has been deleted
|
|
36
|
+
(`/cron gc` removes it). A conversation open in two windows is
|
|
37
|
+
held by one of them: a lease in `leases/<session>.json` names the holding instance, and it changes
|
|
38
|
+
hands when its holder releases it or that process is gone (its pid, or a recycled pid's start
|
|
39
|
+
time), and a window takes it from pi-loops at once; never on a timer.
|
|
40
|
+
The other window is a viewer and fires none of the conversation's plain jobs. A loop
|
|
34
41
|
whose `cwd` disappeared waits half an hour — a mount can be late at boot — and is disabled after
|
|
35
42
|
that.
|
|
36
43
|
|
|
37
44
|
The cost: a list has to be scoped, and a job you cannot see reads as a job that is gone. Hence
|
|
38
|
-
the counts, the markers, and `/cron all`.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
(
|
|
46
|
-
|
|
45
|
+
the counts, the markers, and `/cron all`. And a holder that is alive but frozen (stopped in a
|
|
46
|
+
debugger, a wedged process) keeps its conversation's plain jobs waiting until it exits. The lease
|
|
47
|
+
does not expire on a timer on purpose: a timer cannot tell a frozen window from a suspended
|
|
48
|
+
laptop, and one is what used to move the clock away from a laptop that had only been asleep.
|
|
49
|
+
pi-loops writing into a closed conversation costs one race, stated: a person who opens the
|
|
50
|
+
conversation during the seconds pi-loops spends on a turn there takes it back at once and the
|
|
51
|
+
turn stops, but what pi-loops wrote meanwhile is on disk and not on that screen. Their next
|
|
52
|
+
entry is a sibling branch (a pi session is a tree, so nothing is lost), and the window says so;
|
|
53
|
+
the slot is owed, so that window then runs the job once more, and the conversation shows the
|
|
54
|
+
stopped attempt before the answer. A pi with the conversation open but without pi-loops in it is
|
|
55
|
+
guarded against only by pi-loops leaving a file someone else wrote in the last two minutes alone.
|
|
56
|
+
A held turn is a run like a loop's (a record, its own cost, a failure streak), and its extensions
|
|
57
|
+
stay loaded for that conversation's later turns: up to eight conversations, released after an
|
|
58
|
+
hour idle.
|
|
59
|
+
|
|
60
|
+
2. **Who runs what.** One always-on process runs stateful loops, dynamic-rule checks and MCP push
|
|
61
|
+
evaluation for a loops directory. A window starts it when there is work to run, including the plain jobs and queued rule results of
|
|
62
|
+
closed conversations. Windows run only
|
|
63
|
+
the plain jobs for conversations whose leases they hold. Rule results go to the conversation
|
|
64
|
+
that created the rule, queued for its holder when needed; when no window holds it, pi-loops
|
|
65
|
+
appends the result to the conversation. A queued row pi-loops cannot run (an attended one, which
|
|
66
|
+
waits for a window) costs it no read of the conversation on a tick where the conversation,
|
|
67
|
+
`jobs.json` and the queue are as they were and no backoff has ended; it remembers the three at
|
|
68
|
+
the last "nothing to do" and reads again when one moves. The inbox gets a result only when the conversation cannot
|
|
69
|
+
be written; shell-created rules go to the inbox. During an upgrade, `presence/` is read only to accommodate
|
|
70
|
+
pi-loops 0.22 windows; pi-loops writes `scheduler.json` for those old windows to read.
|
|
71
|
+
|
|
72
|
+
The cost: loops pause while pi-loops cannot start, whereas they previously ran in an interactive pi.
|
|
73
|
+
A loop no longer shares a window's live MCP connections; it connects its own from `mcp.toml`
|
|
74
|
+
and the project. A pi-loops process that is alive but frozen blocks nothing: a window replaces it after three
|
|
75
|
+
failed probes and a heartbeat it stopped writing. After a reboot, nothing runs until a pi window opens, unless the person installed
|
|
76
|
+
a user-managed service (decision 21).
|
|
47
77
|
|
|
48
78
|
3. **Model and thinking level are recorded when a job is created** and editable afterwards
|
|
49
|
-
(`/cron set`, `/triggers set`; `--model -`
|
|
79
|
+
(`/cron set`, `/triggers set`; `--model -` clears the pin: a loop then uses pi's default model, a
|
|
80
|
+
plain job its conversation's). A loop that was set up
|
|
50
81
|
against a capable model does not quietly start running on whatever the newest session happens to
|
|
51
82
|
be using.
|
|
52
83
|
|
|
53
84
|
4. **MCP pushes.** A push that injects into the chat reaches every window that has the server. A
|
|
54
|
-
push evaluated against rules is evaluated once per project, by
|
|
85
|
+
push evaluated against rules is evaluated once per project, by pi-loops. Sub-agents
|
|
55
86
|
ignore pushes; anything that reaches hop ≥ 1 is audited as `cycle_suppressed`.
|
|
56
87
|
|
|
57
|
-
5. **Sub-agents are sessions, not processes** (`createAgentSession`). They share the
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
5. **Sub-agents are sessions, not processes** (`createAgentSession`). They share the live MCP
|
|
89
|
+
clients, extensions and model of the process that runs them. For loops and checks that process
|
|
90
|
+
is pi-loops: a loop sees pi-loops' own MCP connections and pi's default model unless the job
|
|
91
|
+
pins one, not a window's browser tab, `-e` extensions or flags. A run reads a project's own
|
|
92
|
+
resources only when pi has saved a trust decision for that exact directory (never by default).
|
|
93
|
+
That process's extensions get their `session_start` / `session_shutdown` in every sub-session.
|
|
63
94
|
Each run gets its own transcript file and a configurable cap (`[triggers] run_timeout_secs`,
|
|
64
95
|
per-rule `--timeout`). Nothing is re-spawned per run.
|
|
65
96
|
|
|
66
97
|
The cost: a sub-agent has no UI, so a tool that would ask for confirmation is denied
|
|
67
98
|
fail-closed there. Nobody can say yes, so the answer has to be no.
|
|
68
99
|
|
|
69
|
-
6. **Catch-up.** A loop tick missed while
|
|
100
|
+
6. **Catch-up.** A loop tick missed while pi-loops was not running is fired once when pi-loops starts, collapsed
|
|
70
101
|
rather than replayed (`[cron] catch_up = false` or `--no-catchup` turns it off). Plain jobs do
|
|
71
102
|
not catch up unless `--catchup`: they land in a conversation, and a conversation that opens to
|
|
72
103
|
four hours of backfill is worse than one that opens to nothing. A run that died with its process
|
|
@@ -83,23 +114,38 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
83
114
|
9. **A promotion that arrives while the agent is busy** goes to pi's follow-up queue and runs a
|
|
84
115
|
turn after the current one; when the agent is idle it is inserted without a model call.
|
|
85
116
|
|
|
86
|
-
10. **
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
not
|
|
117
|
+
10. **One always-on process per loops directory.** It is a `node` process recorded in
|
|
118
|
+
`host-service.json`, with output in `host.log`. A window starts it on session start, every
|
|
119
|
+
30-second tick, and immediately after a loop, rule or relevant MCP server is added, when there is
|
|
120
|
+
enabled work. It runs stateful loops, dynamic-rule checks and MCP pushes, and the plain jobs of
|
|
121
|
+
conversations no window holds (decision 1).
|
|
122
|
+
It does not exit when windows open or close. `/pi-loops status` shows its pid, version, start time,
|
|
123
|
+
work, held conversations and log. `/pi-loops stop` records a manual stop until `/pi-loops start`, or until `pi-loops` opens the
|
|
124
|
+
page and says that loops and rules resume;
|
|
125
|
+
`[host] auto = false` and `PI_LOOPS_HOST=off` prevent automatic starts. When pi-loops cannot start, it is retried after 1, 5 and 30 minutes. pi-loops does not catch an error and carry on from inside the
|
|
126
|
+
process, because its state after an uncaught error cannot be trusted. An uncaught exception is logged first, with the runs in
|
|
127
|
+
flight, then counted and the process exits; a record left naming a dead process (SIGKILL, OOM)
|
|
128
|
+
counts the same, and so does an eviction. Three of those within five minutes pause pi-loops,
|
|
129
|
+
and it stays paused until `/pi-loops start`; a job whose runs die with their process three
|
|
130
|
+
times in a row is parked with the reason. When pi-loops cannot run here at all, it exits with code 78, which a systemd unit configured as in [cli.md](cli.md#running-pi-loops-under-your-own-service-manager) does not restart. A
|
|
131
|
+
window replaces pi-loops (SIGTERM, then SIGKILL after 10 seconds) only after three unanswered
|
|
132
|
+
control-socket probes and a heartbeat pi-loops stopped writing; pi-loops still ticking
|
|
133
|
+
with a broken socket is reported, not killed, and a starting pi-loops process is never killed. A newer
|
|
134
|
+
window replaces an older pi-loops process and starts its own at once; an older window never replaces a newer
|
|
135
|
+
pi-loops process, nor starts an old one while the newer window that asked for the upgrade is open. Under bun
|
|
136
|
+
pi-loops runs on the `node` found on PATH; with none, loops pause and say why. During an
|
|
137
|
+
upgrade, pi-loops 0.22 windows stand by for loops; pi-loops writes `scheduler.json` for them to
|
|
138
|
+
read. Those windows still run their held conversation's plain jobs and must be restarted to pick
|
|
139
|
+
up the new behaviour. After a reboot nothing runs until a pi opens, or under a service manager configured by the user. The costs are decision 2's.
|
|
140
|
+
|
|
141
|
+
The claim on a job is a read-modify-write of `jobs.json` under `jobs.lock`, a directory lock
|
|
142
|
+
that is broken as stale after 10 seconds (`src/lock.ts`). It has no fencing token. A process
|
|
143
|
+
stopped (SIGSTOP, a debugger) for more than 10 seconds between reading a job as idle and
|
|
144
|
+
writing its claim, and long enough for another process to take the clock and claim the same
|
|
145
|
+
job, writes its claim over that one when it resumes, and the job runs twice. That is outside
|
|
146
|
+
the guarantee: it needs a stop inside a synchronous section that takes microseconds, and a
|
|
147
|
+
laptop that sleeps stops every process at once, so the resumed holder finishes its write before
|
|
148
|
+
any other process's timer fires.
|
|
103
149
|
|
|
104
150
|
11. **Bounded everywhere, and no expiry.** Loop state ≤ 2000 characters, a finding ≤ 500, at most
|
|
105
151
|
16 per run, prompts ≤ 8 KB. A job disappears when you remove it, when a one-shot has fired, or
|
|
@@ -114,7 +160,7 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
114
160
|
13. **Stdio MCP servers reconnect** with backoff (20 attempts by default), each distinct error
|
|
115
161
|
reported once rather than on every retry.
|
|
116
162
|
|
|
117
|
-
14. **A bill, and a cap on it.**
|
|
163
|
+
14. **A bill, and a cap on it.** pi-loops can run for days, so cost cannot be something you
|
|
118
164
|
discover at the end of the month: `[limits] daily_budget_usd` gates every dispatch, `/cron cost`
|
|
119
165
|
adds up the run log, and what log rotation drops is folded into `spend.json` first so the cap
|
|
120
166
|
does not quietly stop capping. A run stopped by the budget is recorded as aborted rather than
|
|
@@ -151,8 +197,8 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
151
197
|
17. **The wizard is deterministic; one step is not.** Choosing a recipe, its autonomy level, the
|
|
152
198
|
copy and the `/cron add` lines run with no model and end in a confirmation that shows exactly
|
|
153
199
|
what will be created. The one thing a template cannot do — describe *this* project's tracker —
|
|
154
|
-
is
|
|
155
|
-
finding: explore the remote, propose, confirm, write the file — and when that turn settles with
|
|
200
|
+
is a request addressed to the running session (`requests/<session>.jsonl`), handed to the agent
|
|
201
|
+
once it is idle, the way `/inbox claim` hands over a finding: explore the remote, propose, confirm, write the file — and when that turn settles with
|
|
156
202
|
the file there, the wizard resumes by itself, so the command is typed once. `pi-loops recipe`
|
|
157
203
|
in the terminal does the deterministic part only.
|
|
158
204
|
|
|
@@ -165,13 +211,14 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
165
211
|
protection on the remote) are the ones that hold.
|
|
166
212
|
|
|
167
213
|
19. **One machine.** Until 0.19.0 every job and rule carried the hostname that created it, another
|
|
168
|
-
machine sharing the `$HOME` ignored it and listed it as
|
|
169
|
-
re-homed it, and the leader file was named per
|
|
214
|
+
machine sharing the `$HOME` ignored it and listed it as belonging to another machine, `/cron set --host`
|
|
215
|
+
re-homed it, and the leader file was named per machine. None of it matched the way pi-loops is
|
|
170
216
|
used: pi runs on one machine, the loops run there, and a phone or a
|
|
171
217
|
laptop reaches the browser front end over a tailnet (`tailscale serve`) without moving the
|
|
172
|
-
execution anywhere. So
|
|
173
|
-
|
|
174
|
-
|
|
218
|
+
execution anywhere. So machine ownership fields are gone from jobs and rules (an old field is
|
|
219
|
+
dropped when the file is read). `--host`, the marker, `scheduler.json` and `next-runs.json` are
|
|
220
|
+
gone too. Host is the code’s name for the background process; user-facing text says
|
|
221
|
+
pi-loops or in the background.
|
|
175
222
|
|
|
176
223
|
The cost: two machines syncing one `$HOME` both run every job. That is not supported, and it
|
|
177
224
|
is said rather than half-handled.
|
|
@@ -197,9 +244,292 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
197
244
|
[../src/cli.ts](../src/cli.ts), beside the one function that renders the script, so it is
|
|
198
245
|
testable without pi; the extension is the caller.
|
|
199
246
|
|
|
200
|
-
The cost: a write outside the loops directory, which the Non-invasive invariant
|
|
201
|
-
|
|
202
|
-
does not make.
|
|
247
|
+
The cost: a write outside the loops directory, which the Non-invasive invariant names as one of
|
|
248
|
+
its two exceptions, and a question on somebody's first start — an interruption this project
|
|
249
|
+
otherwise does not make.
|
|
250
|
+
|
|
251
|
+
21. **The background process serves the one page.** pi-loops in the background owns the browser
|
|
252
|
+
page: `127.0.0.1:4173` for the default loops directory (`[page] port`), a free port for a moved
|
|
253
|
+
one, recorded in `host-service.json`. `pi-loops` with no arguments asks it over the control
|
|
254
|
+
socket (`page-open`) to open a conversation and answers the address and a one-shot open key,
|
|
255
|
+
then opens the browser and exits. Neither the token nor the key enters argv, which any account
|
|
256
|
+
on the machine can read in `/proc/<pid>/cmdline`: the background process writes the keyed
|
|
257
|
+
address into `pages/open/<random>.html` (0600, in a 0700 directory) and answers its path, the
|
|
258
|
+
browser is opened on that `file:` address, and the file sends it on. The file is removed when
|
|
259
|
+
the key is spent or after 60 seconds, whichever comes first, and every one left is removed
|
|
260
|
+
when the page starts. The cost: a process that runs as you can read the file in those 60
|
|
261
|
+
seconds, as it could read `web-token` itself. A browser confined by snap or flatpak cannot read
|
|
262
|
+
a file under a hidden directory, so for it the hand-off fails; the launcher also prints the
|
|
263
|
+
conversation's address with a second one-use key (10 minutes), and never the token, because a
|
|
264
|
+
terminal's scrollback, recordings and shared screens keep what it prints. It asks as soon as
|
|
265
|
+
the control socket is open: the conversation's pi starts while the page is still binding, and only
|
|
266
|
+
the answer, which carries the address, waits for the bind. A pairing code is
|
|
267
|
+
made only when asked for, by `pi-loops pair` or the page's add device button. Each
|
|
268
|
+
open conversation is one `pi --mode rpc` child of the background process under `/c/<session>/`
|
|
269
|
+
(`src/page-children.ts`), stopped after 15 idle minutes, six at most. What 4174 used to show is
|
|
270
|
+
the Background view of the same page, at `/background`.
|
|
271
|
+
|
|
272
|
+
The page keeps `web-token` and checks the Host header against DNS rebinding. The token never
|
|
273
|
+
travels in a cookie, because a cookie ignores ports: the browser sends it to every server on
|
|
274
|
+
127.0.0.1, so any local process that binds a port and gets one request from the browser would
|
|
275
|
+
hold the token. Every route that needs the token takes it from the `x-pi-web-token` header (or,
|
|
276
|
+
on a GET, `?token=`); a POST that carries it anywhere else is refused with 403 before its body is
|
|
277
|
+
read and before anything is written to `requests/`. The page keeps the token in memory. Three
|
|
278
|
+
addresses a browser fetches without that header — the event stream, a file, a preview — take a
|
|
279
|
+
key minted for that address at `POST /c/<session>/key`: 16 random bytes, bound to the
|
|
280
|
+
conversation and the kind, a file's key to its exact path and a preview's to its id, held in
|
|
281
|
+
memory (256 at most). A file or preview key opens one load within 60 seconds; an event-stream
|
|
282
|
+
key lasts 10 minutes, because an EventSource reconnects with the address it was given.
|
|
283
|
+
|
|
284
|
+
A reload does not need the open key or the pairing code again. The first visit (the open key,
|
|
285
|
+
the six digits, or the token itself) sets `pi_web_device`, a device id (`HttpOnly`,
|
|
286
|
+
`SameSite=Strict`, one year), records it in `web-devices.json` (0600, keyed by a hash of the
|
|
287
|
+
token, so a new token starts with no devices), and hands the page a device secret once, which
|
|
288
|
+
the page keeps in `localStorage`. A later load with a known device cookie gets the page without
|
|
289
|
+
the token or the secret in it, and the page trades the cookie and the secret, in the
|
|
290
|
+
`x-pi-web-device-secret` header, for the token at `POST /c/<session>/token`. A wrong secret is
|
|
291
|
+
403, and twenty of them forget the device. `localStorage` belongs to this origin, port included,
|
|
292
|
+
so a server on another port that receives the cookie fetches what has no token in it (the page and the
|
|
293
|
+
Background view) and nothing else. A browser paired before 0.24.0 pairs once more. 0.24.0 keeps
|
|
294
|
+
0.22.8's `web-token`, and 0.22.8 kept it in a `pi_web_token` cookie; any answer to a request that
|
|
295
|
+
still carries that cookie clears it (`Max-Age=0`), so the live token stops travelling to every
|
|
296
|
+
port. Abort, run, stop,
|
|
297
|
+
upgrade and ring (the doorbell: `request:<session>` or `lease:<session>`, answered before the
|
|
298
|
+
delivery runs) stay on the control socket.
|
|
299
|
+
|
|
300
|
+
A page child is a window: it holds its conversation's lease as a terminal in `rpc` mode, and
|
|
301
|
+
marks the lease `page: true`, with `turnSince` from `agent_start` until `agent_settled` (pi
|
|
302
|
+
retries, compacts and continues after `agent_end`) and while a compaction runs. A `tui` window takes a
|
|
303
|
+
conversation from an idle page child at once; while the child's turn runs, the terminal is a
|
|
304
|
+
viewer and takes it at its next refresh after the turn, and the page then shows that the
|
|
305
|
+
conversation is answered in the terminal. An `rpc`, `print` or `json` window does not take it.
|
|
306
|
+
|
|
307
|
+
The cost: a stolen token reaches every existing conversation for as long as pi-loops runs, not
|
|
308
|
+
one session for as long as one terminal stays open, and there is no unauthenticated page any
|
|
309
|
+
more (`--no-auth` is gone). Rotate it by deleting `web-token` and running `pi-loops restart`;
|
|
310
|
+
every browser then signs in again. A device that pairs is trusted until the token is rotated.
|
|
311
|
+
The token is a shell. `/rpc` passes pi's `bash` command, and a prompt reaches pi's `bash` tool,
|
|
312
|
+
so whoever holds the token runs commands as you, in the project of every conversation.
|
|
313
|
+
`pi-loops status --url` prints the address with the token in it, so that output is a
|
|
314
|
+
credential: keep it out of issues, chats and shared terminal logs.
|
|
315
|
+
A script that runs on this origin (an XSS in the page) still reads the token from memory and the
|
|
316
|
+
device secret from `localStorage`. A process that holds a sniffed device cookie can load a
|
|
317
|
+
conversation's page, which starts that conversation's pi as a person's visit does, read the
|
|
318
|
+
masked stderr tail a crash-looping conversation's page shows and start it again, and spend the
|
|
319
|
+
twenty tries, which signs that browser out. After `[page] port` changes, a browser pairs
|
|
320
|
+
again: its secret stays with the old origin. A conversation no lease names opens from one of
|
|
321
|
+
pi's own session files under its sessions root whose header id is the one in the address
|
|
322
|
+
(`ConversationIndex.byId`, `src/conversation-rows.ts`), which is what `pi --session <id>`
|
|
323
|
+
accepts, and which widens what `/c/<id>/` opens from the conversations pi-loops has seen to
|
|
324
|
+
every one pi has written. `+ New` in a project other than the open conversation's posts
|
|
325
|
+
`/conversations/new`, which starts a pi in that project with that project's arguments, never
|
|
326
|
+
the request's; the cost is that a page request can start a pi in any project the page already
|
|
327
|
+
lists. A project with no arguments of its own gets only `-e <this package>`, and only when a
|
|
328
|
+
page pi or this process was started with it, so a launch that loads pi-loops with `-e` has
|
|
329
|
+
pi-loops in every project's conversations without carrying a model or another extension
|
|
330
|
+
across projects.
|
|
331
|
+
|
|
332
|
+
22. **A scheduled prompt runs under its own cap, whichever process runs it.** Every request row
|
|
333
|
+
carries the `mode` its source gets: `attended` for what you typed, a `/goal` continuation, a
|
|
334
|
+
claim, a recipe hand-off and `/new-trigger`; `unattended` for the clock and an `inject_and_run`
|
|
335
|
+
push; `none` for a result. The process that runs a row recomputes it and refuses, audited, one
|
|
336
|
+
that says otherwise, and a window believes an attended source only on a row it wrote itself,
|
|
337
|
+
so a line in the file cannot widen a turn. An unattended turn runs under
|
|
338
|
+
the job's declared tools and the dangerous-command policy in a window exactly as in the
|
|
339
|
+
background; what differs is who answers. The background refuses; a window asks you, with a
|
|
340
|
+
60-second countdown, and refuses when nobody does, telling the model which of the two it was.
|
|
341
|
+
pi-loops' own tools get no question from the guard, because each carries its own: `cron_create`
|
|
342
|
+
makes a job capped to the turn's tools (the window's `toolHost` reads the current turn, as the
|
|
343
|
+
background scopes a run's), and the tools that confirm through `confirmTool` do so once, where
|
|
344
|
+
the background's preflight refuses. The cost is a dialog in the middle of a turn
|
|
345
|
+
you did not start, and `[engine] window_guard = false` buys the uncapped window back. The
|
|
346
|
+
background process runs no attended row at all; one waits for a window, and a `/cron run` row
|
|
347
|
+
left in a closed conversation does not hold the clock's slots, which run as if it were not there.
|
|
348
|
+
`/cron run` is typed: its row goes before the clock's rows.
|
|
349
|
+
|
|
350
|
+
23. **A chat message from the browser is a request too.** The page posts `/prompt` as before; for
|
|
351
|
+
chat text `src/web.mjs` appends a `person` row (`kind: input`, `wake: turn`, `mode: attended`,
|
|
352
|
+
the text and images as the payload, the page's UUID as the id, `created_by` the web.mjs that
|
|
353
|
+
wrote it) to `requests/<session>.jsonl` and answers `{queued: <id>}` at once. Text that starts
|
|
354
|
+
with `/`, a steer and Stop stay rpc: they act on the process or on the running turn, not on the
|
|
355
|
+
queue. The pi holding the session drains the row when it is idle, so a chat message runs in one
|
|
356
|
+
order with what pi-loops addressed to the conversation, survives Esc and pi exiting, and the
|
|
357
|
+
page lists the whole queue, each row with its source and a cancel (a `cancel` row), with what pi
|
|
358
|
+
itself still holds (a command typed while busy) after them, labelled "pi". Two things follow
|
|
359
|
+
from a row with no `[Trigger]` prefix. Its proof of delivery is a user message whose text starts
|
|
360
|
+
with the row's (pi appends a note when it resizes or converts an image; a row with no text of its
|
|
361
|
+
own is proved only by a message with none once those notes are stripped) written at or after
|
|
362
|
+
`handed_at`, the stamp the drain writes on the row under the lock just before each hand-over, at
|
|
363
|
+
the moment of the stamp; a turn pi holds and has not written keeps the lane, so one row is in
|
|
364
|
+
flight at a time, an earlier message with the same words proves nothing, and a row never handed
|
|
365
|
+
over is never done. Read from the file at every drain and never from an event. In the window, a
|
|
366
|
+
user message takes the cap of the handed-over row with the longest text that is a prefix of it
|
|
367
|
+
(a tie goes to the most recently handed over), so a short row's kept cap is not claimed by a
|
|
368
|
+
longer message. And its attended mode has to be believed: after the append the page sends
|
|
369
|
+
`/pi-loops ring <id> <fingerprint>` over its pi's rpc stdin, a channel no model tool can write
|
|
370
|
+
to; the extension records the row as produced by this window only when the row now in the file
|
|
371
|
+
has that fingerprint (a hash of kind, source, wake, text, the images and the declared tools),
|
|
372
|
+
then drains; a ring that arrives after the row was handed over upgrades its cap, started or not.
|
|
373
|
+
A person row is handed over only once rung for, or two seconds after it was written, so the bell
|
|
374
|
+
normally wins the race with the file watcher, and the drain that waits schedules one for when the
|
|
375
|
+
wait ends; one nobody rang for (a terminal answering a page's row, owner decision O2; a line
|
|
376
|
+
written into the file by hand) runs capped as unattended, and the window asks before a dangerous
|
|
377
|
+
command. `/pi-loops ring` with no arguments only drains; it is plumbing, and not in
|
|
378
|
+
`/pi-loops help`. A person row is sent only while its web.mjs lives: one left behind when pi
|
|
379
|
+
exited is offered by the next page as "not sent before pi stopped", with send (which rewrites
|
|
380
|
+
`created_by` to that page under the lock, then rings) and discard (a `cancel` row). A send pi
|
|
381
|
+
refuses (`prompt()` throws before any run starts: compaction in progress, no model, no
|
|
382
|
+
credentials) is found by the holder's tick, or by the drain the page's follow-up ring causes (a
|
|
383
|
+
command handler, outside pi's event cycle), never by the drain `agent_settled` runs, since pi
|
|
384
|
+
defers a prompt sent while it emits that event: a row handed to an idle pi with no `agent_start`
|
|
385
|
+
since, ten seconds after pi's last `input` or `message_start` (its pre-run phase after `input`,
|
|
386
|
+
the other input handlers and the credential check, is what the grace covers), is refused. A
|
|
387
|
+
person row gets `last_error` and is never handed over again (its cap is kept, so a send pi runs
|
|
388
|
+
late still runs under it); the page, which reads the file for `/state`, removes it, audits
|
|
389
|
+
`failed`, and puts the text back in the composer with the error. A pi-loops row, proved by its
|
|
390
|
+
prefix, counts an attempt and is offered again after the backoff, a dead letter at the third;
|
|
391
|
+
a slot due while it waits replaces it rather than collapsing onto it. After Stop (rpc
|
|
392
|
+
`abort`), as after Esc in a terminal, what is queued runs once pi is idle: the owner decided
|
|
393
|
+
(2026-11-02) that Esc and Stop end the current turn only, as in 0.22.8, and that the queue is
|
|
394
|
+
visible and cancellable everywhere instead (the page's per-row ✕, the terminal's `/cron queue`,
|
|
395
|
+
`queued N` on the status line). The post-abort hold this design first had (a minute for
|
|
396
|
+
pi-loops' rows, the person's next input for theirs, `released_at` and "send queued (n)" to lift
|
|
397
|
+
it) is gone with it: a timer that sends words a person stopped was the cost it was meant to avoid,
|
|
398
|
+
and a queue the person can see and cancel is the plainer answer. Send on an offered row makes the
|
|
399
|
+
row this page's under the lock, then rings. `handed_at` is not part of the fingerprint. An offered row is listed
|
|
400
|
+
with its whole text, since send vouches for all of it. The row's image bytes are a file beside
|
|
401
|
+
the queue, one per row (`requests/<session>.<digest of the id>.images.json`), removed with the
|
|
402
|
+
row; the row carries each image's type and SHA-256, the fingerprint covers those, and the holder
|
|
403
|
+
refuses bytes that do not match them, so three screenshots are a line in the queue and not
|
|
404
|
+
thirty megabytes rewritten under the lock at every update; an append that fails takes the file
|
|
405
|
+
back, and the background process sweeps files no row names once they are a minute old. Each
|
|
406
|
+
session's queue has its own lock (`requests/<session>.lock`): one `requests.lock` left by a
|
|
407
|
+
process killed mid-rewrite held every writer of every session for the stale wait, and now holds
|
|
408
|
+
that session's alone. A holder that will not deliver a person row says so on its lease: a window standing
|
|
409
|
+
aside writes `aside` on its holder (`print` and `json` mode are there already; `holderDelivers`
|
|
410
|
+
in src/lease.ts reads all three), the page's own pi then runs the page's rows it was rung for in
|
|
411
|
+
that holder's place, and the page does not say "answered there"; when the page's own pi is the
|
|
412
|
+
one standing aside, the page says so with `/resume` and nothing writes into the conversation. A
|
|
413
|
+
row of the page's nobody took in fifteen seconds with pi idle (past the ten-second refusal grace,
|
|
414
|
+
so a refused send comes back as "not sent" and never as both; short of the thirty-second lease
|
|
415
|
+
tick) is listed as "not taken (why)" with "send it here instead", which removes the row
|
|
416
|
+
(audited `cancelled`) and sends the text over rpc `prompt`, as 0.22 did; a holder that delivers
|
|
417
|
+
and is another process is not called unanswering, since the page sees only its own pi's idle
|
|
418
|
+
state: the row reads "another pi holds this conversation and has not taken it yet (it may be busy)" and
|
|
419
|
+
the offer "answer here instead (a branch the other pi will not see)". The removal and the check that no
|
|
420
|
+
holder has stamped the row are one critical section under the queue's lock, and a drain whose
|
|
421
|
+
stamp finds no row hands nothing over, so a message is answered once. A pi with no pi-loops in
|
|
422
|
+
it gets chat text over rpc `prompt` and no row is written: nobody would drain it, and the ring
|
|
423
|
+
would reach the model as the person's message; the command list is asked for again after every
|
|
424
|
+
slash command (`/reload` changes it) and when it is older than thirty seconds. The
|
|
425
|
+
cost is a second writer of the request file and a bell on every send; what it buys is a queue
|
|
426
|
+
that Esc cannot clear and a message that outlives the process it was typed into.
|
|
427
|
+
|
|
428
|
+
A claim from the page's inbox (**Yes, do it**, **Look into it**) is not a `person` row: it is
|
|
429
|
+
the `inbox:<id>` row `/inbox claim` writes, with the same collapse key, so a page and a terminal
|
|
430
|
+
claiming one finding collapse on each other and the file lock settles which one wrote it. The
|
|
431
|
+
page server signs it and lives on, and a row its producer outlives would wait for it for ever,
|
|
432
|
+
so the row is born left (`created_by.left` set at birth, `pageClaimRow` in src/engine.ts): the
|
|
433
|
+
holder runs it, as it runs a terminal's claim after that window moved on, and nobody else does,
|
|
434
|
+
since a viewer runs only its own rows and a left row is nobody's, and pi-loops in the background
|
|
435
|
+
runs no attended row. Under the inbox's lock, once `Inbox.decide` has found the finding still
|
|
436
|
+
new, the server writes the row and then marks the finding claimed, so a claim that loses writes
|
|
437
|
+
nothing (a terminal's `/inbox claim` does the same in `handOver`); then it delivers: it
|
|
438
|
+
rings the page's pi that holds the conversation, leaves the row to a terminal that holds it, waits
|
|
439
|
+
out pi-loops' scheduled turn there, or starts the conversation's pi when nobody holds it, asking
|
|
440
|
+
for its command list first and never ringing, or starting again, a pi with no pi-loops in it. The
|
|
441
|
+
same step runs at start and every ten seconds, one at a time (a claim made while one runs says
|
|
442
|
+
where it will run and is delivered when that one ends), so a claim tapped on a phone and left there runs
|
|
443
|
+
when the conversation is free, and a claim cut short between the row and the status is finished
|
|
444
|
+
from the files. The cost is the one this decision already pays for a page's message: in a
|
|
445
|
+
terminal the claim's turn runs capped as unattended, since no ring reaches a terminal, and the
|
|
446
|
+
danger check asks in that terminal before a dangerous command.
|
|
447
|
+
|
|
448
|
+
24. **A watch is a question the tool asks.** With a person present, in the person's own turn
|
|
449
|
+
(hop 0, `ctx.hasUI`, not a scheduled turn), `cron_create` and `new_trigger` run every check a
|
|
450
|
+
create would, then ask through pi's own `ctx.ui.select` and wait for the answer in the same
|
|
451
|
+
call. The title is `Set up this watch?` and then the card's rows as `Label: value` lines
|
|
452
|
+
(`Runs` or `Waits for`, `Looks`, `Does` or `Then`, `Careful`, `Command`; a value's
|
|
453
|
+
further lines are indented two spaces). The options are a fixed vocabulary, one string per
|
|
454
|
+
combination of the card's choices, the plan's defaults first: for a schedule
|
|
455
|
+
`Set it up · inbox`, `Set it up · inbox · with checks`, `Set it up · this conversation`; for a
|
|
456
|
+
rule `Set it up · this conversation|inbox · once|every time`; and `Cancel`.
|
|
457
|
+
`watchChoice` in `src/watch-plan.ts` is the one place an answer becomes the stores' fields: a
|
|
458
|
+
schedule's `inbox` is `stateful` and `with checks` is `verify`; a rule's
|
|
459
|
+
`this conversation` is `promote_to_chat` and `once` is `fire_once`. An answer that was not
|
|
460
|
+
offered is a cancel. The tool returns `created cron job …` or `created dynamic trigger …`, or
|
|
461
|
+
`cancelled: the person did not set up this watch (Cancel|aborted|timed out). Nothing was
|
|
462
|
+
created.`, and the model reads that one rule on every surface. The question replaces
|
|
463
|
+
`new_trigger`'s confirm: one question, never two. A terminal shows pi's selector; the page draws
|
|
464
|
+
the same select as its card (chips are the axes of the options, Set it up sends the option they
|
|
465
|
+
name through `/ui-response`), and the tool result in the same turn turns the card into a
|
|
466
|
+
receipt. The page's child drops every dialog asked since the last `agent_start` when the run
|
|
467
|
+
ends, since pi has answered it by then and tells nobody: replayed, it would be a card whose
|
|
468
|
+
answer goes nowhere. A sub-agent, pi-loops in the background, a pi with no dialog and a
|
|
469
|
+
scheduled turn in an open window ask nobody and keep their gate: `cron_create` creates at once
|
|
470
|
+
and `new_trigger` asks the window guard's confirm or is refused. The sentence itself is an
|
|
471
|
+
ordinary chat message, `Watch for: <sentence>`; a sentence with no time and nothing to wait
|
|
472
|
+
for is answered by the page and reaches no model, and both tool descriptions say at hop 0 that
|
|
473
|
+
such a message is never acted on now. The question shows no directory, so a `cwd` outside the
|
|
474
|
+
project is refused before it is asked.
|
|
475
|
+
What it costs: the turn waits on the person, up to thirty minutes, and then the question is
|
|
476
|
+
cancelled and nothing is created; tool calls in a turn are sequential, so the turn's other work
|
|
477
|
+
waits too, and a plain job that comes due in that window queues behind it. The card lives in
|
|
478
|
+
the dialog, not in the transcript: a conversation read later shows the tool result and no card,
|
|
479
|
+
and a question still open when its pi stops is gone with it. A second identical call in one
|
|
480
|
+
turn is a second question; the result tells the model not to call again, and nothing enforces it.
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
25. **A run writes into the conversation only when there is something to see.** A loop's first three
|
|
484
|
+
runs and every failed run are one compact card each, saved in the conversation that created the
|
|
485
|
+
loop; every other run is a line in the terminal's panel and the page's activity strip, read from
|
|
486
|
+
`runs.jsonl` when asked, with no store of its own. Deciding needs to know which run of its loop a
|
|
487
|
+
run was, and the run log is halved as it grows, so the record carries it (`ordinal`), and a
|
|
488
|
+
stopped run carries `aborted`, which is neither a failure nor a card. A 15-minute loop writes
|
|
489
|
+
three entries into its conversation, not 96 a day. The cost: a loop that has worked for a week
|
|
490
|
+
and then fails at night shows that failure, and nothing of the runs before it, in the
|
|
491
|
+
conversation; the strip and `/cron runs` have those.
|
|
492
|
+
|
|
493
|
+
Every matched rule result is one finding in the inbox, whatever `promote_to_chat` says; with
|
|
494
|
+
`promote_to_chat` it is also in the conversation. `Inbox.appendOnce` is the one rule that keeps
|
|
495
|
+
it one finding when the promotion falls back to the inbox or its row dies, the three writers that
|
|
496
|
+
used to file it. A rule result is filed as news: its text comes from outside, and a `· waits:`
|
|
497
|
+
in it would otherwise make a "Needs you" card and a notification on every page. A repeating
|
|
498
|
+
rule that says what its latest new finding says files nothing, or it would fill the inbox one
|
|
499
|
+
poll at a time. The cost: a result read in the chat waits in the inbox too, until someone
|
|
500
|
+
dismisses it.
|
|
501
|
+
|
|
502
|
+
A terminal writes `turnSince` on its lease while a turn runs, as the page's pi does, and `turns`
|
|
503
|
+
says it will. The page reads a terminal-held conversation from the file with pi's exported
|
|
504
|
+
parse, migration and projection functions instead of switching its own pi to it, and reads it
|
|
505
|
+
again once the turn is over and a new message is there. The read is synchronous in the process
|
|
506
|
+
every page shares, so a file over 32 MB is left to the page's pi. A switch changed the epoch under the
|
|
507
|
+
page and answered `get_messages` from either session; a guess at the end of a turn from 8 s of
|
|
508
|
+
quiet read half a turn. The cost: two small lease writes per terminal turn, and a terminal
|
|
509
|
+
older than 0.24 is never read again by the page.
|
|
510
|
+
|
|
511
|
+
26. **A condition that is only a path is looked at with `stat` first.** A rule such as "when
|
|
512
|
+
~/build.done appears" used to cost a model call every poll, for hours, to learn that one file was
|
|
513
|
+
not there yet. `src/path-precheck.ts` decides when the rule is created whether its condition is
|
|
514
|
+
only "a path exists or appears", resolves `~` and `$HOME` in the creating process, and records
|
|
515
|
+
the result on the rule (`precheck`). Each poll, after the poll ledger's claim, `stat`s the path
|
|
516
|
+
(2 s timeout, one in flight per rule); while it is absent the rule stays out of the check's
|
|
517
|
+
prompt and no `running` row is written, and the audit says `waiting` once. Anything the stat
|
|
518
|
+
cannot answer goes to the model, so a refusal or a doubt costs what it cost before and never a
|
|
519
|
+
match. The costs: a condition worded outside the grammar still bills; the grammar knows English
|
|
520
|
+
and Chinese only; a rule created before 0.24.0 keeps billing until it is created again, because
|
|
521
|
+
the path was never resolved by the person's own process; and a repeating rule still matches
|
|
522
|
+
every poll while the file stays, as it always did.
|
|
523
|
+
|
|
524
|
+
27. **What happened while you were away is counted per project.** One cursor per project in
|
|
525
|
+
`seen.json`, moved by `/inbox` in any of its conversations and by the page's **Mark all seen**;
|
|
526
|
+
the terminal's session-start line and the page's away card are one function over the same files
|
|
527
|
+
(`src/seen.ts` `awayFor`). Every loop of the project counts, a recipe's included, and a finding
|
|
528
|
+
from this conversation is marked as such. The cost: a look in one conversation clears the
|
|
529
|
+
count for all of them, and the page's mark clears the terminal's next line. A cursor per
|
|
530
|
+
conversation was the alternative, and it is what left a recipe's findings out: they belonged
|
|
531
|
+
to the one conversation that installed it. With no cursor the first count is the last day,
|
|
532
|
+
never the whole run log.
|
|
203
533
|
|
|
204
534
|
## Where the browser front end came from
|
|
205
535
|
|
|
@@ -208,7 +538,7 @@ goes through the door pi already provides: `pi --mode rpc` — pi with no termin
|
|
|
208
538
|
and events out as JSON lines — with [src/web.mjs](../src/web.mjs), a browser front end in one
|
|
209
539
|
dependency-free file, passing that protocol through to a page. The session is a real pi session,
|
|
210
540
|
and `pi --resume` picks it up afterwards. What only the process knows — which MCP servers connected,
|
|
211
|
-
what they exposed, the active tools,
|
|
541
|
+
what they exposed, the active tools, whether pi-loops is running and its pid — reaches the page as the
|
|
212
542
|
`pi_loops_snapshot` session entry.
|
|
213
543
|
|
|
214
544
|
`pi-loops` with no arguments starts a session and picks the window: the browser at a local
|
|
@@ -224,13 +554,22 @@ pretending. What the page owes you is kept as a gate rather than a wish list in
|
|
|
224
554
|
|
|
225
555
|
Reaching that page from a phone is solved without putting a broker in the middle: `tailscale serve`
|
|
226
556
|
terminates TLS on your tailnet and proxies to this server on loopback, so the page is on your phone
|
|
227
|
-
without anything of yours passing through a third party, and
|
|
557
|
+
without anything of yours passing through a third party, and `[page] host` does the same over a local
|
|
228
558
|
network for people who would rather not run a tailnet. What both need is a way in that a phone can
|
|
229
559
|
manage — the pairing code and the QR ([cli.md](cli.md)). A phone on neither network is the case
|
|
230
560
|
this does not cover; that one would need a relay.
|
|
231
561
|
|
|
232
|
-
While nobody is at the terminal,
|
|
233
|
-
`pi-loops
|
|
562
|
+
While nobody is at the terminal, pi-loops’ control channel is what answers "what is it doing":
|
|
563
|
+
`pi-loops status|abort|stop` ([cli.md](cli.md)), and the Background view of the page, which
|
|
564
|
+
`tailscale serve` puts on a phone the same way.
|
|
565
|
+
|
|
566
|
+
In 0.24 the page moved out of the terminal. Until then `pi-loops` ran the page's server and its one
|
|
567
|
+
pi itself, so closing that terminal took the page off the phone, and pi-loops in the background
|
|
568
|
+
served a separate read-only status page on 4174. Now pi-loops in the background serves the one page
|
|
569
|
+
on 4173, with a pi for each open conversation, and `pi-loops` only asks it to open one and exits
|
|
570
|
+
(decision 21). `src/web.mjs` keeps the page strings; the server is `src/page-server.ts`. The
|
|
571
|
+
listener flags became `[page]` in `config.toml`, because a page that outlives the command cannot
|
|
572
|
+
take its address from one.
|
|
234
573
|
|
|
235
574
|
## What this is not
|
|
236
575
|
|
package/docs/downstream.md
CHANGED
|
@@ -4,7 +4,7 @@ Three things, and this page is the whole list. A program that is not pi-loops an
|
|
|
4
4
|
its source may rely on these and on nothing else.
|
|
5
5
|
|
|
6
6
|
**Any file under `~/.pi/agent/loops/` is not an interface.** `jobs.json`, `inbox.jsonl`,
|
|
7
|
-
`runs.jsonl`, `state/`, `host.json`, the logs: private, and they change without notice. The one
|
|
7
|
+
`runs.jsonl`, `state/`, `host-service.json`, the logs: private, and they change without notice. The one
|
|
8
8
|
file there a program writes is `hooks.toml`, and only for the two events below. The same goes for
|
|
9
9
|
every command, field and path this page does not name, the wording of the slash commands, and the
|
|
10
10
|
browser page.
|
|
@@ -23,7 +23,7 @@ breaks a rule is refused before anything is copied.
|
|
|
23
23
|
## 2. `run_start` and `run_end` in `hooks.toml`
|
|
24
24
|
|
|
25
25
|
A `[[hook]]` rule with `event = "run_start"` or `event = "run_end"` fires around every scheduled
|
|
26
|
-
run, in
|
|
26
|
+
run, in the background, where every loop runs; what the payload carries is
|
|
27
27
|
[CONTRACT.md](../CONTRACT.md) §2.2, and the file, the rule keys and when the two fire are in
|
|
28
28
|
[hooks.md](hooks.md#exactly-when-hooks-fire). Each field reaches the rule's command as a `PI_*`
|
|
29
29
|
variable, unset where the payload has `null`, and all of them together as JSON in the file at
|
package/docs/goal.md
CHANGED
|
@@ -34,9 +34,14 @@ is told to answer `{"ok": false, "reason": "insufficient evidence in transcript"
|
|
|
34
34
|
| Interruption | Esc on a turn pauses the goal — it stays paused across `--resume` until `/goal resume` — and Esc during an evaluation stops that evaluation the same way |
|
|
35
35
|
| Its own timeout | 2 minutes; it is one read, not a piece of work |
|
|
36
36
|
|
|
37
|
-
`/goal resume` after a budget limit starts the allowance again
|
|
37
|
+
`/goal resume` after a budget limit starts the allowance again (also when you paused it in
|
|
38
|
+
between), and after an Esc starts
|
|
38
39
|
evaluating again: stopping a turn is taken as stopping the goal, since the next message you sent
|
|
39
|
-
would otherwise end with the evaluator sending the agent back to work.
|
|
40
|
+
would otherwise end with the evaluator sending the agent back to work. A `/goal` command typed
|
|
41
|
+
while an evaluation runs stops that evaluation, and what it would have decided is discarded: the
|
|
42
|
+
command has already set the goal you asked for. A turn that settles while the evaluator is still reading (you
|
|
43
|
+
asked something meanwhile) is judged as soon as that evaluation returns, so a continuation held for
|
|
44
|
+
it does not wait for you to type again. `pause`, `resume` and `clear` are
|
|
40
45
|
matched as whole words, so `/goal clear the type errors first` sets that as the condition rather
|
|
41
46
|
than dropping the goal.
|
|
42
47
|
|
|
@@ -53,7 +58,11 @@ than dropping the goal.
|
|
|
53
58
|
## Where it lives
|
|
54
59
|
|
|
55
60
|
Each state change is appended to the session as a `goal_state` entry, so `--resume` picks the goal
|
|
56
|
-
up where it left off.
|
|
61
|
+
up where it left off. A continuation is a request addressed to the session
|
|
62
|
+
(`requests/<session>.jsonl`), handed to the agent once it is idle: a window that quits leaves it for the next one while the goal is
|
|
63
|
+
pursued, and a continuation on its way is not judged again, so it is charged once. Esc pauses the
|
|
64
|
+
goal, and a continuation still queued for it is dropped with it; `/goal resume` judges the next turn
|
|
65
|
+
afresh. The status line shows `goal: <status>`; the
|
|
57
66
|
statuses are `pursuing`, `paused`, `achieved`, `budget_limited` and `cleared` (what `/goal clear`
|
|
58
67
|
leaves behind).
|
|
59
68
|
|