@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -1,32 +1,31 @@
1
1
  /**
2
2
  * Session-bus lifecycle and operations.
3
3
  *
4
- * A terminal session registers on `session_start`. RPC, JSON, print, and WeChat
5
- * peer sessions stay invisible until they call the tool or `/sessions`, so a
6
- * subagent or phone peer does not join a team merely because the package loaded.
4
+ * No mode joins on start. A session is invisible until it names itself, or takes
5
+ * an action that makes it addressable (`send`, `claim`, `join`, `watch`), so a
6
+ * subagent or phone peer never joins a team merely because the package loaded,
7
+ * and a fresh terminal session's board is empty. Reading — the board and
8
+ * `status` — never joins: a reader is not a member.
7
9
  * The runtime owns the heartbeat, keeps no module-level state, and uses the
8
10
  * common git directory so worktrees of one repository share one bus.
9
11
  *
10
- * An armed inbox watch polls this session's own inbox directory and hands what
11
- * arrived to the caller's `onWake`, which the extension wires to the shared
12
- * pending reporter. A wake that arrives while the agent is running is queued and
13
- * raised by that reporter once the run settles. It announces and never consumes:
14
- * `inbox` keeps sole ownership of deleting mail, so a wake cannot eat a message
15
- * the model did not read.
12
+ * A wake that arrives while the agent is running is queued and raised by the
13
+ * extension's reporter once the run settles. The watch itself — the poll, the
14
+ * queue, and the reported set — is `mail-watch.ts`; this module supplies one
15
+ * read-only peek and the actions that join.
16
16
  */
17
17
 
18
18
  import { randomBytes } from "node:crypto";
19
- import { join, relative, sep } from "node:path";
19
+ import { join } from "node:path";
20
20
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
- import { isInside, realPathOfNearest } from "../../lib/path.ts";
23
- import { SPLIT_OFF_NOTE, splitEnabled } from "../../lib/plans/setting.ts";
24
- import { plansDir } from "../../lib/plans/store.ts";
22
+ import { claimPathMessage, resolveClaimPaths } from "./resolve.ts";
23
+ import { realPathOfNearest } from "../../lib/path.ts";
25
24
  import { repoKeyFor } from "../../lib/repo-key.ts";
25
+ import { sessionNameOf } from "../../lib/session-label.ts";
26
26
  import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
27
27
  import {
28
28
  defaultLabel,
29
- isAutoJoinMode,
30
29
  MAX_LABEL,
31
30
  MAX_LIMIT,
32
31
  normalizeKind,
@@ -36,21 +35,32 @@ import {
36
35
  normalizeText,
37
36
  resolveRecipients,
38
37
  sessionMode,
39
- claimsConflict,
40
38
  shortId,
41
39
  } from "./protocol.ts";
42
- import { createBusStore, type BusStore } from "./store.ts";
43
- import { newestTeam, resolveUnit, unitHolder } from "./team.ts";
40
+ import { createMailWatch, type WatchingState } from "./mail-watch.ts";
41
+ import { createBusStore, releaseClaimed, type BusStore } from "./store.ts";
44
42
  import type { BusMessage, ClaimConflict, SessionRecord, SessionStatus } from "./types.ts";
43
+ import {
44
+ BUS_DIR_NAME,
45
+ type RosterSeams,
46
+ type RosterState,
47
+ rosterStateOf,
48
+ SESSION_TTL_MS,
49
+ } from "../../lib/session-roster.ts";
50
+ import { isPidAlive, type LivenessFn, startTokenOf, type StartTokenFn } from "../../lib/spawn.ts";
45
51
 
46
52
  export interface SessionBusOptions {
47
53
  now?: () => number;
48
54
  /** Test seam: use this bus directory instead of the agent-dir layout. */
49
55
  dir?: string;
56
+ /** Test seam: the process id this runtime registers as, so two can share one session id. */
57
+ pid?: number;
58
+ /** Test seam: whether a pid is running, so a suite never depends on the host's pids. */
59
+ alive?: LivenessFn;
60
+ /** Test seam: the start-time seam the record is stamped with. */
61
+ startTokenFn?: StartTokenFn;
50
62
  /** Test seam: skip git and use this repository identity. */
51
63
  repo?: { root: string; key: string };
52
- /** Test seam: read teams from this plan store instead of the agent-dir layout. */
53
- plans?: string;
54
64
  /** Heartbeat interval; 0 disables it (tests). */
55
65
  heartbeatMs?: number;
56
66
  /** Inbox-watch poll interval; 0 disables it (tests). */
@@ -80,16 +90,16 @@ export interface SessionBus {
80
90
  inbox(limit?: number): Promise<BusMessage[]>;
81
91
  claim(paths: unknown): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
82
92
  release(paths: unknown): Promise<{ released: string[]; notOwned: string[] }>;
83
- /** Claim every path of one unit in a split plan. */
93
+ /**
94
+ * Join the team: join the bus, name this session when given a label, and listen for
95
+ * mail unless `listen` is false. One action, because they are one act — a session on
96
+ * the bus that will not hear mail is on it, but not on the team.
97
+ */
84
98
  join(input: {
85
- team?: string;
86
- unit?: string;
87
- }): Promise<{ team: string; unit: string; claimed: string[]; conflicts: ClaimConflict[] }>;
88
- /** Release the paths of one unit, or every claim this session holds. */
89
- leave(input: { team?: string; unit?: string }): Promise<{ released: string[]; notOwned: string[] }>;
90
- rename(label: string): Promise<string>;
91
- /** Arm or disarm this session's inbox watch. */
92
- watch(input: { wake?: boolean; stop?: boolean }): Promise<{ armed: boolean; wake: boolean }>;
99
+ label?: string;
100
+ wake?: boolean;
101
+ listen?: boolean;
102
+ }): Promise<{ label: string; watching: WatchingState }>;
93
103
  /** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
94
104
  wantsWake(): boolean;
95
105
  /** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
@@ -103,73 +113,58 @@ export interface SessionBus {
103
113
 
104
114
  const DEFAULT_HEARTBEAT_MS = 10_000;
105
115
  const DEFAULT_WATCH_MS = 2_000;
106
- const DEFAULT_SESSION_TTL_MS = 10 * 60_000;
107
116
  const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
108
117
  function messageId(now: number): string {
109
118
  return `${now}-${randomBytes(4).toString("hex")}`;
110
119
  }
111
120
 
112
- /** Whether a record's heartbeat is fresh: the rule the bus prunes and displays by. */
113
- function isLive(session: SessionRecord, at: number, ttlMs: number): boolean {
114
- return at - session.lastSeen <= ttlMs;
115
- }
116
-
117
121
  /** A label with this session's short id appended, inside the label budget. */
118
122
  function suffixLabel(label: string, id: string): string {
119
123
  const suffix = `-${shortId(id)}`;
120
124
  return `${label.slice(0, MAX_LABEL - suffix.length)}${suffix}`;
121
125
  }
122
126
 
123
- /**
124
- * Re-key accepted paths by their real target, so a symlinked spelling inside the
125
- * repository and the file it names are one claim. The paths arrive relative and
126
- * already inside the root; a link that leaves it is refused here.
127
- */
128
- function canonicalPaths(root: string, paths: readonly string[]): { paths: string[] } | { error: string } {
129
- const realRoot = realPathOfNearest(root);
130
- const canonical: string[] = [];
131
- for (const path of paths) {
132
- const target = realPathOfNearest(join(root, path));
133
- const rel = relative(realRoot, target);
134
- if (rel === "" || !isInside(realRoot, target)) {
135
- return { error: `path "${path}" does not resolve to a file in the repository.` };
136
- }
137
- const normalized = rel.split(sep).join("/");
138
- if (!canonical.includes(normalized)) canonical.push(normalized);
139
- }
140
- return { paths: canonical };
141
- }
142
-
143
- function sessionName(ctx: ExtensionContext): string | undefined {
144
- try {
145
- return ctx.sessionManager.getSessionName();
146
- } catch {
147
- return undefined;
148
- }
149
- }
150
-
151
127
  export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions = {}): SessionBus {
152
128
  const now = options.now ?? Date.now;
153
129
  const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
154
130
  const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
155
131
  const interval = options.interval ?? defaultInterval;
156
- const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
132
+ const pid = options.pid ?? process.pid;
133
+ const seams: RosterSeams = { alive: options.alive ?? isPidAlive, token: options.startTokenFn ?? startTokenOf };
134
+ // Asked once, here: a reused pid is settled from the record afterwards, so no
135
+ // heartbeat tick spawns `ps`.
136
+ const processToken = (seams.token ?? startTokenOf)(pid);
137
+
138
+ /**
139
+ * What one record says about its session.
140
+ *
141
+ * The rule is the roster's, not a copy: the bus writes these records and the page
142
+ * reads them, and a second copy of the heartbeat rule is how a dead session keeps its
143
+ * label and its claims while the screen already says it is gone.
144
+ */
145
+ function stateOf(session: SessionRecord, at = now()): RosterState {
146
+ return rosterStateOf(session, at, sessionTtlMs, seams);
147
+ }
148
+
149
+ /** Whether a record is a session that can still answer. */
150
+ function isLive(session: SessionRecord, at = now()): boolean {
151
+ return stateOf(session, at) === "live";
152
+ }
153
+ const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
157
154
  const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
158
155
 
159
156
  let store: BusStore | undefined;
160
- let plansRoot: string | undefined;
161
157
  let record: SessionRecord | undefined;
162
158
  let joined = false;
163
159
  let stopHeartbeat: (() => void) | undefined;
164
160
  let starting: Promise<void> | undefined;
165
161
 
166
- let armed = false;
167
- let wake = false;
168
- let stopTicker: (() => void) | undefined;
169
- let pendingMail: BusMessage[] = [];
170
- /** Ids already queued, reported, or consumed; a broadcast shares one id. */
171
- let reported = new Set<string>();
172
- let onWake: (() => void) | undefined;
162
+ /** The watch reads this session's own inbox; nothing is consumed or marked. */
163
+ const mailWatch = createMailWatch({
164
+ peek: async () => (store && record ? store.peek(record.id, MAX_LIMIT) : []),
165
+ interval,
166
+ watchMs,
167
+ });
173
168
 
174
169
  const ensureStarted = async (context: ExtensionContext): Promise<void> => {
175
170
  if (starting) return starting;
@@ -178,23 +173,24 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
178
173
  // cwd does not contain the paths a caller resolves against it.
179
174
  const cwd = realPathOfNearest(context.cwd);
180
175
  const repo = options.repo ?? (await repoKeyFor(pi, cwd));
181
- const dir = options.dir ?? join(getAgentDir(), "session-bus", repo.key);
182
- plansRoot = options.plans ?? plansDir(getAgentDir(), repo.key);
176
+ const dir = options.dir ?? join(getAgentDir(), BUS_DIR_NAME, repo.key);
183
177
  store = createBusStore({ dir });
184
178
  await store.ensure();
185
179
  const id = context.sessionManager.getSessionId();
186
180
  const mode = sessionMode(context.mode);
187
181
  record = {
188
182
  id,
189
- label: defaultLabel(sessionName(context), cwd, id),
190
- pid: process.pid,
183
+ label: defaultLabel(sessionNameOf(context), cwd, id),
184
+ pid,
191
185
  cwd,
192
186
  repoRoot: repo.root,
193
187
  mode,
194
188
  startedAt: now(),
195
189
  lastSeen: now(),
190
+ // Asked once, here: a reused pid is settled from the record, not by this session
191
+ // polling `ps` for its own heartbeat.
192
+ ...(processToken === undefined ? {} : { startToken: processToken }),
196
193
  };
197
- if (isAutoJoinMode(context.mode)) await joinBus();
198
194
  })();
199
195
  try {
200
196
  await starting;
@@ -213,29 +209,38 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
213
209
  await store.writeSession(record);
214
210
  await settleLabel();
215
211
  joined = true;
216
- if (heartbeatMs > 0) {
217
- stopHeartbeat = interval(heartbeatMs, () => {
218
- void (async () => {
219
- if (!record || !store) return;
220
- record.lastSeen = now();
221
- await store.writeSession(record);
222
- // A heartbeat that resumed after a long pause can find its label taken; the
223
- // tick is where a revived session notices and yields, after its own write.
224
- await settleLabel();
225
- })().catch(() => {});
226
- });
227
- }
212
+ armHeartbeat();
213
+ }
214
+
215
+ /**
216
+ * Start the heartbeat once, whoever joins: `joinBus` on the first addressable
217
+ * action, and `rename`, which is a join of its own.
218
+ */
219
+ function armHeartbeat(): void {
220
+ if (heartbeatMs <= 0 || stopHeartbeat) return;
221
+ stopHeartbeat = interval(heartbeatMs, () => {
222
+ void (async () => {
223
+ if (!record || !store) return;
224
+ record.lastSeen = now();
225
+ await store.writeSession(record);
226
+ // A heartbeat that resumed after a long pause can find its label taken; the
227
+ // tick is where a revived session notices and yields, after its own write.
228
+ await settleLabel();
229
+ })().catch(() => {});
230
+ });
228
231
  }
229
232
 
230
233
  /** The labels other live sessions hold: a crashed session's label is not taken. */
231
- async function takenLabels(bus: BusStore, mine: string): Promise<Set<string>> {
234
+ async function takenLabels(bus: BusStore, mine: string): Promise<Map<string, SessionRecord>> {
232
235
  const at = now();
233
236
  const sessions = await bus.sessions();
234
- return new Set(
235
- sessions
236
- .filter((session) => session.id !== mine && isLive(session, at, sessionTtlMs))
237
- .map((session) => session.label.toLowerCase()),
238
- );
237
+ const taken = new Map<string, SessionRecord>();
238
+ // The record, not just the string: a refusal names the session that holds the label,
239
+ // which a set of lowercase labels could not do.
240
+ for (const session of sessions) {
241
+ if (session.id !== mine && isLive(session, at)) taken.set(session.label.toLowerCase(), session);
242
+ }
243
+ return taken;
239
244
  }
240
245
 
241
246
  /**
@@ -257,6 +262,27 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
257
262
  await store.writeSession(me);
258
263
  }
259
264
 
265
+ /**
266
+ * What a prune must keep, and what it must let go.
267
+ *
268
+ * `live` is who can still answer, and this session is active by definition. `gone` is
269
+ * who cannot: their rows survive on the heartbeat's clock, but the paths they held are
270
+ * nobody's, which is the half a lead waits for.
271
+ */
272
+ async function pruneSets(): Promise<{ live: Set<string>; gone: Set<string> }> {
273
+ if (!store) throw new Error("session bus is not started.");
274
+ const at = now();
275
+ const live = new Set<string>();
276
+ const gone = new Set<string>();
277
+ for (const session of await store.sessions()) {
278
+ const state = stateOf(session, at);
279
+ if (state === "live") live.add(session.id);
280
+ else if (state === "gone") gone.add(session.id);
281
+ }
282
+ if (record) live.add(record.id);
283
+ return { live, gone };
284
+ }
285
+
260
286
  async function prepare(): Promise<void> {
261
287
  if (!store || !record) throw new Error("session bus is not started.");
262
288
  await joinBus();
@@ -264,12 +290,14 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
264
290
  // session that finally calls a tool does not delete its own record.
265
291
  record.lastSeen = now();
266
292
  await store.writeSession(record);
267
- const sessions = await store.sessions();
268
- const active = new Set(
269
- sessions.filter((session) => isLive(session, now(), sessionTtlMs)).map((session) => session.id),
270
- );
271
- if (record) active.add(record.id);
272
- await store.prune({ now: now(), activeIds: active, sessionTtlMs, messageTtlMs });
293
+ const sets = await pruneSets();
294
+ await store.prune({
295
+ now: now(),
296
+ activeIds: sets.live,
297
+ goneIds: sets.gone,
298
+ sessionTtlMs,
299
+ messageTtlMs,
300
+ });
273
301
  }
274
302
 
275
303
  async function currentRecord(): Promise<SessionRecord> {
@@ -278,148 +306,70 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
278
306
  return record;
279
307
  }
280
308
 
281
- /** The stored form of the paths a caller passed: validated, then keyed by target. */
282
- /**
283
- * The stored form of paths a plan wrote relative to the repository.
284
- *
285
- * A unit's paths are repository-relative by construction, so they resolve
286
- * against the repo root and not the session's cwd: a session started in a
287
- * subdirectory would otherwise claim `packages/web/src/core` for a unit that
288
- * owns `src/core`.
289
- */
290
- function rootedPaths(me: SessionRecord, paths: readonly string[]): string[] {
291
- const keyed = canonicalPaths(
292
- me.repoRoot,
293
- paths.map((path) => path.replace(/^\/+/, "")),
294
- );
295
- if ("error" in keyed) throw new Error(keyed.error);
296
- return keyed.paths;
297
- }
298
-
299
309
  function storedPaths(me: SessionRecord, raw: unknown): string[] {
300
310
  const normalized = normalizePaths(me.repoRoot, me.cwd, raw);
301
311
  if ("error" in normalized) throw new Error(normalized.error);
302
- const keyed = canonicalPaths(me.repoRoot, normalized.paths);
303
- if ("error" in keyed) throw new Error(keyed.error);
312
+ const keyed = resolveClaimPaths(me.repoRoot, normalized.paths);
313
+ if ("error" in keyed) throw new Error(claimPathMessage(keyed.error));
304
314
  return keyed.paths;
305
315
  }
306
316
 
307
- async function withUnread(rows: readonly SessionRecord[]): Promise<Array<SessionRecord & { unread: number }>> {
308
- return Promise.all(rows.map(async (session) => ({ ...session, unread: (await store?.unread(session.id)) ?? 0 })));
317
+ /** The store, for a closure that cannot see the check its caller made. */
318
+ function requireStore(): BusStore {
319
+ if (store === undefined) throw new Error("session bus is not started.");
320
+ return store;
321
+ }
322
+
323
+ async function withUnread(
324
+ rows: readonly SessionRecord[],
325
+ ): Promise<Array<SessionRecord & { unread: number; state: RosterState }>> {
326
+ const at = now();
327
+ return Promise.all(
328
+ rows.map(async (session) => ({
329
+ ...session,
330
+ unread: (await store?.unread(session.id)) ?? 0,
331
+ state: stateOf(session, at),
332
+ })),
333
+ );
309
334
  }
310
335
 
311
336
  /**
312
337
  * The bus as it stands, this session's own record included.
313
338
  *
314
- * `liveOnly` drops a session whose heartbeat went stale, and a claim held by
315
- * one, without writing or deleting anything, so a board can read it every
316
- * second. The prune itself stays with `prepare()` on the next tool or command
317
- * call, which is therefore what the screen's next action sees.
339
+ * `liveOnly` drops a session whose heartbeat went stale, and the claims of one whose
340
+ * process is gone, without writing or deleting anything, so a board can read it every
341
+ * second. A gone session stays listed — a lead needs a face for the paths it still
342
+ * holds — but its claims are nobody's, so they are not drawn. The prune itself stays
343
+ * with `prepare()` on the next tool or command call, which is therefore what the
344
+ * screen's next action sees.
318
345
  */
319
- async function readStatus(me: { id: string; label: string }, liveOnly: boolean): Promise<SessionStatus> {
346
+ async function readStatus(
347
+ me: { id: string; label: string; onBus: boolean },
348
+ liveOnly: boolean,
349
+ ): Promise<SessionStatus> {
320
350
  if (!store) throw new Error("session bus is not started.");
321
351
  const records = await store.sessions();
322
352
  const sessions = liveOnly
323
- ? records.filter((session) => session.id === record?.id || isLive(session, now(), sessionTtlMs))
353
+ ? records.filter((session) => session.id === record?.id || stateOf(session) !== "quiet")
324
354
  : records;
325
- const live = new Set(sessions.map((session) => session.id));
326
- const claims = (await store.claims()).filter((claim) => !liveOnly || live.has(claim.owner));
327
- // The team line is a file read plus a claim scan, so the board's repaint does
328
- // not pay for it: only an explicit status call reports units.
329
- const board = liveOnly || plansRoot === undefined ? undefined : newestTeam(plansRoot);
330
- const units =
331
- board === undefined
332
- ? undefined
333
- : {
334
- team: board.team,
335
- joined: board.units.filter((unit) => unitHolder(unit, claims, claimsConflict) !== undefined).length,
336
- total: board.units.length,
337
- rows: board.units.map((unit) => {
338
- const holder = unitHolder(unit, claims, claimsConflict);
339
- return {
340
- unit: unit.name,
341
- ...(unit.lead ? { lead: true } : {}),
342
- ...(holder === undefined ? {} : { holder }),
343
- };
344
- }),
345
- };
355
+ const ours = new Set(sessions.filter((session) => stateOf(session) !== "gone").map((session) => session.id));
356
+ const claims = (await store.claims()).filter((claim) => !liveOnly || ours.has(claim.owner));
346
357
  return {
347
- me,
358
+ me: { id: me.id, label: me.label },
359
+ onBus: me.onBus,
348
360
  sessions: await withUnread(sessions),
349
361
  claims,
350
- ...(units ? { units } : {}),
351
- ...(armed ? { watching: { armed, wake } } : {}),
362
+ ...(mailWatch.state().armed ? { watching: mailWatch.state() } : {}),
352
363
  };
353
364
  }
354
365
 
355
- function dropFromPending(ids: ReadonlySet<string>): void {
356
- pendingMail = pendingMail.filter((message) => !ids.has(message.id));
357
- }
358
-
359
- function markReported(ids: readonly string[]): void {
360
- for (const id of ids) reported.add(id);
361
- dropFromPending(new Set(ids));
362
- }
363
-
364
- /**
365
- * Queue what arrived since the last poll, once, and wake a turn when this
366
- * session asked for one.
367
- *
368
- * The reported set is narrowed to the mail still on disk, so it is bounded by
369
- * the unread count rather than by the session's whole history; an id is unique
370
- * for good, so a message that was consumed can never be queued again.
371
- */
372
- async function pollOnce(): Promise<void> {
373
- if (!armed || !store || !record) return;
374
- const mail = await store.peek(record.id, MAX_LIMIT);
375
- const unread = new Set(mail.map((message) => message.id));
376
- reported = new Set([...reported].filter((id) => unread.has(id)));
377
- // A message that expired or was consumed leaves the queue with its file.
378
- pendingMail = pendingMail.filter((message) => unread.has(message.id));
379
- const queued = new Set(pendingMail.map((message) => message.id));
380
- const fresh = mail.filter((message) => !reported.has(message.id) && !queued.has(message.id));
381
- if (fresh.length === 0) return;
382
- pendingMail = [...pendingMail, ...fresh];
383
- if (wake) onWake?.();
384
- }
385
-
386
- function disarm(): void {
387
- stopTicker?.();
388
- stopTicker = undefined;
389
- armed = false;
390
- wake = false;
391
- pendingMail = [];
392
- }
393
-
394
- async function watch(input: { wake?: boolean; stop?: boolean }): Promise<{ armed: boolean; wake: boolean }> {
395
- await currentRecord();
396
- if (input.stop) {
397
- disarm();
398
- return { armed: false, wake: false };
399
- }
400
- if (!armed) {
401
- armed = true;
402
- wake = input.wake === true;
403
- await pollOnce();
404
- if (watchMs > 0) stopTicker = interval(watchMs, () => void pollOnce().catch(() => {}));
405
- return { armed, wake };
406
- }
407
- // The mode is a setting, not an arm-time flag: an explicit `wake` changes it
408
- // under a queued notice, and an omitted one keeps it, so bare `watch` never
409
- // demotes an armed session.
410
- if (input.wake !== undefined) wake = input.wake;
411
- // Asking for wake over mail already queued is a request for that burst too;
412
- // mid-turn it is suppressed here and retried once the run settles.
413
- if (wake && pendingMail.length > 0) onWake?.();
414
- return { armed, wake };
415
- }
416
-
417
366
  async function rename(label: string): Promise<string> {
418
367
  if (!store || !record) throw new Error("session bus is not started.");
419
368
  const candidate = normalizeLabel(label, record.label);
420
- if ((await takenLabels(store, record.id)).has(candidate.toLowerCase())) {
421
- throw new Error(`the label "${candidate}" is already in use.`);
422
- }
369
+ // The bus's own answer to "who has it": the session record, named by its id, because
370
+ // the label it holds is the one the caller just asked for.
371
+ const holder = (await takenLabels(store, record.id)).get(candidate.toLowerCase());
372
+ if (holder) throw new Error(`the label "${candidate}" is already in use by session ${holder.id}.`);
423
373
  record.label = candidate;
424
374
  record.lastSeen = now();
425
375
  await store.writeSession(record);
@@ -427,6 +377,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
427
377
  // is what the caller hands to Pi as this session's name.
428
378
  await settleLabel();
429
379
  joined = true;
380
+ armHeartbeat();
430
381
  return record.label;
431
382
  }
432
383
 
@@ -438,11 +389,17 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
438
389
  async stop() {
439
390
  stopHeartbeat?.();
440
391
  stopHeartbeat = undefined;
441
- disarm();
442
- reported = new Set();
392
+ mailWatch.stop();
443
393
  if (store && record && joined) {
444
- await store.releaseAll(record.id).catch(() => {});
445
- await store.removeSession(record.id).catch(() => {});
394
+ // A `/resume` in a second pane registers the same session id twice; the record
395
+ // names the process that owns it, so this one releases and removes only its
396
+ // own. Releasing unconditionally would strip the survivor of the claims it is
397
+ // still working under.
398
+ const owner = await store.session(record.id).catch(() => undefined);
399
+ if (owner === undefined || owner.pid === pid) {
400
+ await store.releaseAll(record.id).catch(() => {});
401
+ await store.removeSession(record.id).catch(() => {});
402
+ }
446
403
  }
447
404
  joined = false;
448
405
  starting = undefined;
@@ -450,23 +407,53 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
450
407
  record = undefined;
451
408
  },
452
409
 
453
- watch,
454
- wantsWake: () => armed && wake,
455
- poll: pollOnce,
456
- pending: () => [...pendingMail],
457
- markReported,
410
+ // Join, name, and listen as one act. Order matters the way it does everywhere else
411
+ // here: the name is validated by `rename` before a watch is armed, so a refused
412
+ // label leaves both the record and the watch exactly as they were.
413
+ async join(input) {
414
+ const me = await currentRecord();
415
+ const label = input.label === undefined ? me.label : await rename(input.label);
416
+ // `listen: false` is the one way to be on the bus without mail: the record stays,
417
+ // so a peer can still address this session, and only the watch is turned off.
418
+ const watching =
419
+ input.listen === false ? await mailWatch.watch({ stop: true }) : await mailWatch.watch({ wake: input.wake });
420
+ // Written to the record so a peer reads it from disk instead of asking: this is the
421
+ // fact `team` reports as "not listening", and an absent field means exactly that.
422
+ if (watching.armed) me.watching = { armed: true, wake: watching.wake };
423
+ else delete me.watching;
424
+ await requireStore().writeSession(me);
425
+ return { label, watching };
426
+ },
427
+ wantsWake: () => mailWatch.wantsWake(),
428
+ poll: () => mailWatch.poll(),
429
+ pending: () => mailWatch.pending(),
430
+ markReported: (ids) => mailWatch.markReported(ids),
458
431
  set onWake(callback: (() => void) | undefined) {
459
- onWake = callback;
432
+ mailWatch.onWake = callback;
460
433
  },
461
434
 
462
435
  async status() {
436
+ if (!store || !record) throw new Error("session bus is not started.");
437
+ // A reader does not join: an unjoined session prunes and reads, and is not
438
+ // listed by the act of looking. Only an addressable action joins.
439
+ if (!joined) {
440
+ const sets = await pruneSets();
441
+ await store.prune({
442
+ now: now(),
443
+ activeIds: sets.live,
444
+ goneIds: sets.gone,
445
+ sessionTtlMs,
446
+ messageTtlMs,
447
+ });
448
+ return readStatus({ id: record.id, label: record.label, onBus: false }, false);
449
+ }
463
450
  const me = await currentRecord();
464
- return readStatus({ id: me.id, label: me.label }, false);
451
+ return readStatus({ id: me.id, label: me.label, onBus: true }, false);
465
452
  },
466
453
 
467
454
  async snapshot() {
468
455
  if (!store || !record) throw new Error("session bus is not started.");
469
- return readStatus({ id: record.id, label: record.label }, true);
456
+ return readStatus({ id: record.id, label: record.label, onBus: joined }, true);
470
457
  },
471
458
 
472
459
  async peek() {
@@ -523,53 +510,19 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
523
510
  if (!store) throw new Error("session bus is not started.");
524
511
  const messages = await store.inbox(me.id, normalizeLimit(limit));
525
512
  // Mail read before its notice fired is never announced afterwards.
526
- markReported(messages.map((message) => message.id));
513
+ mailWatch.markReported(messages.map((message) => message.id));
527
514
  return messages;
528
515
  },
529
516
 
530
517
  async claim(rawPaths) {
531
518
  const me = await currentRecord();
532
- if (!store) throw new Error("session bus is not started.");
533
- return store.claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
519
+ return requireStore().claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
534
520
  },
535
521
 
536
522
  async release(rawPaths) {
537
523
  const me = await currentRecord();
538
- if (!store) throw new Error("session bus is not started.");
539
- const released: string[] = [];
540
- const notOwned: string[] = [];
541
- for (const path of storedPaths(me, rawPaths)) {
542
- if (await store.release(path, me.id)) released.push(path);
543
- else notOwned.push(path);
544
- }
545
- return { released, notOwned };
546
- },
547
-
548
- async join(input) {
549
- const me = await currentRecord();
550
- if (!store) throw new Error("session bus is not started.");
551
- if (!splitEnabled(me.cwd, me.repoRoot)) throw new Error(SPLIT_OFF_NOTE);
552
- const unit = resolveUnit(plansRoot ?? "", input, "join");
553
- const claimed = await store.claimAll(rootedPaths(me, unit.paths), me.id, me.label, now());
554
- return { team: unit.team, unit: unit.name, ...claimed };
555
- },
556
-
557
- async leave(input) {
558
- const me = await currentRecord();
559
- if (!store) throw new Error("session bus is not started.");
560
- const paths =
561
- input.team === undefined && input.unit === undefined
562
- ? (await store.claims()).filter((claim) => claim.owner === me.id).map((claim) => claim.path)
563
- : rootedPaths(me, resolveUnit(plansRoot ?? "", input, "leave").paths);
564
- const released: string[] = [];
565
- const notOwned: string[] = [];
566
- for (const path of paths) {
567
- if (await store.release(path, me.id)) released.push(path);
568
- else notOwned.push(path);
569
- }
570
- return { released, notOwned };
524
+ const bus = requireStore();
525
+ return releaseClaimed(storedPaths(me, rawPaths), (path) => bus.release(path, me.id));
571
526
  },
572
-
573
- rename,
574
527
  };
575
528
  }