@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
@@ -14,21 +14,15 @@ import { realpathSync } from "node:fs";
14
14
  import { stat } from "node:fs/promises";
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
17
- import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
18
- import { paint } from "../../lib/tui/tokens.ts";
19
- import { loadConfig, randomPort } from "./config/config.ts";
20
- import { createGitDiff, createGitStatus } from "./git/git.ts";
21
- import { openInBrowser } from "../../lib/browser.ts";
22
- import { HttpError, resolveRequestPath } from "./files/paths.ts";
23
- import { createFileServer, type FileServer } from "./transport/server.ts";
17
+ import type { openInBrowser } from "../../lib/browser.ts";
18
+ import type { ServeConfig } from "./config/config.ts";
19
+ import type { FileServer } from "./transport/server.ts";
24
20
 
25
21
  export interface FileBrowserDeps {
26
22
  /** Override the server factory (tests). */
27
- createServer?: typeof createFileServer;
23
+ createServer?: typeof import("./transport/server.ts").createFileServer;
28
24
  /** Override the browser opener (tests). */
29
25
  open?: typeof openInBrowser;
30
- /** Override session-port selection (tests). */
31
- pickPort?: () => number;
32
26
  }
33
27
 
34
28
  interface RunningServer {
@@ -40,94 +34,76 @@ interface RunningServer {
40
34
  export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}): void {
41
35
  if (!isExtensionEnabled("file-browse")) return;
42
36
  let running: RunningServer | undefined;
43
- /** Random port fixed for the session after the first successful start. */
44
- let sessionPort: number | undefined;
37
+ /** The session this page serves as; the mount lands on its one server. */
38
+ let sessionId = "";
45
39
 
46
- /** Publish the browse chip, or clear it with `undefined`; best-effort UI. */
47
- const setBrowseStatus = (ctx: ExtensionContext, port: number | undefined): void => {
48
- try {
49
- const value =
50
- port === undefined
51
- ? undefined
52
- : `${paint(ctx.ui.theme, "ok", GLYPHS.browse)} ${paint(ctx.ui.theme, "accent", String(port))}`;
53
- ctx.ui.setStatus(STATUS_KEYS.browse, value);
54
- } catch {
55
- // A UI without a theme or setStatus must not break the command.
56
- }
57
- };
58
-
59
- const stop = async (ctx?: ExtensionContext): Promise<boolean> => {
40
+ const stop = async (): Promise<boolean> => {
60
41
  const current = running;
61
42
  if (!current) return false;
62
43
  running = undefined;
63
44
  await current.server.close();
64
- if (ctx) setBrowseStatus(ctx, undefined);
65
45
  return true;
66
46
  };
67
47
 
68
48
  /**
69
- * Close the server once the page that was its only reader has gone: nothing is
70
- * left to serve, and the chip would otherwise advertise a port that answers no
71
- * one. `/browse` starts it again on the same session port.
49
+ * Give the prefix back once the page that was its only reader has gone: nothing is
50
+ * left to serve. `/browse` starts it again on the session's port.
72
51
  */
73
52
  const stopWhenPageLeft = async (): Promise<void> => {
74
53
  const current = running;
75
54
  if (!current) return;
76
- if (await stop(current.ctx)) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
55
+ if (await stop()) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
77
56
  };
78
57
 
79
- const start = async (args: string, ctx: ExtensionContext): Promise<void> => {
80
- const target = args.trim();
81
- if (target === "status") {
82
- ctx.ui.notify(
83
- running ? `browse: ${running.server.root} at ${running.server.url}` : "browse: not running",
84
- "info",
85
- );
86
- return;
87
- }
88
- if (target === "stop") {
89
- ctx.ui.notify((await stop(ctx)) ? "browse: stopped" : "browse: not running", "info");
90
- return;
91
- }
58
+ type ResolvedRoot = { root: string; config: ServeConfig };
92
59
 
93
- const cwd = resolveEffectiveCwd(ctx.cwd);
60
+ /** The folder `/browse [path]` names, or undefined after telling the user why. */
61
+ const resolveRoot = async (target: string, cwd: string, ctx: ExtensionContext): Promise<ResolvedRoot | undefined> => {
62
+ const { loadConfig } = await import("./config/config.ts");
94
63
  const config = loadConfig(cwd);
95
- // A pinned config port wins; otherwise one random port is fixed for the session.
96
- const unpinned = config.port <= 0;
97
- const port = unpinned ? (sessionPort ?? (deps.pickPort ?? randomPort)()) : config.port;
98
64
  let root = cwd;
99
65
  if (target) {
66
+ const { HttpError, resolveRequestPath } = await import("./files/paths.ts");
100
67
  try {
101
68
  root = await resolveRequestPath(cwd, target);
102
69
  } catch (error) {
103
70
  const message = error instanceof HttpError ? error.message : String(error);
104
71
  ctx.ui.notify(`browse: ${message}`, "error");
105
- return;
72
+ return undefined;
106
73
  }
107
74
  const info = await stat(root).catch(() => undefined);
108
75
  if (!info?.isDirectory()) {
109
76
  ctx.ui.notify(`browse: ${target} is not a folder inside the working directory`, "error");
110
- return;
77
+ return undefined;
111
78
  }
112
79
  }
80
+ return { root, config };
81
+ };
113
82
 
114
- let realRoot = root;
115
- try {
116
- realRoot = realpathSync.native(root);
117
- } catch {
118
- // Keep the unresolved root; the server factory reports a real failure.
119
- }
120
- if (running && running.server.root === realRoot && running.server.port === port) {
121
- ctx.ui.notify(`browse: ${running.server.url} (already running)`, "info");
122
- return;
123
- }
83
+ /**
84
+ * Put the page in front of the user, unless the project asked to stay headless.
85
+ *
86
+ * Every `/browse` opens it, including one that finds the page already served: the
87
+ * command's job is showing the page, and a URL printed over a running server leaves
88
+ * the user to paste it.
89
+ */
90
+ const openPage = async (url: string, config: ServeConfig): Promise<void> => {
91
+ if (!config.autoOpen) return;
92
+ const open = deps.open ?? (await import("../../lib/browser.ts")).openInBrowser;
93
+ open(url);
94
+ };
124
95
 
125
- await stop(ctx);
126
- const create = deps.createServer ?? createFileServer;
96
+ /** Open the page's server; the server, git, HTTP, and browser modules live here. */
97
+ const openServer = async (root: string, config: ServeConfig, ctx: ExtensionContext): Promise<void> => {
98
+ const create = deps.createServer ?? (await import("./transport/server.ts")).createFileServer;
99
+ const { createGitDiff, createGitStatus } = await import("./git/git.ts");
100
+ const { requestMount } = await import("../../lib/http/index.ts");
127
101
  try {
128
- const server = await create({
102
+ const outcome = await create({
129
103
  root,
130
- config: { ...config, port },
104
+ config,
105
+ mount: (spec) => requestMount(pi, spec),
106
+ session: sessionId,
131
107
  git: createGitStatus(pi, root),
132
108
  diff: createGitDiff(pi, root, {
133
109
  context: config.diffContext,
@@ -137,22 +113,53 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
137
113
  }),
138
114
  onIdle: () => void stopWhenPageLeft(),
139
115
  });
140
- if (unpinned) sessionPort = port;
116
+ if (outcome.kind === "refused") {
117
+ ctx.ui.notify(`browse: ${outcome.reason}`, "error");
118
+ return;
119
+ }
120
+ const server = outcome.server;
141
121
  running = { server, ctx };
142
- setBrowseStatus(ctx, server.port);
143
122
  ctx.ui.notify(`browse: ${server.root} at ${server.url}`, "info");
144
- if (config.autoOpen) (deps.open ?? openInBrowser)(server.url);
123
+ await openPage(server.url, config);
145
124
  } catch (error) {
146
- const code = (error as NodeJS.ErrnoException | undefined)?.code;
147
- if (code === "EADDRINUSE") {
148
- ctx.ui.notify(
149
- `browse: port ${port} is in use; run /browse again to try another or set "port" in the bytefly.json file-browse section`,
150
- "error",
151
- );
152
- } else {
153
- ctx.ui.notify(`browse: ${error instanceof Error ? error.message : String(error)}`, "error");
154
- }
125
+ ctx.ui.notify(`browse: ${error instanceof Error ? error.message : String(error)}`, "error");
126
+ }
127
+ };
128
+
129
+ const start = async (args: string, ctx: ExtensionContext): Promise<void> => {
130
+ const target = args.trim();
131
+ if (target === "status") {
132
+ ctx.ui.notify(
133
+ running ? `browse: ${running.server.root} at ${running.server.url}` : "browse: not running",
134
+ "info",
135
+ );
136
+ return;
137
+ }
138
+ if (target === "stop") {
139
+ ctx.ui.notify((await stop()) ? "browse: stopped" : "browse: not running", "info");
140
+ return;
141
+ }
142
+
143
+ const cwd = resolveEffectiveCwd(ctx.cwd);
144
+ const resolved = await resolveRoot(target, cwd, ctx);
145
+ if (resolved === undefined) return;
146
+ const { root, config } = resolved;
147
+
148
+ let realRoot = root;
149
+ try {
150
+ realRoot = realpathSync.native(root);
151
+ } catch {
152
+ // Keep the unresolved root; the server factory reports a real failure.
153
+ }
154
+ if (running && running.server.root === realRoot) {
155
+ ctx.ui.notify(`browse: ${running.server.url} (already running)`, "info");
156
+ // The page is already served, so this is an open and not a restart.
157
+ await openPage(running.server.url, config);
158
+ return;
155
159
  }
160
+
161
+ await stop();
162
+ await openServer(root, config, ctx);
156
163
  };
157
164
 
158
165
  pi.registerCommand("browse", {
@@ -168,8 +175,11 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
168
175
  },
169
176
  });
170
177
 
171
- pi.on("session_shutdown", async (_event, ctx) => {
172
- await stop(ctx);
173
- sessionPort = undefined;
178
+ pi.on("session_start", async (_event, ctx) => {
179
+ sessionId = ctx.sessionManager.getSessionId();
180
+ });
181
+
182
+ pi.on("session_shutdown", async () => {
183
+ await stop();
174
184
  });
175
185
  }
@@ -174,7 +174,7 @@ export interface LiveChannel {
174
174
  * attached, so an idle server does no status work at all; the last page leaving
175
175
  * parks it and the next page starts it again. What happens to a server nobody is
176
176
  * reading — the idle grace and the close it leads to — is the server's policy, so
177
- * the caller hands it to `lib/http`'s `serveLoopback` instead of configuring it
177
+ * the caller hands it to its own mount instead of configuring it
178
178
  * here.
179
179
  */
180
180
  export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
@@ -46,13 +46,16 @@ import {
46
46
  renderErrorPage,
47
47
  renderFileDiffPage,
48
48
  renderFilePage,
49
+ hrefFor,
49
50
  } from "../view/html.ts";
50
51
  import type { ChangeSummary } from "../view/html.ts";
51
- import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
52
+ import { HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
52
53
  import { listingRevision } from "../live/rev.ts";
53
54
 
54
55
  export interface ServeContext {
55
56
  root: string;
57
+ /** The prefix this page answers under, e.g. `/files`; `""` for the origin's root. */
58
+ base: string;
56
59
  config: ServeConfig;
57
60
  /** Read-only git context for the page header; omitted when unavailable. */
58
61
  git?: GitStatusProvider;
@@ -152,6 +155,7 @@ async function errorPage(
152
155
  // Keep the minimal tree.
153
156
  }
154
157
  const body = renderErrorPage({
158
+ base: ctx.base,
155
159
  rootLabel: ctx.root,
156
160
  status,
157
161
  message,
@@ -187,6 +191,7 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
187
191
  return htmlResponse(
188
192
  200,
189
193
  renderDirectoryPage({
194
+ base: ctx.base,
190
195
  rootLabel: ctx.root,
191
196
  rel,
192
197
  listing,
@@ -211,6 +216,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
211
216
  return htmlResponse(
212
217
  200,
213
218
  renderFilePage({
219
+ base: ctx.base,
214
220
  rootLabel: ctx.root,
215
221
  rel,
216
222
  file,
@@ -235,6 +241,7 @@ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeRes
235
241
  return htmlResponse(
236
242
  200,
237
243
  renderDiffPage({
244
+ base: ctx.base,
238
245
  rootLabel: ctx.root,
239
246
  mode,
240
247
  files: list?.files ?? [],
@@ -267,6 +274,7 @@ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Prom
267
274
  return htmlResponse(
268
275
  200,
269
276
  renderFileDiffPage({
277
+ base: ctx.base,
270
278
  rootLabel: ctx.root,
271
279
  rel,
272
280
  mode,
@@ -303,12 +311,12 @@ async function raw(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
303
311
  };
304
312
  }
305
313
 
306
- function treeApiEntry(entry: DirEntry): Record<string, unknown> {
314
+ function treeApiEntry(entry: DirEntry, base: string): Record<string, unknown> {
307
315
  const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
308
316
  return {
309
317
  name: entry.name,
310
318
  path: entry.rel,
311
- href: entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`,
319
+ href: hrefFor(entry, base),
312
320
  isDir: entry.isDir,
313
321
  broken: entry.broken,
314
322
  iconHref: iconHref(spec.icon),
@@ -323,7 +331,7 @@ async function treeApi(ctx: ServeContext, decodedPath: string): Promise<ServeRes
323
331
  const info = await stat(abs).catch(() => undefined);
324
332
  if (info && !info.isDirectory()) throw new HttpError(400, "That path is not a folder.");
325
333
  const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
326
- return jsonResponse(200, { path: rel, entries: listing.entries.map(treeApiEntry) });
334
+ return jsonResponse(200, { path: rel, entries: listing.entries.map((entry) => treeApiEntry(entry, ctx.base)) });
327
335
  }
328
336
 
329
337
  /** The revision of one folder's listing: what a page's reload control compares against. */
@@ -1,44 +1,69 @@
1
1
  /**
2
- * The HTTP server for the browse extension.
2
+ * The browse page's mount: the prefix it holds on the session's one server, plus the
3
+ * live status channel it feeds.
3
4
  *
4
- * Wraps the pure `handleRequest` in `lib/http`'s loopback server: it resolves the
5
- * root once, feeds the live status channel, and exposes an idempotent `close()`.
6
- * The request handler is injectable so tests can avoid a socket when they only
7
- * need routing.
5
+ * `lib/http` owns the socket and `serve` owns the session's port, so this module owns
6
+ * only this page's own policy: the request handler, its request count, the cap that
7
+ * refuses a page rather than opening a stream for it, and how long a page with nobody
8
+ * reading it is worth keeping.
8
9
  *
9
- * What stays here is this extension's own policy: the request handler, its
10
- * request count, the catch that turns a thrown handler into a plain-text 500, the
11
- * cap that refuses a page rather than opening a stream for it, and how long a
12
- * server with nobody reading it is worth keeping.
10
+ * The page takes the namespace `/files`, beside `/board` and `/plan/<id>`: every URL it
11
+ * emits is built from that `base` — the links, the scripts, the destinations it rewrites
12
+ * out of Markdown, and the client's own fetches — so one prefix is all it needs, and the
13
+ * origin's root stays free. `BROWSE_BASE` is that claim, named where the mount and its
14
+ * suite can see it.
13
15
  */
14
16
 
15
17
  import { realpathSync } from "node:fs";
16
- import { type ServeResponse, serveLoopback, textResponse } from "../../../lib/http/index.ts";
18
+ import {
19
+ type HttpMount,
20
+ type MountOutcome,
21
+ type MountSource,
22
+ type ServeResponse,
23
+ textResponse,
24
+ } from "../../../lib/http/index.ts";
17
25
  import { IDLE_STOP_MS, type ServeConfig } from "../config/config.ts";
18
26
  import { createFileIndex } from "../files/files.ts";
19
27
  import type { GitDiffProvider, GitStatusProvider } from "../git/git.ts";
20
28
  import { createLiveChannel, createLiveStatus } from "../live/live.ts";
21
29
  import { handleRequest, type ServeContext } from "./router.ts";
22
30
 
31
+ /**
32
+ * The prefix this page answers under: its own namespace on the session's origin.
33
+ *
34
+ * Route names inside the page (`/`, `/browse`, `/view`, `/raw`, `/diff`, `/api/*`,
35
+ * `/events`) are unchanged and relative to this prefix, so the same page is served under
36
+ * `/files` and nowhere else, and the origin's root answers nothing at all.
37
+ */
38
+ export const BROWSE_BASE = "/files";
39
+
40
+ /** A mounted page. */
23
41
  export interface FileServer {
24
- /** Origin URL, e.g. `http://127.0.0.1:49152/`. */
42
+ /** The page's URL, e.g. `http://127.0.0.1:4780/files/`. */
25
43
  url: string;
26
- /** The bound port. */
44
+ /** The port the session's server bound. */
27
45
  port: number;
28
46
  /** Resolved absolute root that is actually served. */
29
47
  root: string;
30
- /** Stop listening. Safe to call more than once or before `listen` settles. */
48
+ /** Give the prefix back. Safe to call more than once. */
31
49
  close(): Promise<void>;
32
50
  }
33
51
 
52
+ /** The page, or the reason the session's server has no place for it. */
53
+ export type FileServerResult = { kind: "served"; server: FileServer } | { kind: "refused"; reason: string };
54
+
34
55
  export interface FileServerOptions {
35
56
  root: string;
36
57
  config: ServeConfig;
58
+ /** Where the prefix comes from: the session's server, or a suite's own. */
59
+ mount: MountSource;
60
+ /** The Pi session asking, so the prefix lands on this session's socket. */
61
+ session: string;
37
62
  /** Read-only git context for the page header; omitted when unavailable. */
38
63
  git?: GitStatusProvider;
39
64
  /** Read-only diff provider for the change and diff views; omitted when unavailable. */
40
65
  diff?: GitDiffProvider;
41
- /** Called once the last page has been gone for `idleStopMs`; omitted, the server waits for `close()`. */
66
+ /** Called once the last page has been gone for `idleStopMs`; omitted, the mount waits for `close()`. */
42
67
  onIdle?: () => void;
43
68
  /** Grace before `onIdle`, in ms; defaults to `IDLE_STOP_MS`. */
44
69
  idleStopMs?: number;
@@ -46,8 +71,8 @@ export interface FileServerOptions {
46
71
  handler?: typeof handleRequest;
47
72
  }
48
73
 
49
- /** Start a read-only server rooted at `options.root` on 127.0.0.1. */
50
- export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
74
+ /** Mount a read-only page rooted at `options.root` on the session's server. */
75
+ export async function createFileServer(options: FileServerOptions): Promise<FileServerResult> {
51
76
  const root = realpathSync.native(options.root);
52
77
  const { config } = options;
53
78
  // The status feed's two server-owned facts: how long it has been up, and how
@@ -64,6 +89,7 @@ export async function createFileServer(options: FileServerOptions): Promise<File
64
89
  const live = status ? createLiveChannel({ status, intervalMs: config.liveIntervalMs }) : undefined;
65
90
  const context: ServeContext = {
66
91
  root,
92
+ base: BROWSE_BASE,
67
93
  config,
68
94
  git: options.git,
69
95
  diff: options.diff,
@@ -72,10 +98,13 @@ export async function createFileServer(options: FileServerOptions): Promise<File
72
98
  };
73
99
  const handler = options.handler ?? handleRequest;
74
100
 
75
- const server = await serveLoopback({
76
- ports: [config.port],
77
- idle: options.onIdle === undefined ? undefined : { ms: options.idleStopMs ?? IDLE_STOP_MS, onIdle: options.onIdle },
78
- handler: async (request) => {
101
+ const outcome: MountOutcome = await options.mount({
102
+ session: options.session,
103
+ prefix: BROWSE_BASE,
104
+ ...(options.onIdle === undefined
105
+ ? {}
106
+ : { idle: { ms: options.idleStopMs ?? IDLE_STOP_MS, onIdle: options.onIdle } }),
107
+ async handler(request) {
79
108
  requests += 1;
80
109
  let result: ServeResponse;
81
110
  try {
@@ -96,15 +125,19 @@ export async function createFileServer(options: FileServerOptions): Promise<File
96
125
  };
97
126
  },
98
127
  });
99
-
128
+ if (outcome.kind === "refused") return { kind: "refused", reason: outcome.reason };
129
+ const mount: HttpMount = outcome.mount;
100
130
  return {
101
- url: `${server.origin}/`,
102
- port: server.port,
103
- root,
104
- close: async () => {
105
- // End the streams first, so a connected page does not hold the close open.
106
- live?.close();
107
- await server.close();
131
+ kind: "served",
132
+ server: {
133
+ url: `${mount.origin}${mount.base}/`,
134
+ port: mount.port,
135
+ root,
136
+ close: async () => {
137
+ // End the streams first, so a connected page does not hold the mount open.
138
+ live?.close();
139
+ await mount.release();
140
+ },
108
141
  },
109
142
  };
110
143
  }
@@ -46,7 +46,7 @@ export function renderLiveChrome(live: LivePage | undefined): string {
46
46
  if (!live) return "";
47
47
  const rev =
48
48
  live.rev === undefined ? "" : ` data-dir="${escapeHtml(live.dir ?? "")}" data-rev="${escapeHtml(live.rev)}"`;
49
- const chip = `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}"${rev} hidden><span class="browse-live__glyph" aria-hidden="true">${escapeHtml(GLYPHS.browse)}</span><span class="browse-live__text">connecting</span></span>`;
49
+ const chip = `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}"${rev} hidden><span class="browse-live__glyph" aria-hidden="true">◉</span><span class="browse-live__text">connecting</span></span>`;
50
50
  if (live.rev === undefined) return chip;
51
51
  return `${chip}<button type="button" class="bf-chip bf-chip--warn browse-reload" id="reload-hint" title="This folder changed on disk" hidden>⟳ reload</button>`;
52
52
  }
@@ -64,11 +64,11 @@ export function renderLiveStatus(): string {
64
64
  * a hidden shell is always rendered so the chip can appear and disappear as the
65
65
  * working tree changes, without the client inserting elements into the header.
66
66
  */
67
- export function renderChanges(summary: ChangeIndex | undefined, live: LivePage | undefined): string {
67
+ export function renderChanges(summary: ChangeIndex | undefined, live: LivePage | undefined, base: string): string {
68
68
  const count = summary?.count ?? 0;
69
69
  if (count <= 0) {
70
70
  return live
71
- ? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
71
+ ? `<a class="bf-chip browse-diff" id="browse-changes" href="${base}/diff" title="View uncommitted changes" hidden></a>`
72
72
  : "";
73
73
  }
74
74
  const label = `${count} change${count === 1 ? "" : "s"}`;
@@ -79,5 +79,5 @@ export function renderChanges(summary: ChangeIndex | undefined, live: LivePage |
79
79
  ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
80
80
  : "";
81
81
  const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${deletions} deletions` : ""}`;
82
- return `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
82
+ return `<a class="bf-chip browse-diff" id="browse-changes" href="${base}/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
83
83
  }
@@ -12,13 +12,15 @@
12
12
  * and `bun build --no-bundle` needs no separate asset.
13
13
  */
14
14
 
15
- import { keysDialogScript, THEME_TOGGLE_JS } from "../../../lib/web/index.ts";
15
+ import { CLIENT_BASE_JS, keysDialogScript, THEME_TOGGLE_JS } from "../../../lib/web/index.ts";
16
16
  import { LIVE_CLIENT_JS } from "./live-client.ts";
17
17
  import { PALETTE_CLIENT_JS } from "./palette.ts";
18
18
  import { TREE_CLIENT_JS } from "./tree.ts";
19
19
 
20
20
  export const CLIENT_JS = `(function () {
21
21
  "use strict";
22
+ /* Spliced at the outermost scope: every fragment below builds its URLs from it. */
23
+ ${CLIENT_BASE_JS}
22
24
  var rows = Array.prototype.slice.call(document.querySelectorAll("table.bf-table tbody tr"));
23
25
  ${TREE_CLIENT_JS}
24
26
  Array.prototype.forEach.call(document.querySelectorAll("img.bf-table__thumb"), function (img) {