@gavin-hu/my-pi-agent 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/README.md +52 -44
  2. package/README.zh-CN.md +46 -41
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +554 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +61 -50
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +258 -162
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/panel/commands.ts +40 -7
  51. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  52. package/extensions/bytefly/panel/settings.ts +3 -20
  53. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  54. package/extensions/bytefly/picker/discovery.ts +0 -2
  55. package/extensions/bytefly/picker/types.ts +0 -2
  56. package/extensions/bytefly/update/check.ts +39 -0
  57. package/extensions/bytefly/update/format.ts +15 -0
  58. package/extensions/bytefly/update/register.ts +73 -34
  59. package/extensions/bytefly/update/state.ts +6 -3
  60. package/extensions/context/tui.ts +1 -1
  61. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  62. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  63. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  64. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  65. package/extensions/file-browse/index.ts +4 -4
  66. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  67. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
  68. package/extensions/file-browse/transport/server.ts +110 -0
  69. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  70. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  71. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  72. package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
  73. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  74. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  75. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  76. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  77. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  78. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  79. package/extensions/file-read/schema.ts +2 -1
  80. package/extensions/memory/store.ts +12 -5
  81. package/extensions/plan/blocks.ts +23 -2
  82. package/extensions/plan/commands.ts +21 -16
  83. package/extensions/plan/feedback.ts +147 -38
  84. package/extensions/plan/index.ts +6 -4
  85. package/extensions/plan/plan-index.ts +43 -9
  86. package/extensions/plan/plan-text.ts +33 -23
  87. package/extensions/plan/plans.ts +8 -11
  88. package/extensions/plan/policy.ts +20 -12
  89. package/extensions/plan/review.ts +3 -27
  90. package/extensions/plan/runtime.ts +21 -9
  91. package/extensions/plan/sections.ts +112 -0
  92. package/extensions/plan/steps.ts +3 -22
  93. package/extensions/plan/tools/exit.ts +11 -95
  94. package/extensions/plan/tui.ts +5 -34
  95. package/extensions/plan/types.ts +0 -8
  96. package/extensions/plan/web-client.ts +1 -1
  97. package/extensions/plan/web-page.ts +0 -7
  98. package/extensions/plan/web-router.ts +3 -57
  99. package/extensions/plan/web-share-session.ts +24 -30
  100. package/extensions/plan/web-streams.ts +14 -14
  101. package/extensions/plan/web-tokens.ts +2 -2
  102. package/extensions/plan/web.ts +66 -120
  103. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  104. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  105. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  106. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  107. package/extensions/rewind/index.ts +3 -3
  108. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  109. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  110. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  111. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  112. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  113. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  114. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  115. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  116. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  117. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  118. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  119. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  120. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  121. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  122. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  123. package/extensions/session-bus/commands.ts +17 -69
  124. package/extensions/session-bus/format.ts +22 -45
  125. package/extensions/session-bus/index.ts +13 -7
  126. package/extensions/session-bus/join-request.ts +40 -0
  127. package/extensions/session-bus/mail-watch.ts +134 -0
  128. package/extensions/session-bus/notice.ts +32 -4
  129. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  130. package/extensions/session-bus/protocol.ts +19 -49
  131. package/extensions/session-bus/resolve.ts +67 -0
  132. package/extensions/session-bus/result.ts +16 -51
  133. package/extensions/session-bus/rows.ts +4 -2
  134. package/extensions/session-bus/runtime.ts +129 -238
  135. package/extensions/session-bus/store.ts +42 -6
  136. package/extensions/session-bus/tools.ts +31 -66
  137. package/extensions/session-bus/tui.ts +4 -4
  138. package/extensions/session-bus/types.ts +13 -13
  139. package/extensions/subagent/agents.ts +9 -8
  140. package/extensions/subagent/invocation.ts +4 -2
  141. package/extensions/team/commands.ts +112 -0
  142. package/extensions/team/dispatch.ts +133 -0
  143. package/extensions/team/format.ts +105 -0
  144. package/extensions/team/index.ts +33 -0
  145. package/extensions/team/schema.ts +29 -0
  146. package/extensions/team/tools.ts +246 -0
  147. package/extensions/team/types.ts +48 -0
  148. package/extensions/web-access/fetch/page.ts +4 -1
  149. package/extensions/web-access/fetch/structured.ts +447 -0
  150. package/extensions/web-access/fetch/tool.ts +2 -1
  151. package/extensions/web-access/search/tool.ts +2 -1
  152. package/extensions/wechat/bridge.ts +76 -92
  153. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  154. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  155. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  156. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  157. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  158. package/extensions/wechat/dialog/channels.ts +136 -0
  159. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  160. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  161. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  162. package/extensions/wechat/index.ts +10 -10
  163. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  164. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  165. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  166. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  167. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  168. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  169. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  170. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  171. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  172. package/extensions/wechat/tools.ts +29 -12
  173. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  174. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  175. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  176. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  177. package/extensions/wechat/transport/outbound.ts +142 -0
  178. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  179. package/extensions/wechat/types.ts +10 -1
  180. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  181. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  182. package/extensions/worktree/guard.ts +3 -5
  183. package/lib/README.md +8 -6
  184. package/lib/board/card.ts +290 -0
  185. package/lib/board/links.ts +117 -0
  186. package/lib/board/page-pointer.ts +256 -0
  187. package/lib/board/round-text.ts +72 -0
  188. package/lib/board/round.ts +89 -0
  189. package/lib/board/rules.ts +304 -0
  190. package/lib/board/session.ts +105 -0
  191. package/lib/board/store.ts +310 -0
  192. package/lib/board/view.ts +150 -0
  193. package/lib/errors.ts +12 -4
  194. package/lib/format.ts +13 -0
  195. package/lib/http/guard.ts +112 -0
  196. package/lib/http/index.ts +16 -5
  197. package/lib/http/serve.ts +234 -0
  198. package/lib/http/server.ts +12 -10
  199. package/lib/http/sink.ts +110 -0
  200. package/lib/http/token.ts +38 -0
  201. package/lib/policy.ts +12 -2
  202. package/lib/requirements.ts +6 -7
  203. package/lib/session-label.ts +64 -0
  204. package/lib/session-roster.ts +130 -0
  205. package/lib/session-tool.ts +184 -0
  206. package/lib/tool-names.ts +25 -2
  207. package/lib/tui/glyphs.ts +2 -0
  208. package/lib/web/README.md +5 -2
  209. package/lib/web/components.ts +5 -0
  210. package/lib/web/runtime.ts +7 -1
  211. package/lib/web/tokens.ts +25 -2
  212. package/package.json +26 -14
  213. package/skills/next/SKILL.md +4 -3
  214. package/skills/project-docs/SKILL.md +133 -0
  215. package/skills/project-docs/references/audit.md +131 -0
  216. package/skills/project-docs/references/changelog.md +105 -0
  217. package/skills/project-docs/references/discovery.md +127 -0
  218. package/skills/project-docs/references/readme.md +80 -0
  219. package/skills/project-docs/references/release.md +84 -0
  220. package/skills/project-docs/references/roadmap.md +93 -0
  221. package/skills/project-docs/scripts/state.ts +316 -0
  222. package/skills/refactor/SKILL.md +100 -125
  223. package/skills/refactor/references/handoff.md +35 -0
  224. package/skills/refactor/references/signals.md +54 -0
  225. package/skills/refactor/scripts/inventory.ts +587 -0
  226. package/extensions/file-browse/README.md +0 -506
  227. package/extensions/file-browse/server.ts +0 -141
  228. package/extensions/plan/split.ts +0 -87
  229. package/extensions/session-bus/README.md +0 -247
  230. package/extensions/session-bus/team.ts +0 -102
  231. package/extensions/wechat/outbound.ts +0 -100
  232. package/lib/plans/sections.ts +0 -172
  233. package/lib/plans/setting.ts +0 -52
  234. package/lib/plans/split.ts +0 -215
  235. package/lib/plans/store.ts +0 -19
  236. package/lib/plans/team.ts +0 -151
  237. package/skills/changelog/SKILL.md +0 -148
  238. package/skills/parallel-dev/SKILL.md +0 -150
  239. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  240. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  241. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  242. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  243. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  244. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  245. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  246. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  247. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  248. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  249. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -25,7 +25,6 @@ import {
25
25
  WEB_LAYOUT_CSS,
26
26
  WEB_THEME_CSS,
27
27
  } from "../../lib/web/index.ts";
28
- import { escapeHtml } from "../../lib/escape.ts";
29
28
  import type { PlanBlock } from "./blocks.ts";
30
29
  import type { PlanAnnotation } from "./feedback.ts";
31
30
  import type { ReviewRole } from "./types.ts";
@@ -78,11 +77,6 @@ export interface ReviewPageData {
78
77
  * whether its name control is still a choice.
79
78
  */
80
79
  author?: string;
81
- /**
82
- * One line the owner needs before acting, such as why approval happens in the
83
- * terminal; absent when the page needs no caveat.
84
- */
85
- note?: string;
86
80
  /** A running share, so a reload re-renders the link the owner already minted. */
87
81
  share?: ShareState;
88
82
  /** Per-session secret the client echoes on every request. */
@@ -322,7 +316,6 @@ export function renderReviewPage(data: ReviewPageData): string {
322
316
  <div class="bf-panel review-panel">
323
317
  ${reviewFacts()}
324
318
  <nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
325
- ${data.note ? `<p class="review-hint">${escapeHtml(data.note)}</p>` : ""}
326
319
  ${reviewHints(data.role, data.canApprove)}
327
320
  </div>
328
321
  </details>
@@ -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,7 +115,7 @@ 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);
@@ -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 {
@@ -6,39 +6,38 @@
6
6
  * for one plan, opens the default browser at a tokenized URL, and resolves
7
7
  * `done` when the user saves feedback, cancels, closes the tab (detected by the
8
8
  * event stream dropping), or aborts the turn. The route logic lives in the pure
9
- * `web-router.ts`; this module is the only part that owns a socket and timers,
10
- * and it takes injected seams so tests can run one on `127.0.0.1:0`.
9
+ * `web-router.ts`; this module owns the routes and the timers, `lib/http` owns
10
+ * the socket, and it takes injected seams so tests can run one on `127.0.0.1:0`.
11
11
  */
12
12
 
13
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
14
13
  import { networkInterfaces } from "node:os";
15
14
  import { basename } from "node:path";
16
15
  import { openInBrowser } from "../../lib/browser.ts";
17
16
  import {
18
- bindLoopback,
19
- createCloser,
20
17
  jsonHeaders,
21
- openEventStream,
18
+ type LoopbackRequest,
19
+ type LoopbackReply,
20
+ type LoopbackServer,
22
21
  parseRequestUrl,
23
- respond,
22
+ serveLoopback,
24
23
  type ServeResponse,
25
24
  } from "../../lib/http/index.ts";
26
25
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
27
26
  import { type ArmTimer, defaultInterval, defaultTimer, type RepeatTimer } from "../../lib/timer.ts";
28
- import { codeBody, fenceInfo, planBlocks } from "./blocks.ts";
27
+ import { codeBody, fenceInfo } from "./blocks.ts";
29
28
  import {
30
29
  claimAuthor,
31
30
  MAX_ANNOTATIONS,
32
31
  MAX_COMMENT_LENGTH,
33
32
  MAX_QUOTE_LENGTH,
34
33
  normalizeAuthor,
35
- parseFeedback,
34
+ readFeedback,
36
35
  stripFeedback,
37
36
  type PlanAnnotation,
38
37
  } from "./feedback.ts";
39
38
  import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
40
39
  import { changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
41
- import { extractPlanSections, extractPlanSteps } from "./steps.ts";
40
+ import { extractPlanSteps } from "./steps.ts";
42
41
  import type { ReviewCaller } from "./types.ts";
43
42
  import { handleReviewRequest, type ReviewContext, type SaveResult } from "./web-router.ts";
44
43
  import type { ReviewPageData } from "./web-page.ts";
@@ -71,8 +70,6 @@ export interface PlanReviewOptions {
71
70
  signal?: AbortSignal;
72
71
  /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
73
72
  canApprove?: boolean;
74
- /** A line the page shows the owner, such as why approval belongs in the terminal. */
75
- note?: string;
76
73
  /** The section digests the previous review showed, so this page can mark changed sections. */
77
74
  baseline?: SectionDigest[];
78
75
  /**
@@ -143,40 +140,6 @@ const DROP_GRACE_MS = 1500;
143
140
  */
144
141
  const PLAN_POLL_MS = 1000;
145
142
 
146
- /** A request body, or the fact that it was too large to read. */
147
- type BodyResult = { ok: true; body: string } | { ok: false };
148
-
149
- /**
150
- * Read a request body, stopping at {@link MAX_BODY_BYTES}. An oversize body is
151
- * reported rather than destroyed here, so the handler can still answer 413 over
152
- * the open socket instead of the client seeing a dropped connection.
153
- */
154
- function readBody(request: IncomingMessage, limit: number): Promise<BodyResult> {
155
- return new Promise((resolve) => {
156
- const chunks: Buffer[] = [];
157
- let size = 0;
158
- let done = false;
159
- const settle = (result: BodyResult): void => {
160
- if (done) return;
161
- done = true;
162
- resolve(result);
163
- };
164
- request.on("data", (chunk: Buffer) => {
165
- if (done) return;
166
- size += chunk.length;
167
- if (size > limit) {
168
- chunks.length = 0;
169
- request.pause();
170
- settle({ ok: false });
171
- return;
172
- }
173
- chunks.push(chunk);
174
- });
175
- request.on("end", () => settle({ ok: true, body: Buffer.concat(chunks).toString("utf-8") }));
176
- request.on("error", () => settle({ ok: false }));
177
- });
178
- }
179
-
180
143
  export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
181
144
  const sessions = new Set<PlanReviewSession>();
182
145
  // What each running review can do about sharing; the session itself stays the
@@ -193,7 +156,9 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
193
156
  ): Promise<PlanReviewSession | undefined> => {
194
157
  const { signal } = options;
195
158
  const canApprove = options.canApprove === true;
196
- const maxBody = deps.maxBodyBytes ?? MAX_BODY_BYTES;
159
+ // One cap for both listeners: a reviewer's page posts the same bodies as the
160
+ // owner's, so the shared one cannot be the stricter or the looser of the two.
161
+ const bodyLimit = deps.maxBodyBytes ?? MAX_BODY_BYTES;
197
162
  // One token per holder: the owner's is minted here and never shared, and a
198
163
  // reviewer token is minted by `share()`. The table owns the collision rule and
199
164
  // the name each link has bound.
@@ -279,13 +244,16 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
279
244
  const page = async (caller: ReviewCaller): Promise<ReviewPageData> => {
280
245
  const { role } = caller;
281
246
  const stored = await refreshPageText();
282
- const current = pageText;
283
- const blocks = planBlocks(current);
284
- const sections = extractPlanSections(current);
285
- // Per-occurrence change flags, aligned with the sections list: the same shape
286
- // the terminal review renders, so duplicate headings cannot both light up.
287
- const changed = changedSections(options.baseline, current);
288
- const outline = sections.flatMap((section, index) =>
247
+ // The body, its blocks, and the comments out of one read of the file: an
248
+ // anchor names a block from this same parse, so the island cannot mark a
249
+ // comment onto a line the page never rendered.
250
+ const read = readFeedback(stored.content);
251
+ const current = read.body;
252
+ const blocks = read.blocks;
253
+ // Per-section change marks, each carrying its own section: the same shape the
254
+ // terminal review renders, so a heading and its mark cannot be paired wrongly.
255
+ const marks = changedSections(options.baseline, current);
256
+ const outline = marks.flatMap(({ section, changed }) =>
289
257
  section.title === ""
290
258
  ? []
291
259
  : [
@@ -294,7 +262,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
294
262
  line: section.line,
295
263
  level: section.level,
296
264
  text: section.title,
297
- ...(changed[index] ? { changed: true } : {}),
265
+ ...(changed ? { changed: true } : {}),
298
266
  },
299
267
  ],
300
268
  );
@@ -312,9 +280,9 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
312
280
  return {
313
281
  // The document's own H1 reads better than the file-name slug, and the
314
282
  // slug is still the fallback for a plan that starts with something else.
315
- title: sections.find((section) => section.level === 1)?.title || planTitle(basename(plan.path)),
283
+ title: marks.find((mark) => mark.section.level === 1)?.section.title || planTitle(basename(plan.path)),
316
284
  blocks,
317
- annotations: parseFeedback(stored.content),
285
+ annotations: read.annotations,
318
286
  outline,
319
287
  steps: {
320
288
  done: stepList.filter((step) => step.status === "completed").length,
@@ -328,7 +296,6 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
328
296
  rev: planDigest(current),
329
297
  ...(caller.name === undefined ? {} : { author: caller.name }),
330
298
  canApprove: role === "owner" && canApprove,
331
- ...(role === "owner" && options.note !== undefined ? { note: options.note } : {}),
332
299
  share: role === "owner" ? shareSession.state() : undefined,
333
300
  };
334
301
  };
@@ -376,6 +343,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
376
343
  // frame hooks, so sharing stays one concern instead of five fields here.
377
344
  const shareSession = createShareSession({
378
345
  handleRequest,
346
+ maxBodyBytes: bodyLimit,
379
347
  ownerPort: () => currentPort,
380
348
  networkInterfaces: deps.networkInterfaces ?? networkInterfaces,
381
349
  mintReviewer: () => tokens.mint("reviewer"),
@@ -410,78 +378,56 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
410
378
  // Live event streams, used only to notice a closed owner tab: a reviewer's
411
379
  // tab is not the review's lifetime. The explicit Cancel button and the abort
412
380
  // signal are the deterministic paths.
413
- const server = createServer(handleRequest);
414
381
  /** One request, on either listener: the loopback page or a shared one. */
415
- function handleRequest(request: IncomingMessage, response: ServerResponse): void {
416
- void (async () => {
417
- const method = request.method ?? "GET";
418
- const read =
419
- method.toUpperCase() === "POST" ? await readBody(request, maxBody) : { ok: true as const, body: "" };
420
- if (!read.ok) {
421
- response.writeHead(413, { ...jsonHeaders(), Connection: "close" });
422
- response.end(JSON.stringify({ error: "The request body is too large." }));
423
- return;
424
- }
425
- const body = read.body;
426
- let result: ServeResponse;
427
- try {
428
- result = await handleReviewRequest(context, method, request.url ?? "/", request.headers, body);
429
- } catch {
430
- result = {
431
- status: 500,
432
- headers: jsonHeaders(),
433
- body: JSON.stringify({ error: "Internal error" }),
434
- };
435
- }
436
- if (result.stream) {
437
- openEventStream(response, result);
438
- // A page that walks away mid-frame must not take the session down.
439
- response.on("error", () => {});
440
- const token = parseRequestUrl(request.url ?? "/")?.search.get("token") ?? "";
441
- const streamCaller = context.caller(token);
442
- if (streamCaller !== undefined) streams.add(response, { role: streamCaller.role, token });
443
- request.on("close", () => streams.remove(response));
444
- return;
445
- }
446
- // A save or cancel only resolves the session after its response is on the
447
- // wire; otherwise `close()` would force-close the socket mid-reply. The
448
- // `close` path covers a client that walks away before the flush callback
449
- // runs, so a settle cannot be lost with the socket.
450
- const settlePending = (): void => {
451
- const terminal = pendingResult;
452
- if (!terminal) return;
453
- pendingResult = undefined;
454
- finish(terminal);
382
+ async function handleRequest(request: LoopbackRequest): Promise<LoopbackReply> {
383
+ let result: ServeResponse;
384
+ try {
385
+ result = await handleReviewRequest(context, request.method, request.url, request.headers, request.body);
386
+ } catch {
387
+ result = {
388
+ status: 500,
389
+ headers: jsonHeaders(),
390
+ body: JSON.stringify({ error: "Internal error" }),
455
391
  };
456
- respond(request, response, result, settlePending);
457
- response.on("close", settlePending);
458
- })();
392
+ }
393
+ // A save or cancel only resolves the session after its response is on the
394
+ // wire; otherwise `close()` would force-close the socket mid-reply. The same
395
+ // hook covers a client that walks away before the flush, so a settle cannot be
396
+ // lost with the socket.
397
+ const settlePending = (): void => {
398
+ const terminal = pendingResult;
399
+ if (!terminal) return;
400
+ pendingResult = undefined;
401
+ finish(terminal);
402
+ };
403
+ if (!result.stream) return { result, afterResponse: settlePending };
404
+ const token = parseRequestUrl(request.url)?.search.get("token") ?? "";
405
+ const streamCaller = context.caller(token);
406
+ return {
407
+ result,
408
+ openSink: (sink) => {
409
+ if (streamCaller !== undefined) streams.add(sink, { role: streamCaller.role, token });
410
+ return () => streams.remove(sink);
411
+ },
412
+ };
459
413
  }
460
414
 
461
- // The accept-level `error` listener lives in `bindHost`, so a transient
462
- // failure cannot take the host down; the review still ends through its own
463
- // paths: cancel, abort, drop, or shutdown.
464
415
  const preferred = deps.port ?? ports.get(plan.path) ?? 0;
465
- let port: number | undefined;
416
+ let server: LoopbackServer;
466
417
  try {
467
- port = await bindLoopback(server, preferred);
418
+ server = await serveLoopback({
419
+ ports: preferred === 0 ? [0] : [preferred, 0],
420
+ maxBodyBytes: bodyLimit,
421
+ handler: handleRequest,
422
+ });
468
423
  } catch {
469
- // The remembered port may have been taken between turns; fall back once.
470
- if (preferred !== 0) {
471
- try {
472
- port = await bindLoopback(server, 0);
473
- } catch {
474
- port = undefined;
475
- }
476
- }
477
- }
478
- if (port === undefined) {
479
- server.close();
424
+ // The remembered port may have been taken between turns; the fallback above
425
+ // covers that, so reaching here means neither port answered.
480
426
  return undefined;
481
427
  }
428
+ const port = server.port;
482
429
  ports.set(plan.path, port);
483
430
  currentPort = port;
484
- const closeServer = createCloser(server);
485
431
 
486
432
  const url = `http://127.0.0.1:${port}/?token=${token}`;
487
433
  let closed = false;
@@ -498,7 +444,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
498
444
  stopTicker();
499
445
  streams.clear();
500
446
  await shareSession.close();
501
- await closeServer();
447
+ await server.close();
502
448
  };
503
449
 
504
450
  const session: PlanReviewSession = { url, port, done, close };
@@ -8,13 +8,13 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
- import { withRailsSuppressed } from "../../lib/rails.ts";
12
- import { formatRewindListText, formatRewindRow } from "./format.ts";
11
+ import { withRailsSuppressed } from "../../../lib/rails.ts";
12
+ import { formatRewindListText, formatRewindRow } from "../text/format.ts";
13
13
  import { openReview } from "./review.ts";
14
14
  import { rewindTo } from "./rewind.ts";
15
- import type { RewindRuntime } from "./runtime.ts";
16
- import { RewindListComponent } from "./rewind-tui.ts";
17
- import { buildRewindPoints, type RewindPoint } from "./timeline.ts";
15
+ import type { RewindRuntime } from "../state/runtime.ts";
16
+ import { RewindListComponent } from "../view/rewind-tui.ts";
17
+ import { buildRewindPoints, type RewindPoint } from "../state/timeline.ts";
18
18
 
19
19
  /** Pick a point from the timeline, or undefined when the user cancels. */
20
20
  async function choosePoint(ctx: ExtensionCommandContext, points: RewindPoint[]): Promise<RewindPoint | undefined> {