@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The review page's plan renderer: the DOM a frame draws, thrown away and rebuilt.
3
+ *
4
+ * The seventh capability out of the review client's template literal, and the one with the
5
+ * most to read: it builds every block, its comment rail, and the cards under it, then puts
6
+ * the reader back where they were. Reads arrive through getters and mutations leave through
7
+ * callbacks, so the client keeps every variable it owns — `activeIndex`, the quote a click
8
+ * is waiting for, the observer — and this function holds only the shape of one frame.
9
+ *
10
+ * Two rules it owns, both about a repaint not stealing the reader's place:
11
+ *
12
+ * - focus is restored only when it was inside the plan to begin with, so a frame the stream
13
+ * caused cannot pull the keyboard out of the page the reviewer is working in;
14
+ * - the active block's class is put back in place rather than by scrolling, so the arrow
15
+ * keys resume where they were instead of jumping to the top.
16
+ */
17
+
18
+ import type { ClientNode } from "../../lib/web/client-runtime.ts";
19
+ import type { ComposerDeps } from "./web-composer.ts";
20
+ import { blockElements, type IslandBlock, setActive } from "./web-blocks.ts";
21
+ import type { SectionWatcher } from "./web-outline.ts";
22
+
23
+ /** What `el` has to answer for a frame. Spliced beside it; declared for the checker. */
24
+ type Build = (tag: string, className?: string, text?: string | null) => ClientNode;
25
+ declare const el: Build;
26
+
27
+ /** The quote a click on a block's control is waiting for: the reader's selection. */
28
+ export interface PendingQuote {
29
+ wrap: ClientNode;
30
+ quote: string;
31
+ }
32
+
33
+ /** What a repaint reads, and the callbacks it changes the client's state through. */
34
+ export interface PlanRenderDeps {
35
+ planEl: ClientNode | null;
36
+ outlineList: ClientNode | null;
37
+ /** The element that had focus before this frame, or null when nothing did. */
38
+ focused: ClientNode | null;
39
+ blocks(): readonly IslandBlock[];
40
+ /** Whether this block is new or edited since the previous review; absent means no marks. */
41
+ changed?(line: number): boolean;
42
+ annotations(): readonly { anchor: number }[];
43
+ activeIndex(): number;
44
+ setActiveIndex(index: number): void;
45
+ pendingQuote(): PendingQuote | null;
46
+ clearPendingQuote(): void;
47
+ /** Whether the review has ended, so the rail offers no new comment. */
48
+ settled?(): boolean;
49
+ /** The composer lifecycle, for the close a repaint begins with. */
50
+ composer(): ComposerDeps;
51
+ /** The block's content, from the Markdown half. */
52
+ renderBlock(wrap: ClientNode, block: IslandBlock): void;
53
+ /** The cards under one block, and the loose ones that lost their anchor. */
54
+ renderComments(scope: ClientNode, line: number): void;
55
+ renderCommentCards(scope: ClientNode, list: readonly unknown[]): void;
56
+ /** The text a reader selected inside a block, used as the next comment's quote. */
57
+ selectionIn(wrap: ClientNode): string;
58
+ open(wrap: ClientNode, block: IslandBlock, quote: string): void;
59
+ /** The facts and the header chip, repainted after the blocks. */
60
+ facts(): void;
61
+ updateState(): void;
62
+ }
63
+
64
+ /**
65
+ * Draw the plan, and put the reader back. Returns the section watcher it installed.
66
+ *
67
+ * The client's own zero-argument entry is `renderAll()`: it builds the frame's deps and
68
+ * hands them here, which is how the fragments keep calling a name that takes nothing.
69
+ */
70
+ export function renderPlanFrame(
71
+ deps: PlanRenderDeps,
72
+ closePlan: () => void,
73
+ observe: (
74
+ planEl: ClientNode | null,
75
+ list: ClientNode | null,
76
+ previous: SectionWatcher | null,
77
+ ) => SectionWatcher | null,
78
+ previousObserver: SectionWatcher | null,
79
+ ): SectionWatcher | null {
80
+ const { planEl } = deps;
81
+ if (!planEl) return previousObserver;
82
+ // Focus is only put back when it was inside this document to begin with.
83
+ const keepFocus = !!(deps.focused && deps.focused !== planEl && planEl.contains(deps.focused));
84
+ closePlan();
85
+ const scrollTop = planEl.scrollTop;
86
+ planEl.replaceChildren();
87
+ const anchored: Record<number, boolean> = {};
88
+ for (const block of deps.blocks()) {
89
+ if (block.kind === "blank") continue;
90
+ anchored[block.start] = true;
91
+ const wrap = el("div", "bf-block");
92
+ wrap.setAttribute("data-line", String(block.start));
93
+ wrap.setAttribute("data-kind", String(block.kind));
94
+ // The one mark that says "read this line again": a block the last review did not have.
95
+ if (deps.changed?.(block.start) === true) wrap.setAttribute("data-changed", "1");
96
+ if (block.kind === "heading") wrap.id = "s-" + block.start;
97
+ deps.renderBlock(wrap, block);
98
+ const mine = deps.annotations().filter((annotation) => annotation.anchor === block.start);
99
+ if (mine.length > 0) wrap.classList.add("bf-block--commented");
100
+ const rail = el("div", "bf-block__rail");
101
+ // A settled review takes no new comment: this control would open a composer whose
102
+ // note could never be saved, so it is not drawn rather than drawn and refused.
103
+ if (deps.settled?.() !== true) {
104
+ const add = el("button", "bf-block__add");
105
+ add.type = "button";
106
+ add.textContent = "+";
107
+ add.setAttribute("aria-label", "Comment on this block");
108
+ add.addEventListener("click", function () {
109
+ // Read at click time, not at render time: a selection made after this frame is
110
+ // the quote the reader means.
111
+ const waiting = deps.pendingQuote();
112
+ const quoted = waiting && waiting.wrap === wrap ? waiting.quote : deps.selectionIn(wrap);
113
+ deps.clearPendingQuote();
114
+ deps.open(wrap, block, quoted);
115
+ });
116
+ rail.appendChild(add);
117
+ }
118
+ if (mine.length > 0) {
119
+ const badge = el("span", "bf-block__count");
120
+ badge.textContent = String(mine.length);
121
+ badge.title = mine.length === 1 ? "1 review comment" : mine.length + " review comments";
122
+ rail.appendChild(badge);
123
+ }
124
+ wrap.appendChild(rail);
125
+ wrap.addEventListener("focusin", function () {
126
+ const next = setActive(planEl, blockElements(planEl).indexOf(wrap));
127
+ if (next !== null) deps.setActiveIndex(next);
128
+ });
129
+ planEl.appendChild(wrap);
130
+ deps.renderComments(planEl, block.start);
131
+ }
132
+ const orphans = deps.annotations().filter((annotation) => !anchored[annotation.anchor]);
133
+ if (orphans.length > 0) {
134
+ const box = el("div", "bf-comments bf-comments--orphans");
135
+ const note = el("p");
136
+ note.textContent = "Comments not attached to a current block";
137
+ box.appendChild(note);
138
+ deps.renderCommentCards(box, orphans);
139
+ planEl.appendChild(box);
140
+ }
141
+ planEl.scrollTop = scrollTop;
142
+ // The DOM was rebuilt, so the active block's class is gone; restore it in place rather
143
+ // than scrolling, so the keyboard resumes where the reviewer was.
144
+ const activeIndex = deps.activeIndex();
145
+ const active = activeIndex >= 0 ? blockElements(planEl)[activeIndex] : null;
146
+ if (active) active.classList.add("bf-block--active");
147
+ // The focused element went with the old DOM, so put the tab stop back on the block's
148
+ // comment control: that is where adding, editing, or deleting left it.
149
+ if (keepFocus && active) {
150
+ const stop = active.querySelector(".bf-block__add");
151
+ if (stop) stop.focus();
152
+ }
153
+ deps.facts();
154
+ const observer = observe(planEl, deps.outlineList, previousObserver);
155
+ deps.updateState();
156
+ return observer;
157
+ }
158
+
159
+ /** The block renderer as source, for the client bundle. */
160
+ export const WEB_PLAN_JS = String(renderPlanFrame);
@@ -122,7 +122,6 @@ export async function handleReviewRequest(
122
122
  if (!parsed) return jsonError(400, "Malformed request URL.");
123
123
  const { pathname, search } = parsed;
124
124
 
125
- if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
126
125
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
127
126
  if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
128
127
 
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The review page's settlement: the four ways a review ends, and the one POST they share.
3
+ *
4
+ * The eighth capability out of the review client's template literal. Every write has the
5
+ * same shape — POST the annotations, read the answer, report it in the status line — and
6
+ * what differs is whether the review settles: the owner's save and approve finish it, a
7
+ * reviewer's post does not (the server keeps the review open so she can keep editing), and
8
+ * cancel is the owner's own decision and carries no body at all.
9
+ *
10
+ * The page owns its flags, its buttons, and its stream; this module owns the rules about
11
+ * them, so it reads through getters and reports changes through the callbacks it is given.
12
+ */
13
+
14
+ /** What a settlement reads of the page, and the changes it reports. */
15
+ export interface SettleDeps {
16
+ /** The path a write goes to, without its query string. */
17
+ token(): string;
18
+ /** The review's mount on the session's server, e.g. `/plan/3f9c21aa`. */
19
+ base(): string;
20
+ role(): string;
21
+ /** The comments this writer may write: her own when she is a reviewer, all when owner. */
22
+ mine(): readonly { author?: string }[];
23
+ annotations(): readonly { author?: string }[];
24
+ /** Whether the review has already ended, by any of the three routes. */
25
+ settled(): boolean;
26
+ setStatus(text: string, kind?: string): void;
27
+ /** The three buttons a conflict disables, and the fourth a close does. */
28
+ buttons(): {
29
+ approve: { disabled: boolean } | null;
30
+ save: { disabled: boolean } | null;
31
+ post: { disabled: boolean } | null;
32
+ };
33
+ /** The draft this tab's work lives in: cleared once the server has it. */
34
+ clearDraft(): void;
35
+ /** The page is no longer dirty: the server has everything this tab wrote. */
36
+ markClean(): void;
37
+ /** The server bound this reviewer's name to her link: adopt it. */
38
+ adoptName(name: string): void;
39
+ /** Put a reload button beside the controls: the page cannot recover on its own. */
40
+ offerReload(): void;
41
+ /** Record how the review ended, stop following it, and stop offering to change it. */
42
+ markSettled(kind: "saved" | "approved" | "closed"): void;
43
+ settleStream(): void;
44
+ lockActions(): void;
45
+ /** Repaint the header chip and the facts. */
46
+ updateState(): void;
47
+ /** Refuse to leave a half-written comment, saying why. */
48
+ confirmNoDraft(): boolean;
49
+ post(url: string, body: unknown): Promise<{ ok: boolean; status: number; body: unknown }>;
50
+ /** A body-less POST, for the close that carries nothing. */
51
+ send(url: string): Promise<unknown>;
52
+ /** What a body-less conflict says, when the server gave no sentence of its own. */
53
+ planMoved: string;
54
+ }
55
+
56
+ /** What a settlement reports back: the flag it set, and whether the page is finished. */
57
+ export interface SettleResult {
58
+ outcome: "ok" | "conflict" | "failed";
59
+ reason?: string;
60
+ }
61
+
62
+ /**
63
+ * POST the annotations, and read the answer on both branches.
64
+ *
65
+ * A 409 disables the three write buttons and offers a reload, because the page's island is
66
+ * out of date and no retry from here can fix that; anything else surfaces the server's own
67
+ * sentence, which is why the body is parsed on both branches rather than only on success.
68
+ */
69
+ export function submitAnnotations(
70
+ deps: SettleDeps,
71
+ url: string,
72
+ okText: string,
73
+ failText: string,
74
+ onOk: (() => void) | null,
75
+ ): Promise<SettleResult> {
76
+ const payload = deps.role() === "reviewer" ? deps.mine() : deps.annotations();
77
+ return deps
78
+ .post(url + "?token=" + encodeURIComponent(deps.token()), { annotations: payload })
79
+ .then((result) => {
80
+ if (result.ok) {
81
+ deps.markClean();
82
+ deps.clearDraft();
83
+ deps.setStatus(okText, "saved");
84
+ if (onOk) onOk();
85
+ deps.updateState();
86
+ // The server binds a reviewer's name to her link on the first post, so a
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.
92
+ const body = result.body as { author?: unknown } | null;
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;
95
+ deps.adoptName(body.author);
96
+ }
97
+ return { outcome: "ok" as const };
98
+ }
99
+ if (result.status === 409) {
100
+ const buttons = deps.buttons();
101
+ if (buttons.approve) buttons.approve.disabled = true;
102
+ if (buttons.save) buttons.save.disabled = true;
103
+ if (buttons.post) buttons.post.disabled = true;
104
+ deps.offerReload();
105
+ const body = result.body as { error?: unknown } | null;
106
+ const reason = body && typeof body.error === "string" ? body.error : deps.planMoved;
107
+ deps.setStatus(reason, "error");
108
+ return { outcome: "conflict" as const, reason };
109
+ }
110
+ const body = result.body as { error?: unknown } | null;
111
+ throw new Error(body && typeof body.error === "string" ? body.error : "server returned " + result.status);
112
+ })
113
+ .catch((error: { message?: string }) => {
114
+ const reason = failText + (error && error.message ? error.message : "");
115
+ deps.setStatus(reason, "error");
116
+ return { outcome: "failed" as const, reason };
117
+ });
118
+ }
119
+
120
+ /** Save the annotations and finish the review: the owner's way to close an approved plan. */
121
+ export function saveReview(deps: SettleDeps): Promise<SettleResult> | null {
122
+ if (deps.settled()) return null;
123
+ if (deps.confirmNoDraft()) return null;
124
+ deps.setStatus("Saving\u2026");
125
+ return submitAnnotations(
126
+ deps,
127
+ deps.base() + "/api/feedback",
128
+ "Saved \u2014 return to Pi to continue.",
129
+ "Could not save: ",
130
+ function () {
131
+ deps.markSettled("saved");
132
+ deps.settleStream();
133
+ deps.lockActions();
134
+ },
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Post a reviewer's comments and keep the review open.
140
+ *
141
+ * A reviewer's write never settles the page: the server keeps the review open so she can
142
+ * keep editing her own comments, and closing the stream here would strand her.
143
+ */
144
+ export function postReviewComments(deps: SettleDeps): Promise<SettleResult> | null {
145
+ if (deps.settled()) return null;
146
+ if (deps.confirmNoDraft()) return null;
147
+ deps.setStatus("Posting\u2026");
148
+ return submitAnnotations(
149
+ deps,
150
+ deps.base() + "/api/comment",
151
+ "Comments posted \u2014 the owner sees them now.",
152
+ "Could not post: ",
153
+ null,
154
+ );
155
+ }
156
+
157
+ /** Approve the plan, which finishes the review. */
158
+ export function approveReview(deps: SettleDeps): Promise<SettleResult> | null {
159
+ if (deps.settled()) return null;
160
+ deps.setStatus("Approving\u2026");
161
+ return submitAnnotations(
162
+ deps,
163
+ deps.base() + "/api/approve",
164
+ "Approved \u2014 return to Pi to continue.",
165
+ "Could not approve: ",
166
+ function () {
167
+ deps.markSettled("approved");
168
+ deps.settleStream();
169
+ deps.lockActions();
170
+ },
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Close the review without saving: only the owner may, and the page says so while it asks.
176
+ *
177
+ * The close carries no body, so it is the one write that does not go through
178
+ * `submitAnnotations`; its answer is a status line either way, because a tab that closed the
179
+ * review has nothing left to do with a failure.
180
+ */
181
+ export function cancelReview(deps: SettleDeps): Promise<void> | null {
182
+ if (deps.role() !== "owner" || deps.settled()) return null;
183
+ if (deps.confirmNoDraft()) return null;
184
+ deps.markSettled("closed");
185
+ deps.settleStream();
186
+ deps.lockActions();
187
+ deps.setStatus("Closing\u2026");
188
+ deps.updateState();
189
+ return deps
190
+ .send(deps.base() + "/api/cancel?token=" + encodeURIComponent(deps.token()))
191
+ .catch(function () {})
192
+ .then(function () {
193
+ deps.setStatus("Closed \u2014 return to Pi.");
194
+ deps.updateState();
195
+ });
196
+ }
197
+
198
+ /** The settlement rules as source, for the client bundle. */
199
+ export const WEB_SETTLE_JS = [submitAnnotations, saveReview, postReviewComments, approveReview, cancelReview]
200
+ .map(String)
201
+ .join("\n\n");
@@ -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
  }),