@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
@@ -0,0 +1,310 @@
1
+ /**
2
+ * The page pointer: how a second session finds the page a repository already serves.
3
+ *
4
+ * A page is one loopback server owned by one session's process, so nothing about it is
5
+ * discoverable without a note left where the repository's state already lives. That note
6
+ * is `<board>/page.json`: the port, the seat serving it, and the token the page reads by.
7
+ * The page cannot change a card, so the note carries nothing a reader could misuse.
8
+ *
9
+ * Two rules make the file safe to trust:
10
+ *
11
+ * - **Created exclusively**, by hard-linking a temp file onto the name, so two sessions
12
+ * starting at the same instant cannot both believe they are the page: the loser reads
13
+ * the winner's pointer and attaches.
14
+ * - **Believed only while it answers.** A crash leaves the file behind, and the file says
15
+ * nothing about whether the process is alive, so a caller probes the page before it
16
+ * attaches or takes the seat over. `probePage` is that question, with the network as a
17
+ * seam so a test drives it.
18
+ *
19
+ * Pure I/O, no state: one call answers one caller, and the caller owns the path it hands in.
20
+ */
21
+
22
+ import { access, link, readFile, rename, rm, writeFile } from "node:fs/promises";
23
+ import { join } from "node:path";
24
+ import { hasErrorCode } from "../errors.ts";
25
+ import { abortableSleep } from "../timer.ts";
26
+
27
+ /** The file, beside the board's `cards/` directory. */
28
+ export const PAGE_FILE = "page.json";
29
+
30
+ /**
31
+ * How long a probe waits before it calls the page gone.
32
+ *
33
+ * Long enough for a page that is busy answering a browser: the probe shares the event
34
+ * loop with the snapshot route, and a page declared dead while it is merely slow is taken
35
+ * over — two servers, one note.
36
+ */
37
+ export const PROBE_MS = 1_500;
38
+
39
+ /** The port, the seat, and the credential the page reads by. */
40
+ export interface PagePointer {
41
+ port: number;
42
+ /** The page's one token: every read, and nothing else to authorise. */
43
+ token: string;
44
+ /** The seat the page writes as. */
45
+ seat: { id?: string; label: string };
46
+ /** When the page started, so a reader can tell a fresh note from a stale one. */
47
+ startedAt: number;
48
+ }
49
+
50
+ /** The fetch a probe uses: the global one in production, a stub in a test. */
51
+ export type ProbeFetch = (url: string, init: { signal: AbortSignal }) => Promise<{ ok: boolean; status?: number }>;
52
+
53
+ /** The URL for this page, wherever it is opened from. */
54
+ export function urlOf(pointer: PagePointer): string {
55
+ return `http://127.0.0.1:${pointer.port}/?token=${pointer.token}`;
56
+ }
57
+
58
+ /**
59
+ * Whether a note is there at all, readable or not.
60
+ *
61
+ * `readPointer` answers nothing for two very different states — no page has ever written a
62
+ * note here, and a note this build cannot read — and a caller that has just failed to
63
+ * create one has to tell them apart: the first is a race with a page that cleared its note
64
+ * and is worth another attempt, the second is a file only its owner can explain.
65
+ */
66
+ export async function noteExists(dir: string): Promise<boolean> {
67
+ try {
68
+ await access(join(dir, PAGE_FILE));
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** The pointer written for one board directory, or nothing when there is none to trust. */
76
+ export async function readPointer(dir: string): Promise<PagePointer | undefined> {
77
+ return readPointerAt(join(dir, PAGE_FILE));
78
+ }
79
+
80
+ /** The pointer a file holds, by path: the same read, for a note that has been moved aside. */
81
+ async function readPointerAt(path: string): Promise<PagePointer | undefined> {
82
+ let text: string;
83
+ try {
84
+ text = await readFile(path, "utf-8");
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ try {
89
+ return asPointer(JSON.parse(text));
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Write a pointer for a page this caller has just started.
97
+ *
98
+ * Returns whether it became the file: `false` means another session got there first, and
99
+ * the caller should read that one and attach rather than serve a second page.
100
+ */
101
+ export async function createPointer(dir: string, pointer: PagePointer): Promise<boolean> {
102
+ const target = join(dir, PAGE_FILE);
103
+ const temp = `${target}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
104
+ try {
105
+ await writeFile(temp, `${JSON.stringify(pointer, null, "\t")}\n`, { mode: 0o600 });
106
+ try {
107
+ await link(temp, target);
108
+ return true;
109
+ } catch (error) {
110
+ // The name is taken: another session is serving this repository, and this one
111
+ // becomes a viewer of it. Any other failure is the caller's to hear about.
112
+ if (hasErrorCode(error, "EEXIST")) return false;
113
+ throw error;
114
+ }
115
+ } finally {
116
+ await rm(temp, { force: true });
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Remove the pointer for a page that has stopped.
122
+ *
123
+ * Only a caller that has proved the note is not a live page may clear it: the page that
124
+ * wrote it, on its own way out, or a session that just found it answering nobody. The
125
+ * token is what decides — a restart of the same seat has the same seat and a new token,
126
+ * so a note that no longer names a live page never takes the live one's place away.
127
+ * Returns whether the file was the one named.
128
+ *
129
+ * The note is moved aside before it is removed, and the token is checked again on what was
130
+ * moved. A read followed by an unlink is two instants, and a session that judged the old
131
+ * note dead acts on the first one: if it removed the name, it could delete the note a
132
+ * faster session had written in between, and both would serve a page, only one of them
133
+ * findable. The move is atomic, so the file this call holds is the file that was there;
134
+ * a note that is not the one proved dead goes back where it came from.
135
+ */
136
+ export async function clearPointer(dir: string, token: string): Promise<boolean> {
137
+ const pointer = await readPointer(dir);
138
+ if (pointer === undefined || pointer.token !== token) return false;
139
+ const target = join(dir, PAGE_FILE);
140
+ const held = `${target}.gone-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
141
+ try {
142
+ await rename(target, held);
143
+ } catch {
144
+ // It left while this call was looking, which is somebody else having cleared it.
145
+ return false;
146
+ }
147
+ const moved = await readPointerAt(held);
148
+ if (moved === undefined || moved.token !== token) {
149
+ await putBack(held, target);
150
+ return false;
151
+ }
152
+ await rm(held, { force: true });
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * Put a note this call moved aside but did not earn the right to remove back in place.
158
+ *
159
+ * A newer note may have taken the name in the same instant; that one stays, and this one
160
+ * was already superseded by it.
161
+ */
162
+ async function putBack(held: string, target: string): Promise<void> {
163
+ try {
164
+ await link(held, target);
165
+ } catch {
166
+ // The name is taken again: whatever holds it now is newer than what this call moved.
167
+ }
168
+ await rm(held, { force: true });
169
+ }
170
+
171
+ /** How many times a caller tries to own the note before it gives up for good. */
172
+ export const NOTE_ATTEMPTS = 3;
173
+
174
+ /** How long a page that has written its note but does not answer yet is given, in steps. */
175
+ export const YOUNG_POLLS = 8;
176
+ const YOUNG_POLL_MS = 75;
177
+
178
+ /** How many times a caller looks again at a name that is momentarily free. */
179
+ const EMPTY_LOOKS = 20;
180
+ const EMPTY_LOOK_MS = 25;
181
+
182
+ /** What one `/board` found where the note should be. */
183
+ export type PointerOutcome =
184
+ | { kind: "written"; took?: PagePointer }
185
+ | { kind: "attached"; pointer: PagePointer }
186
+ /** The name is taken by a file this build cannot read: nothing to attach to, nothing to take. */
187
+ | { kind: "unreadable" }
188
+ /** Every attempt lost, and the note there still answers nobody. */
189
+ | { kind: "stale"; pointer: PagePointer }
190
+ /** Every attempt lost to a page that came and went, so there is no note to name. */
191
+ | { kind: "busy" };
192
+
193
+ /**
194
+ * Become the repository's page, or find the one that already is.
195
+ *
196
+ * The note is written exclusively, so the loser of a simultaneous start attaches to the
197
+ * winner rather than serving a second page nobody can find. A note that answers nobody is
198
+ * a page that died between its note and its server — and while it sits there no later
199
+ * `/board` can serve either, so it is taken over once it has been proved dead.
200
+ *
201
+ * More than one attempt, because two sessions reach this point in the same instant and
202
+ * the loser of a *takeover* has to end up a viewer of the page that won: it re-reads and
203
+ * probes again rather than reporting a page it never asked about. A failure here names
204
+ * the file, because "another session is serving" is a thing this function can see and
205
+ * therefore must not guess.
206
+ */
207
+ export async function settlePointer(dir: string, note: PagePointer, probe?: ProbeFetch): Promise<PointerOutcome> {
208
+ let took: PagePointer | undefined;
209
+ let attempts = 0;
210
+ let looks = 0;
211
+ while (attempts < NOTE_ATTEMPTS) {
212
+ // A retry writes a fresh `startedAt`, so a reader can tell it from the first attempt.
213
+ const candidate = attempts === 0 ? note : { ...note, startedAt: note.startedAt + attempts };
214
+ if (await createPointer(dir, candidate))
215
+ return took === undefined ? { kind: "written" } : { kind: "written", took };
216
+ const winner = await readPointer(dir);
217
+ if (winner === undefined) {
218
+ // No note at all means a page is between clearing its note and writing the next one.
219
+ // That is a few microseconds of somebody else's work, not a takeover attempt of this
220
+ // caller's, so the name is chased without spending one; a file that stays, and cannot
221
+ // be read, is the caller's dead end.
222
+ if (await noteExists(dir)) return { kind: "unreadable" };
223
+ if (looks >= EMPTY_LOOKS) return { kind: "busy" };
224
+ looks += 1;
225
+ await abortableSleep(EMPTY_LOOK_MS);
226
+ continue;
227
+ }
228
+ attempts += 1;
229
+ if (await answerOrWait(winner, probe)) return { kind: "attached", pointer: winner };
230
+ // Only that exact note: a note a newer page wrote is not this caller's to take. A
231
+ // refused clear means somebody else got there first, so the next attempt re-reads.
232
+ if (await clearPointer(dir, winner.token)) took = winner;
233
+ }
234
+ const last = await readPointer(dir);
235
+ if (last !== undefined) return { kind: "stale", pointer: last };
236
+ return (await noteExists(dir)) ? { kind: "unreadable" } : { kind: "busy" };
237
+ }
238
+
239
+ /**
240
+ * Whether the winner answers, giving a page that is still coming up its full probe window.
241
+ *
242
+ * A note is written before its server can accept a connection, so the loser of a simultaneous
243
+ * start reads a note whose page is a few milliseconds behind it. One refused connection is
244
+ * therefore not proof of death: stealing that note serves a second page, and calling it stale
245
+ * reports a page that exists as one that did not start. The poll is bounded at the same
246
+ * 600 ms a probe itself may spend, so a note whose page is really gone is still taken over.
247
+ */
248
+ async function answerOrWait(pointer: PagePointer, probe?: ProbeFetch): Promise<boolean> {
249
+ for (let attempt = 0; attempt < YOUNG_POLLS; attempt++) {
250
+ if (await probePage(pointer, probe)) return true;
251
+ await abortableSleep(YOUNG_POLL_MS);
252
+ }
253
+ return false;
254
+ }
255
+
256
+ /**
257
+ * Whether the page a pointer names is answering.
258
+ *
259
+ * The question goes to the cheap liveness route, not to the snapshot: a probe runs while
260
+ * another session may be serving a browser, and asking for the snapshot would make a busy
261
+ * page look like a dead one — the very race that costs a takeover and leaves two servers.
262
+ * A page from a build without that route answers 404 there and is asked for its snapshot
263
+ * once, so an upgrade mid-session attaches instead of stealing the seat.
264
+ *
265
+ * A page that answers with anything but a 200 is not one to attach to: a port reused by
266
+ * another program, a server shutting down, and a page that refuses the token all read the
267
+ * same, and all mean "serve your own".
268
+ */
269
+ export async function probePage(
270
+ pointer: PagePointer,
271
+ fetchLike: ProbeFetch = (url, init) => fetch(url, init),
272
+ timeoutMs: number = PROBE_MS,
273
+ ): Promise<boolean> {
274
+ const ask = (path: string): Promise<{ ok: boolean; status?: number }> =>
275
+ fetchLike(`http://127.0.0.1:${pointer.port}${path}?token=${pointer.token}`, {
276
+ signal: AbortSignal.timeout(timeoutMs),
277
+ });
278
+ try {
279
+ const ping = await ask("/api/ping");
280
+ if (ping.ok) return true;
281
+ // A page this build started always answers the ping; a 404 is one started by an
282
+ // older build, whose only liveness answer is its snapshot.
283
+ if (ping.status !== 404) return false;
284
+ return (await ask("/api/board")).ok;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+
290
+ /** A parsed file as a pointer, or undefined when it is not one this build can trust. */
291
+ function asPointer(value: unknown): PagePointer | undefined {
292
+ if (typeof value !== "object" || value === null) return undefined;
293
+ const { port, seat, startedAt } = value as Record<string, unknown>;
294
+ // A note written by the build with two tokens carries `viewToken`; it is this page's
295
+ // only credential now, so an upgrade reads it instead of refusing to serve its own note.
296
+ const { token = (value as { viewToken?: unknown }).viewToken } = value as { token?: unknown };
297
+ if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65_535) return undefined;
298
+ if (typeof token !== "string" || token === "") return undefined;
299
+ if (typeof startedAt !== "number") return undefined;
300
+ if (typeof seat !== "object" || seat === null) return undefined;
301
+ const { id, label } = seat as Record<string, unknown>;
302
+ if (typeof label !== "string" || label === "") return undefined;
303
+ if (id !== undefined && typeof id !== "string") return undefined;
304
+ return {
305
+ port,
306
+ token,
307
+ seat: id === undefined ? { label } : { id, label },
308
+ startedAt,
309
+ };
310
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The lead's rollup, as the lines a reader gets.
3
+ *
4
+ * Two surfaces want the same sentences: `board`'s tool returns them, because the model's
5
+ * next decision is taken from the counts and the ids, and `team`'s `/team` prints them,
6
+ * because a lead in a terminal should not have to open a browser to see what needs a
7
+ * decision. One text, so the two cannot describe one board differently.
8
+ *
9
+ * It reads the cards and the round and nothing else — no clock, no roster, no store — so
10
+ * `plural` and the group lines are pure and a caller supplies what it already read.
11
+ */
12
+
13
+ import { type Card, cardRef } from "./card.ts";
14
+ import type { RoundSummary } from "./round.ts";
15
+
16
+ /**
17
+ * The rollup: a headline of what a lead decides on, then one line per group.
18
+ *
19
+ * Groups name their cards by id and state, because the ids are what the next action
20
+ * takes, and an empty group is left out rather than drawn empty.
21
+ */
22
+ export function formatRound(cards: readonly Card[], round: RoundSummary): string {
23
+ const owners = round.owners.length;
24
+ const headline = [
25
+ plural(owners, "owner"),
26
+ plural(cards.length, "feature"),
27
+ `${round.unassigned.length} unassigned`,
28
+ `${round.unplanned.length} unplanned`,
29
+ `${round.blocked.length} blocked`,
30
+ `${round.idle.length} idle`,
31
+ `${round.done.length} done`,
32
+ ].join(" · ");
33
+ const lines = [headline];
34
+ for (const group of round.owners) {
35
+ // An owner who finished everything is still listed, with what it finished: the
36
+ // count says which kind of work is left, and an empty list is a legitimate answer.
37
+ const counts =
38
+ group.done.length > 0 ? `${group.cards.length} live, ${group.done.length} done` : `${group.cards.length}`;
39
+ const listed = group.cards.map((card) => brief(card)).join(", ");
40
+ lines.push(listed === "" ? `${group.label} (${counts})` : `${group.label} (${counts}): ${listed}`);
41
+ }
42
+ lines.push(...group("unassigned", round.unassigned));
43
+ lines.push(...group("unplanned", round.unplanned));
44
+ lines.push(...group("blocked", round.blocked, true));
45
+ lines.push(...group("idle", round.idle));
46
+ lines.push(...group("done", round.done));
47
+ return lines.join("\n");
48
+ }
49
+
50
+ /**
51
+ * `1 owner`, `2 owners`.
52
+ *
53
+ * Written as concatenation rather than a template, because the page's bundle carries
54
+ * this function's *source*: a backtick or an interpolation in it would end the string it
55
+ * is spliced into. `round-text.test.ts` pins both, and `PLURAL_JS` is how it crosses.
56
+ */
57
+ export function plural(count: number, noun: string): string {
58
+ return count + " " + noun + (count === 1 ? "" : "s");
59
+ }
60
+
61
+ /** The same body, as the browser receives it: one wording for `1 card`, not two. */
62
+ export const PLURAL_JS = String(plural);
63
+
64
+ /** `#4 doing planned`, the brief a rollup group needs. */
65
+ function brief(card: Card): string {
66
+ const planned = card.status === "doing" ? (card.plan === undefined ? " unplanned" : " planned") : "";
67
+ return `${cardRef(card.id)} ${card.status}${planned}`;
68
+ }
69
+
70
+ /** One rollup group, or nothing when it is empty. */
71
+ function group(label: string, cards: readonly Card[], withReason = false): string[] {
72
+ if (cards.length === 0) return [];
73
+ const items = cards.map((card) => (withReason ? blockedBrief(card) : brief(card)));
74
+ return [`${label} (${cards.length}): ${items.join(", ")}`];
75
+ }
76
+
77
+ /** A blocked card names the note that explains it, if there is one. */
78
+ function blockedBrief(card: Card): string {
79
+ const last = card.notes.at(-1);
80
+ return `${brief(card)}${last === undefined ? "" : ` — ${last.text}`}`;
81
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The rollup: the lead's view of a board, computed from the cards alone.
3
+ *
4
+ * This is what `board list --round` returns and what the page's Team tab draws, so
5
+ * the two cannot disagree about who holds what. It deliberately reads nothing but
6
+ * the cards: a session's liveness is `session status`'s answer, and the board has
7
+ * to work when `session-bus` is not loaded. `idle` therefore means what a card can
8
+ * know — nobody has touched it in half an hour — not that its owner's process is
9
+ * gone.
10
+ *
11
+ * The groups overlap on purpose. A `doing` card with no plan and no touch for half
12
+ * an hour is both `unplanned` and `idle`, and hiding it in one of the two would lose
13
+ * half the reason a lead should look at it.
14
+ *
15
+ * Pure: no I/O and no clock of its own.
16
+ */
17
+
18
+ import type { Card } from "./card.ts";
19
+
20
+ /** How long a `doing` card may go untouched before the rollup calls it idle. */
21
+ export const IDLE_MS = 30 * 60 * 1_000;
22
+
23
+ /**
24
+ * One owner, the live cards it holds, and the ones it finished.
25
+ *
26
+ * `T` is what the caller handed in: the rollup never builds a card, so a snapshot whose
27
+ * cards carry derived facts hands them back on the same terms it received them.
28
+ */
29
+ export interface RoundGroup<T extends Card = Card> {
30
+ label: string;
31
+ cards: T[];
32
+ /** This owner's finished cards: history, kept so a teammate who is done is not erased. */
33
+ done: T[];
34
+ }
35
+
36
+ /** The lead's rollup of one board. */
37
+ export interface RoundSummary<T extends Card = Card> {
38
+ /** Every owner with at least one unfinished card, ordered by label. */
39
+ owners: RoundGroup<T>[];
40
+ /** `todo` cards: nobody has taken them yet. */
41
+ unassigned: T[];
42
+ /** `doing` cards with no plan recorded: work started before it was planned. */
43
+ unplanned: T[];
44
+ /** `blocked` cards, and the note that says why. */
45
+ blocked: T[];
46
+ /** `doing` cards untouched for {@link IDLE_MS}: possibly a dead owner. */
47
+ idle: T[];
48
+ /** Finished cards, oldest first, kept as history. */
49
+ done: T[];
50
+ }
51
+
52
+ /**
53
+ * The rollup of `cards` at `now`.
54
+ *
55
+ * `cards` holds the unfinished cards only: a finished one is also in `done` and
56
+ * counting it twice would make the tallies disagree with the lanes. A finished card
57
+ * still names its owner, in the group's `done`, because an owner whose last card is
58
+ * done has not left the board — dropping the group erased a teammate the round had
59
+ * just finished. Two owners
60
+ * whose labels differ only by case are one owner — a name is case-insensitive when
61
+ * it is a send target, so it is here too — and the first spelling seen is kept.
62
+ */
63
+ export function buildRound<T extends Card = Card>(cards: readonly T[], now: number): RoundSummary<T> {
64
+ const groups = new Map<string, RoundGroup<T>>();
65
+ const unassigned: T[] = [];
66
+ const unplanned: T[] = [];
67
+ const blocked: T[] = [];
68
+ const idle: T[] = [];
69
+ const done: T[] = [];
70
+
71
+ for (const card of cards) {
72
+ if (card.status === "todo") unassigned.push(card);
73
+ if (card.status === "done") done.push(card);
74
+ if (card.status === "blocked") blocked.push(card);
75
+ if (card.status === "doing" && card.plan === undefined) unplanned.push(card);
76
+ if (card.status === "doing" && now - card.updatedAt >= IDLE_MS) idle.push(card);
77
+
78
+ if (card.owner === undefined) continue;
79
+ const key = card.owner.label.toLowerCase();
80
+ const group = groups.get(key) ?? { label: card.owner.label, cards: [], done: [] };
81
+ if (card.status === "done") group.done.push(card);
82
+ else group.cards.push(card);
83
+ groups.set(key, group);
84
+ }
85
+
86
+ return {
87
+ owners: [...groups.values()].sort((a, b) => a.label.localeCompare(b.label)),
88
+ unassigned,
89
+ unplanned,
90
+ blocked,
91
+ idle,
92
+ done,
93
+ };
94
+ }