@alphacoder-v0/pi-loops 0.22.8 → 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.
Files changed (91) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -42
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +595 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
package/docs/triggers.md CHANGED
@@ -11,27 +11,80 @@ A rule is a natural-language **condition** and **action**:
11
11
 
12
12
  `/new-trigger` asks the agent to extract condition and action and call the `new_trigger` tool
13
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.
14
+ for repeating). A matched rule's result is a finding in the inbox and a row in the page's loop
15
+ activity strip, whatever `promote_to_chat` says; `promote_to_chat` also puts it in the conversation,
16
+ and it is still one finding. The finding is news whatever the result says: it comes from outside, so
17
+ a `· waits:` in it does not make it a decision. Its card and its strip row lead with what the check
18
+ said after the `matched <rule-id>` it starts with; the finding keeps the whole text. A check whose
19
+ result says exactly what the latest new finding of the same rule and project says files nothing
20
+ more. The check records on its result's audit row whether it filed the finding or skipped it as a
21
+ repeat; when the promotion falls back to the inbox, or its row dies, nothing more is filed for that
22
+ check. A result that no check decided, such as a push with no rule, is filed once by its trace id.
15
23
  Time-based requests ("every hour", "daily", "定时任务") are refused and routed to `cron_create`.
16
24
 
17
25
  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
26
+ `remove_trigger` (id | all), `set_trigger_state`. Removing and re-enabling ask the user to
19
27
  confirm, with `ctx.ui.confirm`.
20
28
 
29
+ With a person present, in their own turn, `new_trigger` asks one question before it creates
30
+ anything, with pi's `ctx.ui.select`: a terminal shows pi's selector, and the browser page draws the
31
+ same question as a card. Its answers are Result, `promote_to_chat` (*Show it in this conversation*,
32
+ the default, or *Put it in my inbox*), and Repeats, `fire_once` (*Once, then turn itself off*, the
33
+ default, or *Every time it happens*). The tool returns `created dynamic trigger …` or
34
+ `cancelled: …` in the same call; Cancel, Esc, an abort and thirty minutes with no answer write
35
+ nothing. The page's **Watch for…** box sends one sentence as the chat message
36
+ `Watch for: <sentence>`; a sentence with no time and nothing to wait for is answered by the page
37
+ itself ("this reads like something to do now") and reaches no model. A sub-agent and a scheduled
38
+ turn ask nobody: a scheduled turn in an open window asks the window guard's confirm, and a
39
+ sub-agent is refused. See `docs/design.md`, decision 24.
40
+
21
41
  The model-facing tools see the calling project only: `list_triggers` takes `all_projects: true`
22
42
  when the user asks about the rest, and `remove_trigger { all: true }` clears this project's rules,
23
43
  never the machine's.
24
44
 
45
+ ### Rules that wait for a path
46
+
47
+ A condition that is only "a path exists or appears" is recorded as pre-checkable when the rule is
48
+ created (`precheck` on the rule in `triggers.json`: the condition it was read from, the path as
49
+ typed, and the absolute path). The path is one token: `~/…`, `$HOME/…`, `${HOME}/…`, an absolute
50
+ path, `./…` or `../…`, a relative path with a `/`, a file name with an extension (`build.done`), or a
51
+ quoted path that may hold spaces. The verb is one of `exists`, `appears`, `is created`, `shows up`,
52
+ `is there`, `is present`, `存在`, `出现`, `被创建`, `生成` (a `文件` or `目录` before the verb is the
53
+ noun only when a space sets it apart from the path: `~/备份文件 存在` waits for `~/备份文件`), with an optional leading `when`, `if`,
54
+ `once`, `当`, `如果` or `一旦` and an optional trailing `的时候`, `时` or `后`. `~` and `$HOME` are
55
+ resolved in the process that creates the rule, so they are your home even when pi-loops in the
56
+ background was started with another one; a relative path is resolved against the rule's project.
57
+
58
+ Anything else is refused as path-only and checked by the model, as before: a glob, a second clause
59
+ (`~/x appears and it contains ERROR`, `~/a exists or ~/b exists`), another verb, a bare word with no
60
+ `/` or extension (`README`), a variable other than `$HOME`, `~user`, a URL, or a network path
61
+ (`//server/…`, `/net/…`, `/Network/…`). A rule created before
62
+ 0.24.0 has no `precheck` and keeps calling the model; create it again to get the pre-check. So does
63
+ a rule whose condition was edited by hand after it was created. An imported rule, from 0.22 or later,
64
+ is read again when it is imported, against the project it is imported into and this machine's home,
65
+ so a path-only one gets the pre-check then.
66
+
25
67
  ## Evaluation
26
68
 
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
69
+ While at least one enabled rule exists, every `[triggers] poll_interval_secs` in `config.toml`
70
+ (default 600; pi-loops re-reads it every minute) one check per project that has rules is run by pi-loops. The check receives the
31
71
  event JSON and every enabled rule and is told to inspect the filesystem, commands, clock or network
32
72
  as needed, execute the actions of matching rules, and reply `matched dyn-…` or exactly
33
73
  `no dynamic trigger rule matched`. Matched fire-once rules are disabled with `fired_at`.
34
74
 
75
+ A rule that waits for a path (`precheck`, above) is looked at first: after the poll is claimed,
76
+ pi-loops `stat`s the path, which follows symlinks, and gives up after 2 seconds. At most two looks
77
+ are out at once in the process, and one per path; past that a look counts as one that cannot answer. While the path is
78
+ absent the rule is left out of the check, so a project whose only rules wait for absent paths
79
+ makes no model call and writes no `running` row. The audit gets one `trigger` row `waiting`
80
+ ("no model call until ~/build.done exists") at the first look pi-loops makes and whenever the path
81
+ goes away, and one `appeared` when it is back; the check that follows names the rules the look let
82
+ through in `details.prechecked` on its `running` row. When the look cannot answer (no permission, a
83
+ mount that does not answer in time) the model checks the rule, as before. A repeating rule is
84
+ checked every poll while its path exists, and matches again after the path is deleted and comes
85
+ back. The rule's row on the page says what it waits for and when the next look is: "waits for
86
+ ~/build.done · next look 09:22 · no model call until the file exists".
87
+
35
88
  Pushed MCP notifications (see [mcp.md](mcp.md)) enter the same runtime and are evaluated against
36
89
  the rules unless the server is configured as an `inject_summary` / `inject_and_run` feed.
37
90
 
@@ -41,7 +94,9 @@ job backoff, same numbers — literally the same function, `backoffWaitMs` in `s
41
94
  The count lives on the rule (`consecutive_failures`,
42
95
  `last_check_failed_at`), a check that completes clears it — including `/triggers run <id>`, which is
43
96
  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.
97
+ rule ids and the failure count. Pushes are unaffected: an event is worth one attempt. While a rule
98
+ is in that state `/triggers rules` marks it `[failing ×N]` after its state bracket; a disabled rule
99
+ is not polled and is not marked.
45
100
 
46
101
  ## What happens when the machine is busy or over budget
47
102
 
@@ -66,10 +121,26 @@ on what was refused:
66
121
 
67
122
  ## Promotion and audit
68
123
 
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
124
+ A matched rule with `promote_to_chat` is a request addressed to a conversation: the one that
125
+ created the rule while a window holds it; else the conversation a window holds in the rule's project
126
+ (lowest pid first, as 0.22.8 chose its owner), so the result reaches the chat you are working in;
127
+ else the closed creating conversation. It is one row in `requests/<session>.jsonl`, which stays there until `[Trigger <trace>] <result>` is
128
+ in the conversation. The window holding the conversation inserts it (visible to future turns; no
129
+ model call when idle, a follow-up turn when busy: `promoted`, `to: "chat"` in the audit when it
130
+ landed at once, `to: "queued"` when another process or a later drain delivers it). If no window
131
+ holds the conversation, pi-loops appends the result to it, holding the conversation for that one
132
+ write, so it is there when the conversation is resumed (`appended`); the rule's finding in the
133
+ inbox is what the next session start's `[inbox] since you last looked` line counts. pi-loops appends it within moments of the row being
134
+ written: pi-loops watches `requests/` and `leases/`, and a window that wrote the row rings it over
135
+ the control socket; its 30-second tick is the floor under both, so a ring that is lost costs at most
136
+ one tick, and never a row. If pi-loops cannot take the conversation at
137
+ that instant (someone wrote it in the last two minutes), the row waits and pi-loops appends it on a
138
+ later tick. A row whose delivery fails waits a minute, then five, and blocks nothing behind it; at
139
+ the third failure, or when the conversation's file is gone, it is a dead letter: a finding in the
140
+ inbox marked for the conversation, then a `request` audit row (`dead`), then the row is removed, in
141
+ that order so a crash in between loses nothing. A rule's result has its finding already (every match is one), so its dead letter files no second one; any other row's finding reads `could not be delivered (<why>): <prompt>`. A rule with no
142
+ creating conversation (made from a shell, imported, or made by a loop's run) sends results to the
143
+ project's open window as above, and to the inbox (`redirected`, which finds the match's finding and adds nothing) when none is open. One check evaluates every
73
144
  rule of the project at once, so it runs under **one** model: the first rule that has one recorded
74
145
  (`/triggers set <id> --model … | -`), and that rule's thinking level with it — a thinking level
75
146
  belongs to the model it was chosen for, and a rule with a model but no level falls back to the
@@ -78,22 +149,41 @@ the longest of the project's rules (`--timeout`), else `[triggers] run_timeout_s
78
149
  session entries (`trigger`, `trigger_result`, `trigger_promotion`); `/triggers audit [N] [--all]`
79
150
  shows this project's rows with decisions and transcript paths. The complete set of states:
80
151
 
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);
152
+ - admission — `accepted`, `deduped`, `deferred` (no slot), `dropped` (the held list was full),
153
+ `budget_exceeded`,
154
+ `cycle_suppressed` (it reached a sub-agent), `backoff`, `disabled` (the rule's project is gone),
155
+ `waiting` (a rule's path is absent: no model call until it exists) and `appeared` (it is back);
84
156
  - 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`.
157
+ - promotion — `promoted` (into this chat, or queued for the conversation), `appended` (pi-loops wrote
158
+ a queued result into the closed conversation), `redirected` (to the inbox: no window is open in the rule's project, and the rule has no
159
+ conversation or its conversation's file is unknown or gone; or a pi-loops 0.22 window has it open), `skipped` (no matched rule has `promote_to_chat`).
160
+
161
+ Rows of type `request` record what became of a row in `requests/<session>.jsonl` that did not end
162
+ in the conversation: `failed` (one delivery failed, pi refused the hand-over, or pi dropped it
163
+ unwritten; the row waits out a backoff, or is offered again), `dead` (three failures, or the
164
+ conversation's file is gone: it is a finding in the inbox now; never a person's message), `cancelled`,
165
+ `done` (a stop or cancel that acted: the turn aborted, or the queued row taken out), `stale` (a stop
166
+ or cancel with nothing to act on, a goal row without a goal, or a person's message whose
167
+ conversation is gone), `job-removed`, `refused` (the row's
168
+ `mode` is not the one its source gets, so a line edited in the file, or written by another version,
169
+ cannot widen what a turn may do; the row never runs). Two more come from the inbox in the browser
170
+ page: `claimed` (a finding's claim was queued for the conversation that created its loop; the row
171
+ is the `inbox:<id>` one `/inbox claim` writes, and the trace id is its id) and `dismissed` (a
172
+ finding was dismissed from the page; the trace id is the finding's, and `details.reason` is what the
173
+ person gave, redacted, or null).
174
+
175
+ Rows of type `cron_control_plane` use the operation as the state instead: `add`, `set` (a
176
+ schedule changed in place by `/cron set --schedule` or by a suggestion applied from the page, with
177
+ `details.before_schedule` and `details.after_schedule`; an edit that leaves the schedule alone
178
+ writes none), `enable`, `disable`, `remove`. The page's and `/cron set`'s rows are in
179
+ `triggers-audit.jsonl` (source `page` or `slash`); the others are also session entries.
90
180
 
91
181
  A 5-minute dedup window collapses repeated events with the same idempotency key (per project for
92
182
  rule evaluation, per window for injected pushes).
93
183
 
94
184
  ```text
95
- /triggers status rule counts, checker ownership, last check, push sources
96
- /triggers rules this project's rules (--all for every project)
185
+ /triggers this project's rules; the same as /triggers rules (--all for every project)
186
+ /triggers status rule counts, what checks them, the last check, push sources
97
187
  /triggers sources local checker + each MCP server: state, queued/dropped/deduped, tools
98
188
  (/triggers hooks is the same view)
99
189
  /triggers enable <id> /triggers disable <id> /triggers remove <id> also --all | --all-projects
@@ -106,7 +196,10 @@ rule evaluation, per window for injected pushes).
106
196
  ```
107
197
 
108
198
  Cron runs share this runtime's views: they appear in `/triggers running` and `/triggers audit`
109
- and can be aborted by run id.
199
+ and can be aborted by run id. A plain job's turn running in a closed conversation in the background
200
+ is stopped through a request (`stop` in `requests/<session>.jsonl`), which that process reads within
201
+ a tick whether or not its socket answers: `/triggers abort <run-id>` says `requested stop for
202
+ <run-id> (session <id>)`. Loop runs and checks, and `--all`, are asked of it over the socket as before.
110
203
 
111
204
  `/triggers run <id>` takes the same path a periodic check takes — dedup, audit, the check
112
205
  sub-agent, promotion — and skips only the poll ledger, which is what running it now means. The
@@ -118,8 +211,9 @@ Cycles are bounded by a hop count: sub-agents run at hop 1 and still have the
118
211
  cron/trigger tools, so a trigger action can schedule a job; they never run the trigger runtime, so
119
212
  nothing nests. Prompt-class operations — creating or removing a trigger,
120
213
  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
214
+ prompt channel there). `cron_create` works there, and so does disabling a job of the sub-agent's own
215
+ project; the control-plane audit records `actor: sub-agent`. `cron_remove`, re-enabling a job and
216
+ touching another project's job are denied there like the other prompt-class operations. Sub-agents never handle triggers themselves: they ignore MCP pushes, and the
123
217
  runtime audits anything reaching hop ≥ 1 as `cycle_suppressed` (hops are counted per trace, but the
124
218
  bound that matters is structural: a sub-agent session never acts on a trigger at all).
125
219
 
@@ -127,8 +221,8 @@ bound that matters is structural: a sub-agent session never acts on a trigger at
127
221
 
128
222
  `/triggers enable|disable <id>` prints the rule's state, condition and action (and the
129
223
  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:
224
+ checker in registration order with a `Sources: N, M connected, K need attention` summary in
225
+ `/triggers status` (the last part only when K is not 0). Errors are worded once and kept that way (`unknown /triggers command:
132
226
  …`, `usage: /triggers remove <id>|--all`, `/new-trigger` parse messages). `/triggers remove --all`
133
227
  clears this project's rules; `--all-projects` is the explicit machine-wide sweep.
134
228
 
@@ -1,14 +1,23 @@
1
1
  # Troubleshooting
2
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. A job shows `next …` when it will run
5
- *here*. A plain job marked `[session <id> — resume it to run]` belongs to another session and fires
6
- only there (`--resume` it, or `/cron add` it again in this chat); a loop marked
3
+ **Nothing fires.** `/pi-loops status` shows whether pi-loops is running. If it cannot start, the window
4
+ shows `loops paused: pi-loops could not start`; `/pi-loops status` gives the reason and log path. A job shows
5
+ `next …` when it will run somewhere: here, in another window, or in the background. `paused: <why>` means
6
+ pi-loops will not run it; `/pi-loops status` says why. A plain job marked `[session <id> — resume it to run]`
7
+ belongs to a conversation whose file pi-loops never saw (`--resume` it once, or `/cron add` it again
8
+ in this chat); a loop marked
7
9
  `[orphan: cwd missing]` was disabled because its checkout is gone.
8
10
 
9
11
  **A run failed.** `/cron runs` shows the error; `/cron trace <job> 1` shows the sub-agent's
10
- transcript; `pi --session <file>` resumes it. Sub-agents run inside your interactive pi with its
11
- credentials, tools and MCP servers; they cannot answer permission prompts (use `--tools` to restrict).
12
+ transcript; `pi --session <file>` resumes it. Loop runs and rule checks run in the background. They
13
+ use the credentials pi saved, the built-in tools of the chat the loop was made in (or the job's
14
+ `--tools`), the MCP servers in `mcp.toml`, and the project's own servers and settings only when pi
15
+ has saved trust for that exact directory (`/trust` in a pi opened there). A plain job's run in its
16
+ closed conversation uses pi's saved trust for that directory or a parent of it. They do not get `-e` extensions or flags from your pi
17
+ window, and they cannot answer permission prompts. The background process has the environment of
18
+ the process that started it: a pi window's, whose shell's exported keys it then has, or a service
19
+ manager's, which has none of them. `pi-loops status` names that process; `pi-loops restart` from a
20
+ shell gives it that shell's environment. Keys saved with pi's `/login` always reach it.
12
21
 
13
22
  **Findings never arrive.** The loop must end its reply with `<inbox>…</inbox>` tags; check
14
23
  `/cron trace`. Quiet runs are normal. With `--verify`, dropped findings and reasons are on the run
@@ -39,7 +48,7 @@ digits into the box on that page. The terminal that started the session printed
39
48
 
40
49
  **The phone cannot reach it at all.** The server binds loopback by default, and a phone has no
41
50
  route to that. Either `tailscale serve --bg 4173` (the server stays on loopback; the tailnet does
42
- TLS) or start with `pi-loops --host 0.0.0.0` for a phone on the same wifi. See
51
+ TLS) or set `[page] host = "0.0.0.0"` in `config.toml` and run `pi-loops restart` for a phone on the same wifi. See
43
52
  [cli.md](cli.md#from-a-phone).
44
53
 
45
54
  **The QR is offered but scanning it goes nowhere.** The QR encodes the address *the browser you
@@ -48,20 +57,21 @@ address a phone could use — it says so instead of drawing one. Open the tailne
48
57
  the desktop first, then press it there. Where the machine has several addresses, the dialog lists
49
58
  them with the interface each belongs to; a docker or libvirt bridge is not the one.
50
59
 
51
- **"port 4173 is already in use" — or a second `pi-loops` opened the first one's window.** Starting
52
- a second session while one is up hands you the window that is already there rather than failing.
53
- That is deliberate. `--port <n>` starts a genuinely separate one, and the browser stays signed in:
54
- the token is the one file both of them read, and a cookie is not scoped to a port — `localhost:4173`
55
- and `localhost:4180` are the same site to a browser, which is the fact the `Sec-Fetch-Site` check
56
- here is built around. A `--port` that is not a number is refused rather than quietly served on 4173.
60
+ **"an older pi-loops page is still open on 127.0.0.1:4173" or "is used by another program".** The
61
+ page is served by pi-loops in the background, and a second `pi-loops` opens a conversation in it
62
+ rather than starting a second page. Before 0.24 the page ran in its terminal: quit that one (Ctrl-C
63
+ in its terminal) and the page takes the port within 30 seconds. Another program on 4173 is not
64
+ waited for: set `[page] port` in `config.toml`, then `pi-loops restart`. A browser pairs again on the
65
+ new port: the secret it keeps belongs to the old address, port included. Run `pi-loops` and it opens
66
+ a window that is signed in.
57
67
 
58
68
  **A pairing code stopped working.** They last ten minutes, are good for one use, and closing the
59
69
  dialog retires the one it was showing. Twenty wrong guesses disable pairing until another code is
60
70
  made. Press **add device** again.
61
71
 
62
72
  **Every device was signed out at once.** Something removed `~/.pi/agent/loops/web-token`, or
63
- `PI_LOOPS_DIR` points somewhere new — the token is that file, and the cookie every device holds is
64
- its contents.
73
+ `PI_LOOPS_DIR` points somewhere new — the token is that file, and every paired device is recorded
74
+ against it in `web-devices.json`.
65
75
 
66
76
  **The window stops answering — you type, your message appears, no reply ever does.** The event
67
77
  stream is numbered per run of the server process, so a page that was open across a restart can end
@@ -79,7 +89,9 @@ Bearer tokens come from `$TOKEN_REF` or pi's credential store; endpoints must be
79
89
  127.0.0.1. Custom notifications without `_meta.pi_dedup_key` are dropped and counted.
80
90
 
81
91
  **Hooks do not run.** Startup warnings list malformed rules. Project hooks need
82
- `allow_project_hooks = true`. Hooks fire only in the interactive pi, not in sub-agents.
92
+ `allow_project_hooks = true`. `agent_*`, `turn_*`, `message_*`, `tool_*` and `compaction` fire in the pi you are talking to.
93
+ `run_start` / `run_end` fire only in pi-loops in the background, for loop runs (output in
94
+ `host.log`); a plain job fires neither. Sub-agents fire nothing. See [hooks.md](hooks.md).
83
95
 
84
96
  **`/session-export` says the session is ephemeral.** pi writes the session file after the first
85
97
  message; `--no-session` sessions cannot be exported.
@@ -93,17 +105,16 @@ run is recorded as aborted rather than failed, so the slot is still owed and the
93
105
  streak is untouched; it simply will not be dispatched again until the day rolls over or the cap is
94
106
  raised.
95
107
 
96
- **`pi-loops host status` says the host "is not answering" but it is running.** Before 0.4.0 this
108
+ **`pi-loops status` says pi-loops "is not answering" but it is running.** Before 0.4.0 this
97
109
  happened whenever `PI_LOOPS_DIR` was deep: a unix socket path is capped at 108 bytes, so
98
- `<dir>/host.sock` failed to bind and the control channel silently did not exist. It now falls back
99
- to a short path under a per-user directory in the temp directory. If you still see it, the host is
100
- genuinely wedged — `host.log` has its last line, and `pi-loops host stop` escalates to SIGTERM.
101
-
102
- **A host is running that you did not start.** That is the design: the last interactive pi to quit
103
- hands the clock to a headless host when there is work for it (an enabled loop or rule for this
104
- machine, or an MCP server that pushes). `pi-loops host status` says what it is doing and what it
105
- has spent; `pi-loops host stop` ends it; `[host] auto = false` stops the hand-off happening at all.
106
- After testing with a throwaway `PI_LOOPS_DIR`, remember that the host it spawned outlives the pi
110
+ the old socket path failed to bind and the control channel silently did not exist. It now falls back
111
+ to a short path under a per-user directory in the temp directory. If you still see it, pi-loops is
112
+ genuinely wedged — `host.log` has its last line, and `pi-loops stop` escalates to SIGTERM.
113
+
114
+ **pi-loops is running and you did not start it.** A window starts pi-loops when there is work to run, and
115
+ `pi-loops status` shows its state; `pi-loops stop`
116
+ stops it until `pi-loops start`, or until `pi-loops` opens the page. `[host] auto = false` prevents windows from starting it.
117
+ After testing with a throwaway `PI_LOOPS_DIR`, remember that pi-loops it started outlives the pi
107
118
  that spawned it — it is polling and billing against *that* directory until stopped.
108
119
 
109
120
  **The browser front end refuses a slash command.** It drives `pi --mode rpc`,