@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
@@ -1,25 +1,25 @@
1
1
  /**
2
2
  * The request router for browser plan review.
3
3
  *
4
- * A request is validated (method, `Host`, per-session token), matched to a
5
- * route, and answered with a plain {@link ServeResponse}, so every route can be
6
- * tested without a socket. The `/events` route is a marker: the node server in
7
- * `web.ts` keeps that response open as a Server-Sent Events stream used only to
8
- * notice when the browser tab is gone.
4
+ * A request is validated (method, `Host`, origin, token), matched to a route,
5
+ * and answered with a plain {@link ServeResponse}, so every route can be tested
6
+ * without a socket. The `/events` route is a marker: the node server in `web.ts`
7
+ * keeps that response open as a Server-Sent Events stream.
9
8
  *
10
- * The token is a per-session secret carried in the page URL. It is not an
11
- * authentication system — the server is loopback-only — but it stops another
12
- * local page from driving the session.
9
+ * The token is a per-session secret carried in the page URL, and on a shared
10
+ * review it is the only credential a reviewer has: it names the caller — the
11
+ * role it holds, and the name it posts under — and the router refuses an
12
+ * owner-only route for a reviewer however the request was built.
13
13
  */
14
14
 
15
15
  import {
16
+ envelopeRefusal,
16
17
  eventHeaders,
17
- isLocalHostHeader,
18
18
  jsonError,
19
- jsonHeaders,
20
19
  jsonResponse,
21
20
  pageHeaders,
22
21
  parseRequestUrl,
22
+ postOnly,
23
23
  scriptHeaders,
24
24
  type ServeResponse,
25
25
  } from "../../lib/http/index.ts";
@@ -27,25 +27,38 @@ import { THEME_JS, THEME_JS_PATH } from "../../lib/web/index.ts";
27
27
  import { coerceAnnotations, type PlanAnnotation } from "./feedback.ts";
28
28
  import { REVIEW_CLIENT_JS } from "./web-client.ts";
29
29
  import { renderReviewPage, type ReviewPageData } from "./web-page.ts";
30
+ import type { ReviewCaller, ReviewRole } from "./types.ts";
31
+ import type { ShareResult } from "./web-share.ts";
30
32
 
31
- /** What a save callback reports back. */
32
- export type SaveResult = { ok: true } | { ok: false; status: number; message: string };
33
+ /** What a save callback reports back. A reviewer's post reports the name it was
34
+ * stored under, so a page created before the first write can adopt it. */
35
+ export type SaveResult = { ok: true; author?: string } | { ok: false; status: number; message: string };
33
36
 
34
37
  /** Everything the router needs from the running session. */
35
38
  export interface ReviewContext {
36
- /** Per-session secret required on the page, event stream, and API routes. */
37
- token: string;
39
+ /** The caller a token names, or undefined when it is not one of this session's. */
40
+ caller: (token: string) => ReviewCaller | undefined;
41
+ /** The host names this server answers to now, so a rebound page is refused. */
42
+ hosts: () => readonly string[];
38
43
  /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
39
44
  canApprove: boolean;
40
- /** Build the page data (recomputed per request from the current plan). */
41
- page: () => Promise<ReviewPageData>;
42
- /** Persist the annotations and report the outcome. */
43
- save: (annotations: PlanAnnotation[]) => Promise<SaveResult>;
44
- /** Called after a successful save; the server resolves the session once the response has flushed. */
45
+ /** Build the page data for the caller (recomputed per request). */
46
+ page: (caller: ReviewCaller) => Promise<ReviewPageData>;
47
+ /** Persist the annotations as the caller and report the outcome. */
48
+ save: (annotations: PlanAnnotation[], caller: ReviewCaller) => Promise<SaveResult>;
49
+ /** Start sharing the review, or report why it cannot be shared. */
50
+ share: () => Promise<ShareResult>;
51
+ /** Mint one more reviewer link, so each teammate holds their own. */
52
+ invite: () => Promise<ShareResult>;
53
+ /** Revoke one link by id; undefined when this review does not hold it. */
54
+ revoke: (id: string) => Promise<ShareResult | undefined>;
55
+ /** Revoke reviewer tokens and stop accepting reviewers. */
56
+ stopShare: () => Promise<ShareResult>;
57
+ /** Called after the owner's save; the server resolves the session once the response has flushed. */
45
58
  onSaved: (annotations: PlanAnnotation[]) => void;
46
59
  /** Called after a successful approve; the server resolves the session once the response has flushed. */
47
60
  onApprove: (annotations: PlanAnnotation[]) => void;
48
- /** Called when the user cancels; the server resolves the session once the response has flushed. */
61
+ /** Called when the owner cancels; the server resolves the session once the response has flushed. */
49
62
  onCancel: () => void;
50
63
  }
51
64
 
@@ -70,24 +83,25 @@ function isResponse(value: PlanAnnotation[] | ServeResponse): value is ServeResp
70
83
  return !Array.isArray(value);
71
84
  }
72
85
 
73
- /** Validate method and `Host`; returns an error response to send instead. */
74
- function checkEnvelope(
75
- method: string,
76
- headers: Record<string, string | string[] | undefined>,
77
- ): ServeResponse | undefined {
78
- const upper = method.toUpperCase();
79
- if (upper !== "GET" && upper !== "HEAD" && upper !== "POST") {
80
- return {
81
- ...jsonError(405, "Only GET, HEAD, and POST are served."),
82
- headers: { ...jsonHeaders(), Allow: "GET, HEAD, POST" },
83
- };
84
- }
85
- // Fail closed: a missing or non-string Host is rejected too, not just a
86
- // foreign one, so the DNS-rebinding guard cannot be skipped.
87
- if (!isLocalHostHeader(headers, { required: true })) {
88
- return jsonError(403, "Unexpected Host header.");
86
+ /**
87
+ * The 403 for a route the caller's role may not use, or nothing when it may. Each
88
+ * route keeps its own sentence, because the reason differs: a reviewer posts
89
+ * comments, and only the owner decides.
90
+ */
91
+ function roleOnly(caller: ReviewCaller, roles: readonly ReviewRole[], message: string): ServeResponse | undefined {
92
+ return roles.includes(caller.role) ? undefined : jsonError(403, message);
93
+ }
94
+
95
+ /** The link id a revoke request names, or undefined when the body has none. */
96
+ function linkIdOf(body: string): string | undefined {
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(body);
100
+ } catch {
101
+ return undefined;
89
102
  }
90
- return undefined;
103
+ const id = (parsed as { id?: unknown } | null)?.id;
104
+ return typeof id === "string" && id !== "" ? id : undefined;
91
105
  }
92
106
 
93
107
  /**
@@ -101,7 +115,7 @@ export async function handleReviewRequest(
101
115
  headers: Record<string, string | string[] | undefined> = {},
102
116
  body = "",
103
117
  ): Promise<ServeResponse> {
104
- const envelope = checkEnvelope(method, headers);
118
+ const envelope = envelopeRefusal({ method, headers, hosts: ctx.hosts() });
105
119
  if (envelope) return envelope;
106
120
 
107
121
  const parsed = parseRequestUrl(rawUrl);
@@ -112,34 +126,82 @@ export async function handleReviewRequest(
112
126
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
113
127
  if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
114
128
 
115
- if (search.get("token") !== ctx.token) return jsonError(403, "Invalid or missing token.");
129
+ const token = search.get("token") ?? "";
130
+ const caller = ctx.caller(token);
131
+ if (caller === undefined) return jsonError(403, "Invalid or missing token.");
116
132
 
117
133
  if (pathname === "/" || pathname === "/index.html") {
118
- return { status: 200, headers: pageHeaders(), body: renderReviewPage(await ctx.page()) };
134
+ return { status: 200, headers: pageHeaders(), body: renderReviewPage(await ctx.page(caller)) };
119
135
  }
120
136
  if (pathname === "/events") return { status: 200, headers: eventHeaders(), stream: true };
121
137
  if (pathname === "/api/cancel") {
122
- if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to cancel.");
138
+ // Ending the review is the owner's decision; a reviewer closes their tab.
139
+ const refusal =
140
+ postOnly(method, "cancel") ?? roleOnly(caller, ["owner"], "Only the plan owner can close this review.");
141
+ if (refusal) return refusal;
123
142
  ctx.onCancel();
124
143
  return jsonResponse(200, { ok: true });
125
144
  }
145
+ if (pathname === "/api/share" || pathname === "/api/share/link" || pathname === "/api/share/stop") {
146
+ // A shared link must never be able to mint another one.
147
+ const refusal =
148
+ postOnly(method, "share or stop sharing") ??
149
+ roleOnly(caller, ["owner"], "Only the plan owner can share this review.");
150
+ if (refusal) return refusal;
151
+ const result =
152
+ pathname === "/api/share/stop"
153
+ ? await ctx.stopShare()
154
+ : pathname === "/api/share/link"
155
+ ? await ctx.invite()
156
+ : await ctx.share();
157
+ return jsonResponse(200, { ok: true, share: result.state, svg: result.svg });
158
+ }
159
+ if (pathname === "/api/share/revoke") {
160
+ const refusal =
161
+ postOnly(method, "revoke a link") ?? roleOnly(caller, ["owner"], "Only the plan owner can revoke a link.");
162
+ if (refusal) return refusal;
163
+ const id = linkIdOf(body);
164
+ if (id === undefined) return jsonError(400, "The request body must name the link's id.");
165
+ const result = await ctx.revoke(id);
166
+ if (!result) return jsonError(404, "That link is not shared.");
167
+ return jsonResponse(200, { ok: true, share: result.state, svg: result.svg });
168
+ }
169
+ if (pathname === "/api/comment") {
170
+ // A reviewer's whole surface: persist the comments they wrote and end
171
+ // nothing. Only the owner's own save decides the review.
172
+ const refusal =
173
+ postOnly(method, "post comments") ?? roleOnly(caller, ["reviewer"], "Only a shared reviewer posts comments.");
174
+ if (refusal) return refusal;
175
+ const annotations = parseAnnotations(body);
176
+ if (isResponse(annotations)) return annotations;
177
+ const result = await ctx.save(annotations, caller);
178
+ if (!result.ok) return jsonError(result.status, result.message);
179
+ return jsonResponse(200, { ok: true, ...(result.author === undefined ? {} : { author: result.author }) });
180
+ }
126
181
  if (pathname === "/api/feedback") {
127
- if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to save feedback.");
182
+ // Returning to the model is the owner's decision; a reviewer posts to
183
+ // `/api/comment`, so this route can never settle a shared review.
184
+ const refusal =
185
+ postOnly(method, "save feedback") ??
186
+ roleOnly(caller, ["owner"], "Only the plan owner saves this review; a reviewer posts comments.");
187
+ if (refusal) return refusal;
128
188
  const annotations = parseAnnotations(body);
129
189
  if (isResponse(annotations)) return annotations;
130
- const result = await ctx.save(annotations);
190
+ const result = await ctx.save(annotations, caller);
131
191
  if (!result.ok) return jsonError(result.status, result.message);
132
192
  ctx.onSaved(annotations);
133
193
  return jsonResponse(200, { ok: true });
134
194
  }
135
195
  if (pathname === "/api/approve") {
136
- if (method.toUpperCase() !== "POST") return jsonError(405, "Use POST to approve.");
137
- // Only the exit_plan_mode flow owns the approval side effects, so a comment-only
138
- // review refuses this even if a client posts it directly.
196
+ // Only the owner's own page approves, and only the exit_plan_mode flow owns
197
+ // the side effects: a reviewer token is refused whatever it posts.
198
+ const refusal =
199
+ postOnly(method, "approve") ?? roleOnly(caller, ["owner"], "A shared review cannot approve the plan.");
200
+ if (refusal) return refusal;
139
201
  if (!ctx.canApprove) return jsonError(403, "This review cannot be approved from the browser.");
140
202
  const annotations = parseAnnotations(body);
141
203
  if (isResponse(annotations)) return annotations;
142
- const result = await ctx.save(annotations);
204
+ const result = await ctx.save(annotations, caller);
143
205
  if (!result.ok) return jsonError(result.status, result.message);
144
206
  ctx.onApprove(annotations);
145
207
  return jsonResponse(200, { ok: true });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The shared review's second listener: one reviewer token, one address on this
3
+ * network, and the state the owner's page and `/plan share` read.
4
+ *
5
+ * Separate from `web.ts` because the review session's job is its own lifecycle —
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
+ * hooks, and the frame hooks, so it never reaches into the session's maps.
9
+ */
10
+
11
+ import { LOOPBACK_HOSTS, type LoopbackHandler, type LoopbackServer, serveLoopback } from "../../lib/http/index.ts";
12
+ import { qrSvg } from "../../lib/qr.ts";
13
+ import type { ReviewFrame, ReviewRole } from "./types.ts";
14
+ import {
15
+ AT_CAP_REASON,
16
+ createLinkTable,
17
+ lanAddresses,
18
+ NO_SHARE_REASON,
19
+ shareUrl,
20
+ type LinkTable,
21
+ type NetworkInterfaces,
22
+ type ShareResult,
23
+ type ShareState,
24
+ } from "./web-share.ts";
25
+
26
+ /** What the share listener needs from the review session it belongs to. */
27
+ export interface ShareSessionDeps {
28
+ /** The one request handler: a loopback page and a shared page are the same page. */
29
+ handleRequest: LoopbackHandler;
30
+ /** The body cap both listeners read under, so a shared page is not read differently. */
31
+ maxBodyBytes: number;
32
+ /** The owner's bound port, reused for the link when it is free. */
33
+ ownerPort: () => number;
34
+ /** The machine's interfaces, or a snapshot a test injected. */
35
+ networkInterfaces: () => NetworkInterfaces;
36
+ /** Add a reviewer token to the session's role map. */
37
+ mintReviewer: () => string;
38
+ /** Remove a reviewer token from the session's role map. */
39
+ revokeReviewer: (token: string) => void;
40
+ /** The name a reviewer token has bound, or undefined before its first post. */
41
+ nameOf: (token: string) => string | undefined;
42
+ /** Tell the open pages something changed, optionally only one role or link. */
43
+ broadcast: (kind: ReviewFrame, only?: ReviewRole) => void;
44
+ /** End streams with a last frame: every reviewer, or one link's. */
45
+ endStreams: (kind: ReviewFrame, only?: { role?: ReviewRole; token?: string }) => void;
46
+ }
47
+
48
+ /** The share surface one running review exposes. */
49
+ export interface ShareSession {
50
+ /** Start sharing with the first link, or report why it cannot; idempotent while on. */
51
+ share(): Promise<ShareResult>;
52
+ /** Mint one more link, so each teammate holds their own. */
53
+ invite(): Promise<ShareResult>;
54
+ /** Revoke one link, or undefined when this review does not hold that id. */
55
+ revoke(id: string): Promise<ShareResult | undefined>;
56
+ /** Revoke every link, tell the reviewers, and close the listener. */
57
+ stop(): Promise<ShareResult>;
58
+ /** The current state, without changing it. */
59
+ state(): ShareState;
60
+ /** Host names the server answers to: loopback plus the shared address. */
61
+ hosts(): readonly string[];
62
+ /** Close the listener as the review ends, without reading it as a decision. */
63
+ close(): Promise<void>;
64
+ }
65
+
66
+ export function createShareSession(deps: ShareSessionDeps): ShareSession {
67
+ let address = "";
68
+ let port = 0;
69
+ let server: LoopbackServer | undefined;
70
+ const links: LinkTable = createLinkTable({
71
+ mint: deps.mintReviewer,
72
+ revoke: deps.revokeReviewer,
73
+ nameOf: deps.nameOf,
74
+ });
75
+ let reason: string | undefined;
76
+ const hosts = (): readonly string[] => (address ? [...LOOPBACK_HOSTS, address] : [...LOOPBACK_HOSTS]);
77
+
78
+ /**
79
+ * The share surface as it is now, read rather than cached: a link's bound name
80
+ * changes when its reviewer posts, and the owner's page has to see that without
81
+ * another share action.
82
+ */
83
+ const snapshot = (): ShareState => {
84
+ const base: ShareState = server === undefined ? { sharing: false, port: 0, links: [] } : links.state(port, address);
85
+ return reason === undefined ? base : { ...base, reason };
86
+ };
87
+
88
+ /** Bind the shared listener on `chosen`, reusing the owner's port when it is free. */
89
+ const bind = async (chosen: string): Promise<void> => {
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;
103
+ }
104
+ };
105
+
106
+ /**
107
+ * Bind the one shared listener the first time a link needs it, or report why
108
+ * there is nothing to bind to. Every link answers on this same listener, so this
109
+ * runs once per share and the rest is bookkeeping.
110
+ */
111
+ const ensureListener = async (): Promise<boolean> => {
112
+ if (server !== undefined) return true;
113
+ const chosen = lanAddresses(deps.networkInterfaces())[0];
114
+ if (chosen === undefined) {
115
+ reason = NO_SHARE_REASON;
116
+ return false;
117
+ }
118
+ await bind(chosen);
119
+ if (server === undefined) {
120
+ reason = NO_SHARE_REASON;
121
+ return false;
122
+ }
123
+ reason = undefined;
124
+ return true;
125
+ };
126
+
127
+ /** Announce the change and hand back the state, plus the QR of a link just minted. */
128
+ const settle = async (minted?: string): Promise<ShareResult> => {
129
+ deps.broadcast("share", "owner");
130
+ const state = snapshot();
131
+ if (minted === undefined) return { state };
132
+ return { state, svg: await qrSvg(shareUrl(address, port, minted)) };
133
+ };
134
+
135
+ const share = async (): Promise<ShareResult> => {
136
+ reason = undefined;
137
+ if (!(await ensureListener())) return { state: snapshot() };
138
+ // One link per share, so a second click re-reads the link it already minted
139
+ // rather than making another one the owner has to revoke.
140
+ const added = links.list().length === 0 ? links.add() : undefined;
141
+ return settle(added?.token ?? links.list().at(-1)?.token);
142
+ };
143
+
144
+ const invite = async (): Promise<ShareResult> => {
145
+ reason = undefined;
146
+ if (!(await ensureListener())) return { state: snapshot() };
147
+ const added = links.add();
148
+ if (added === undefined) {
149
+ // At the cap: the page keeps rendering the links it holds and says why.
150
+ reason = AT_CAP_REASON;
151
+ return { state: snapshot() };
152
+ }
153
+ return settle(added.token);
154
+ };
155
+
156
+ const revoke = async (id: string): Promise<ShareResult | undefined> => {
157
+ reason = undefined;
158
+ const link = links.find(id);
159
+ if (link === undefined) return undefined;
160
+ // The link is told before its token goes away, so its page reads the reason
161
+ // rather than falling back to "Offline".
162
+ deps.endStreams("revoked", { token: link.token });
163
+ links.remove(id);
164
+ // The last link leaving is the same thing as stopping: there is nothing left to
165
+ // answer on the shared listener.
166
+ if (links.list().length === 0) return stop();
167
+ return settle();
168
+ };
169
+
170
+ const stop = async (): Promise<ShareResult> => {
171
+ for (const link of links.list()) links.remove(link.id);
172
+ // The reviewers are told before their listener goes away; the review itself
173
+ // stays open for its owner.
174
+ deps.endStreams("revoked", { role: "reviewer" });
175
+ const closer = server;
176
+ server = undefined;
177
+ address = "";
178
+ port = 0;
179
+ reason = undefined;
180
+ if (closer) await closer.close();
181
+ return settle();
182
+ };
183
+
184
+ const close = async (): Promise<void> => {
185
+ const closer = server;
186
+ server = undefined;
187
+ if (closer) await closer.close();
188
+ };
189
+
190
+ return { share, invite, revoke, stop, state: snapshot, hosts, close };
191
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Sharing a review with the people on this network (pure).
3
+ *
4
+ * A shared review needs one thing the running server cannot discover for
5
+ * itself: which address a teammate's browser should name. This module answers
6
+ * that from an injected `networkInterfaces()` snapshot — never by reading the
7
+ * machine's own NICs — and turns it into the links the owner is shown and the
8
+ * page renders.
9
+ *
10
+ * Only addresses another machine can plausibly reach are offered: IPv4, not
11
+ * internal, and not the link-local range a machine assigns itself when DHCP
12
+ * failed. Everything else would print a link that cannot work.
13
+ */
14
+
15
+ import type { NetworkInterfaceInfo } from "node:os";
16
+
17
+ /** The interfaces a review may be shared to, by name. */
18
+ export type NetworkInterfaces = Record<string, NetworkInterfaceInfo[] | undefined>;
19
+
20
+ /** One address a reviewer can open the review at. */
21
+ export interface ShareLink {
22
+ /** Stable for the life of this review and never reused, so a page can name one link. */
23
+ id: string;
24
+ /** The bare address, as it appears in the URL. */
25
+ address: string;
26
+ /** The full tokenized URL a reviewer opens. */
27
+ url: string;
28
+ /** The name this link posts under, once its first post has bound it. */
29
+ name?: string;
30
+ }
31
+
32
+ /** What the page and the terminal show about a running share. */
33
+ export interface ShareState {
34
+ /** True while reviewer tokens exist and an address is bound. */
35
+ sharing: boolean;
36
+ /** The port reviewers connect to. */
37
+ port: number;
38
+ /** Every shared link, oldest first. */
39
+ links: ShareLink[];
40
+ /** Why sharing is unavailable, when it is. */
41
+ reason?: string;
42
+ }
43
+
44
+ /** What the page gets back from a share or a stop: the state, plus a QR to render. */
45
+ export interface ShareResult {
46
+ state: ShareState;
47
+ /** Inline SVG for the first link; absent when nothing is shared. */
48
+ svg?: string;
49
+ }
50
+
51
+ /** Why a machine cannot share a review. */
52
+ export const NO_SHARE_REASON =
53
+ "This machine has no network address to share on. Reviewers can still use a loopback link on this computer.";
54
+
55
+ /**
56
+ * IPv4 addresses another machine on this network can reach, deduplicated and
57
+ * sorted, so the same machine prints the same link every time.
58
+ */
59
+ export function lanAddresses(interfaces: NetworkInterfaces): string[] {
60
+ const addresses = new Set<string>();
61
+ for (const entries of Object.values(interfaces)) {
62
+ for (const entry of entries ?? []) {
63
+ if (entry.family !== "IPv4" || entry.internal) continue;
64
+ if (entry.address === "0.0.0.0" || entry.address.startsWith("169.254.")) continue;
65
+ addresses.add(entry.address);
66
+ }
67
+ }
68
+ return [...addresses].sort(byAddress);
69
+ }
70
+
71
+ /** Numeric IPv4 comparison, so `…1.9` sorts before `…1.24`. */
72
+ function byAddress(a: string, b: string): number {
73
+ const left = a.split(".").map(Number);
74
+ const right = b.split(".").map(Number);
75
+ for (let index = 0; index < 4; index++) {
76
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
77
+ if (difference !== 0) return difference;
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ /** The tokenized link for one address. */
83
+ export function shareUrl(address: string, port: number, token: string): string {
84
+ return `http://${address}:${port}/?token=${token}`;
85
+ }
86
+
87
+ /**
88
+ * One link is one person.
89
+ *
90
+ * A shared review holds one reviewer token per link, and the token is the only
91
+ * credential a reviewer has: it names the role, and it binds the name the link
92
+ * posts under on its first write. Two people on one link are one writer, so the
93
+ * table exists to let the owner hand out as many as the team has members — and to
94
+ * take one back without touching the others.
95
+ */
96
+
97
+ /** The most links one review may hold: each is a live bearer credential. */
98
+ export const MAX_SHARE_LINKS = 8;
99
+
100
+ /** Why another link cannot be minted: every link is a live credential. */
101
+ export const AT_CAP_REASON = `This review already holds ${MAX_SHARE_LINKS} links; revoke one before minting another.`;
102
+
103
+ /** One share the table holds: the id a page names it by, and its token. */
104
+ export interface SharedLink {
105
+ /** Stable for the life of the review, and never reused after a revoke. */
106
+ id: string;
107
+ /** The reviewer token this link carries. */
108
+ token: string;
109
+ }
110
+
111
+ /** What the table needs from the review that owns the tokens. */
112
+ export interface LinkTableDeps {
113
+ /** Mint a reviewer token. */
114
+ mint: () => string;
115
+ /** Release a token, so the link that carried it stops answering. */
116
+ revoke: (token: string) => void;
117
+ /** The name a link's token has bound, so the owner can see who holds it. */
118
+ nameOf: (token: string) => string | undefined;
119
+ }
120
+
121
+ /** The reviewer links one running review has shared. */
122
+ export interface LinkTable {
123
+ /** Mint a link on a fresh token, or nothing when the review is at the cap. */
124
+ add(): SharedLink | undefined;
125
+ /** The link with this id, or nothing when the review does not hold it. */
126
+ find(id: string): SharedLink | undefined;
127
+ /** Release one link by id; false when the review does not hold it. */
128
+ remove(id: string): boolean;
129
+ /** The links currently shared, oldest first. */
130
+ list(): readonly SharedLink[];
131
+ /** The state the owner's page and the terminal read. */
132
+ state(port: number, address: string): ShareState;
133
+ }
134
+
135
+ export function createLinkTable(deps: LinkTableDeps): LinkTable {
136
+ const links: SharedLink[] = [];
137
+ let minted = 0;
138
+ return {
139
+ add() {
140
+ if (links.length >= MAX_SHARE_LINKS) return undefined;
141
+ minted += 1;
142
+ const link: SharedLink = { id: `link-${minted}`, token: deps.mint() };
143
+ links.push(link);
144
+ return link;
145
+ },
146
+ find: (id) => links.find((link) => link.id === id),
147
+ remove(id) {
148
+ const index = links.findIndex((link) => link.id === id);
149
+ if (index < 0) return false;
150
+ const [removed] = links.splice(index, 1);
151
+ if (removed) deps.revoke(removed.token);
152
+ return true;
153
+ },
154
+ list: () => [...links],
155
+ state: (port, address) => ({
156
+ sharing: links.length > 0,
157
+ port,
158
+ links: links.map((link) => {
159
+ const name = deps.nameOf(link.token);
160
+ return {
161
+ id: link.id,
162
+ address,
163
+ url: shareUrl(address, port, link.token),
164
+ ...(name === undefined ? {} : { name }),
165
+ };
166
+ }),
167
+ }),
168
+ };
169
+ }