@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -16,15 +16,15 @@ Status records what a reader can rely on: **Shipped** is in a tagged release, **
16
16
 
17
17
  | Capability | How | What you get | Status |
18
18
  |---|---|---|---|
19
- | Membership | `session join [label]`, `/team join [<label>]`, the board's `w` | The join: this session is on the bus under a stable label peers address, and listens for mail unless told not to. A label given to `session join` or `/team join` is also Pi's session name, so the footer and `/resume` agree with the record; `w` joins under the name the bus derives and writes nothing to Pi. | Unreleased |
20
- | Peer discovery | `session status` | Every active session with its unread count and claims, plus this session's identity, in one read. | Shipped |
19
+ | Membership | `session join [label]`, `/team join [<label>]`, `/board`, the first `board` claim, the board's `w` | The join: this session is on the bus under a stable label peers address, and listens for mail unless told not to. A label given to `session join` or `/team join` is also Pi's session name, so the footer and `/resume` agree with the record; `/board` joins as the page's seat (`team-lead`) or, on a page it is only watching, under its own `team-agent-<id>` name; the first `board` claim joins under that same name, and renames nothing; `w` joins under the name the bus derives and writes nothing to Pi. | Shipped |
20
+ | Peer discovery | `session status` | Every active session with its unread count and claims, plus this session's identity, in one read — and a session whose process is gone says so, its paths free from that read on. | Unreleased |
21
21
  | Live board | `/sessions` | Sessions, this session's unread mail, and every claim held on the repository, on one screen. | Shipped |
22
22
  | Board actions | `/sessions` (`w`, `x`) | Start or stop listening from the board, or release an own claim behind a confirmation, without leaving it. | Shipped |
23
23
  | Short messages | `session send` | One message per recipient, addressed by label or id, with a broadcast for events. | Shipped |
24
24
  | Message kinds | `session send` with `kind` | A `message`, `event`, `ask`, or `handoff` keeps its meaning; `ask` does not block the sender, and a `handoff` is named as the work it assigns. | Shipped |
25
25
  | Message paths | `session send` with `paths` | A message can name the repository paths it concerns, echoed in the inbox row. | Shipped |
26
26
  | Inbox on demand | `session inbox` | Up to `limit` messages, oldest first, consumed only when the model asks. | Shipped |
27
- | Listening | `session join` | New mail reported at the next turn; `join({wake: true})` raises one turn once the agent is idle, and `join({listen: false})` stops it. | Unreleased |
27
+ | Listening | `session join` | New mail reported at the next turn; `join({wake: true})` raises one turn once the agent is idle, and `join({listen: false})` stops it. The record carries whether this session listens, so a peer's status read answers it without asking. | Shipped |
28
28
  | Path claims | `session claim`, `session release` | A file or directory prefix held by one session at a time. | Shipped |
29
29
 
30
30
  ## Quickstart
@@ -55,12 +55,12 @@ collapsed line that expands to the board's rows.
55
55
 
56
56
  | Action | Inputs | Result |
57
57
  |---|---|---|
58
- | `status` | — | Active sessions with unread counts, claims, and this session's identity. Prunes stale records. A read: it never adds this session to the bus. |
58
+ | `status` | — | Active sessions with unread counts, their state, claims, and this session's identity. A session whose process is gone is listed as `gone` until its heartbeat ages out, and its claims are not listed at all. Prunes stale records. A read: it never adds this session to the bus. |
59
59
  | `send` | `to`, `text`, optional `kind`, optional `paths` | Writes one message per recipient. `to: "*"` is only valid with `kind: "event"`. |
60
60
  | `inbox` | optional `limit` | Returns and deletes up to `limit` messages, oldest first (default 20, max 50). |
61
61
  | `claim` | `paths` | Claims repository-relative files or directory prefixes; returns conflicts with their owner. |
62
62
  | `release` | `paths` | Removes claims owned by this session; reports paths owned by someone else. |
63
- | `join` | optional `label`, `wake`, `listen` | Joins the bus, names this session on it and in Pi when a label is given — the same path and uniqueness rule as `/team join`, so the footer and `/resume` agree with the record peers address — and starts listening for mail. `wake: true` raises one turn per burst once the agent is idle; `wake: false` reports at the next turn; `listen: false` joins without listening. A label a live session holds is refused with its owner named, and the refusal leaves both names and the watch alone. The result reports the label the bus settled on and whether mail will reach this session. |
63
+ | `join` | optional `label`, `wake`, `listen` | Joins the bus, names this session on it and in Pi when a label is given — the same path and uniqueness rule as `/team join`, so the footer and `/resume` agree with the record peers address — and starts listening for mail. `wake: true` raises one turn per burst once the agent is idle; `wake: false` reports at the next turn; `listen: false` joins without listening. A label a live session holds is refused, naming the session that holds it — its id, since the label it holds is the one you asked for — and the refusal leaves both names and the watch alone. The result reports the label the bus settled on and whether mail will reach this session. |
64
64
 
65
65
  A claim is all-or-nothing: if one path conflicts, nothing is claimed. A claim on
66
66
  a directory conflicts with claims below it and above it, so `src/api` and
@@ -167,8 +167,11 @@ listening watch is what raises the turn.
167
167
  session to the bus, so an unjoined session reads a board it is not on until it
168
168
  joins or acts.
169
169
  - **Membership is per session and session-only.** It survives neither a resume nor a
170
- new process; unread mail is previewed once more after `/resume`, and the record
171
- goes when the session stops.
170
+ new process: the record and every claim it held go when the session stops, and a
171
+ resumed pane is off the bus until it joins again. What does survive is everything
172
+ stored as a file — unread mail is previewed once more after `/resume`, and the
173
+ label a rejoining session takes is the same one, because it derives from Pi's
174
+ session name (`runtime.test.ts`, *a resume*).
172
175
  - **The board acts on your own state only.** `w` starts or stops listening and `x`
173
176
  releases a claim it holds. Mail is a preview, not a consume: the board never
174
177
  deletes a message, never marks one reported, and never touches a peer's inbox or
@@ -178,8 +181,18 @@ listening watch is what raises the turn.
178
181
  both sides to keep replying; the answer is to stop listening on one of them.
179
182
  - **No broadcast history.** A broadcast reaches the sessions active at send time;
180
183
  a session that joins later does not receive it.
181
- - **No forced claims.** A crashed owner blocks its paths until the session TTL
182
- expires. This is deliberate; stealing a claim is worse than waiting.
184
+ - **No forced claims.** There is no takeover while an owner can still answer: a
185
+ claim is released by its owner, or by the next bus call once the owner's process
186
+ is gone. A *hung* owner still blocks its paths until the session TTL expires,
187
+ because a running process is not evidence of a working one.
188
+ - **A dead process is named, not waited out.** A record carries its `pid` and, where
189
+ the host can name one, that process's start time. A pid that is no longer running,
190
+ or one whose start time is not the record's (a reused pid), reads `gone` at once:
191
+ its label is free, the paths it held are nobody's, and `session status` and
192
+ `/sessions` count it. It keeps its row until its heartbeat ages out, so a lead can
193
+ see what died and reclaim it. A record with no `pid` — and a platform without
194
+ `ps`, such as Windows — falls back to the heartbeat rather than being called dead
195
+ on no evidence.
183
196
  - **Bounded by TTL.** An idle session with a stopped heartbeat is pruned after
184
197
  ten minutes; its record, its claims, and an inbox directory left empty go with
185
198
  it, and its unread mail expires after 24 hours. A claim belongs to a record
@@ -202,7 +215,7 @@ listening watch is what raises the turn.
202
215
  | Tools | `session` — one action-shaped tool; `readOnlyHint: false`, `destructiveHint: true`, `openWorldHint: false`, `executionMode: "sequential"`. |
203
216
  | Commands | `/sessions` — the board in `tui`, the listing elsewhere; no arguments and no sub-commands. |
204
217
  | UI | `tui` mounts `SessionListComponent` through `withPolledScreen` (rails suppressed, a 1 s read-only poll of `snapshot()` + `peek()`); every other mode prints through `ctx.ui.notify`. No status chip, so listening never changes the footer. |
205
- | Events | `session_start` resolves the repository and arms the mail trigger, without joining; `before_agent_start` reports queued non-wake mail at the next turn, `agent_settled` retries a wake the run suppressed, and `context` prunes stale notices; `session_shutdown` clears the heartbeat, the watch ticker, and the record. The seven hooks are `lib/report.ts`'s `registerPendingChannel`, not a local copy. |
218
+ | Events | `session_start` resolves the repository and arms the mail trigger, without joining; `session_tree` only repaints, since navigating the tree changes no pending record; `agent_start` marks the run busy; `before_agent_start` reports queued non-wake mail at the next turn; `agent_settled` retries a wake the run suppressed; `context` prunes stale notices; `session_shutdown` clears the heartbeat, the watch ticker, and the record. The seven hooks are `lib/report.ts`'s `registerPendingChannel`, not a local copy. |
206
219
  | State | Files under `<agent-dir>/session-bus/<repo-key>/`; nothing in the session branch. Listening is session-only. `/resume` re-registers the same session id. |
207
220
  | Lifecycle | The factory only registers. The runtime starts on `session_start`; heartbeat, watch ticker, and cleanup belong to it. Disabling `session-bus` through `PI_DISABLED_EXTENSIONS` registers nothing. |
208
221
  | Mode | Uses `ctx.mode` to decide whether `/sessions` mounts the board and how a label is derived; nothing joins at start in any mode; `askHuman` is not involved and no status chip is published. |
@@ -2,14 +2,16 @@
2
2
 
3
3
  `board` owns the cards. `team` owns the lead's three actions: `assign` hands a card to a
4
4
  session that is **already running**, `offer` hands one to whoever is free, and `reclaim`
5
- takes one back when its owner has gone quiet. The first two are a write on the board and a
6
- message on the session bus; `reclaim` writes the card alone, so it needs no bus at all. The
7
- report says what the peer will actually do with it.
5
+ takes one back when its owner is gone or has gone quiet. `assign` writes the card and puts
6
+ a handoff on the session bus, `offer` writes only the moment it was made — never an owner,
7
+ because the first peer to claim is what makes an offer into work — and `reclaim` writes the
8
+ card alone, so it needs no bus.
9
+ The report says what the peer will actually do with it.
8
10
 
9
11
  Nothing starts a process. A message lands in the peer's inbox and is reported at its next
10
12
  turn, so `assign` and `offer` are messages, not launches.
11
13
 
12
- <img src="../../assets/screens/team.png" alt="The Team tab as a dashboard: on the left, a Needs a decision queue of rows with a coloured kind edge, the reason on its own line, and a Reclaim or Claim action on the row edge, or no action on a card that waits; on the right, a Teammates list with a state dot, label, you chip, mail badge, a not listening chip on a peer whose watch is off, and a line of what each holds; and one muted Done line under both" width="100%">
14
+ <img src="../../assets/screens/team.png" alt="The Team tab as a dashboard: on the left, a Needs a decision queue of rows with a coloured kind edge, the reason on its own line, and the owner as a chip on the row's right; on the right, a Teammates list with a state dot, label, you chip, mail badge, a not listening chip on a peer whose watch is off, and a line of what each holds; and one muted Done line under both" width="100%">
13
15
 
14
16
  *The Team tab: the cards that need the lead, then who is on the team — including an
15
17
  owner whose label nothing answers to, and a teammate that is not listening.*
@@ -18,14 +20,14 @@ owner whose label nothing answers to, and a teammate that is not listening.*
18
20
 
19
21
  | Capability | How | What you get |
20
22
  |---|---|---|
21
- | Join the team | `/board` as the lead, `/team join <label>` as a teammate, or any card you work | The session is on the team under a label peers address, listening, and `/team` and the Team tab list it |
23
+ | Join the team | `/board` as the lead, `/team join <label>` as a teammate, or the first card you claim | The session is on the team under a name peers address, listening, and `/team` and the Team tab list it |
22
24
  | Hand a feature to a running session | `team` action `assign` | The card is `doing`, owned by that session, and a handoff is in its inbox |
23
25
  | Tell the peer what to do first | `team` action `assign` with `note` | The handoff carries the card, its paths, its plan, and that line |
24
- | Hand a feature to whoever is free | `team` action `offer` | Every other live session gets an event naming the card; the card stays unowned until one claims it |
26
+ | Hand a feature to whoever is free | `team` action `offer` | Every other live session gets an event naming the card; the card stays unowned until one claims it, and it records when the offer was made, so a lead can tell an offer nobody took from a card nobody has looked at |
25
27
  | Know whether a message will be woken for | an `assign` or `offer` result, `/team`, and the Team tab | All three name the peers whose record says their next turn is when it lands: the result as `Not listening: <labels>`, `/team` as a lowercase `not listening:` inside its presence line, the Team tab as a chip |
26
28
  | Take a card back | `team` action `reclaim` | The owner clears, the card returns to To do, and its notes stay |
27
29
  | Keep the round to the team | `team` | Only `assign`, `offer`, and `reclaim`: no card is created, moved, planned, or finished here — that is `board`'s surface |
28
- | See the round without a browser | `/team` | `buildRound`'s summary — the one the page's Team tab is served — laid out as text by `formatRound`: what needs a decision, who holds what, then one presence line for who is live, who is not listening, and who has gone quiet. Ten lines at most, then `… N more`; an empty board is one line |
30
+ | See the round without a browser | `/team` | `buildRound`'s summary — the one the page's Team tab is served — laid out as text by `formatRound`: what needs a decision, who holds what, then one line naming the cards a dead owner left behind, and one presence line for who is live, who is not listening, who has gone quiet, and who is gone. The rollup itself is capped at ten lines, then `… N more`, and the gone and presence lines follow the cap rather than counting against it — the cap is a notify's limit, not a screen's. An empty board is one line |
29
31
 
30
32
  ## Quickstart
31
33
 
@@ -56,19 +58,20 @@ its surface is the point: `board` and `team` join this session for you.
56
58
  | You are | Action | What happens |
57
59
  |---|---|---|
58
60
  | The lead | `/board` | The session joins as `team-lead` — the page's own seat name — and listens, so a handoff or an offer is reported at its next turn |
59
- | A teammate | `/team join billing-core` | The session joins as `billing-core`, and Pi's footer and `/resume` show that name |
60
- | Either | any `board` action that writes | The first one joins the session under the name it already has, and listens |
61
- | A viewer | `/board` on a session that is not serving | The page is somebody else's to serve, so this session joins under its own label instead of the seat |
61
+ | A teammate | the first card it claims | The session joins as its own `team-agent-<id>` name and listens, and Pi's footer keeps the name it already had |
62
+ | A teammate who wants a name | `/team join billing-core` | The session joins as `billing-core`, and Pi's footer and `/resume` show that name — the one join that renames |
63
+ | A viewer | `/board` on a session that is not serving | The page is somebody else's to serve, so this session joins under its own name instead of the seat |
62
64
 
63
65
  Joining is listening: mail is reported at that session's next turn, and **no turn is
64
66
  raised** for it, so being on the team never spends a turn. `/team` says who is live and
65
- who is live but not listening. `/board status` repeats only what the last `/board` in this
66
- session did about the team — a join made by `/team join` or by a card write is not on that
67
- line, and a page that is not running prints no team clause at all.
67
+ who is live but not listening. `/board status` reports the team this session is on — which
68
+ label, and whether mail reaches it — whichever door put it there, and prints no team clause
69
+ only when this session never joined at all.
68
70
 
69
- **The seat is not a session.** The page writes as `team-lead` with an id of its own, so a
70
- card it claimed comes back with `reclaim` rather than a `release` from the terminal. The
71
- label is shared with the session that serves the page; the ids are not.
71
+ **The seat is not a session.** The page is a view: it has one name, `team-lead`, and an id
72
+ of its own derived from it, and it writes nothing, so it holds no card for a lead to take
73
+ back. Its cards come from the session that serves it, whose label is the same and whose id
74
+ is not: a card that session claimed is the session's, and `release` returns it.
72
75
 
73
76
  ## Tool
74
77
 
@@ -78,12 +81,26 @@ label is shared with the session that serves the page; the ids are not.
78
81
  | Action | Parameters | Behaviour |
79
82
  |---|---|---|
80
83
  | `assign` | `card`, `to`, `note?` | Checks the card's preconditions, sends the handoff as a `handoff` message, then records the owner |
81
- | `offer` | `card`, `note?` | Checks the preconditions a claim would meet, broadcasts the card as an `event` to every other live session, and writes nothing |
84
+ | `offer` | `card`, `note?` | Checks the preconditions a claim would meet, broadcasts the card as an `event` to every other live session, then records the offer on the card — the moment, and nothing else: status and owner are untouched |
82
85
  | `reclaim` | `card` | Clears the owner and returns the card to To do, keeping its notes and plan |
83
86
 
84
87
  `card` takes `4` or `#4`. `to` is a teammate's label or id, as `/team` lists them — a
85
88
  label nobody answers to cannot be handed a card, and the result says so.
86
89
 
90
+ The parameters are one flat object, so a field is published to every action at once.
91
+ An action reads only the fields in its row above, and a call carrying one that its
92
+ action does not read is **refused**, naming the action that takes it — not ignored.
93
+ `to` is the one that matters: an `offer` that named a peer was broadcast to every live
94
+ session while the lead believed it had named one, and a `reclaim` that named one took
95
+ the card back from its holder anyway.
96
+
97
+ ```
98
+ offer does not act on to: `assign` takes it.
99
+ ```
100
+
101
+ `ACTION_FIELDS` in `types.ts` is the table that answers this, `lib/stray-fields.ts` is
102
+ the sentence, and a test holds every published parameter to an action that acts on it.
103
+
87
104
  ### The order is the point
88
105
 
89
106
  `assign` sends **before** it assigns, and each failure leaves a state the report names:
@@ -94,10 +111,15 @@ label nobody answers to cannot be handed a card, and the result says so.
94
111
  | the send | untouched — `todo`, no owner | `#4 was not handed off to billing-core: …`, plus `The card is unchanged.` |
95
112
  | the assignment, after a successful send | whatever the other writer left | `#4 was handed off to billing-core, but the card changed before it could be assigned: …`, and the peer is told to `board claim` it |
96
113
 
97
- `offer` has no order to get wrong, because it writes nothing: a refused broadcast leaves
98
- the card exactly as the rules just read it, and the first `board claim` is what turns an
99
- offer into work. Two peers claiming at once are settled by the store's compare-and-set —
100
- one claim wins, and the other is refused with the owner named.
114
+ `offer` broadcasts **before** it records, for the same reason: an offer nobody received
115
+ must not leave a record claiming one happened. A refused broadcast leaves the card exactly
116
+ as the rules read it, and the write that follows changes one field — when the offer was
117
+ made. If the card changed in between, the peers already have the message, so the refusal
118
+ says the offer stands as a message and the board is what it now says. The first `board
119
+ claim` is what turns an offer into work, and the claim is what clears the record: every
120
+ answer to an offer — a claim, an assignment, a release — takes it with it, so a record
121
+ here means the offer is still open. Two peers claiming at once are settled by the store's
122
+ compare-and-set; one claim wins, and the other is refused with the owner named.
101
123
 
102
124
  `delivered` in the details means a message file was written for that label — not that the
103
125
  peer read it. `notListening` names the peers it reached whose record says they are not
@@ -118,7 +140,11 @@ read it with `session inbox`, record the plan, and start. The card is already th
118
140
  moves it to `done` or `blocked`.
119
141
 
120
142
  An offer is advice: nothing is theirs until they claim it, and a card nobody claims stays
121
- `todo`, so it keeps showing up as unassigned in the round.
143
+ `todo`, so it keeps showing up as unassigned in the round — with its age, which is the one
144
+ thing an event cannot carry. `board` prints it as `offered 1m ago, and nobody took it`, the
145
+ card face says `offered 1m ago · nobody took it`, and the Team tab's queue says
146
+ `offered 1m ago — nobody took it` in place of "nobody holds it". A lead reading that has a
147
+ decision the plain unassigned row never offered: re-offer, `assign` it, or take it.
122
148
 
123
149
  ## Security
124
150
 
@@ -133,7 +159,7 @@ An offer is advice: nothing is theirs until they claim it, and a card nobody cla
133
159
 
134
160
  - **Nothing here can start a peer's turn.** A message is reported at the recipient's next
135
161
  turn; a session that is not listening at all reads it only when it reads its inbox.
136
- - **`to` must be on the team.** A session that never opened the board, joined, or worked a
162
+ - **`to` must be on the team.** A session that never opened the board, joined, or claimed a
137
163
  card cannot be handed a card, and an offer to nobody says so.
138
164
  - **`team-lead` is a label, not a person.** The seat is not a session, so its actor id
139
165
  `board:team-lead` answers no message; the label is the one the session serving the page
@@ -150,8 +176,10 @@ An offer is advice: nothing is theirs until they claim it, and a card nobody cla
150
176
  every ten seconds and the record is believed for ten minutes, so `quiet:` means the
151
177
  record went stale rather than that the peer died, while `not listening` is the separate
152
178
  `watching` field, written when the session joins or arms and disarms its watch.
153
- - **Plan mode makes this unavailable.** `session` is a mutating tool, so while plan mode is
154
- active the bus call is refused. Assign after the plan is approved.
179
+ - **Plan mode makes this unavailable.** Neither `team` nor `session` is a read-only tool, so
180
+ plan mode refuses both by name and tells the model to exit and get approval; the board
181
+ stays readable, since `list` is the one action it admits. Assign after the plan is
182
+ approved.
155
183
  - **Ownership is advisory.** `assign` does not create a path claim; the peer still claims
156
184
  the paths it edits through `session`.
157
185
 
@@ -179,9 +207,9 @@ An offer is advice: nothing is theirs until they claim it, and a card nobody cla
179
207
  and must work with the bus disabled; `team` is the lead's round and is the only thing
180
208
  that needs the bus. If the round ever stops needing it, `team` folds back in.
181
209
  - **Joining is the bus's, wearing `board`'s clothes.** A lead should not have to learn a
182
- second tool to be on a team, so `/board` joins, a card write joins, and `/team join` is
183
- the one naming action. `wake` stays a bus-level choice and is never armed on a user's
184
- behalf, because it spends turns.
210
+ second tool to be on a team, so `/board` joins, the first claimed card joins, and
211
+ `/team join` is the one naming action. `wake` stays a bus-level choice and is never
212
+ armed on a user's behalf, because it spends turns.
185
213
  - **Push and pull are two verbs.** `assign` writes the owner and sends one message;
186
214
  `offer` broadcasts and writes nothing. One verb with an optional `to` would have two
187
215
  outcomes and one details shape, and a reader could not tell which happened.
@@ -594,7 +594,8 @@ status, which is what the old start/stop guidance used to do.
594
594
  | File | Responsibility |
595
595
  |---|---|
596
596
  | `index.ts` | Factory: gate, register the command and tool, wire the lifecycle events, build the bridge, open peer sessions from the host context. |
597
- | `bridge.ts` | The composition root: wires the poll loop, delivery, presence, admission, and inbound modules to the live client/account/cursor/state; owns the connection lifecycle, slash dispatch, the status snapshot, and `sendToOwner`. |
597
+ | `bridge.ts` | The composition root: wires the poll loop, delivery, presence, admission, and inbound modules to the live client/account/cursor/state; owns the connection lifecycle, the status snapshot, and `sendToOwner`. |
598
+ | `intake.ts` | The inbound path: who is refused, what is a replay, what is a command, what answers a pending prompt, and what is queued or turned away; the admission predicates live here too. |
598
599
  | `tools.ts` | The `send_wechat` tool: owner-only proactive text or one image, with the neither-or-both refusal. |
599
600
  | `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
600
601
  | `types.ts` | Wire and domain types. |
@@ -65,7 +65,7 @@ four are `executionMode: "sequential"`, and only `list_worktrees` is read-only.
65
65
  | Tool | Usage |
66
66
  |---|---|
67
67
  | `enter_worktree` | `name` to create a worktree, or `path` to enter an existing one. Errors if already isolated. |
68
- | `exit_worktree` | optional `remove` and `keepBranch`. Returns the clean/keep/remove decision and a `handoff` (`branch`, `base`, `ahead`, `merged`). |
68
+ | `exit_worktree` | optional `remove` and `keepBranch`. Returns the clean/keep/remove decision and a `handoff` (`branch`, `base`, `ahead`, `commits`, `merged`). |
69
69
  | `prune_worktrees` | no parameters. Removes eligible managed worktrees and reports the rest. |
70
70
  | `list_worktrees` | no parameters. Read-only status: active root, inactive overrides, isolation gaps, managed worktrees. |
71
71
 
@@ -77,11 +77,14 @@ it.
77
77
  `exit_worktree` returns to the main checkout. A clean worktree is removed
78
78
  automatically; one with uncommitted changes or new commits prompts (or is kept
79
79
  in non-interactive mode). Its result ends with a **handoff**: the branch, how
80
- many commits it holds beyond the merge target, whether that target already
81
- contains them, and the `git merge --no-ff` and `gh pr create` commands that
82
- finish the task. The tool never commits, merges, pushes, or opens the PR. A
83
- branch that still holds work (uncommitted changes, or commits not reachable from
84
- the recorded base) is never force-deleted, even with `remove: true`.
80
+ many commits it holds beyond the merge target, how many it made since it was
81
+ created, whether that target already contains them, and the `git merge --no-ff`
82
+ and `gh pr create` commands that finish the task. A branch with no commits of its
83
+ own says so rather than claiming a merge — a worktree that never committed is an
84
+ ancestor of its base, and "merged" would read as work that landed. The tool
85
+ never commits, merges, pushes, or opens the PR. A branch that still holds work
86
+ (uncommitted changes, or commits not reachable from the recorded base) is never
87
+ force-deleted, even with `remove: true`.
85
88
 
86
89
  While isolated, `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` all resolve
87
90
  relative paths inside the worktree, and:
@@ -113,6 +116,12 @@ instead, so there is no gap to report. `unverifiable` appears only while
113
116
  reset at session start. `doctor` reports the `powershell` switch as `degraded`
114
117
  when it is off and notes the two structural gaps, which no setting closes.
115
118
 
119
+ Managed worktrees follow the header, one row each, as `path`, branch, and the
120
+ marks in brackets: `current`, `missing`, `dirty (2)`, `ahead 3`, `behind 1`, `no
121
+ commits`, `commits 4 merged`, `locked`, `used 2d ago`. A row with nothing else to
122
+ report says `clean` — the same word the `/worktree` screen prints, because both
123
+ read `statusMarks` rather than each deciding what an empty row means.
124
+
116
125
  ## Commands and flags
117
126
 
118
127
  | Command / flag | What it does |
@@ -136,10 +145,14 @@ being ignored.
136
145
  <img src="../../assets/screens/worktree.png" alt="The /worktree screen: a Worktrees title rule with a `4 worktrees · 1 dirty · 1 ahead` summary, the current dark-mode row carrying its ahead count, a dirty row, a merged row, and a stale-locked row, the focused row's path and base with the Not isolated gap under it, and the key hints" width="100%">
137
146
 
138
147
  `/worktree` on its own opens a list screen: one row per managed worktree with its
139
- state (current, dirty, ahead, behind, merged, locked, last used), the active
140
- worktree's base and isolation gaps under the focused row, and a detail pane for
141
- the selected one. Outside a terminal — print, RPC, JSON — the same facts print as
142
- text instead, which is also what the model's `list_worktrees` reads.
148
+ state (current, dirty, ahead, behind, commits, merged, locked, last used), the
149
+ active worktree's base and isolation gaps under the focused row, and a detail
150
+ pane for the selected one. A worktree that has not committed since it was created
151
+ says `no commits` instead of `merged`: it is an ancestor of its base, which git
152
+ alone cannot tell from a branch that landed. When work did land, the row says how
153
+ much — `commits 2 merged`. Outside a terminal — print, RPC, JSON — the same
154
+ facts print as text instead, which is also what the model's `list_worktrees`
155
+ reads.
143
156
 
144
157
  | Key | What it does |
145
158
  |---|---|
@@ -298,10 +311,11 @@ use, falling back to directory mtime for a worktree with no record.
298
311
  writes are guarded (relative paths are not isolated), and `list_worktrees`
299
312
  reports it. `skipOverrides` is read at load time, from the process working
300
313
  directory.
301
- - `git branch --merged main` calls a worktree branch merged whenever it matches
302
- its base, so a worktree that never committed looks landed while its
303
- uncommitted work is still there. Read `git -C <path> status` before removing
304
- one; `list_worktrees` reports the same clean/merged flags.
314
+ - The commit count behind `no commits` comes from the commit a worktree was
315
+ created at, which the registry records. A worktree this package did not create
316
+ has no fork point on record, so it falls back to what `git branch --merged`
317
+ says: for one of those, a worktree that never committed still reads as merged.
318
+ Read `git -C <path> status` before removing one.
305
319
  - The guard is best-effort at the shell level: command substitution, `eval`, and
306
320
  `sh -c` are only covered when `blockUnparsableCommands` is enabled, and real
307
321
  isolation of arbitrary shell writes needs an OS sandbox. `/worktree` names the
@@ -330,7 +344,7 @@ use, falling back to directory mtime for a worktree with no record.
330
344
  | Contract | Detail |
331
345
  |---|---|
332
346
  | Registration | The default export calls `registerWorktree`: root-tool overrides, the `--worktree` flag, lifecycle listeners, model tools, and the `/worktree` command. Nothing long-lived starts at load. |
333
- | Tools | `enter_worktree`, `exit_worktree`, `prune_worktrees`, `list_worktrees` via `registerTool`. All `executionMode: "sequential"`. Annotations: `enter_worktree` destructive + open-world; `exit_worktree`/`prune_worktrees` destructive; `list_worktrees` read-only. No `outputSchema`; structured state is returned in tool-result `details` — `exit_worktree` carries `{ state, removed, handoff }`, where `handoff` is `{ branch, base, ahead, merged }`. |
347
+ | Tools | `enter_worktree`, `exit_worktree`, `prune_worktrees`, `list_worktrees` via `registerTool`. All `executionMode: "sequential"`. Annotations: `enter_worktree` destructive + open-world; `exit_worktree`/`prune_worktrees` destructive; `list_worktrees` read-only. No `outputSchema`; structured state is returned in tool-result `details` — `exit_worktree` carries `{ state, removed, handoff }`, where `handoff` is `{ branch, base, ahead, commits, merged }` (`commits` absent when the worktree has no recorded base commit). |
334
348
  | Built-in overrides | `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` are re-registered under the same names with `defaultActive: false`; each spreads the built-in definition and replaces only `execute`. `skipOverrides` is applied at load time. `powershell` is deliberately not overridden: it cannot be re-rooted without a guard, so the `tool_call` handler refuses it (see `guard.blockPowershell`). |
335
349
  | Commands | `/worktree` via `registerCommand`, with argument completions for `enter`, `exit`, and `prune`. The bare command mounts `screen.ts` in a terminal and prints the status text otherwise; `enter`/`exit`/`prune` refuse while plan mode is read-only. |
336
350
  | Flags | `--worktree` via `registerFlag`. |
@@ -407,6 +421,7 @@ one read and two renderings.
407
421
  | `root-tools.ts` | Load-time built-in overrides bound to the active root |
408
422
  | `git.ts` | Git plumbing (shared runner + read helpers) |
409
423
  | `guard.ts` | Path, shell, and git-redirect isolation checks, plus the `powershell` refusal |
424
+ | `shell.ts` | The command-line reader those checks ask: words, segments, and redirection targets |
410
425
  | `gaps.ts` | The isolation gaps a session carries, and how they read (pure) |
411
426
  | `include.ts` | `.worktreeinclude` matcher and copy |
412
427
  | `config.ts` / `state.ts` / `status.ts` | Config, session entry shape, status text and the shared snapshot |
@@ -9,21 +9,25 @@
9
9
 
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
11
  import { plural } from "../../lib/board/round-text.ts";
12
+ import type { BoardTeam } from "../../lib/board/session.ts";
12
13
  import type { JoinResult } from "../../lib/session-tool.ts";
13
14
  import { createTeamJoin, type TeamJoin } from "../../lib/session-tool.ts";
14
- import { joinLabel, teamLine } from "./join.ts";
15
+ import { teamLine } from "./join.ts";
15
16
  import type { BoardPage, BoardPageStatus } from "./page.ts";
16
17
 
17
18
  /** The command's name, which is also the word a user types. */
18
19
  export const BOARD_COMMAND = "board";
19
20
 
20
21
  /** The words the command accepts, in the order a user is likely to want them. */
21
- export const BOARD_WORDS = ["status", "stop", "team"] as const;
22
+ const BOARD_WORDS = ["status", "stop", "team"] as const;
22
23
 
23
- export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team?: TeamJoin } = {}): void {
24
+ export function registerCommands(
25
+ pi: ExtensionAPI,
26
+ session: BoardTeam,
27
+ page: BoardPage,
28
+ deps: { team?: TeamJoin } = {},
29
+ ): void {
24
30
  const team = deps.team ?? createTeamJoin(pi);
25
- /** What the last `/board` did about the team, for the status line. */
26
- let joined: JoinResult | undefined;
27
31
  pi.registerCommand(BOARD_COMMAND, {
28
32
  description: "Open the shared board in a local browser: /board [team | status | stop]",
29
33
  getArgumentCompletions: (prefix: string) => {
@@ -35,12 +39,24 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
35
39
  handler: async (args: string, ctx: ExtensionCommandContext) => {
36
40
  const word = args.trim().toLowerCase();
37
41
  if (word === "status") {
38
- ctx.ui.notify(statusLine(page.status(), joined), "info");
42
+ ctx.ui.notify(statusLine(await page.status(), session.team.last()), "info");
39
43
  return;
40
44
  }
41
45
  if (word === "stop") {
42
- const status = page.status();
43
- // A viewer's page is somebody else's to stop, and saying whose is the answer.
46
+ const status = await page.status();
47
+ // A note nobody answers is not a page to leave for the next session to trip over:
48
+ // it is dropped here, and the line says whose it was.
49
+ if (status.stale === true) {
50
+ const dropped = await page.dropStale(ctx);
51
+ ctx.ui.notify(
52
+ dropped
53
+ ? `board: dropped ${status.seat}'s note for :${status.port} — it answered nobody`
54
+ : `board: nothing to stop here — ${status.seat} is answering on :${status.port}`,
55
+ "info",
56
+ );
57
+ return;
58
+ }
59
+ // A page another session serves is that session's to stop, and saying whose it is is the answer.
44
60
  if (status.attached === true) {
45
61
  ctx.ui.notify(
46
62
  `board: nothing to stop here — ${status.seat} serves this repository on :${status.port}`,
@@ -48,7 +64,11 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
48
64
  );
49
65
  return;
50
66
  }
51
- ctx.ui.notify((await page.stop(ctx)) ? "board: stopped" : "board: not running", "info");
67
+ const stopped = await page.stop();
68
+ // The word is this session saying it serves nothing now: the page clears the seat too
69
+ // when it closes itself (the idle grace), and both writes are the same fact.
70
+ session.serve(undefined);
71
+ ctx.ui.notify(stopped ? "board: stopped" : "board: not running", "info");
52
72
  return;
53
73
  }
54
74
  if (word !== "" && word !== "team") {
@@ -59,15 +79,21 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
59
79
  return;
60
80
  }
61
81
  const started = await page.start(ctx, word === "team" ? "team" : "board");
62
- // A failed start has already said why. Otherwise the join happens here, because
63
- // opening the page is the lead's one action: the seat when this session serves it,
64
- // this session's own label when it watches a page somebody else serves.
82
+ // A failed start has already said why. Otherwise the seat is set from the page's own
83
+ // answer — the page a session serves is a role, and every name this session writes and
84
+ // is addressed by follows from it — and the join happens here, because opening the page
85
+ // is the lead's one action.
65
86
  if (started.kind === "failed") return;
66
- joined = await team.join(ctx, joinLabel(started.kind === "served"));
87
+ session.serve(started.kind === "served" ? started.seat : undefined);
88
+ const joined = await team.join(ctx, { label: session.who(ctx, ctx.cwd).label });
89
+ session.team.record(joined);
90
+ // The address is on the line because the browser is not the only way in: a machine
91
+ // with no default browser prints it here, and a tab closed by mistake is one
92
+ // command from being opened again.
67
93
  ctx.ui.notify(
68
94
  started.kind === "attached"
69
- ? `board: ${started.seat} is serving this repository on :${started.port} — viewing${teamLine(joined)}`
70
- : `board: ${started.seat} serving ${started.where} on :${started.port}${teamLine(joined)}`,
95
+ ? `board: ${started.seat} is serving this repository on :${started.port} — viewing · open ${started.url}${teamLine(joined)}`
96
+ : `board: ${started.seat} serving ${started.repo} on :${started.port} · open ${started.url}${teamLine(joined)}`,
71
97
  "info",
72
98
  );
73
99
  },
@@ -78,10 +104,15 @@ export function registerCommands(pi: ExtensionAPI, page: BoardPage, deps: { team
78
104
  export function statusLine(status: BoardPageStatus, team?: JoinResult): string {
79
105
  if (status.running) {
80
106
  const watching = plural(status.watchers ?? 0, "tab");
81
- return `board: ${status.seat} serving on :${status.port} · ${watching} watching · ${status.where}${teamLine(team)}`;
107
+ return `board: ${status.seat} serving ${status.repo} on :${status.port} · ${watching} watching · ${status.where}${teamLine(team)}`;
108
+ }
109
+ if (status.stale === true) {
110
+ return `board: watching ${status.seat} on :${status.port} — that note answers nobody; /board stop drops it${teamLine(team)}`;
82
111
  }
83
112
  if (status.attached === true) {
84
113
  return `board: watching ${status.seat} on :${status.port} — nothing to stop here${teamLine(team)}`;
85
114
  }
86
- return "board: not running — /board opens it";
115
+ // The team outlives the page: a session that claimed a card is on it with no page open, so
116
+ // the line says which team this session is on whichever door put it there.
117
+ return `board: not running — /board opens it${teamLine(team)}`;
87
118
  }