@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The request router for browser plan review.
3
3
  *
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.
4
+ * A request is validated (method, `Host`, origin, token, and — on a route that
5
+ * reads a body — that the body is JSON), matched to a route, and answered with a
6
+ * plain {@link ServeResponse}, so every route can be tested without a socket. The
7
+ * `/events` route is a marker: the node server in `web.ts` keeps that response open
8
+ * as a Server-Sent Events stream.
8
9
  *
9
10
  * The token is a per-session secret carried in the page URL, and on a shared
10
11
  * review it is the only credential a reviewer has: it names the caller — the
@@ -20,6 +21,7 @@ import {
20
21
  pageHeaders,
21
22
  parseRequestUrl,
22
23
  postOnly,
24
+ requireJsonBody,
23
25
  scriptHeaders,
24
26
  type ServeResponse,
25
27
  } from "../../lib/http/index.ts";
@@ -40,7 +42,7 @@ export interface ReviewContext {
40
42
  caller: (token: string) => ReviewCaller | undefined;
41
43
  /** The host names this server answers to now, so a rebound page is refused. */
42
44
  hosts: () => readonly string[];
43
- /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
45
+ /** Whether the browser may approve the plan (only the `plan` tool's `exit` flow). */
44
46
  canApprove: boolean;
45
47
  /** Build the page data for the caller (recomputed per request). */
46
48
  page: (caller: ReviewCaller) => Promise<ReviewPageData>;
@@ -157,7 +159,9 @@ export async function handleReviewRequest(
157
159
  }
158
160
  if (pathname === "/api/share/revoke") {
159
161
  const refusal =
160
- postOnly(method, "revoke a link") ?? roleOnly(caller, ["owner"], "Only the plan owner can revoke a link.");
162
+ postOnly(method, "revoke a link") ??
163
+ requireJsonBody(headers["content-type"]) ??
164
+ roleOnly(caller, ["owner"], "Only the plan owner can revoke a link.");
161
165
  if (refusal) return refusal;
162
166
  const id = linkIdOf(body);
163
167
  if (id === undefined) return jsonError(400, "The request body must name the link's id.");
@@ -169,7 +173,9 @@ export async function handleReviewRequest(
169
173
  // A reviewer's whole surface: persist the comments they wrote and end
170
174
  // nothing. Only the owner's own save decides the review.
171
175
  const refusal =
172
- postOnly(method, "post comments") ?? roleOnly(caller, ["reviewer"], "Only a shared reviewer posts comments.");
176
+ postOnly(method, "post comments") ??
177
+ requireJsonBody(headers["content-type"]) ??
178
+ roleOnly(caller, ["reviewer"], "Only a shared reviewer posts comments.");
173
179
  if (refusal) return refusal;
174
180
  const annotations = parseAnnotations(body);
175
181
  if (isResponse(annotations)) return annotations;
@@ -182,6 +188,7 @@ export async function handleReviewRequest(
182
188
  // `/api/comment`, so this route can never settle a shared review.
183
189
  const refusal =
184
190
  postOnly(method, "save feedback") ??
191
+ requireJsonBody(headers["content-type"]) ??
185
192
  roleOnly(caller, ["owner"], "Only the plan owner saves this review; a reviewer posts comments.");
186
193
  if (refusal) return refusal;
187
194
  const annotations = parseAnnotations(body);
@@ -192,10 +199,12 @@ export async function handleReviewRequest(
192
199
  return jsonResponse(200, { ok: true });
193
200
  }
194
201
  if (pathname === "/api/approve") {
195
- // Only the owner's own page approves, and only the exit_plan_mode flow owns
202
+ // Only the owner's own page approves, and only the `exit` flow owns
196
203
  // the side effects: a reviewer token is refused whatever it posts.
197
204
  const refusal =
198
- postOnly(method, "approve") ?? roleOnly(caller, ["owner"], "A shared review cannot approve the plan.");
205
+ postOnly(method, "approve") ??
206
+ requireJsonBody(headers["content-type"]) ??
207
+ roleOnly(caller, ["owner"], "A shared review cannot approve the plan.");
199
208
  if (refusal) return refusal;
200
209
  if (!ctx.canApprove) return jsonError(403, "This review cannot be approved from the browser.");
201
210
  const annotations = parseAnnotations(body);
@@ -15,10 +15,12 @@
15
15
  export interface SettleDeps {
16
16
  /** The path a write goes to, without its query string. */
17
17
  token(): string;
18
+ /** The review's mount on the session's server, e.g. `/plan/3f9c21aa`. */
19
+ base(): string;
18
20
  role(): string;
19
21
  /** 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
+ mine(): readonly { author?: string }[];
23
+ annotations(): readonly { author?: string }[];
22
24
  /** Whether the review has already ended, by any of the three routes. */
23
25
  settled(): boolean;
24
26
  setStatus(text: string, kind?: string): void;
@@ -82,9 +84,14 @@ export function submitAnnotations(
82
84
  if (onOk) onOk();
83
85
  deps.updateState();
84
86
  // 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.
87
+ // second tab of the same link posts under the same name — and it may normalise
88
+ // what she typed (`@alice` is stored as `alice`). The comments she just posted
89
+ // are in the file under the bound name, so the page's copy has to say that too:
90
+ // otherwise they stop being hers to edit, and her next post would replace
91
+ // markers it never sent.
86
92
  const body = result.body as { author?: unknown } | null;
87
93
  if (deps.role() === "reviewer" && body && typeof body.author === "string" && body.author) {
94
+ for (const annotation of payload) if (annotation.author !== undefined) annotation.author = body.author;
88
95
  deps.adoptName(body.author);
89
96
  }
90
97
  return { outcome: "ok" as const };
@@ -117,7 +124,7 @@ export function saveReview(deps: SettleDeps): Promise<SettleResult> | null {
117
124
  deps.setStatus("Saving\u2026");
118
125
  return submitAnnotations(
119
126
  deps,
120
- "/api/feedback",
127
+ deps.base() + "/api/feedback",
121
128
  "Saved \u2014 return to Pi to continue.",
122
129
  "Could not save: ",
123
130
  function () {
@@ -140,7 +147,7 @@ export function postReviewComments(deps: SettleDeps): Promise<SettleResult> | nu
140
147
  deps.setStatus("Posting\u2026");
141
148
  return submitAnnotations(
142
149
  deps,
143
- "/api/comment",
150
+ deps.base() + "/api/comment",
144
151
  "Comments posted \u2014 the owner sees them now.",
145
152
  "Could not post: ",
146
153
  null,
@@ -153,7 +160,7 @@ export function approveReview(deps: SettleDeps): Promise<SettleResult> | null {
153
160
  deps.setStatus("Approving\u2026");
154
161
  return submitAnnotations(
155
162
  deps,
156
- "/api/approve",
163
+ deps.base() + "/api/approve",
157
164
  "Approved \u2014 return to Pi to continue.",
158
165
  "Could not approve: ",
159
166
  function () {
@@ -180,7 +187,7 @@ export function cancelReview(deps: SettleDeps): Promise<void> | null {
180
187
  deps.setStatus("Closing\u2026");
181
188
  deps.updateState();
182
189
  return deps
183
- .send("/api/cancel?token=" + encodeURIComponent(deps.token()))
190
+ .send(deps.base() + "/api/cancel?token=" + encodeURIComponent(deps.token()))
184
191
  .catch(function () {})
185
192
  .then(function () {
186
193
  deps.setStatus("Closed \u2014 return to Pi.");
@@ -8,31 +8,25 @@
8
8
  * hooks, and the frame hooks, so it never reaches into the session's maps.
9
9
  */
10
10
 
11
- import { LOOPBACK_HOSTS, type LoopbackHandler, type LoopbackServer, serveLoopback } from "../../lib/http/index.ts";
11
+ import { type HttpMount, LOOPBACK_HOSTS, type SharedListener } from "../../lib/http/index.ts";
12
12
  import { qrSvg } from "../../lib/qr.ts";
13
13
  import type { ReviewFrame, ReviewRole } from "./types.ts";
14
14
  import {
15
15
  AT_CAP_REASON,
16
16
  createLinkTable,
17
- lanAddresses,
18
17
  NO_SHARE_REASON,
19
18
  shareUrl,
20
19
  type LinkTable,
21
- type NetworkInterfaces,
22
20
  type ShareResult,
23
21
  type ShareState,
24
22
  } from "./web-share.ts";
25
23
 
26
24
  /** What the share listener needs from the review session it belongs to. */
27
25
  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;
26
+ /** The review's mount on the session's server; sharing borrows its LAN listener. */
27
+ mount: HttpMount;
28
+ /** The mount's prefix, so a reviewer's link names the same page the owner has. */
29
+ base: string;
36
30
  /** Add a reviewer token to the session's role map. */
37
31
  mintReviewer: () => string;
38
32
  /** Remove a reviewer token from the session's role map. */
@@ -64,16 +58,14 @@ export interface ShareSession {
64
58
  }
65
59
 
66
60
  export function createShareSession(deps: ShareSessionDeps): ShareSession {
67
- let address = "";
68
- let port = 0;
69
- let server: LoopbackServer | undefined;
61
+ let shared: SharedListener | undefined;
70
62
  const links: LinkTable = createLinkTable({
71
63
  mint: deps.mintReviewer,
72
64
  revoke: deps.revokeReviewer,
73
65
  nameOf: deps.nameOf,
74
66
  });
75
67
  let reason: string | undefined;
76
- const hosts = (): readonly string[] => (address ? [...LOOPBACK_HOSTS, address] : [...LOOPBACK_HOSTS]);
68
+ const hosts = (): readonly string[] => (shared ? [...LOOPBACK_HOSTS, shared.address] : [...LOOPBACK_HOSTS]);
77
69
 
78
70
  /**
79
71
  * The share surface as it is now, read rather than cached: a link's bound name
@@ -81,42 +73,22 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
81
73
  * another share action.
82
74
  */
83
75
  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
- }
76
+ const state: ShareState =
77
+ shared === undefined
78
+ ? { sharing: false, origin: "", links: [] }
79
+ : links.state(deps.base, shared.origin, shared.address);
80
+ return reason === undefined ? state : { ...state, reason };
104
81
  };
105
82
 
106
83
  /**
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.
84
+ * Ask the session's server for the LAN listener the first time a link needs it, or
85
+ * report why there is nothing to bind to. Every link answers on this same listener, so
86
+ * this runs once per share and the rest is bookkeeping.
110
87
  */
111
88
  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) {
89
+ if (shared !== undefined) return true;
90
+ shared = await deps.mount.share();
91
+ if (shared === undefined) {
120
92
  reason = NO_SHARE_REASON;
121
93
  return false;
122
94
  }
@@ -129,7 +101,7 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
129
101
  deps.broadcast("share", "owner");
130
102
  const state = snapshot();
131
103
  if (minted === undefined) return { state };
132
- return { state, svg: await qrSvg(shareUrl(address, port, minted)) };
104
+ return { state, svg: await qrSvg(shareUrl(deps.base, shared?.origin ?? "", minted)) };
133
105
  };
134
106
 
135
107
  const share = async (): Promise<ShareResult> => {
@@ -172,19 +144,16 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
172
144
  // The reviewers are told before their listener goes away; the review itself
173
145
  // stays open for its owner.
174
146
  deps.endStreams("revoked", { role: "reviewer" });
175
- const closer = server;
176
- server = undefined;
177
- address = "";
178
- port = 0;
147
+ shared = undefined;
179
148
  reason = undefined;
180
- if (closer) await closer.close();
149
+ await deps.mount.unshare();
181
150
  return settle();
182
151
  };
183
152
 
184
153
  const close = async (): Promise<void> => {
185
- const closer = server;
186
- server = undefined;
187
- if (closer) await closer.close();
154
+ if (shared === undefined) return;
155
+ shared = undefined;
156
+ await deps.mount.unshare();
188
157
  };
189
158
 
190
159
  return { share, invite, revoke, stop, state: snapshot, hosts, close };
@@ -12,10 +12,12 @@
12
12
  * failed. Everything else would print a link that cannot work.
13
13
  */
14
14
 
15
- import type { NetworkInterfaceInfo } from "node:os";
15
+ import { lanAddresses, type NetworkInterfaces } from "../../lib/http/index.ts";
16
16
 
17
- /** The interfaces a review may be shared to, by name. */
18
- export type NetworkInterfaces = Record<string, NetworkInterfaceInfo[] | undefined>;
17
+ /** The interfaces a review may be shared to, by name; `lib/http` owns the rule. */
18
+ export type { NetworkInterfaces };
19
+
20
+ export { lanAddresses };
19
21
 
20
22
  /** One address a reviewer can open the review at. */
21
23
  export interface ShareLink {
@@ -33,8 +35,12 @@ export interface ShareLink {
33
35
  export interface ShareState {
34
36
  /** True while reviewer tokens exist and an address is bound. */
35
37
  sharing: boolean;
36
- /** The port reviewers connect to. */
37
- port: number;
38
+ /**
39
+ * The origin reviewers connect to, `http://192.168.1.24:4780`, or `""` when nothing is
40
+ * shared. The port is the listener's own, which is why it is reported with its address
41
+ * rather than as a number the review could have guessed.
42
+ */
43
+ origin: string;
38
44
  /** Every shared link, oldest first. */
39
45
  links: ShareLink[];
40
46
  /** Why sharing is unavailable, when it is. */
@@ -53,35 +59,11 @@ export const NO_SHARE_REASON =
53
59
  "This machine has no network address to share on. Reviewers can still use a loopback link on this computer.";
54
60
 
55
61
  /**
56
- * IPv4 addresses another machine on this network can reach, deduplicated and
57
- * sorted, so the same machine prints the same link every time.
62
+ * The tokenized link for one reviewer: the origin the page answers on, its mount inside
63
+ * that origin, and the token the link carries.
58
64
  */
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}`;
65
+ export function shareUrl(base: string, origin: string, token: string): string {
66
+ return `${origin}${base}/?token=${token}`;
85
67
  }
86
68
 
87
69
  /**
@@ -129,7 +111,7 @@ export interface LinkTable {
129
111
  /** The links currently shared, oldest first. */
130
112
  list(): readonly SharedLink[];
131
113
  /** The state the owner's page and the terminal read. */
132
- state(port: number, address: string): ShareState;
114
+ state(base: string, origin: string, address: string): ShareState;
133
115
  }
134
116
 
135
117
  export function createLinkTable(deps: LinkTableDeps): LinkTable {
@@ -152,15 +134,15 @@ export function createLinkTable(deps: LinkTableDeps): LinkTable {
152
134
  return true;
153
135
  },
154
136
  list: () => [...links],
155
- state: (port, address) => ({
137
+ state: (base, origin, address) => ({
156
138
  sharing: links.length > 0,
157
- port,
139
+ origin,
158
140
  links: links.map((link) => {
159
141
  const name = deps.nameOf(link.token);
160
142
  return {
161
143
  id: link.id,
162
144
  address,
163
- url: shareUrl(address, port, link.token),
145
+ url: shareUrl(base, origin, link.token),
164
146
  ...(name === undefined ? {} : { name }),
165
147
  };
166
148
  }),
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The review's two write paths, over the plan store.
3
+ *
4
+ * An owner's save decides the review and returns to Pi; a reviewer's post only writes,
5
+ * under the name its link has bound. Both write markers through the one codec
6
+ * (`feedback.ts`), both refuse a file that changed under the page, and neither touches
7
+ * another writer's markers — the store replaces exactly the writer it is handed.
8
+ *
9
+ * Splitting them out of `web.ts` is what leaves that module the session and its sockets:
10
+ * every request that writes the plan comes through here, so a change to the marker
11
+ * grammar or to the refusal wording has one file to land in.
12
+ */
13
+
14
+ import { claimAuthor, normalizeAuthor, stripFeedback, type PlanAnnotation } from "./feedback.ts";
15
+ import type { PlanStore, StoredPlan } from "./plans.ts";
16
+ import type { ReviewCaller } from "./types.ts";
17
+ import type { SaveResult } from "./web-router.ts";
18
+
19
+ /** What a write path reads: the store, and the anchor a save is validated against. */
20
+ export interface WriteDeps {
21
+ /** The plan store the markers are written through. */
22
+ store: PlanStore;
23
+ /** The directory the store resolves its paths against. */
24
+ cwd: string;
25
+ /** The plan's path, as the store knows it. */
26
+ path: string;
27
+ /**
28
+ * The stripped text the page rendered. A file whose body no longer matches is a
29
+ * `409`: the comments would otherwise land beside text nobody read.
30
+ */
31
+ pageText(): string;
32
+ }
33
+
34
+ /** An owner's save: the plan as it was written, or why it was refused. */
35
+ export type OwnerWrite = { ok: true; plan: StoredPlan } | { ok: false; status: number; message: string };
36
+
37
+ /** Every refusal both paths share, before anyone's markers are touched. */
38
+ const GONE = { ok: false, status: 404, message: "The plan file is gone." } as const;
39
+ const MOVED = {
40
+ ok: false,
41
+ status: 409,
42
+ message: "The plan changed on disk; reload the page and review again.",
43
+ } as const;
44
+
45
+ /** The file as it is now, when it still matches what the page rendered. */
46
+ async function verified(
47
+ deps: WriteDeps,
48
+ ): Promise<{ ok: true; plan: StoredPlan } | { ok: false; status: number; message: string }> {
49
+ const current = await deps.store.read(deps.cwd, deps.path);
50
+ if (!current) return GONE;
51
+ if (stripFeedback(current.content) !== deps.pageText()) return MOVED;
52
+ return { ok: true, plan: current };
53
+ }
54
+
55
+ /**
56
+ * Save the owner's comments and report the plan they were written into.
57
+ *
58
+ * The caller decides what a save means (it ends the review); this path only writes, so
59
+ * the decision and the write cannot disagree about which file was saved.
60
+ */
61
+ export async function saveOwnerAnnotations(
62
+ deps: WriteDeps,
63
+ annotations: readonly PlanAnnotation[],
64
+ ): Promise<OwnerWrite> {
65
+ const check = await verified(deps);
66
+ if (!check.ok) return check;
67
+ try {
68
+ const plan = await deps.store.saveFeedback(deps.cwd, deps.path, [...annotations]);
69
+ return { ok: true, plan };
70
+ } catch (error) {
71
+ return { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) };
72
+ }
73
+ }
74
+
75
+ /** What a reviewer's post additionally needs: who posted, and which names are taken. */
76
+ export interface ReviewerWriteDeps extends WriteDeps {
77
+ /** The caller the link names; its `name` is the one the link has bound, if any. */
78
+ caller: ReviewCaller;
79
+ /** Names other live links hold, so a first post cannot claim one of them. */
80
+ taken: readonly string[];
81
+ }
82
+
83
+ /** A reviewer's post: the name it stored under, so a page can adopt it. */
84
+ export type ReviewerWrite = { save: SaveResult; count: number };
85
+
86
+ /**
87
+ * Write one reviewer's comments, signed and scoped to that link.
88
+ *
89
+ * The link, not the payload, decides whose markers this replaces: a page that signs
90
+ * another name is a stale or foreign tab, and re-signing it would move markers that
91
+ * belong to whoever picked that name. Only that writer's annotations are written, so a
92
+ * post cannot disturb the owner's notes or a second reviewer's.
93
+ */
94
+ export async function postReviewerAnnotations(
95
+ deps: ReviewerWriteDeps,
96
+ annotations: readonly PlanAnnotation[],
97
+ ): Promise<ReviewerWrite> {
98
+ const check = await verified(deps);
99
+ if (!check.ok) return { save: check, count: 0 };
100
+ const claim = claimAuthor({ bound: deps.caller.name, taken: deps.taken }, annotations);
101
+ if (!("name" in claim)) return { save: { ok: false, status: claim.status, message: claim.message }, count: 0 };
102
+ const own = annotations.filter((annotation) => normalizeAuthor(annotation.author) === claim.name);
103
+ try {
104
+ await deps.store.saveFeedback(deps.cwd, deps.path, own, { author: claim.name });
105
+ return { save: { ok: true, author: claim.name }, count: own.length };
106
+ } catch (error) {
107
+ return {
108
+ save: { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) },
109
+ count: 0,
110
+ };
111
+ }
112
+ }