@gavin-hu/my-pi-agent 0.18.0 → 0.19.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 (249) hide show
  1. package/README.md +52 -44
  2. package/README.zh-CN.md +46 -41
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
  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 +554 -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} +61 -50
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
  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 +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +226 -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} +258 -162
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -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 +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/panel/commands.ts +40 -7
  51. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  52. package/extensions/bytefly/panel/settings.ts +3 -20
  53. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  54. package/extensions/bytefly/picker/discovery.ts +0 -2
  55. package/extensions/bytefly/picker/types.ts +0 -2
  56. package/extensions/bytefly/update/check.ts +39 -0
  57. package/extensions/bytefly/update/format.ts +15 -0
  58. package/extensions/bytefly/update/register.ts +73 -34
  59. package/extensions/bytefly/update/state.ts +6 -3
  60. package/extensions/context/tui.ts +1 -1
  61. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  62. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  63. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  64. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  65. package/extensions/file-browse/index.ts +4 -4
  66. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  67. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
  68. package/extensions/file-browse/transport/server.ts +110 -0
  69. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  70. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  71. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  72. package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
  73. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  74. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  75. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  76. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  77. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  78. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  79. package/extensions/file-read/schema.ts +2 -1
  80. package/extensions/memory/store.ts +12 -5
  81. package/extensions/plan/blocks.ts +23 -2
  82. package/extensions/plan/commands.ts +21 -16
  83. package/extensions/plan/feedback.ts +147 -38
  84. package/extensions/plan/index.ts +6 -4
  85. package/extensions/plan/plan-index.ts +43 -9
  86. package/extensions/plan/plan-text.ts +33 -23
  87. package/extensions/plan/plans.ts +8 -11
  88. package/extensions/plan/policy.ts +20 -12
  89. package/extensions/plan/review.ts +3 -27
  90. package/extensions/plan/runtime.ts +21 -9
  91. package/extensions/plan/sections.ts +112 -0
  92. package/extensions/plan/steps.ts +3 -22
  93. package/extensions/plan/tools/exit.ts +11 -95
  94. package/extensions/plan/tui.ts +5 -34
  95. package/extensions/plan/types.ts +0 -8
  96. package/extensions/plan/web-client.ts +1 -1
  97. package/extensions/plan/web-page.ts +0 -7
  98. package/extensions/plan/web-router.ts +3 -57
  99. package/extensions/plan/web-share-session.ts +24 -30
  100. package/extensions/plan/web-streams.ts +14 -14
  101. package/extensions/plan/web-tokens.ts +2 -2
  102. package/extensions/plan/web.ts +66 -120
  103. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  104. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  105. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  106. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  107. package/extensions/rewind/index.ts +3 -3
  108. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  109. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  110. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  111. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  112. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  113. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  114. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  115. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  116. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  117. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  118. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  119. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  120. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  121. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  122. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  123. package/extensions/session-bus/commands.ts +17 -69
  124. package/extensions/session-bus/format.ts +22 -45
  125. package/extensions/session-bus/index.ts +13 -7
  126. package/extensions/session-bus/join-request.ts +40 -0
  127. package/extensions/session-bus/mail-watch.ts +134 -0
  128. package/extensions/session-bus/notice.ts +32 -4
  129. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  130. package/extensions/session-bus/protocol.ts +19 -49
  131. package/extensions/session-bus/resolve.ts +67 -0
  132. package/extensions/session-bus/result.ts +16 -51
  133. package/extensions/session-bus/rows.ts +4 -2
  134. package/extensions/session-bus/runtime.ts +129 -238
  135. package/extensions/session-bus/store.ts +42 -6
  136. package/extensions/session-bus/tools.ts +31 -66
  137. package/extensions/session-bus/tui.ts +4 -4
  138. package/extensions/session-bus/types.ts +13 -13
  139. package/extensions/subagent/agents.ts +9 -8
  140. package/extensions/subagent/invocation.ts +4 -2
  141. package/extensions/team/commands.ts +112 -0
  142. package/extensions/team/dispatch.ts +133 -0
  143. package/extensions/team/format.ts +105 -0
  144. package/extensions/team/index.ts +33 -0
  145. package/extensions/team/schema.ts +29 -0
  146. package/extensions/team/tools.ts +246 -0
  147. package/extensions/team/types.ts +48 -0
  148. package/extensions/web-access/fetch/page.ts +4 -1
  149. package/extensions/web-access/fetch/structured.ts +447 -0
  150. package/extensions/web-access/fetch/tool.ts +2 -1
  151. package/extensions/web-access/search/tool.ts +2 -1
  152. package/extensions/wechat/bridge.ts +76 -92
  153. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  154. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  155. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  156. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  157. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  158. package/extensions/wechat/dialog/channels.ts +136 -0
  159. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  160. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  161. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  162. package/extensions/wechat/index.ts +10 -10
  163. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  164. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  165. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  166. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  167. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  168. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  169. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  170. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  171. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  172. package/extensions/wechat/tools.ts +29 -12
  173. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  174. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  175. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  176. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  177. package/extensions/wechat/transport/outbound.ts +142 -0
  178. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  179. package/extensions/wechat/types.ts +10 -1
  180. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  181. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  182. package/extensions/worktree/guard.ts +3 -5
  183. package/lib/README.md +8 -6
  184. package/lib/board/card.ts +290 -0
  185. package/lib/board/links.ts +117 -0
  186. package/lib/board/page-pointer.ts +256 -0
  187. package/lib/board/round-text.ts +72 -0
  188. package/lib/board/round.ts +89 -0
  189. package/lib/board/rules.ts +304 -0
  190. package/lib/board/session.ts +105 -0
  191. package/lib/board/store.ts +310 -0
  192. package/lib/board/view.ts +150 -0
  193. package/lib/errors.ts +12 -4
  194. package/lib/format.ts +13 -0
  195. package/lib/http/guard.ts +112 -0
  196. package/lib/http/index.ts +16 -5
  197. package/lib/http/serve.ts +234 -0
  198. package/lib/http/server.ts +12 -10
  199. package/lib/http/sink.ts +110 -0
  200. package/lib/http/token.ts +38 -0
  201. package/lib/policy.ts +12 -2
  202. package/lib/requirements.ts +6 -7
  203. package/lib/session-label.ts +64 -0
  204. package/lib/session-roster.ts +130 -0
  205. package/lib/session-tool.ts +184 -0
  206. package/lib/tool-names.ts +25 -2
  207. package/lib/tui/glyphs.ts +2 -0
  208. package/lib/web/README.md +5 -2
  209. package/lib/web/components.ts +5 -0
  210. package/lib/web/runtime.ts +7 -1
  211. package/lib/web/tokens.ts +25 -2
  212. package/package.json +26 -14
  213. package/skills/next/SKILL.md +4 -3
  214. package/skills/project-docs/SKILL.md +133 -0
  215. package/skills/project-docs/references/audit.md +131 -0
  216. package/skills/project-docs/references/changelog.md +105 -0
  217. package/skills/project-docs/references/discovery.md +127 -0
  218. package/skills/project-docs/references/readme.md +80 -0
  219. package/skills/project-docs/references/release.md +84 -0
  220. package/skills/project-docs/references/roadmap.md +93 -0
  221. package/skills/project-docs/scripts/state.ts +316 -0
  222. package/skills/refactor/SKILL.md +100 -125
  223. package/skills/refactor/references/handoff.md +35 -0
  224. package/skills/refactor/references/signals.md +54 -0
  225. package/skills/refactor/scripts/inventory.ts +587 -0
  226. package/extensions/file-browse/README.md +0 -506
  227. package/extensions/file-browse/server.ts +0 -141
  228. package/extensions/plan/split.ts +0 -87
  229. package/extensions/session-bus/README.md +0 -247
  230. package/extensions/session-bus/team.ts +0 -102
  231. package/extensions/wechat/outbound.ts +0 -100
  232. package/lib/plans/sections.ts +0 -172
  233. package/lib/plans/setting.ts +0 -52
  234. package/lib/plans/split.ts +0 -215
  235. package/lib/plans/store.ts +0 -19
  236. package/lib/plans/team.ts +0 -151
  237. package/skills/changelog/SKILL.md +0 -148
  238. package/skills/parallel-dev/SKILL.md +0 -150
  239. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  240. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  241. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  242. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  243. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  244. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  245. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  246. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  247. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  248. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  249. /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,21 @@ 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 { BUS_DIR_NAME, SESSION_TTL_MS } from "../../lib/session-roster.ts";
45
44
 
46
45
  export interface SessionBusOptions {
47
46
  now?: () => number;
48
47
  /** Test seam: use this bus directory instead of the agent-dir layout. */
49
48
  dir?: string;
49
+ /** Test seam: the process id this runtime registers as, so two can share one session id. */
50
+ pid?: number;
50
51
  /** Test seam: skip git and use this repository identity. */
51
52
  repo?: { root: string; key: string };
52
- /** Test seam: read teams from this plan store instead of the agent-dir layout. */
53
- plans?: string;
54
53
  /** Heartbeat interval; 0 disables it (tests). */
55
54
  heartbeatMs?: number;
56
55
  /** Inbox-watch poll interval; 0 disables it (tests). */
@@ -80,16 +79,16 @@ export interface SessionBus {
80
79
  inbox(limit?: number): Promise<BusMessage[]>;
81
80
  claim(paths: unknown): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
82
81
  release(paths: unknown): Promise<{ released: string[]; notOwned: string[] }>;
83
- /** Claim every path of one unit in a split plan. */
82
+ /**
83
+ * Join the team: join the bus, name this session when given a label, and listen for
84
+ * mail unless `listen` is false. One action, because they are one act — a session on
85
+ * the bus that will not hear mail is on it, but not on the team.
86
+ */
84
87
  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 }>;
88
+ label?: string;
89
+ wake?: boolean;
90
+ listen?: boolean;
91
+ }): Promise<{ label: string; watching: WatchingState }>;
93
92
  /** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
94
93
  wantsWake(): boolean;
95
94
  /** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
@@ -103,7 +102,6 @@ export interface SessionBus {
103
102
 
104
103
  const DEFAULT_HEARTBEAT_MS = 10_000;
105
104
  const DEFAULT_WATCH_MS = 2_000;
106
- const DEFAULT_SESSION_TTL_MS = 10 * 60_000;
107
105
  const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
108
106
  function messageId(now: number): string {
109
107
  return `${now}-${randomBytes(4).toString("hex")}`;
@@ -120,56 +118,27 @@ function suffixLabel(label: string, id: string): string {
120
118
  return `${label.slice(0, MAX_LABEL - suffix.length)}${suffix}`;
121
119
  }
122
120
 
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
121
  export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions = {}): SessionBus {
152
122
  const now = options.now ?? Date.now;
153
123
  const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
154
124
  const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
155
125
  const interval = options.interval ?? defaultInterval;
156
- const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
126
+ const pid = options.pid ?? process.pid;
127
+ const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
157
128
  const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
158
129
 
159
130
  let store: BusStore | undefined;
160
- let plansRoot: string | undefined;
161
131
  let record: SessionRecord | undefined;
162
132
  let joined = false;
163
133
  let stopHeartbeat: (() => void) | undefined;
164
134
  let starting: Promise<void> | undefined;
165
135
 
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;
136
+ /** The watch reads this session's own inbox; nothing is consumed or marked. */
137
+ const mailWatch = createMailWatch({
138
+ peek: async () => (store && record ? store.peek(record.id, MAX_LIMIT) : []),
139
+ interval,
140
+ watchMs,
141
+ });
173
142
 
174
143
  const ensureStarted = async (context: ExtensionContext): Promise<void> => {
175
144
  if (starting) return starting;
@@ -178,23 +147,21 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
178
147
  // cwd does not contain the paths a caller resolves against it.
179
148
  const cwd = realPathOfNearest(context.cwd);
180
149
  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);
150
+ const dir = options.dir ?? join(getAgentDir(), BUS_DIR_NAME, repo.key);
183
151
  store = createBusStore({ dir });
184
152
  await store.ensure();
185
153
  const id = context.sessionManager.getSessionId();
186
154
  const mode = sessionMode(context.mode);
187
155
  record = {
188
156
  id,
189
- label: defaultLabel(sessionName(context), cwd, id),
190
- pid: process.pid,
157
+ label: defaultLabel(sessionNameOf(context), cwd, id),
158
+ pid,
191
159
  cwd,
192
160
  repoRoot: repo.root,
193
161
  mode,
194
162
  startedAt: now(),
195
163
  lastSeen: now(),
196
164
  };
197
- if (isAutoJoinMode(context.mode)) await joinBus();
198
165
  })();
199
166
  try {
200
167
  await starting;
@@ -213,18 +180,25 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
213
180
  await store.writeSession(record);
214
181
  await settleLabel();
215
182
  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
- }
183
+ armHeartbeat();
184
+ }
185
+
186
+ /**
187
+ * Start the heartbeat once, whoever joins: `joinBus` on the first addressable
188
+ * action, and `rename`, which is a join of its own.
189
+ */
190
+ function armHeartbeat(): void {
191
+ if (heartbeatMs <= 0 || stopHeartbeat) return;
192
+ stopHeartbeat = interval(heartbeatMs, () => {
193
+ void (async () => {
194
+ if (!record || !store) return;
195
+ record.lastSeen = now();
196
+ await store.writeSession(record);
197
+ // A heartbeat that resumed after a long pause can find its label taken; the
198
+ // tick is where a revived session notices and yields, after its own write.
199
+ await settleLabel();
200
+ })().catch(() => {});
201
+ });
228
202
  }
229
203
 
230
204
  /** The labels other live sessions hold: a crashed session's label is not taken. */
@@ -257,6 +231,17 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
257
231
  await store.writeSession(me);
258
232
  }
259
233
 
234
+ /** The live session ids a prune must keep: this session is active by definition. */
235
+ async function liveIds(): Promise<Set<string>> {
236
+ if (!store) throw new Error("session bus is not started.");
237
+ const at = now();
238
+ const active = new Set(
239
+ (await store.sessions()).filter((session) => isLive(session, at, sessionTtlMs)).map((session) => session.id),
240
+ );
241
+ if (record) active.add(record.id);
242
+ return active;
243
+ }
244
+
260
245
  async function prepare(): Promise<void> {
261
246
  if (!store || !record) throw new Error("session bus is not started.");
262
247
  await joinBus();
@@ -278,32 +263,20 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
278
263
  return record;
279
264
  }
280
265
 
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
266
  function storedPaths(me: SessionRecord, raw: unknown): string[] {
300
267
  const normalized = normalizePaths(me.repoRoot, me.cwd, raw);
301
268
  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);
269
+ const keyed = resolveClaimPaths(me.repoRoot, normalized.paths);
270
+ if ("error" in keyed) throw new Error(claimPathMessage(keyed.error));
304
271
  return keyed.paths;
305
272
  }
306
273
 
274
+ /** The store, for a closure that cannot see the check its caller made. */
275
+ function requireStore(): BusStore {
276
+ if (store === undefined) throw new Error("session bus is not started.");
277
+ return store;
278
+ }
279
+
307
280
  async function withUnread(rows: readonly SessionRecord[]): Promise<Array<SessionRecord & { unread: number }>> {
308
281
  return Promise.all(rows.map(async (session) => ({ ...session, unread: (await store?.unread(session.id)) ?? 0 })));
309
282
  }
@@ -316,7 +289,10 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
316
289
  * second. The prune itself stays with `prepare()` on the next tool or command
317
290
  * call, which is therefore what the screen's next action sees.
318
291
  */
319
- async function readStatus(me: { id: string; label: string }, liveOnly: boolean): Promise<SessionStatus> {
292
+ async function readStatus(
293
+ me: { id: string; label: string; onBus: boolean },
294
+ liveOnly: boolean,
295
+ ): Promise<SessionStatus> {
320
296
  if (!store) throw new Error("session bus is not started.");
321
297
  const records = await store.sessions();
322
298
  const sessions = liveOnly
@@ -324,96 +300,15 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
324
300
  : records;
325
301
  const live = new Set(sessions.map((session) => session.id));
326
302
  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
- };
346
303
  return {
347
- me,
304
+ me: { id: me.id, label: me.label },
305
+ onBus: me.onBus,
348
306
  sessions: await withUnread(sessions),
349
307
  claims,
350
- ...(units ? { units } : {}),
351
- ...(armed ? { watching: { armed, wake } } : {}),
308
+ ...(mailWatch.state().armed ? { watching: mailWatch.state() } : {}),
352
309
  };
353
310
  }
354
311
 
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
312
  async function rename(label: string): Promise<string> {
418
313
  if (!store || !record) throw new Error("session bus is not started.");
419
314
  const candidate = normalizeLabel(label, record.label);
@@ -427,6 +322,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
427
322
  // is what the caller hands to Pi as this session's name.
428
323
  await settleLabel();
429
324
  joined = true;
325
+ armHeartbeat();
430
326
  return record.label;
431
327
  }
432
328
 
@@ -438,11 +334,17 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
438
334
  async stop() {
439
335
  stopHeartbeat?.();
440
336
  stopHeartbeat = undefined;
441
- disarm();
442
- reported = new Set();
337
+ mailWatch.stop();
443
338
  if (store && record && joined) {
444
- await store.releaseAll(record.id).catch(() => {});
445
- await store.removeSession(record.id).catch(() => {});
339
+ // A `/resume` in a second pane registers the same session id twice; the record
340
+ // names the process that owns it, so this one releases and removes only its
341
+ // own. Releasing unconditionally would strip the survivor of the claims it is
342
+ // still working under.
343
+ const owner = await store.session(record.id).catch(() => undefined);
344
+ if (owner === undefined || owner.pid === pid) {
345
+ await store.releaseAll(record.id).catch(() => {});
346
+ await store.removeSession(record.id).catch(() => {});
347
+ }
446
348
  }
447
349
  joined = false;
448
350
  starting = undefined;
@@ -450,23 +352,46 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
450
352
  record = undefined;
451
353
  },
452
354
 
453
- watch,
454
- wantsWake: () => armed && wake,
455
- poll: pollOnce,
456
- pending: () => [...pendingMail],
457
- markReported,
355
+ // Join, name, and listen as one act. Order matters the way it does everywhere else
356
+ // here: the name is validated by `rename` before a watch is armed, so a refused
357
+ // label leaves both the record and the watch exactly as they were.
358
+ async join(input) {
359
+ const me = await currentRecord();
360
+ const label = input.label === undefined ? me.label : await rename(input.label);
361
+ // `listen: false` is the one way to be on the bus without mail: the record stays,
362
+ // so a peer can still address this session, and only the watch is turned off.
363
+ const watching =
364
+ input.listen === false ? await mailWatch.watch({ stop: true }) : await mailWatch.watch({ wake: input.wake });
365
+ // Written to the record so a peer reads it from disk instead of asking: this is the
366
+ // fact `team` reports as "not listening", and an absent field means exactly that.
367
+ if (watching.armed) me.watching = { armed: true, wake: watching.wake };
368
+ else delete me.watching;
369
+ await requireStore().writeSession(me);
370
+ return { label, watching };
371
+ },
372
+ wantsWake: () => mailWatch.wantsWake(),
373
+ poll: () => mailWatch.poll(),
374
+ pending: () => mailWatch.pending(),
375
+ markReported: (ids) => mailWatch.markReported(ids),
458
376
  set onWake(callback: (() => void) | undefined) {
459
- onWake = callback;
377
+ mailWatch.onWake = callback;
460
378
  },
461
379
 
462
380
  async status() {
381
+ if (!store || !record) throw new Error("session bus is not started.");
382
+ // A reader does not join: an unjoined session prunes and reads, and is not
383
+ // listed by the act of looking. Only an addressable action joins.
384
+ if (!joined) {
385
+ await store.prune({ now: now(), activeIds: await liveIds(), sessionTtlMs, messageTtlMs });
386
+ return readStatus({ id: record.id, label: record.label, onBus: false }, false);
387
+ }
463
388
  const me = await currentRecord();
464
- return readStatus({ id: me.id, label: me.label }, false);
389
+ return readStatus({ id: me.id, label: me.label, onBus: true }, false);
465
390
  },
466
391
 
467
392
  async snapshot() {
468
393
  if (!store || !record) throw new Error("session bus is not started.");
469
- return readStatus({ id: record.id, label: record.label }, true);
394
+ return readStatus({ id: record.id, label: record.label, onBus: joined }, true);
470
395
  },
471
396
 
472
397
  async peek() {
@@ -523,53 +448,19 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
523
448
  if (!store) throw new Error("session bus is not started.");
524
449
  const messages = await store.inbox(me.id, normalizeLimit(limit));
525
450
  // Mail read before its notice fired is never announced afterwards.
526
- markReported(messages.map((message) => message.id));
451
+ mailWatch.markReported(messages.map((message) => message.id));
527
452
  return messages;
528
453
  },
529
454
 
530
455
  async claim(rawPaths) {
531
456
  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());
457
+ return requireStore().claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
534
458
  },
535
459
 
536
460
  async release(rawPaths) {
537
461
  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 };
462
+ const bus = requireStore();
463
+ return releaseClaimed(storedPaths(me, rawPaths), (path) => bus.release(path, me.id));
571
464
  },
572
-
573
- rename,
574
465
  };
575
466
  }
@@ -11,8 +11,9 @@ import { createHash, randomBytes } from "node:crypto";
11
11
  import { mkdir, link, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
12
12
  import { dirname, join } from "node:path";
13
13
  import { asRecord, readJson } from "../../lib/config.ts";
14
- import { claimsConflict } from "./protocol.ts";
14
+ import { claimsConflict } from "./overlap.ts";
15
15
  import type { BusMessage, ClaimConflict, ClaimRecord, SessionRecord } from "./types.ts";
16
+ import { INBOX_DIR_NAME, SESSIONS_DIR_NAME } from "../../lib/session-roster.ts";
16
17
 
17
18
  const DIR_MODE = 0o700;
18
19
  const FILE_MODE = 0o600;
@@ -27,6 +28,8 @@ export interface BusStore {
27
28
  ensure(): Promise<void>;
28
29
  writeSession(record: SessionRecord): Promise<void>;
29
30
  sessions(): Promise<SessionRecord[]>;
31
+ /** One session's record, or undefined when it is not on disk. */
32
+ session(id: string): Promise<SessionRecord | undefined>;
30
33
  removeSession(id: string): Promise<void>;
31
34
  writeMessage(target: string, message: BusMessage): Promise<void>;
32
35
  /** Read a recipient's messages without consuming them, oldest first. */
@@ -50,6 +53,24 @@ export interface BusStore {
50
53
  }): Promise<void>;
51
54
  }
52
55
 
56
+ /**
57
+ * Release each path through `release`, reporting the ones this session did not
58
+ * hold. One copy, because `release` and `leave` answer the same two lists, and a
59
+ * second loop is how the two answers drift.
60
+ */
61
+ export async function releaseClaimed(
62
+ paths: readonly string[],
63
+ release: (path: string) => Promise<boolean>,
64
+ ): Promise<{ released: string[]; notOwned: string[] }> {
65
+ const released: string[] = [];
66
+ const notOwned: string[] = [];
67
+ for (const path of paths) {
68
+ if (await release(path)) released.push(path);
69
+ else notOwned.push(path);
70
+ }
71
+ return { released, notOwned };
72
+ }
73
+
53
74
  const SHA = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 32);
54
75
 
55
76
  function isSession(value: unknown): value is SessionRecord {
@@ -63,10 +84,17 @@ function isSession(value: unknown): value is SessionRecord {
63
84
  typeof entry.repoRoot === "string" &&
64
85
  typeof entry.mode === "string" &&
65
86
  typeof entry.startedAt === "number" &&
66
- typeof entry.lastSeen === "number"
87
+ typeof entry.lastSeen === "number" &&
88
+ (entry.watching === undefined || isWatchingState(entry.watching))
67
89
  );
68
90
  }
69
91
 
92
+ /** The optional listening field, validated only when it is there. */
93
+ function isWatchingState(value: unknown): boolean {
94
+ const entry = asRecord(value);
95
+ return entry !== undefined && typeof entry.armed === "boolean" && typeof entry.wake === "boolean";
96
+ }
97
+
70
98
  function isMessage(value: unknown): value is BusMessage {
71
99
  const entry = asRecord(value);
72
100
  if (!entry) return false;
@@ -136,8 +164,8 @@ async function dropIfEmpty(dir: string): Promise<void> {
136
164
  /** Create a store rooted at one repository's bus directory. */
137
165
  export function createBusStore(options: BusStoreOptions): BusStore {
138
166
  const { dir } = options;
139
- const sessionsDir = join(dir, "sessions");
140
- const inboxRoot = join(dir, "inbox");
167
+ const sessionsDir = join(dir, SESSIONS_DIR_NAME);
168
+ const inboxRoot = join(dir, INBOX_DIR_NAME);
141
169
  const claimsDir = join(dir, "claims");
142
170
 
143
171
  const sessionFile = (id: string): string => join(sessionsDir, `${SHA(id)}.json`);
@@ -270,6 +298,8 @@ export function createBusStore(options: BusStoreOptions): BusStore {
270
298
 
271
299
  sessions: readSessions,
272
300
 
301
+ session: async (id) => readTyped(sessionFile(id), isSession),
302
+
273
303
  async removeSession(id) {
274
304
  await rm(sessionFile(id), { force: true });
275
305
  await dropIfEmpty(inboxDir(id));
@@ -306,15 +336,21 @@ export function createBusStore(options: BusStoreOptions): BusStore {
306
336
  },
307
337
 
308
338
  async prune({ now: at, activeIds, sessionTtlMs, messageTtlMs }) {
339
+ // The caller's set can lag: a peer that joined and claimed since that read is
340
+ // live on disk, and its claims are its own. This read is the answer, so the
341
+ // set only adds to what it found rather than deciding who survives.
342
+ const live = new Set(activeIds);
309
343
  for (const session of await readSessions()) {
310
344
  if (at - session.lastSeen > sessionTtlMs) {
311
345
  await rm(sessionFile(session.id), { force: true });
312
346
  await dropIfEmpty(inboxDir(session.id));
313
- activeIds = new Set([...activeIds].filter((id) => id !== session.id));
347
+ live.delete(session.id);
348
+ } else {
349
+ live.add(session.id);
314
350
  }
315
351
  }
316
352
  for (const claim of await readClaims()) {
317
- if (!activeIds.has(claim.owner)) await rm(claimFile(claim.path), { force: true });
353
+ if (!live.has(claim.owner)) await rm(claimFile(claim.path), { force: true });
318
354
  }
319
355
  for (const name of await readdir(inboxRoot).catch(() => [] as string[])) {
320
356
  const directory = join(inboxRoot, name);