@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
@@ -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";
@@ -68,7 +57,7 @@ export interface PlanReviewSession {
68
57
  export interface PlanReviewOptions {
69
58
  /** Abort signal; aborting closes the session as cancelled. */
70
59
  signal?: AbortSignal;
71
- /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
60
+ /** Whether the browser may approve the plan (only the `plan` tool's `exit` flow). */
72
61
  canApprove?: boolean;
73
62
  /** The section digests the previous review showed, so this page can mark changed sections. */
74
63
  baseline?: SectionDigest[];
@@ -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,11 @@ 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
+ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps): PlanWebReview {
144
135
  const sessions = new Set<PlanReviewSession>();
145
136
  // What each running review can do about sharing; the session itself stays the
146
137
  // four things a caller awaits and closes.
147
138
  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
139
 
152
140
  const start = async (
153
141
  cwd: string,
@@ -164,8 +152,8 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
164
152
  // the name each link has bound.
165
153
  const tokens = createTokens(deps.token);
166
154
  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;
155
+ // The review's own path segment: short, opaque, and stable for this review's life.
156
+ const id = mintToken().slice(0, 8);
169
157
  let settled = false;
170
158
  let resolveDone!: (result: PlanReviewResult) => void;
171
159
  const done = new Promise<PlanReviewResult>((resolve) => {
@@ -239,65 +227,21 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
239
227
  if (!current) return;
240
228
  if (stripFeedback(current.content) !== pageText) notifyPlanChange();
241
229
  };
242
- stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
243
230
 
244
231
  const page = async (caller: ReviewCaller): Promise<ReviewPageData> => {
245
- const { role } = caller;
246
232
  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 },
233
+ // The island is one function of what the page renders: the text, the baseline
234
+ // the previous review left, and who is asking.
235
+ return reviewPageData({
236
+ base,
237
+ path: plan.path,
238
+ content: stored.content,
239
+ baseline: options.baseline,
240
+ caller,
293
241
  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
- };
242
+ canApprove,
243
+ share: () => shareSession.state(),
244
+ });
301
245
  };
302
246
 
303
247
  /**
@@ -309,43 +253,48 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
309
253
  */
310
254
  const save = async (annotations: PlanAnnotation[], caller: ReviewCaller): Promise<SaveResult> => {
311
255
  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;
256
+ const write = { store, cwd, path: plan.path, pageText: () => pageText };
257
+ if (role === "owner") {
258
+ const saved = await saveOwnerAnnotations(write, annotations);
259
+ if (!saved.ok) return saved;
260
+ savedPlan = saved.plan;
333
261
  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) };
262
+ return { ok: true };
338
263
  }
264
+ // The saved plan is deliberately not recorded for a reviewer: a post is not a
265
+ // result the session can return, so there is nothing for a later flush to resolve.
266
+ const posted = await postReviewerAnnotations(
267
+ { ...write, caller, taken: tokens.namesExcept(caller) },
268
+ annotations,
269
+ );
270
+ if (!posted.save.ok) return posted.save;
271
+ caller.name ??= posted.save.author;
272
+ streams.broadcast("comment");
273
+ // The page is told the name its link stored, and the terminal is told a peer wrote.
274
+ if (posted.count > 0) options.onReviewerComment?.(posted.save.author ?? "", posted.count);
275
+ return posted.save;
339
276
  };
340
277
 
278
+ // One review, one prefix on the session's server: the id is the review's, so two
279
+ // reviews of two plans (or one plan twice) never share a namespace, and the origin
280
+ // is the session's single port.
281
+ const base = `/plan/${id}`;
282
+ const outcome: MountOutcome = await deps.mount({
283
+ session: deps.session(),
284
+ prefix: base,
285
+ maxBodyBytes: bodyLimit,
286
+ handler: handleRequest,
287
+ });
288
+ if (outcome.kind === "refused") return undefined;
289
+ const mount: HttpMount = outcome.mount;
290
+ const port = mount.port;
291
+
341
292
  // The second listener, its reviewer token, and the state the page and
342
293
  // `/plan share` read. It is handed the request handler, the role map, and the
343
294
  // frame hooks, so sharing stays one concern instead of five fields here.
344
295
  const shareSession = createShareSession({
345
- handleRequest,
346
- maxBodyBytes: bodyLimit,
347
- ownerPort: () => currentPort,
348
- networkInterfaces: deps.networkInterfaces ?? networkInterfaces,
296
+ mount,
297
+ base,
349
298
  mintReviewer: () => tokens.mint("reviewer"),
350
299
  revokeReviewer: (value) => tokens.revoke(value),
351
300
  nameOf: (value) => tokens.get(value)?.name,
@@ -412,24 +361,11 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
412
361
  };
413
362
  }
414
363
 
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;
364
+ // Armed only now: a review whose listener never bound has no page to keep current,
365
+ // and an interval started for it would outlive the session that failed to start.
366
+ stopTicker = (deps.tick ?? defaultInterval)(deps.pollMs ?? PLAN_POLL_MS, () => void checkPlanFile());
431
367
 
432
- const url = `http://127.0.0.1:${port}/?token=${token}`;
368
+ const url = `${mount.origin}${base}/?token=${token}`;
433
369
  let closed = false;
434
370
  let abortHandler: (() => void) | undefined;
435
371
  const close = async (): Promise<void> => {
@@ -444,7 +380,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
444
380
  stopTicker();
445
381
  streams.clear();
446
382
  await shareSession.close();
447
- await server.close();
383
+ await mount.release();
448
384
  };
449
385
 
450
386
  const session: PlanReviewSession = { url, port, done, close };
@@ -14,7 +14,6 @@ import { ASK_USER_QUESTION_TOOL } from "../../lib/tool-names.ts";
14
14
  import { UNAVAILABLE_TEXT, formatAnswerText, optionSummary, questionHeaders, unavailableResult } from "./answers.ts";
15
15
  import { askViaDialogs, type QuestionUI } from "./dialogs.ts";
16
16
  import { AskUserQuestionParams, normalizeQuestions, type AskUserQuestionArgs } from "./schema.ts";
17
- import { askViaTui } from "./tui.ts";
18
17
  import type { AskResult } from "./types.ts";
19
18
 
20
19
  export const TOOL_NAME = ASK_USER_QUESTION_TOOL;
@@ -59,7 +58,12 @@ export function registerTools(pi: ExtensionAPI): void {
59
58
  // A remote-answered turn falls back to dialogs; only a local TUI turn uses
60
59
  // the full-screen questionnaire.
61
60
  result = await askHuman(ctx, {
62
- custom: () => withRailsSuppressed(pi, () => askViaTui(ctx, questions, signal)),
61
+ custom: async () => {
62
+ // The questionnaire is the TUI's: a remote or headless answer below loads
63
+ // neither it nor the rails wrapper.
64
+ const { askViaTui } = await import("./view/question-screen.ts");
65
+ return withRailsSuppressed(pi, () => askViaTui(ctx, questions, signal));
66
+ },
63
67
  dialogs: () => askViaDialogs(dialogUI(ctx), questions, signal),
64
68
  });
65
69
  }
@@ -15,15 +15,15 @@ import {
15
15
  type TuiMouseEvent,
16
16
  type TuiMouseEventResult,
17
17
  } from "@earendil-works/pi-tui";
18
- import { sanitizeLine } from "../../lib/format.ts";
19
- import { flowCells } from "../../lib/tui/columns.ts";
20
- import { clampScroll, formatRange, keepVisible, selectionMarker, wheelDelta } from "../../lib/tui/cursor.ts";
21
- import { editorTheme, screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
22
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
23
- import { checkbox } from "../../lib/tui/rows.ts";
24
- import { plainRule, titleRule } from "../../lib/tui/rules.ts";
25
- import { paint } from "../../lib/tui/tokens.ts";
26
- import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult, describeSelection } from "./answers.ts";
18
+ import { sanitizeLine } from "../../../lib/format.ts";
19
+ import { flowCells } from "../../../lib/tui/columns.ts";
20
+ import { clampScroll, formatRange, keepVisible, selectionMarker, wheelDelta } from "../../../lib/tui/cursor.ts";
21
+ import { editorTheme, screenHint, type ScreenHint } from "../../../lib/tui/chrome.ts";
22
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
23
+ import { checkbox } from "../../../lib/tui/rows.ts";
24
+ import { plainRule, titleRule } from "../../../lib/tui/rules.ts";
25
+ import { paint } from "../../../lib/tui/tokens.ts";
26
+ import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult, describeSelection } from "../answers.ts";
27
27
  import {
28
28
  allAnswered,
29
29
  applyCustomText,
@@ -34,8 +34,8 @@ import {
34
34
  type Effect,
35
35
  type KeyAction,
36
36
  type TuiState,
37
- } from "./tui-state.ts";
38
- import type { AskResult, Question, Selection } from "./types.ts";
37
+ } from "../tui-state.ts";
38
+ import type { AskResult, Question, Selection } from "../types.ts";
39
39
 
40
40
  type Outcome =
41
41
  | { type: "cancelled"; selections: Map<string, Selection> }
@@ -10,15 +10,14 @@
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
11
  import { withRailsSuppressed } from "../../../lib/rails.ts";
12
12
  import { formatRewindListText, formatRewindRow } from "../text/format.ts";
13
- import { openReview } from "./review.ts";
14
- import { rewindTo } from "./rewind.ts";
15
13
  import type { RewindRuntime } from "../state/runtime.ts";
16
- import { RewindListComponent } from "../view/rewind-tui.ts";
17
14
  import { buildRewindPoints, type RewindPoint } from "../state/timeline.ts";
18
15
 
19
16
  /** Pick a point from the timeline, or undefined when the user cancels. */
20
17
  async function choosePoint(ctx: ExtensionCommandContext, points: RewindPoint[]): Promise<RewindPoint | undefined> {
21
18
  if (ctx.mode === "tui") {
19
+ // The timeline is the TUI's: the picker below and registration load neither.
20
+ const { RewindListComponent } = await import("../view/rewind-tui.ts");
22
21
  return ctx.ui.custom<RewindPoint | undefined>(
23
22
  (tui, theme, _keybindings, done) =>
24
23
  new RewindListComponent({
@@ -62,6 +61,9 @@ async function openRewind(pi: ExtensionAPI, runtime: RewindRuntime, ctx: Extensi
62
61
 
63
62
  const point = await withRailsSuppressed(pi, () => choosePoint(ctx, points));
64
63
  if (!point) return;
64
+ // The rewind run and the review gate both load on the verb that needs them: the
65
+ // listing above and registration load neither.
66
+ const { rewindTo } = await import("./rewind.ts");
65
67
  await rewindTo(pi, runtime, ctx, root, point);
66
68
  }
67
69
 
@@ -76,6 +78,7 @@ export function registerCommands(pi: ExtensionAPI, runtime: RewindRuntime): void
76
78
  pi.registerCommand("review", {
77
79
  description: "Review the latest turn's changes and revert a file or the whole turn",
78
80
  handler: async (_args, ctx) => {
81
+ const { openReview } = await import("./review.ts");
79
82
  await openReview(pi, runtime, ctx);
80
83
  },
81
84
  });
@@ -24,7 +24,7 @@ import { createOperationQueue } from "../transport/queue.ts";
24
24
  import { applyRestore, planRestore, type PlanResult, type RestoreInput } from "../transport/restore.ts";
25
25
  import type { PathStat } from "../text/paths.ts";
26
26
  import { createSnapshot } from "../transport/snapshot.ts";
27
- import { createStatusChip } from "../view/status.ts";
27
+ import { createStatusChip } from "../status.ts";
28
28
  import { belongsToRoot, listSnapshots, pruneSnapshots } from "../transport/store.ts";
29
29
  import { createTempIndexStore } from "../transport/temp-index.ts";
30
30
  import { lastUserEntryId } from "./timeline.ts";
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { GLYPHS, STATUS_KEYS } from "../../../lib/tui/glyphs.ts";
12
- import { paint } from "../../../lib/tui/tokens.ts";
13
- import type { RewindConfig } from "../config/config.ts";
14
- import { userMessagesFromBranch } from "../state/timeline.ts";
11
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
12
+ import { paint } from "../../lib/tui/tokens.ts";
13
+ import type { RewindConfig } from "./config/config.ts";
14
+ import { userMessagesFromBranch } from "./state/timeline.ts";
15
15
 
16
16
  const STATUS_KEY = STATUS_KEYS.rewind;
17
17
  const STATUS_GLYPH = GLYPHS.rewind;
@@ -193,10 +193,10 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
193
193
  protected hint(width: number): string {
194
194
  return screenHint(this.theme, width, [
195
195
  "Esc close",
196
- { text: "Enter diff", color: "accent" },
196
+ { text: "Enter diff", color: "accent" as const },
197
197
  "Space select",
198
- { text: "r revert", color: "warning" },
199
- { text: "R all", color: "warning" },
198
+ { text: "r revert", color: "warn" },
199
+ { text: "R all", color: "warn" },
200
200
  "a/n all/none",
201
201
  ]);
202
202
  }
@@ -314,7 +314,7 @@ export class ReviewDiffComponent implements Component {
314
314
  return screenHint(this.theme, width, [
315
315
  "Esc back",
316
316
  ...(this.options.canRevertHunks ? [{ text: "h hunks", color: "accent" as const }] : []),
317
- { text: "r revert file", color: "warning" },
317
+ { text: "r revert file", color: "warn" },
318
318
  "j/k scroll",
319
319
  "g/G top/end",
320
320
  ]);
@@ -0,0 +1,39 @@
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 the project's own port: its repository key hashed into the
6
+ * band `lib/http/port.ts` holds, so a session that starts again lands where a bookmark
7
+ * already points and two repositories do not compete for one number. A positive value
8
+ * pins the session's server, so every page it serves keeps the address a bookmark
9
+ * already points at. Either way a port another process holds costs the start its
10
+ * OS-chosen fallback — unless it was pinned, which fails the start rather than moving
11
+ * the pages out from under the bookmark.
12
+ *
13
+ * Pin it in the project document when a repository needs a number of its own: a global
14
+ * value is one port for every session.
15
+ */
16
+
17
+ import { clampInteger, loadConfigSection } from "../../lib/config.ts";
18
+
19
+ /** The `serve` section. */
20
+ export interface ServeConfig {
21
+ /** TCP port on `127.0.0.1`; `0` takes the project's port, a positive value pins it. */
22
+ port: number;
23
+ }
24
+
25
+ export const DEFAULT_SERVE_CONFIG: ServeConfig = { port: 0 };
26
+
27
+ /** Validate and clamp a raw config object over the defaults. */
28
+ export function normalizeServeConfig(
29
+ raw: Record<string, unknown> | undefined,
30
+ base: ServeConfig = DEFAULT_SERVE_CONFIG,
31
+ ): ServeConfig {
32
+ if (!raw) return base;
33
+ return { port: clampInteger(raw.port, base.port, 0, 65_535) };
34
+ }
35
+
36
+ /** Load the effective config for a working directory. */
37
+ export function loadServeConfig(cwd: string): ServeConfig {
38
+ return loadConfigSection(cwd, "serve", DEFAULT_SERVE_CONFIG, normalizeServeConfig);
39
+ }
@@ -0,0 +1,85 @@
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, and the
9
+ * port it binds is the project's own number unless `serve.port` pins another — so a
10
+ * bookmarked page URL survives a restart.
11
+ *
12
+ * Load with: pi --extension ./extensions/serve
13
+ */
14
+
15
+ import { networkInterfaces } from "node:os";
16
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import { isExtensionEnabled } from "../../lib/env.ts";
18
+ import { HTTP_MOUNT_CHANNEL, type MountRequest } from "../../lib/http/mount.ts";
19
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
20
+ import { paint } from "../../lib/tui/tokens.ts";
21
+ import { loadServeConfig } from "./config.ts";
22
+ import { createServeRegistry } from "./registry.ts";
23
+
24
+ export default function serve(pi: ExtensionAPI): void {
25
+ if (!isExtensionEnabled("serve")) return;
26
+ /** Each session's working directory, so its pin is read from its own config. */
27
+ const cwds = new Map<string, string>();
28
+ /** Each session's context, which is where the port chip is drawn. */
29
+ const contexts = new Map<string, ExtensionContext>();
30
+
31
+ /** The directory a session serves: its config and its port both come from it. */
32
+ const cwdOf = (session: string): string => cwds.get(session) ?? process.cwd();
33
+
34
+ /**
35
+ * Publish the session's port, or clear the chip with `undefined`. The chip belongs
36
+ * to the server, not to a page: one number, shown while anything is served on it.
37
+ */
38
+ const setChip = (session: string, port: number | undefined): void => {
39
+ const ctx = contexts.get(session);
40
+ if (ctx === undefined) return;
41
+ try {
42
+ const value =
43
+ port === undefined
44
+ ? undefined
45
+ : `${paint(ctx.ui.theme, "ok", GLYPHS.http)} ${paint(ctx.ui.theme, "accent", String(port))}`;
46
+ ctx.ui.setStatus(STATUS_KEYS.http, value);
47
+ } catch {
48
+ // A host without a status line is not a reason to keep a page from starting.
49
+ }
50
+ };
51
+
52
+ const registry = createServeRegistry({
53
+ portFor: (session) => loadServeConfig(cwdOf(session)).port,
54
+ pickPort: async (session) => {
55
+ // The repository key is a git answer, so it is paid on the first bind rather than
56
+ // at registration: a session that opens no page never asks for it.
57
+ const [{ repoKeyFor }, { projectPort }] = await Promise.all([
58
+ import("../../lib/repo-key.ts"),
59
+ import("../../lib/http/port.ts"),
60
+ ]);
61
+ return projectPort((await repoKeyFor(pi, cwdOf(session))).key);
62
+ },
63
+ interfaces: () => networkInterfaces(),
64
+ onPort: setChip,
65
+ });
66
+
67
+ pi.events.on(HTTP_MOUNT_CHANNEL, (data) => {
68
+ const request = data as Partial<MountRequest> | undefined;
69
+ if (request?.spec === undefined) return;
70
+ registry.handle(request as MountRequest);
71
+ });
72
+
73
+ pi.on("session_start", async (_event, ctx) => {
74
+ const id = ctx.sessionManager.getSessionId();
75
+ cwds.set(id, ctx.cwd);
76
+ contexts.set(id, ctx);
77
+ });
78
+
79
+ pi.on("session_shutdown", async (_event, ctx) => {
80
+ const id = ctx.sessionManager.getSessionId();
81
+ await registry.closeSession(id);
82
+ cwds.delete(id);
83
+ contexts.delete(id);
84
+ });
85
+ }