@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,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
+ }
@@ -10,37 +10,26 @@
10
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 { networkInterfaces } from "node:os";
14
- import { basename } from "node:path";
15
13
  import { openInBrowser } from "../../lib/browser.ts";
16
14
  import {
15
+ type HttpMount,
17
16
  jsonHeaders,
18
17
  type LoopbackRequest,
19
18
  type LoopbackReply,
20
- type LoopbackServer,
19
+ type MountOutcome,
20
+ type MountSource,
21
21
  parseRequestUrl,
22
- serveLoopback,
23
22
  type ServeResponse,
24
23
  } from "../../lib/http/index.ts";
25
- import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
26
24
  import { type ArmTimer, defaultInterval, defaultTimer, type RepeatTimer } from "../../lib/timer.ts";
27
- import { codeBody, fenceInfo } from "./blocks.ts";
28
- import {
29
- claimAuthor,
30
- MAX_ANNOTATIONS,
31
- MAX_COMMENT_LENGTH,
32
- MAX_QUOTE_LENGTH,
33
- normalizeAuthor,
34
- readFeedback,
35
- stripFeedback,
36
- type PlanAnnotation,
37
- } from "./feedback.ts";
38
- import { planTitle, type PlanStore, type StoredPlan } from "./plans.ts";
39
- import { changedSections, planDigest, type SectionDigest } from "./plan-text.ts";
40
- import { extractPlanSteps } from "./steps.ts";
25
+ import { mintToken } from "../../lib/http/index.ts";
26
+ import { stripFeedback, type PlanAnnotation } from "./feedback.ts";
27
+ import type { PlanStore, StoredPlan } from "./plans.ts";
28
+ import type { SectionDigest } from "./plan-text.ts";
41
29
  import type { ReviewCaller } from "./types.ts";
42
30
  import { handleReviewRequest, type ReviewContext, type SaveResult } from "./web-router.ts";
43
- import type { ReviewPageData } from "./web-page.ts";
31
+ import { reviewPageData, type ReviewPageData } from "./web-payload.ts";
32
+ import { postReviewerAnnotations, saveOwnerAnnotations } from "./web-write.ts";
44
33
  import { createShareSession, type ShareSession } from "./web-share-session.ts";
45
34
  import { createStreams } from "./web-streams.ts";
46
35
  import { createTokens } from "./web-tokens.ts";
@@ -100,8 +89,10 @@ export interface PlanWebReviewDeps {
100
89
  open?: (url: string) => void;
101
90
  /** Override the session token (tests). */
102
91
  token?: () => string;
103
- /** Override the bind port; the default `0` lets the OS pick. */
104
- port?: number;
92
+ /** Where the review's prefix comes from: the session's server, or a suite's own. */
93
+ mount: MountSource;
94
+ /** The Pi session asking, so the prefix lands on this session's socket. */
95
+ session: () => string;
105
96
  /** Override the closed-tab grace; the default leaves a reload time to reconnect. */
106
97
  dropGraceMs?: number;
107
98
  /**
@@ -140,14 +131,22 @@ const DROP_GRACE_MS = 1500;
140
131
  */
141
132
  const PLAN_POLL_MS = 1000;
142
133
 
143
- export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
134
+ /**
135
+ * The deps a caller that injects its own review leaves out.
136
+ *
137
+ * A review needs a place on the session's server, so a caller with no wiring says so
138
+ * rather than binding a socket of its own: the start is refused with a sentence.
139
+ */
140
+ export const NO_SERVER_DEPS: PlanWebReviewDeps = {
141
+ mount: async () => ({ kind: "refused", reason: "this review has no server wired" }),
142
+ session: () => "",
143
+ };
144
+
145
+ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps): PlanWebReview {
144
146
  const sessions = new Set<PlanReviewSession>();
145
147
  // What each running review can do about sharing; the session itself stays the
146
148
  // four things a caller awaits and closes.
147
149
  const shares = new Map<PlanReviewSession, ShareSession>();
148
- // Last bound port per plan path, so the same plan keeps one origin across
149
- // review turns (and its saved browser theme with it).
150
- const ports = new Map<string, number>();
151
150
 
152
151
  const start = async (
153
152
  cwd: string,
@@ -164,8 +163,8 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
164
163
  // the name each link has bound.
165
164
  const tokens = createTokens(deps.token);
166
165
  const token = tokens.mint("owner");
167
- // The owner's bound port, so a shared listener can reuse it for the same URL shape.
168
- let currentPort = 0;
166
+ // The review's own path segment: short, opaque, and stable for this review's life.
167
+ const id = mintToken().slice(0, 8);
169
168
  let settled = false;
170
169
  let resolveDone!: (result: PlanReviewResult) => void;
171
170
  const done = new Promise<PlanReviewResult>((resolve) => {
@@ -239,65 +238,21 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
239
238
  if (!current) return;
240
239
  if (stripFeedback(current.content) !== pageText) notifyPlanChange();
241
240
  };
242
- stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
243
241
 
244
242
  const page = async (caller: ReviewCaller): Promise<ReviewPageData> => {
245
- const { role } = caller;
246
243
  const stored = await refreshPageText();
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 }) =>
257
- section.title === ""
258
- ? []
259
- : [
260
- {
261
- id: `s-${section.line}`,
262
- line: section.line,
263
- level: section.level,
264
- text: section.title,
265
- ...(changed ? { changed: true } : {}),
266
- },
267
- ],
268
- );
269
- // The same step list approval seeds and set_plan_steps indexes, so the page's
270
- // progress cannot disagree with the todo list.
271
- const stepList = extractPlanSteps(current);
272
- // Highlight code blocks once, at the boundary: the client copies only the
273
- // tok-* spans out of the server-escaped HTML.
274
- const code: Record<string, string[]> = {};
275
- for (const block of blocks) {
276
- if (block.kind !== "code") continue;
277
- const language = fenceLanguage(fenceInfo(block.lines[0] ?? ""));
278
- if (language) code[String(block.start)] = highlightLines(codeBody(block), language);
279
- }
280
- return {
281
- // The document's own H1 reads better than the file-name slug, and the
282
- // slug is still the fallback for a plan that starts with something else.
283
- title: marks.find((mark) => mark.section.level === 1)?.section.title || planTitle(basename(plan.path)),
284
- blocks,
285
- annotations: read.annotations,
286
- outline,
287
- steps: {
288
- done: stepList.filter((step) => step.status === "completed").length,
289
- total: stepList.length,
290
- },
291
- code,
292
- limits: { comments: MAX_ANNOTATIONS, comment: MAX_COMMENT_LENGTH, quote: MAX_QUOTE_LENGTH },
244
+ // The island is one function of what the page renders: the text, the baseline
245
+ // the previous review left, and who is asking.
246
+ return reviewPageData({
247
+ base,
248
+ path: plan.path,
249
+ content: stored.content,
250
+ baseline: options.baseline,
251
+ caller,
293
252
  token,
294
- role,
295
- // What the page keys this tab's unsaved draft on: same plan text, same draft.
296
- rev: planDigest(current),
297
- ...(caller.name === undefined ? {} : { author: caller.name }),
298
- canApprove: role === "owner" && canApprove,
299
- share: role === "owner" ? shareSession.state() : undefined,
300
- };
253
+ canApprove,
254
+ share: () => shareSession.state(),
255
+ });
301
256
  };
302
257
 
303
258
  /**
@@ -309,43 +264,48 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
309
264
  */
310
265
  const save = async (annotations: PlanAnnotation[], caller: ReviewCaller): Promise<SaveResult> => {
311
266
  const { role } = caller;
312
- const current = await store.read(cwd, plan.path);
313
- if (!current) return { ok: false, status: 404, message: "The plan file is gone." };
314
- if (stripFeedback(current.content) !== pageText) {
315
- return { ok: false, status: 409, message: "The plan changed on disk; reload the page and review again." };
316
- }
317
- try {
318
- if (role === "owner") {
319
- savedPlan = await store.saveFeedback(cwd, plan.path, annotations);
320
- streams.broadcast("comment");
321
- return { ok: true };
322
- }
323
- // The link, not the payload, decides whose markers this post replaces: the name
324
- // is bound to the token on its first write, and a page that signs another one
325
- // is a stale tab rather than an authorized writer.
326
- const claim = claimAuthor({ bound: caller.name, taken: tokens.namesExcept(caller) }, annotations);
327
- if (!("name" in claim)) return { ok: false, status: claim.status, message: claim.message };
328
- const own = annotations.filter((annotation) => normalizeAuthor(annotation.author) === claim.name);
329
- // The saved plan is deliberately not recorded here: a post is not a result
330
- // the session can return, so there is nothing for a later flush to resolve.
331
- await store.saveFeedback(cwd, plan.path, own, { author: claim.name });
332
- caller.name ??= claim.name;
267
+ const write = { store, cwd, path: plan.path, pageText: () => pageText };
268
+ if (role === "owner") {
269
+ const saved = await saveOwnerAnnotations(write, annotations);
270
+ if (!saved.ok) return saved;
271
+ savedPlan = saved.plan;
333
272
  streams.broadcast("comment");
334
- if (own.length > 0) options.onReviewerComment?.(claim.name, own.length);
335
- return { ok: true, author: claim.name };
336
- } catch (error) {
337
- return { ok: false, status: 500, message: error instanceof Error ? error.message : String(error) };
273
+ return { ok: true };
338
274
  }
275
+ // The saved plan is deliberately not recorded for a reviewer: a post is not a
276
+ // result the session can return, so there is nothing for a later flush to resolve.
277
+ const posted = await postReviewerAnnotations(
278
+ { ...write, caller, taken: tokens.namesExcept(caller) },
279
+ annotations,
280
+ );
281
+ if (!posted.save.ok) return posted.save;
282
+ caller.name ??= posted.save.author;
283
+ streams.broadcast("comment");
284
+ // The page is told the name its link stored, and the terminal is told a peer wrote.
285
+ if (posted.count > 0) options.onReviewerComment?.(posted.save.author ?? "", posted.count);
286
+ return posted.save;
339
287
  };
340
288
 
289
+ // One review, one prefix on the session's server: the id is the review's, so two
290
+ // reviews of two plans (or one plan twice) never share a namespace, and the origin
291
+ // is the session's single port.
292
+ const base = `/plan/${id}`;
293
+ const outcome: MountOutcome = await deps.mount({
294
+ session: deps.session(),
295
+ prefix: base,
296
+ maxBodyBytes: bodyLimit,
297
+ handler: handleRequest,
298
+ });
299
+ if (outcome.kind === "refused") return undefined;
300
+ const mount: HttpMount = outcome.mount;
301
+ const port = mount.port;
302
+
341
303
  // The second listener, its reviewer token, and the state the page and
342
304
  // `/plan share` read. It is handed the request handler, the role map, and the
343
305
  // frame hooks, so sharing stays one concern instead of five fields here.
344
306
  const shareSession = createShareSession({
345
- handleRequest,
346
- maxBodyBytes: bodyLimit,
347
- ownerPort: () => currentPort,
348
- networkInterfaces: deps.networkInterfaces ?? networkInterfaces,
307
+ mount,
308
+ base,
349
309
  mintReviewer: () => tokens.mint("reviewer"),
350
310
  revokeReviewer: (value) => tokens.revoke(value),
351
311
  nameOf: (value) => tokens.get(value)?.name,
@@ -412,24 +372,11 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
412
372
  };
413
373
  }
414
374
 
415
- const preferred = deps.port ?? ports.get(plan.path) ?? 0;
416
- let server: LoopbackServer;
417
- try {
418
- server = await serveLoopback({
419
- ports: preferred === 0 ? [0] : [preferred, 0],
420
- maxBodyBytes: bodyLimit,
421
- handler: handleRequest,
422
- });
423
- } catch {
424
- // The remembered port may have been taken between turns; the fallback above
425
- // covers that, so reaching here means neither port answered.
426
- return undefined;
427
- }
428
- const port = server.port;
429
- ports.set(plan.path, port);
430
- currentPort = port;
375
+ // Armed only now: a review whose listener never bound has no page to keep current,
376
+ // and an interval started for it would outlive the session that failed to start.
377
+ stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
431
378
 
432
- const url = `http://127.0.0.1:${port}/?token=${token}`;
379
+ const url = `${mount.origin}${base}/?token=${token}`;
433
380
  let closed = false;
434
381
  let abortHandler: (() => void) | undefined;
435
382
  const close = async (): Promise<void> => {
@@ -444,7 +391,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
444
391
  stopTicker();
445
392
  streams.clear();
446
393
  await shareSession.close();
447
- await server.close();
394
+ await mount.release();
448
395
  };
449
396
 
450
397
  const session: PlanReviewSession = { url, port, done, close };
@@ -12,6 +12,7 @@
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import { messageOf } from "../../../lib/errors.ts";
14
14
  import { sanitizeLine } from "../../../lib/format.ts";
15
+ import { planModeRefusal } from "../../../lib/plan-entry.ts";
15
16
  import { isPlanModeEnabled } from "../../../lib/plan-state.ts";
16
17
  import { withRailsSuppressed } from "../../../lib/rails.ts";
17
18
  import {
@@ -75,7 +76,7 @@ async function loadReview(runtime: RewindRuntime, ctx: ExtensionCommandContext):
75
76
  return {
76
77
  ok: false,
77
78
  kind: "warning",
78
- message: "Plan mode is read-only. Approve the plan before reviewing and reverting its changes.",
79
+ message: planModeRefusal("reviewing and reverting its changes"),
79
80
  };
80
81
  }
81
82
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The session's one port: the `serve` section of the shared `bytefly.json` (global
3
+ * then project, project wins).
4
+ *
5
+ * `0` — the default — takes one free port for the session and remembers it, which
6
+ * is what a session that never bookmarks wants. A positive value pins the session's
7
+ * server, so every page it serves keeps the address a bookmark already points at.
8
+ * A pinned port that is taken fails the start rather than moving the pages out from
9
+ * under the bookmark.
10
+ *
11
+ * Pin it in the project document when more than one repository runs pages on the
12
+ * machine: a global value is one port for every session.
13
+ */
14
+
15
+ import { clampInteger, loadConfigSection } from "../../lib/config.ts";
16
+
17
+ /** The `serve` section. */
18
+ export interface ServeConfig {
19
+ /** TCP port on `127.0.0.1`; `0` picks one for the session, a positive value pins it. */
20
+ port: number;
21
+ }
22
+
23
+ export const DEFAULT_SERVE_CONFIG: ServeConfig = { port: 0 };
24
+
25
+ /** Validate and clamp a raw config object over the defaults. */
26
+ export function normalizeServeConfig(
27
+ raw: Record<string, unknown> | undefined,
28
+ base: ServeConfig = DEFAULT_SERVE_CONFIG,
29
+ ): ServeConfig {
30
+ if (!raw) return base;
31
+ return { port: clampInteger(raw.port, base.port, 0, 65_535) };
32
+ }
33
+
34
+ /** Load the effective config for a working directory. */
35
+ export function loadServeConfig(cwd: string): ServeConfig {
36
+ return loadConfigSection(cwd, "serve", DEFAULT_SERVE_CONFIG, normalizeServeConfig);
37
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * serve — the one loopback server a session serves its pages on.
3
+ *
4
+ * It registers one bus listener and closes what it opened; it draws no page of its
5
+ * own. `plan`, `board`, and `file-browse` ask for a path prefix on
6
+ * {@link HTTP_MOUNT_CHANNEL} and the registry answers with the session's origin, so
7
+ * a session has one port, one close path, and one chip instead of one socket per
8
+ * extension. Binding happens on the first page that asks, never in the factory.
9
+ *
10
+ * Load with: pi --extension ./extensions/serve
11
+ */
12
+
13
+ import { networkInterfaces } from "node:os";
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { isExtensionEnabled } from "../../lib/env.ts";
16
+ import { HTTP_MOUNT_CHANNEL, type MountRequest } from "../../lib/http/index.ts";
17
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
18
+ import { paint } from "../../lib/tui/tokens.ts";
19
+ import { loadServeConfig } from "./config.ts";
20
+ import { createServeRegistry } from "./registry.ts";
21
+
22
+ export default function serve(pi: ExtensionAPI): void {
23
+ if (!isExtensionEnabled("serve")) return;
24
+ /** Each session's working directory, so its pin is read from its own config. */
25
+ const cwds = new Map<string, string>();
26
+ /** Each session's context, which is where the port chip is drawn. */
27
+ const contexts = new Map<string, ExtensionContext>();
28
+
29
+ /**
30
+ * Publish the session's port, or clear the chip with `undefined`. The chip belongs
31
+ * to the server, not to a page: one number, shown while anything is served on it.
32
+ */
33
+ const setChip = (session: string, port: number | undefined): void => {
34
+ const ctx = contexts.get(session);
35
+ if (ctx === undefined) return;
36
+ try {
37
+ const value =
38
+ port === undefined
39
+ ? undefined
40
+ : `${paint(ctx.ui.theme, "ok", GLYPHS.http)} ${paint(ctx.ui.theme, "accent", String(port))}`;
41
+ ctx.ui.setStatus(STATUS_KEYS.http, value);
42
+ } catch {
43
+ // A host without a status line is not a reason to keep a page from starting.
44
+ }
45
+ };
46
+
47
+ const registry = createServeRegistry({
48
+ portFor: (session) => loadServeConfig(cwds.get(session) ?? process.cwd()).port,
49
+ interfaces: () => networkInterfaces(),
50
+ onPort: setChip,
51
+ });
52
+
53
+ pi.events.on(HTTP_MOUNT_CHANNEL, (data) => {
54
+ const request = data as Partial<MountRequest> | undefined;
55
+ if (request?.spec === undefined) return;
56
+ registry.handle(request as MountRequest);
57
+ });
58
+
59
+ pi.on("session_start", async (_event, ctx) => {
60
+ const id = ctx.sessionManager.getSessionId();
61
+ cwds.set(id, ctx.cwd);
62
+ contexts.set(id, ctx);
63
+ });
64
+
65
+ pi.on("session_shutdown", async (_event, ctx) => {
66
+ const id = ctx.sessionManager.getSessionId();
67
+ await registry.closeSession(id);
68
+ cwds.delete(id);
69
+ contexts.delete(id);
70
+ });
71
+ }