@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,150 @@
1
+ /**
2
+ * The review page's plan renderer: the DOM a frame draws, thrown away and rebuilt.
3
+ *
4
+ * The seventh capability out of the review client's template literal, and the one with the
5
+ * most to read: it builds every block, its comment rail, and the cards under it, then puts
6
+ * the reader back where they were. Reads arrive through getters and mutations leave through
7
+ * callbacks, so the client keeps every variable it owns — `activeIndex`, the quote a click
8
+ * is waiting for, the observer — and this function holds only the shape of one frame.
9
+ *
10
+ * Two rules it owns, both about a repaint not stealing the reader's place:
11
+ *
12
+ * - focus is restored only when it was inside the plan to begin with, so a frame the stream
13
+ * caused cannot pull the keyboard out of the page the reviewer is working in;
14
+ * - the active block's class is put back in place rather than by scrolling, so the arrow
15
+ * keys resume where they were instead of jumping to the top.
16
+ */
17
+
18
+ import type { ClientNode } from "../../lib/web/client-runtime.ts";
19
+ import type { ComposerDeps } from "./web-composer.ts";
20
+ import { blockElements, type IslandBlock, setActive } from "./web-blocks.ts";
21
+ import type { SectionWatcher } from "./web-outline.ts";
22
+
23
+ /** What `el` has to answer for a frame. Spliced beside it; declared for the checker. */
24
+ type Build = (tag: string, className?: string, text?: string | null) => ClientNode;
25
+ declare const el: Build;
26
+
27
+ /** The quote a click on a block's control is waiting for: the reader's selection. */
28
+ export interface PendingQuote {
29
+ wrap: ClientNode;
30
+ quote: string;
31
+ }
32
+
33
+ /** What a repaint reads, and the callbacks it changes the client's state through. */
34
+ export interface PlanRenderDeps {
35
+ planEl: ClientNode | null;
36
+ outlineList: ClientNode | null;
37
+ /** The element that had focus before this frame, or null when nothing did. */
38
+ focused: ClientNode | null;
39
+ blocks(): readonly IslandBlock[];
40
+ annotations(): readonly { anchor: number }[];
41
+ activeIndex(): number;
42
+ setActiveIndex(index: number): void;
43
+ pendingQuote(): PendingQuote | null;
44
+ clearPendingQuote(): void;
45
+ /** The composer lifecycle, for the close a repaint begins with. */
46
+ composer(): ComposerDeps;
47
+ /** The block's content, from the Markdown half. */
48
+ renderBlock(wrap: ClientNode, block: IslandBlock): void;
49
+ /** The cards under one block, and the loose ones that lost their anchor. */
50
+ renderComments(scope: ClientNode, line: number): void;
51
+ renderCommentCards(scope: ClientNode, list: readonly unknown[]): void;
52
+ /** The text a reader selected inside a block, used as the next comment's quote. */
53
+ selectionIn(wrap: ClientNode): string;
54
+ open(wrap: ClientNode, block: IslandBlock, quote: string): void;
55
+ /** The facts and the header chip, repainted after the blocks. */
56
+ facts(): void;
57
+ updateState(): void;
58
+ }
59
+
60
+ /**
61
+ * Draw the plan, and put the reader back. Returns the section watcher it installed.
62
+ *
63
+ * The client's own zero-argument entry is `renderAll()`: it builds the frame's deps and
64
+ * hands them here, which is how the fragments keep calling a name that takes nothing.
65
+ */
66
+ export function renderPlanFrame(
67
+ deps: PlanRenderDeps,
68
+ closePlan: () => void,
69
+ observe: (
70
+ planEl: ClientNode | null,
71
+ list: ClientNode | null,
72
+ previous: SectionWatcher | null,
73
+ ) => SectionWatcher | null,
74
+ previousObserver: SectionWatcher | null,
75
+ ): SectionWatcher | null {
76
+ const { planEl } = deps;
77
+ if (!planEl) return previousObserver;
78
+ // Focus is only put back when it was inside this document to begin with.
79
+ const keepFocus = !!(deps.focused && deps.focused !== planEl && planEl.contains(deps.focused));
80
+ closePlan();
81
+ const scrollTop = planEl.scrollTop;
82
+ planEl.replaceChildren();
83
+ const anchored: Record<number, boolean> = {};
84
+ for (const block of deps.blocks()) {
85
+ if (block.kind === "blank") continue;
86
+ anchored[block.start] = true;
87
+ const wrap = el("div", "bf-block");
88
+ wrap.setAttribute("data-line", String(block.start));
89
+ wrap.setAttribute("data-kind", String(block.kind));
90
+ if (block.kind === "heading") wrap.id = "s-" + block.start;
91
+ deps.renderBlock(wrap, block);
92
+ const mine = deps.annotations().filter((annotation) => annotation.anchor === block.start);
93
+ if (mine.length > 0) wrap.classList.add("bf-block--commented");
94
+ const rail = el("div", "bf-block__rail");
95
+ const add = el("button", "bf-block__add");
96
+ add.type = "button";
97
+ add.textContent = "+";
98
+ add.setAttribute("aria-label", "Comment on this block");
99
+ add.addEventListener("click", function () {
100
+ // Read at click time, not at render time: a selection made after this frame is
101
+ // the quote the reader means.
102
+ const waiting = deps.pendingQuote();
103
+ const quoted = waiting && waiting.wrap === wrap ? waiting.quote : deps.selectionIn(wrap);
104
+ deps.clearPendingQuote();
105
+ deps.open(wrap, block, quoted);
106
+ });
107
+ rail.appendChild(add);
108
+ if (mine.length > 0) {
109
+ const badge = el("span", "bf-block__count");
110
+ badge.textContent = String(mine.length);
111
+ badge.title = mine.length === 1 ? "1 review comment" : mine.length + " review comments";
112
+ rail.appendChild(badge);
113
+ }
114
+ wrap.appendChild(rail);
115
+ wrap.addEventListener("focusin", function () {
116
+ const next = setActive(planEl, blockElements(planEl).indexOf(wrap));
117
+ if (next !== null) deps.setActiveIndex(next);
118
+ });
119
+ planEl.appendChild(wrap);
120
+ deps.renderComments(planEl, block.start);
121
+ }
122
+ const orphans = deps.annotations().filter((annotation) => !anchored[annotation.anchor]);
123
+ if (orphans.length > 0) {
124
+ const box = el("div", "bf-comments bf-comments--orphans");
125
+ const note = el("p");
126
+ note.textContent = "Comments not attached to a current block";
127
+ box.appendChild(note);
128
+ deps.renderCommentCards(box, orphans);
129
+ planEl.appendChild(box);
130
+ }
131
+ planEl.scrollTop = scrollTop;
132
+ // The DOM was rebuilt, so the active block's class is gone; restore it in place rather
133
+ // than scrolling, so the keyboard resumes where the reviewer was.
134
+ const activeIndex = deps.activeIndex();
135
+ const active = activeIndex >= 0 ? blockElements(planEl)[activeIndex] : null;
136
+ if (active) active.classList.add("bf-block--active");
137
+ // The focused element went with the old DOM, so put the tab stop back on the block's
138
+ // comment control: that is where adding, editing, or deleting left it.
139
+ if (keepFocus && active) {
140
+ const stop = active.querySelector(".bf-block__add");
141
+ if (stop) stop.focus();
142
+ }
143
+ deps.facts();
144
+ const observer = observe(planEl, deps.outlineList, previousObserver);
145
+ deps.updateState();
146
+ return observer;
147
+ }
148
+
149
+ /** The block renderer as source, for the client bundle. */
150
+ export const WEB_PLAN_JS = String(renderPlanFrame);
@@ -13,14 +13,13 @@
13
13
  */
14
14
 
15
15
  import {
16
+ envelopeRefusal,
16
17
  eventHeaders,
17
- hostHeaderName,
18
- isAllowedHostHeader,
19
18
  jsonError,
20
- jsonHeaders,
21
19
  jsonResponse,
22
20
  pageHeaders,
23
21
  parseRequestUrl,
22
+ postOnly,
24
23
  scriptHeaders,
25
24
  type ServeResponse,
26
25
  } from "../../lib/http/index.ts";
@@ -84,12 +83,6 @@ function isResponse(value: PlanAnnotation[] | ServeResponse): value is ServeResp
84
83
  return !Array.isArray(value);
85
84
  }
86
85
 
87
- /** The 405 for a route that only accepts POST, or nothing when the method is right. */
88
- function postOnly(method: string, what: string): ServeResponse | undefined {
89
- if (method.toUpperCase() === "POST") return undefined;
90
- return jsonError(405, `Use POST to ${what}.`);
91
- }
92
-
93
86
  /**
94
87
  * The 403 for a route the caller's role may not use, or nothing when it may. Each
95
88
  * route keeps its own sentence, because the reason differs: a reviewer posts
@@ -111,53 +104,6 @@ function linkIdOf(body: string): string | undefined {
111
104
  return typeof id === "string" && id !== "" ? id : undefined;
112
105
  }
113
106
 
114
- /** Validate method and `Host`; returns an error response to send instead. */
115
- function checkEnvelope(
116
- ctx: ReviewContext,
117
- method: string,
118
- headers: Record<string, string | string[] | undefined>,
119
- ): ServeResponse | undefined {
120
- const upper = method.toUpperCase();
121
- if (upper !== "GET" && upper !== "HEAD" && upper !== "POST") {
122
- return {
123
- ...jsonError(405, "Only GET, HEAD, and POST are served."),
124
- headers: { ...jsonHeaders(), Allow: "GET, HEAD, POST" },
125
- };
126
- }
127
- // Fail closed: a missing or non-string Host is rejected too, not just a
128
- // foreign one, so the DNS-rebinding guard cannot be skipped.
129
- if (!isAllowedHostHeader(headers, { hosts: ctx.hosts(), required: true })) {
130
- return jsonError(403, "Unexpected Host header.");
131
- }
132
- return upper === "POST" ? checkOrigin(headers) : undefined;
133
- }
134
-
135
- /**
136
- * Refuse a state-changing request another site made. A browser sends `Origin`
137
- * on every POST and `Sec-Fetch-Site` with its own classification; a request
138
- * with neither is a script or a test, which has no ambient credential to abuse.
139
- */
140
- function checkOrigin(headers: Record<string, string | string[] | undefined>): ServeResponse | undefined {
141
- const site = headers["sec-fetch-site"];
142
- if (typeof site === "string" && site !== "" && site !== "same-origin" && site !== "none") {
143
- return jsonError(403, "Cross-origin request refused.");
144
- }
145
- const origin = headers.origin;
146
- if (origin === undefined) return undefined;
147
- if (typeof origin !== "string" || origin === "") return jsonError(403, "Unexpected Origin header.");
148
- let parsed: URL;
149
- try {
150
- parsed = new URL(origin);
151
- } catch {
152
- return jsonError(403, "Unexpected Origin header.");
153
- }
154
- const host = headers.host;
155
- if (typeof host !== "string" || hostHeaderName(parsed.host) !== hostHeaderName(host)) {
156
- return jsonError(403, "Cross-origin request refused.");
157
- }
158
- return undefined;
159
- }
160
-
161
107
  /**
162
108
  * Handle one request. `method`, `rawUrl`, and `headers` are the raw HTTP
163
109
  * values; `body` is the request body for a POST.
@@ -169,14 +115,13 @@ export async function handleReviewRequest(
169
115
  headers: Record<string, string | string[] | undefined> = {},
170
116
  body = "",
171
117
  ): Promise<ServeResponse> {
172
- const envelope = checkEnvelope(ctx, method, headers);
118
+ const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts() });
173
119
  if (envelope) return envelope;
174
120
 
175
121
  const parsed = parseRequestUrl(rawUrl);
176
122
  if (!parsed) return jsonError(400, "Malformed request URL.");
177
123
  const { pathname, search } = parsed;
178
124
 
179
- if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
180
125
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
181
126
  if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
182
127
 
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The review page's settlement: the four ways a review ends, and the one POST they share.
3
+ *
4
+ * The eighth capability out of the review client's template literal. Every write has the
5
+ * same shape — POST the annotations, read the answer, report it in the status line — and
6
+ * what differs is whether the review settles: the owner's save and approve finish it, a
7
+ * reviewer's post does not (the server keeps the review open so she can keep editing), and
8
+ * cancel is the owner's own decision and carries no body at all.
9
+ *
10
+ * The page owns its flags, its buttons, and its stream; this module owns the rules about
11
+ * them, so it reads through getters and reports changes through the callbacks it is given.
12
+ */
13
+
14
+ /** What a settlement reads of the page, and the changes it reports. */
15
+ export interface SettleDeps {
16
+ /** The path a write goes to, without its query string. */
17
+ token(): string;
18
+ role(): string;
19
+ /** The comments this writer may write: her own when she is a reviewer, all when owner. */
20
+ mine(): readonly unknown[];
21
+ annotations(): readonly unknown[];
22
+ /** Whether the review has already ended, by any of the three routes. */
23
+ settled(): boolean;
24
+ setStatus(text: string, kind?: string): void;
25
+ /** The three buttons a conflict disables, and the fourth a close does. */
26
+ buttons(): {
27
+ approve: { disabled: boolean } | null;
28
+ save: { disabled: boolean } | null;
29
+ post: { disabled: boolean } | null;
30
+ };
31
+ /** The draft this tab's work lives in: cleared once the server has it. */
32
+ clearDraft(): void;
33
+ /** The page is no longer dirty: the server has everything this tab wrote. */
34
+ markClean(): void;
35
+ /** The server bound this reviewer's name to her link: adopt it. */
36
+ adoptName(name: string): void;
37
+ /** Put a reload button beside the controls: the page cannot recover on its own. */
38
+ offerReload(): void;
39
+ /** Record how the review ended, stop following it, and stop offering to change it. */
40
+ markSettled(kind: "saved" | "approved" | "closed"): void;
41
+ settleStream(): void;
42
+ lockActions(): void;
43
+ /** Repaint the header chip and the facts. */
44
+ updateState(): void;
45
+ /** Refuse to leave a half-written comment, saying why. */
46
+ confirmNoDraft(): boolean;
47
+ post(url: string, body: unknown): Promise<{ ok: boolean; status: number; body: unknown }>;
48
+ /** A body-less POST, for the close that carries nothing. */
49
+ send(url: string): Promise<unknown>;
50
+ /** What a body-less conflict says, when the server gave no sentence of its own. */
51
+ planMoved: string;
52
+ }
53
+
54
+ /** What a settlement reports back: the flag it set, and whether the page is finished. */
55
+ export interface SettleResult {
56
+ outcome: "ok" | "conflict" | "failed";
57
+ reason?: string;
58
+ }
59
+
60
+ /**
61
+ * POST the annotations, and read the answer on both branches.
62
+ *
63
+ * A 409 disables the three write buttons and offers a reload, because the page's island is
64
+ * out of date and no retry from here can fix that; anything else surfaces the server's own
65
+ * sentence, which is why the body is parsed on both branches rather than only on success.
66
+ */
67
+ export function submitAnnotations(
68
+ deps: SettleDeps,
69
+ url: string,
70
+ okText: string,
71
+ failText: string,
72
+ onOk: (() => void) | null,
73
+ ): Promise<SettleResult> {
74
+ const payload = deps.role() === "reviewer" ? deps.mine() : deps.annotations();
75
+ return deps
76
+ .post(url + "?token=" + encodeURIComponent(deps.token()), { annotations: payload })
77
+ .then((result) => {
78
+ if (result.ok) {
79
+ deps.markClean();
80
+ deps.clearDraft();
81
+ deps.setStatus(okText, "saved");
82
+ if (onOk) onOk();
83
+ deps.updateState();
84
+ // The server binds a reviewer's name to her link on the first post, so a
85
+ // second tab of the same link posts under the same name.
86
+ const body = result.body as { author?: unknown } | null;
87
+ if (deps.role() === "reviewer" && body && typeof body.author === "string" && body.author) {
88
+ deps.adoptName(body.author);
89
+ }
90
+ return { outcome: "ok" as const };
91
+ }
92
+ if (result.status === 409) {
93
+ const buttons = deps.buttons();
94
+ if (buttons.approve) buttons.approve.disabled = true;
95
+ if (buttons.save) buttons.save.disabled = true;
96
+ if (buttons.post) buttons.post.disabled = true;
97
+ deps.offerReload();
98
+ const body = result.body as { error?: unknown } | null;
99
+ const reason = body && typeof body.error === "string" ? body.error : deps.planMoved;
100
+ deps.setStatus(reason, "error");
101
+ return { outcome: "conflict" as const, reason };
102
+ }
103
+ const body = result.body as { error?: unknown } | null;
104
+ throw new Error(body && typeof body.error === "string" ? body.error : "server returned " + result.status);
105
+ })
106
+ .catch((error: { message?: string }) => {
107
+ const reason = failText + (error && error.message ? error.message : "");
108
+ deps.setStatus(reason, "error");
109
+ return { outcome: "failed" as const, reason };
110
+ });
111
+ }
112
+
113
+ /** Save the annotations and finish the review: the owner's way to close an approved plan. */
114
+ export function saveReview(deps: SettleDeps): Promise<SettleResult> | null {
115
+ if (deps.settled()) return null;
116
+ if (deps.confirmNoDraft()) return null;
117
+ deps.setStatus("Saving\u2026");
118
+ return submitAnnotations(
119
+ deps,
120
+ "/api/feedback",
121
+ "Saved \u2014 return to Pi to continue.",
122
+ "Could not save: ",
123
+ function () {
124
+ deps.markSettled("saved");
125
+ deps.settleStream();
126
+ deps.lockActions();
127
+ },
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Post a reviewer's comments and keep the review open.
133
+ *
134
+ * A reviewer's write never settles the page: the server keeps the review open so she can
135
+ * keep editing her own comments, and closing the stream here would strand her.
136
+ */
137
+ export function postReviewComments(deps: SettleDeps): Promise<SettleResult> | null {
138
+ if (deps.settled()) return null;
139
+ if (deps.confirmNoDraft()) return null;
140
+ deps.setStatus("Posting\u2026");
141
+ return submitAnnotations(
142
+ deps,
143
+ "/api/comment",
144
+ "Comments posted \u2014 the owner sees them now.",
145
+ "Could not post: ",
146
+ null,
147
+ );
148
+ }
149
+
150
+ /** Approve the plan, which finishes the review. */
151
+ export function approveReview(deps: SettleDeps): Promise<SettleResult> | null {
152
+ if (deps.settled()) return null;
153
+ deps.setStatus("Approving\u2026");
154
+ return submitAnnotations(
155
+ deps,
156
+ "/api/approve",
157
+ "Approved \u2014 return to Pi to continue.",
158
+ "Could not approve: ",
159
+ function () {
160
+ deps.markSettled("approved");
161
+ deps.settleStream();
162
+ deps.lockActions();
163
+ },
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Close the review without saving: only the owner may, and the page says so while it asks.
169
+ *
170
+ * The close carries no body, so it is the one write that does not go through
171
+ * `submitAnnotations`; its answer is a status line either way, because a tab that closed the
172
+ * review has nothing left to do with a failure.
173
+ */
174
+ export function cancelReview(deps: SettleDeps): Promise<void> | null {
175
+ if (deps.role() !== "owner" || deps.settled()) return null;
176
+ if (deps.confirmNoDraft()) return null;
177
+ deps.markSettled("closed");
178
+ deps.settleStream();
179
+ deps.lockActions();
180
+ deps.setStatus("Closing\u2026");
181
+ deps.updateState();
182
+ return deps
183
+ .send("/api/cancel?token=" + encodeURIComponent(deps.token()))
184
+ .catch(function () {})
185
+ .then(function () {
186
+ deps.setStatus("Closed \u2014 return to Pi.");
187
+ deps.updateState();
188
+ });
189
+ }
190
+
191
+ /** The settlement rules as source, for the client bundle. */
192
+ export const WEB_SETTLE_JS = [submitAnnotations, saveReview, postReviewComments, approveReview, cancelReview]
193
+ .map(String)
194
+ .join("\n\n");
@@ -3,13 +3,12 @@
3
3
  * network, and the state the owner's page and `/plan share` read.
4
4
  *
5
5
  * Separate from `web.ts` because the review session's job is its own lifecycle —
6
- * open, settle, close — while this module owns the socket that exists only while
7
- * the owner has sharing on. It is handed the one request handler, the token
6
+ * open, settle, close — while this module owns the address, the links, and the
7
+ * listener's lifetime. It is handed the one request handler, the token
8
8
  * hooks, and the frame hooks, so it never reaches into the session's maps.
9
9
  */
10
10
 
11
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
12
- import { bindHost, createCloser, LOOPBACK_HOSTS } from "../../lib/http/index.ts";
11
+ import { LOOPBACK_HOSTS, type LoopbackHandler, type LoopbackServer, serveLoopback } from "../../lib/http/index.ts";
13
12
  import { qrSvg } from "../../lib/qr.ts";
14
13
  import type { ReviewFrame, ReviewRole } from "./types.ts";
15
14
  import {
@@ -27,7 +26,9 @@ import {
27
26
  /** What the share listener needs from the review session it belongs to. */
28
27
  export interface ShareSessionDeps {
29
28
  /** The one request handler: a loopback page and a shared page are the same page. */
30
- handleRequest: (request: IncomingMessage, response: ServerResponse) => void;
29
+ handleRequest: LoopbackHandler;
30
+ /** The body cap both listeners read under, so a shared page is not read differently. */
31
+ maxBodyBytes: number;
31
32
  /** The owner's bound port, reused for the link when it is free. */
32
33
  ownerPort: () => number;
33
34
  /** The machine's interfaces, or a snapshot a test injected. */
@@ -65,8 +66,7 @@ export interface ShareSession {
65
66
  export function createShareSession(deps: ShareSessionDeps): ShareSession {
66
67
  let address = "";
67
68
  let port = 0;
68
- let server: Server | undefined;
69
- let closeServer: (() => Promise<void>) | undefined;
69
+ let server: LoopbackServer | undefined;
70
70
  const links: LinkTable = createLinkTable({
71
71
  mint: deps.mintReviewer,
72
72
  revoke: deps.revokeReviewer,
@@ -87,24 +87,20 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
87
87
 
88
88
  /** Bind the shared listener on `chosen`, reusing the owner's port when it is free. */
89
89
  const bind = async (chosen: string): Promise<void> => {
90
- const candidate = createServer(deps.handleRequest);
91
- let bound: number | undefined;
92
- for (const attempt of [deps.ownerPort(), 0]) {
93
- try {
94
- bound = await bindHost(candidate, chosen, attempt);
95
- break;
96
- } catch {
97
- bound = undefined;
98
- }
90
+ try {
91
+ const bound = await serveLoopback({
92
+ host: chosen,
93
+ ports: [deps.ownerPort(), 0],
94
+ maxBodyBytes: deps.maxBodyBytes,
95
+ handler: deps.handleRequest,
96
+ });
97
+ server = bound;
98
+ address = chosen;
99
+ port = bound.port;
100
+ } catch {
101
+ // Neither the owner's port nor a free one answered: the caller reports it.
102
+ server = undefined;
99
103
  }
100
- if (bound === undefined) {
101
- candidate.close();
102
- return;
103
- }
104
- server = candidate;
105
- closeServer = createCloser(candidate);
106
- address = chosen;
107
- port = bound;
108
104
  };
109
105
 
110
106
  /**
@@ -176,21 +172,19 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
176
172
  // The reviewers are told before their listener goes away; the review itself
177
173
  // stays open for its owner.
178
174
  deps.endStreams("revoked", { role: "reviewer" });
179
- const closer = closeServer;
175
+ const closer = server;
180
176
  server = undefined;
181
- closeServer = undefined;
182
177
  address = "";
183
178
  port = 0;
184
179
  reason = undefined;
185
- if (closer) await closer();
180
+ if (closer) await closer.close();
186
181
  return settle();
187
182
  };
188
183
 
189
184
  const close = async (): Promise<void> => {
190
- const closer = closeServer;
185
+ const closer = server;
191
186
  server = undefined;
192
- closeServer = undefined;
193
- if (closer) await closer();
187
+ if (closer) await closer.close();
194
188
  };
195
189
 
196
190
  return { share, invite, revoke, stop, state: snapshot, hosts, close };
@@ -3,9 +3,11 @@
3
3
  * when a closed tab ends the review.
4
4
  *
5
5
  * A frame carries only a kind, so the registry is the only thing that knows who
6
- * is connected. It is keyed by response — a page may hold more than one stream —
7
- * and records the caller it belongs to, so an owner-only frame, or one revoked
8
- * link, can be addressed without the caller reaching into the map.
6
+ * is connected. It is keyed by sink — a page may hold more than one stream — and
7
+ * records the caller it belongs to, so an owner-only frame, or one revoked link,
8
+ * can be addressed without the caller reaching into the map. Liveness is the
9
+ * sink's own (a write after the end is a no-op), so the registry never asks the
10
+ * socket whether it is still writable.
9
11
  *
10
12
  * The closed-tab rule lives here because it is a fact about streams: a reviewer's
11
13
  * tab opening or closing changes nothing, while the owner's own page going away
@@ -13,7 +15,7 @@
13
15
  * against that reload, so it takes the timer as a seam a test can drive by hand.
14
16
  */
15
17
 
16
- import type { ServerResponse } from "node:http";
18
+ import type { EventSink } from "../../lib/http/index.ts";
17
19
  import type { ArmTimer } from "../../lib/timer.ts";
18
20
  import type { ReviewFrame, ReviewRole } from "./types.ts";
19
21
 
@@ -44,9 +46,9 @@ export interface StreamRegistryDeps {
44
46
  /** The stream set one running review holds. */
45
47
  export interface StreamRegistry {
46
48
  /** Register a stream, and stand down a pending closed-tab grace. */
47
- add(response: ServerResponse, record: StreamRecord): void;
49
+ add(sink: EventSink, record: StreamRecord): void;
48
50
  /** Forget a stream, arming the grace when it was the owner's last one. */
49
- remove(response: ServerResponse): void;
51
+ remove(sink: EventSink): void;
50
52
  /** Tell the open pages something changed. */
51
53
  broadcast(kind: ReviewFrame, only?: StreamFilter): void;
52
54
  /** Send one last frame and end the stream with it. */
@@ -60,7 +62,7 @@ export interface StreamRegistry {
60
62
  }
61
63
 
62
64
  export function createStreams(deps: StreamRegistryDeps): StreamRegistry {
63
- const clients = new Map<ServerResponse, StreamRecord>();
65
+ const clients = new Map<EventSink, StreamRecord>();
64
66
  let sawOwner = false;
65
67
  let cancelDrop: (() => void) | undefined;
66
68
 
@@ -91,29 +93,27 @@ export function createStreams(deps: StreamRegistryDeps): StreamRegistry {
91
93
  };
92
94
 
93
95
  return {
94
- add(response, record) {
95
- clients.set(response, record);
96
+ add(sink, record) {
97
+ clients.set(sink, record);
96
98
  if (record.role === "owner") {
97
99
  sawOwner = true;
98
100
  cancel();
99
101
  }
100
102
  },
101
- remove(response) {
102
- const record = clients.get(response);
103
- clients.delete(response);
103
+ remove(sink) {
104
+ const record = clients.get(sink);
105
+ clients.delete(sink);
104
106
  if (record?.role === "owner") scheduleDrop();
105
107
  },
106
108
  broadcast(kind, only) {
107
109
  for (const [client, record] of clients) {
108
110
  if (!matches(record, only)) continue;
109
- if (client.writableEnded || client.destroyed) continue;
110
111
  client.write(`data: ${kind}\n\n`);
111
112
  }
112
113
  },
113
114
  end(kind, only) {
114
115
  for (const [client, record] of clients) {
115
116
  if (!matches(record, only)) continue;
116
- if (client.writableEnded || client.destroyed) continue;
117
117
  client.end(`data: ${kind}\n\n`);
118
118
  }
119
119
  },
@@ -15,7 +15,7 @@
15
15
  * carries only the name, so a shared name is a shared marker.
16
16
  */
17
17
 
18
- import { randomBytes } from "node:crypto";
18
+ import { mintToken } from "../../lib/http/index.ts";
19
19
  import type { ReviewCaller, ReviewRole } from "./types.ts";
20
20
 
21
21
  /** The token → caller table one running review holds. */
@@ -38,7 +38,7 @@ export interface TokenTable {
38
38
  const COLLISION_ATTEMPTS = 8;
39
39
 
40
40
  /** A 128-bit token; the one credential a reviewer holds. */
41
- const defaultToken = (): string => randomBytes(16).toString("hex");
41
+ const defaultToken = (): string => mintToken();
42
42
 
43
43
  /** Build the table for one review. `mint` is the generator a test replaces. */
44
44
  export function createTokens(mint: () => string = defaultToken): TokenTable {