@gavin-hu/my-pi-agent 0.17.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 (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  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} +465 -416
  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} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  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} +166 -119
  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} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  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/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Session-bus lifecycle and operations.
3
+ *
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.
9
+ * The runtime owns the heartbeat, keeps no module-level state, and uses the
10
+ * common git directory so worktrees of one repository share one bus.
11
+ *
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
+ */
17
+
18
+ import { randomBytes } from "node:crypto";
19
+ import { join } from "node:path";
20
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
+ import { claimPathMessage, resolveClaimPaths } from "./resolve.ts";
23
+ import { realPathOfNearest } from "../../lib/path.ts";
24
+ import { repoKeyFor } from "../../lib/repo-key.ts";
25
+ import { sessionNameOf } from "../../lib/session-label.ts";
26
+ import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
27
+ import {
28
+ defaultLabel,
29
+ MAX_LABEL,
30
+ MAX_LIMIT,
31
+ normalizeKind,
32
+ normalizeLabel,
33
+ normalizeLimit,
34
+ normalizePaths,
35
+ normalizeText,
36
+ resolveRecipients,
37
+ sessionMode,
38
+ shortId,
39
+ } from "./protocol.ts";
40
+ import { createMailWatch, type WatchingState } from "./mail-watch.ts";
41
+ import { createBusStore, releaseClaimed, type BusStore } from "./store.ts";
42
+ import type { BusMessage, ClaimConflict, SessionRecord, SessionStatus } from "./types.ts";
43
+ import { BUS_DIR_NAME, SESSION_TTL_MS } from "../../lib/session-roster.ts";
44
+
45
+ export interface SessionBusOptions {
46
+ now?: () => number;
47
+ /** Test seam: use this bus directory instead of the agent-dir layout. */
48
+ dir?: string;
49
+ /** Test seam: the process id this runtime registers as, so two can share one session id. */
50
+ pid?: number;
51
+ /** Test seam: skip git and use this repository identity. */
52
+ repo?: { root: string; key: string };
53
+ /** Heartbeat interval; 0 disables it (tests). */
54
+ heartbeatMs?: number;
55
+ /** Inbox-watch poll interval; 0 disables it (tests). */
56
+ watchMs?: number;
57
+ /** Test seam: the repeating timer behind the heartbeat and the poll. */
58
+ interval?: RepeatTimer;
59
+ sessionTtlMs?: number;
60
+ messageTtlMs?: number;
61
+ }
62
+
63
+ export interface SessionBus {
64
+ start(ctx: ExtensionContext): Promise<void>;
65
+ stop(): Promise<void>;
66
+ status(): Promise<SessionStatus>;
67
+ /** The live bus, read only: no record refresh, no prune. */
68
+ snapshot(): Promise<SessionStatus>;
69
+ /** This session's unread mail, read only: nothing is consumed or marked. */
70
+ peek(): Promise<BusMessage[]>;
71
+ /** Release a claim by its stored repository-relative path. */
72
+ releaseStored(path: string): Promise<boolean>;
73
+ send(input: {
74
+ to: string;
75
+ text: string;
76
+ kind?: string;
77
+ paths?: unknown;
78
+ }): Promise<{ delivered: string[]; messageId: string }>;
79
+ inbox(limit?: number): Promise<BusMessage[]>;
80
+ claim(paths: unknown): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
81
+ release(paths: unknown): Promise<{ released: string[]; notOwned: string[] }>;
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
+ */
87
+ join(input: {
88
+ label?: string;
89
+ wake?: boolean;
90
+ listen?: boolean;
91
+ }): Promise<{ label: string; watching: WatchingState }>;
92
+ /** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
93
+ wantsWake(): boolean;
94
+ /** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
95
+ poll(): Promise<void> /** Mail that arrived and has not been reported yet, oldest first. */;
96
+ pending(): BusMessage[];
97
+ /** Drop mail from the pending queue, once reported or consumed. */
98
+ markReported(ids: readonly string[]): void;
99
+ /** Set what to call when new mail arrives and this session asked to be woken. */
100
+ set onWake(callback: (() => void) | undefined);
101
+ }
102
+
103
+ const DEFAULT_HEARTBEAT_MS = 10_000;
104
+ const DEFAULT_WATCH_MS = 2_000;
105
+ const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
106
+ function messageId(now: number): string {
107
+ return `${now}-${randomBytes(4).toString("hex")}`;
108
+ }
109
+
110
+ /** Whether a record's heartbeat is fresh: the rule the bus prunes and displays by. */
111
+ function isLive(session: SessionRecord, at: number, ttlMs: number): boolean {
112
+ return at - session.lastSeen <= ttlMs;
113
+ }
114
+
115
+ /** A label with this session's short id appended, inside the label budget. */
116
+ function suffixLabel(label: string, id: string): string {
117
+ const suffix = `-${shortId(id)}`;
118
+ return `${label.slice(0, MAX_LABEL - suffix.length)}${suffix}`;
119
+ }
120
+
121
+ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions = {}): SessionBus {
122
+ const now = options.now ?? Date.now;
123
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
124
+ const watchMs = options.watchMs ?? DEFAULT_WATCH_MS;
125
+ const interval = options.interval ?? defaultInterval;
126
+ const pid = options.pid ?? process.pid;
127
+ const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
128
+ const messageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
129
+
130
+ let store: BusStore | undefined;
131
+ let record: SessionRecord | undefined;
132
+ let joined = false;
133
+ let stopHeartbeat: (() => void) | undefined;
134
+ let starting: Promise<void> | undefined;
135
+
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
+ });
142
+
143
+ const ensureStarted = async (context: ExtensionContext): Promise<void> => {
144
+ if (starting) return starting;
145
+ starting = (async () => {
146
+ // Canonical once, here: the root git reports is a real path, so an unresolved
147
+ // cwd does not contain the paths a caller resolves against it.
148
+ const cwd = realPathOfNearest(context.cwd);
149
+ const repo = options.repo ?? (await repoKeyFor(pi, cwd));
150
+ const dir = options.dir ?? join(getAgentDir(), BUS_DIR_NAME, repo.key);
151
+ store = createBusStore({ dir });
152
+ await store.ensure();
153
+ const id = context.sessionManager.getSessionId();
154
+ const mode = sessionMode(context.mode);
155
+ record = {
156
+ id,
157
+ label: defaultLabel(sessionNameOf(context), cwd, id),
158
+ pid,
159
+ cwd,
160
+ repoRoot: repo.root,
161
+ mode,
162
+ startedAt: now(),
163
+ lastSeen: now(),
164
+ };
165
+ })();
166
+ try {
167
+ await starting;
168
+ } catch (error) {
169
+ starting = undefined;
170
+ throw error;
171
+ }
172
+ };
173
+
174
+ async function joinBus(): Promise<void> {
175
+ if (!store || !record) throw new Error("session bus is not started.");
176
+ if (joined) return;
177
+ const used = await takenLabels(store, record.id);
178
+ if (used.has(record.label.toLowerCase())) record.label = suffixLabel(record.label, record.id);
179
+ record.lastSeen = now();
180
+ await store.writeSession(record);
181
+ await settleLabel();
182
+ joined = true;
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
+ });
202
+ }
203
+
204
+ /** The labels other live sessions hold: a crashed session's label is not taken. */
205
+ async function takenLabels(bus: BusStore, mine: string): Promise<Set<string>> {
206
+ const at = now();
207
+ const sessions = await bus.sessions();
208
+ return new Set(
209
+ sessions
210
+ .filter((session) => session.id !== mine && isLive(session, at, sessionTtlMs))
211
+ .map((session) => session.label.toLowerCase()),
212
+ );
213
+ }
214
+
215
+ /**
216
+ * A collision is settled by yielding, never by comparing session ids: a session
217
+ * that keeps the label does not look again, and two of them can each decide that.
218
+ * Every writer that observes a rival yields, and the record written last always
219
+ * observes the other, so a collision ends with two distinct labels.
220
+ */
221
+ async function settleLabel(): Promise<void> {
222
+ if (!store || !record) return;
223
+ const me = record;
224
+ let yields = 0;
225
+ while (yields < 3 && (await takenLabels(store, me.id)).has(me.label.toLowerCase())) {
226
+ me.label = suffixLabel(me.label, me.id);
227
+ yields += 1;
228
+ }
229
+ if (yields === 0) return;
230
+ me.lastSeen = now();
231
+ await store.writeSession(me);
232
+ }
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
+
245
+ async function prepare(): Promise<void> {
246
+ if (!store || !record) throw new Error("session bus is not started.");
247
+ await joinBus();
248
+ // The caller is active by definition; refresh it before pruning so a quiet
249
+ // session that finally calls a tool does not delete its own record.
250
+ record.lastSeen = now();
251
+ await store.writeSession(record);
252
+ const sessions = await store.sessions();
253
+ const active = new Set(
254
+ sessions.filter((session) => isLive(session, now(), sessionTtlMs)).map((session) => session.id),
255
+ );
256
+ if (record) active.add(record.id);
257
+ await store.prune({ now: now(), activeIds: active, sessionTtlMs, messageTtlMs });
258
+ }
259
+
260
+ async function currentRecord(): Promise<SessionRecord> {
261
+ await prepare();
262
+ if (!record) throw new Error("session bus is not started.");
263
+ return record;
264
+ }
265
+
266
+ function storedPaths(me: SessionRecord, raw: unknown): string[] {
267
+ const normalized = normalizePaths(me.repoRoot, me.cwd, raw);
268
+ if ("error" in normalized) throw new Error(normalized.error);
269
+ const keyed = resolveClaimPaths(me.repoRoot, normalized.paths);
270
+ if ("error" in keyed) throw new Error(claimPathMessage(keyed.error));
271
+ return keyed.paths;
272
+ }
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
+
280
+ async function withUnread(rows: readonly SessionRecord[]): Promise<Array<SessionRecord & { unread: number }>> {
281
+ return Promise.all(rows.map(async (session) => ({ ...session, unread: (await store?.unread(session.id)) ?? 0 })));
282
+ }
283
+
284
+ /**
285
+ * The bus as it stands, this session's own record included.
286
+ *
287
+ * `liveOnly` drops a session whose heartbeat went stale, and a claim held by
288
+ * one, without writing or deleting anything, so a board can read it every
289
+ * second. The prune itself stays with `prepare()` on the next tool or command
290
+ * call, which is therefore what the screen's next action sees.
291
+ */
292
+ async function readStatus(
293
+ me: { id: string; label: string; onBus: boolean },
294
+ liveOnly: boolean,
295
+ ): Promise<SessionStatus> {
296
+ if (!store) throw new Error("session bus is not started.");
297
+ const records = await store.sessions();
298
+ const sessions = liveOnly
299
+ ? records.filter((session) => session.id === record?.id || isLive(session, now(), sessionTtlMs))
300
+ : records;
301
+ const live = new Set(sessions.map((session) => session.id));
302
+ const claims = (await store.claims()).filter((claim) => !liveOnly || live.has(claim.owner));
303
+ return {
304
+ me: { id: me.id, label: me.label },
305
+ onBus: me.onBus,
306
+ sessions: await withUnread(sessions),
307
+ claims,
308
+ ...(mailWatch.state().armed ? { watching: mailWatch.state() } : {}),
309
+ };
310
+ }
311
+
312
+ async function rename(label: string): Promise<string> {
313
+ if (!store || !record) throw new Error("session bus is not started.");
314
+ const candidate = normalizeLabel(label, record.label);
315
+ if ((await takenLabels(store, record.id)).has(candidate.toLowerCase())) {
316
+ throw new Error(`the label "${candidate}" is already in use.`);
317
+ }
318
+ record.label = candidate;
319
+ record.lastSeen = now();
320
+ await store.writeSession(record);
321
+ // A rename that raced another one ends on the label the bus settled on, which
322
+ // is what the caller hands to Pi as this session's name.
323
+ await settleLabel();
324
+ joined = true;
325
+ armHeartbeat();
326
+ return record.label;
327
+ }
328
+
329
+ return {
330
+ async start(context) {
331
+ await ensureStarted(context);
332
+ },
333
+
334
+ async stop() {
335
+ stopHeartbeat?.();
336
+ stopHeartbeat = undefined;
337
+ mailWatch.stop();
338
+ if (store && record && joined) {
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
+ }
348
+ }
349
+ joined = false;
350
+ starting = undefined;
351
+ store = undefined;
352
+ record = undefined;
353
+ },
354
+
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),
376
+ set onWake(callback: (() => void) | undefined) {
377
+ mailWatch.onWake = callback;
378
+ },
379
+
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
+ }
388
+ const me = await currentRecord();
389
+ return readStatus({ id: me.id, label: me.label, onBus: true }, false);
390
+ },
391
+
392
+ async snapshot() {
393
+ if (!store || !record) throw new Error("session bus is not started.");
394
+ return readStatus({ id: record.id, label: record.label, onBus: joined }, true);
395
+ },
396
+
397
+ async peek() {
398
+ if (!store || !record) throw new Error("session bus is not started.");
399
+ return store.peek(record.id, MAX_LIMIT);
400
+ },
401
+
402
+ /**
403
+ * Release a claim by its stored path.
404
+ *
405
+ * No normalization: a claim is stored relative to the repo root of the
406
+ * session that took it, and resolving it again against a cwd below that root
407
+ * would rewrite the target (`src/api` becomes `src/src/api`). The path comes
408
+ * from the store and `releaseOne` still refuses a claim owned by someone
409
+ * else, so this cannot touch a peer's claim.
410
+ */
411
+ async releaseStored(path) {
412
+ if (!store || !record) throw new Error("session bus is not started.");
413
+ return store.release(path, record.id);
414
+ },
415
+
416
+ async send(input) {
417
+ const me = await currentRecord();
418
+ if (!store) throw new Error("session bus is not started.");
419
+ const text = normalizeText(input.text);
420
+ if ("error" in text) throw new Error(text.error);
421
+ const kind = normalizeKind(input.kind);
422
+ if (input.to.trim() === "*" && kind !== "event") throw new Error('a broadcast needs kind: "event".');
423
+ const sessions = await store.sessions();
424
+ const resolved = resolveRecipients(sessions, input.to, me.id);
425
+ if ("error" in resolved) throw new Error(resolved.error);
426
+ let paths: string[] | undefined;
427
+ if (input.paths !== undefined) paths = storedPaths(me, input.paths);
428
+ const id = messageId(now());
429
+ for (const recipient of resolved.recipients) {
430
+ const message: BusMessage = {
431
+ id,
432
+ from: me.id,
433
+ fromLabel: me.label,
434
+ to: recipient.id,
435
+ toLabel: recipient.label,
436
+ kind,
437
+ text: text.text,
438
+ ...(paths ? { paths } : {}),
439
+ at: now(),
440
+ };
441
+ await store.writeMessage(recipient.id, message);
442
+ }
443
+ return { delivered: resolved.recipients.map((recipient) => recipient.label), messageId: id };
444
+ },
445
+
446
+ async inbox(limit) {
447
+ const me = await currentRecord();
448
+ if (!store) throw new Error("session bus is not started.");
449
+ const messages = await store.inbox(me.id, normalizeLimit(limit));
450
+ // Mail read before its notice fired is never announced afterwards.
451
+ mailWatch.markReported(messages.map((message) => message.id));
452
+ return messages;
453
+ },
454
+
455
+ async claim(rawPaths) {
456
+ const me = await currentRecord();
457
+ return requireStore().claimAll(storedPaths(me, rawPaths), me.id, me.label, now());
458
+ },
459
+
460
+ async release(rawPaths) {
461
+ const me = await currentRecord();
462
+ const bus = requireStore();
463
+ return releaseClaimed(storedPaths(me, rawPaths), (path) => bus.release(path, me.id));
464
+ },
465
+ };
466
+ }