@gavin-hu/my-pi-agent 0.20.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 (106) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/docs/extensions/README.md +1 -0
  4. package/docs/extensions/board.md +57 -42
  5. package/docs/extensions/bytefly.md +85 -89
  6. package/docs/extensions/file-browse.md +36 -41
  7. package/docs/extensions/plan.md +31 -14
  8. package/docs/extensions/serve.md +129 -0
  9. package/docs/extensions/session-bus.md +1 -1
  10. package/docs/extensions/team.md +11 -11
  11. package/extensions/board/commands.ts +27 -14
  12. package/extensions/board/index.ts +14 -2
  13. package/extensions/board/join.ts +31 -10
  14. package/extensions/board/page.ts +53 -83
  15. package/extensions/board/result.ts +1 -24
  16. package/extensions/board/rows.ts +1 -1
  17. package/extensions/board/schema.ts +9 -3
  18. package/extensions/board/snapshot.ts +7 -6
  19. package/extensions/board/tools.ts +27 -15
  20. package/extensions/board/transport/router.ts +3 -5
  21. package/extensions/board/transport/server.ts +59 -28
  22. package/extensions/board/types.ts +0 -10
  23. package/extensions/board/view/client.ts +3 -2
  24. package/extensions/board/view/live.ts +1 -7
  25. package/extensions/board/view/page.ts +16 -5
  26. package/extensions/board/view/render.ts +22 -17
  27. package/extensions/board/view/styles.ts +7 -1
  28. package/extensions/bytefly/footer/config.ts +3 -6
  29. package/extensions/bytefly/footer/render/footer.ts +18 -4
  30. package/extensions/bytefly/footer/render/key.ts +46 -0
  31. package/extensions/bytefly/footer/render/layout.ts +13 -3
  32. package/extensions/bytefly/footer/render/lines.ts +4 -4
  33. package/extensions/bytefly/footer/render/segments.ts +10 -34
  34. package/extensions/bytefly/header/component.ts +4 -20
  35. package/extensions/check/render.ts +10 -1
  36. package/extensions/file-browse/config/config.ts +4 -19
  37. package/extensions/file-browse/index.ts +27 -48
  38. package/extensions/file-browse/live/live.ts +1 -1
  39. package/extensions/file-browse/transport/router.ts +12 -4
  40. package/extensions/file-browse/transport/server.ts +61 -28
  41. package/extensions/file-browse/view/chrome.ts +4 -4
  42. package/extensions/file-browse/view/client.ts +3 -1
  43. package/extensions/file-browse/view/html.ts +73 -40
  44. package/extensions/file-browse/view/live-client.ts +2 -2
  45. package/extensions/file-browse/view/markdown.ts +13 -14
  46. package/extensions/file-browse/view/palette.ts +2 -2
  47. package/extensions/file-browse/view/raw-html.ts +9 -9
  48. package/extensions/file-browse/view/tree.ts +1 -1
  49. package/extensions/file-browse/view/urls.ts +20 -6
  50. package/extensions/goal/tui.ts +12 -2
  51. package/extensions/job/tools.ts +19 -7
  52. package/extensions/job/tui.ts +2 -28
  53. package/extensions/plan/commands.ts +2 -2
  54. package/extensions/plan/feedback.ts +62 -16
  55. package/extensions/plan/index.ts +7 -1
  56. package/extensions/plan/plan-text.ts +114 -0
  57. package/extensions/plan/runtime.ts +12 -2
  58. package/extensions/plan/tools.ts +2 -2
  59. package/extensions/plan/web-blocks.ts +76 -5
  60. package/extensions/plan/web-chrome.ts +4 -0
  61. package/extensions/plan/web-client.ts +99 -2
  62. package/extensions/plan/web-collab.ts +4 -4
  63. package/extensions/plan/web-comments.ts +39 -8
  64. package/extensions/plan/web-composer.ts +14 -3
  65. package/extensions/plan/web-keys.ts +149 -25
  66. package/extensions/plan/web-markdown.ts +1 -1
  67. package/extensions/plan/web-outline.ts +22 -2
  68. package/extensions/plan/web-page.ts +56 -86
  69. package/extensions/plan/web-payload.ts +188 -0
  70. package/extensions/plan/web-plan.ts +23 -13
  71. package/extensions/plan/web-settle.ts +14 -7
  72. package/extensions/plan/web-share-session.ts +24 -55
  73. package/extensions/plan/web-share.ts +19 -37
  74. package/extensions/plan/web-write.ts +112 -0
  75. package/extensions/plan/web.ts +77 -130
  76. package/extensions/serve/config.ts +37 -0
  77. package/extensions/serve/index.ts +71 -0
  78. package/extensions/serve/registry.ts +251 -0
  79. package/extensions/session-bus/join-request.ts +16 -9
  80. package/extensions/session-bus/result.ts +1 -27
  81. package/extensions/session-bus/tools.ts +5 -4
  82. package/extensions/team/commands.ts +3 -1
  83. package/extensions/todo/tui.ts +21 -3
  84. package/lib/README.md +5 -4
  85. package/lib/board/page-pointer.ts +29 -4
  86. package/lib/board/session.ts +84 -18
  87. package/lib/board/store.ts +34 -8
  88. package/lib/board/view.ts +20 -14
  89. package/lib/bus-request.ts +83 -0
  90. package/lib/http/index.ts +11 -7
  91. package/lib/http/mount.ts +142 -0
  92. package/lib/http/port.ts +25 -0
  93. package/lib/http/serve.ts +170 -46
  94. package/lib/http/server.ts +3 -3
  95. package/lib/plan-entry.ts +9 -13
  96. package/lib/requirements.ts +2 -2
  97. package/lib/session-label.ts +23 -7
  98. package/lib/session-tool.ts +37 -21
  99. package/lib/tui/README.md +6 -3
  100. package/lib/tui/glyphs.ts +2 -4
  101. package/lib/tui/render-cache.ts +29 -0
  102. package/lib/tui/tool.ts +30 -1
  103. package/lib/web/client-runtime.ts +27 -1
  104. package/lib/web/markup.ts +18 -1
  105. package/package.json +3 -2
  106. package/extensions/board/config.ts +0 -36
@@ -16,6 +16,7 @@ import { screenHint } from "../../../lib/tui/chrome.ts";
16
16
  import { keyHintText } from "../../../lib/tui/glyphs.ts";
17
17
  import { BODY_INDENT } from "../../../lib/tui/indent.ts";
18
18
  import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
19
+ import { RenderCache } from "../../../lib/tui/render-cache.ts";
19
20
  import { paint } from "../../../lib/tui/tokens.ts";
20
21
  import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
21
22
  import { identityCells } from "./identity.ts";
@@ -69,11 +70,7 @@ function helpParts(entry: HelpEntry): { key: string; description: string } {
69
70
 
70
71
  /** The themed header component. */
71
72
  export class ByteflyHeader implements Component {
72
- private cachedWidth: number | null = null;
73
- private cachedTheme: Theme | undefined;
74
- private cachedView = "";
75
- private cachedKeys = "";
76
- private cachedLines: string[] = [];
73
+ private readonly cache = new RenderCache();
77
74
 
78
75
  constructor(
79
76
  private readonly getTheme: () => Theme,
@@ -82,7 +79,7 @@ export class ByteflyHeader implements Component {
82
79
  ) {}
83
80
 
84
81
  invalidate(): void {
85
- this.cachedWidth = null;
82
+ this.cache.invalidate();
86
83
  }
87
84
 
88
85
  /**
@@ -97,20 +94,7 @@ export class ByteflyHeader implements Component {
97
94
  const view = this.view();
98
95
  const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
99
96
  const keys = this.keySignature();
100
- if (
101
- this.cachedWidth === w &&
102
- this.cachedTheme === theme &&
103
- this.cachedView === viewKey &&
104
- this.cachedKeys === keys
105
- ) {
106
- return this.cachedLines;
107
- }
108
- this.cachedLines = this.build(w, theme, view);
109
- this.cachedWidth = w;
110
- this.cachedTheme = theme;
111
- this.cachedView = viewKey;
112
- this.cachedKeys = keys;
113
- return this.cachedLines;
97
+ return this.cache.keyed([w, theme, viewKey, keys], () => this.build(w, theme, view));
114
98
  }
115
99
 
116
100
  /** The chord of every help binding, so a rebind repaints the help rows. */
@@ -16,6 +16,7 @@ import type { Component } from "@earendil-works/pi-tui";
16
16
  import { sanitizeLine } from "../../lib/format.ts";
17
17
  import { clip } from "../../lib/tui/columns.ts";
18
18
  import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
19
+ import { RenderCache } from "../../lib/tui/render-cache.ts";
19
20
  import { listRow } from "../../lib/tui/rows.ts";
20
21
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
21
22
  import { earlierHint } from "../../lib/tui/tool.ts";
@@ -134,6 +135,7 @@ export interface CheckResultInput {
134
135
  export class CheckResultBlock implements Component {
135
136
  private input: CheckResultInput;
136
137
  private theme: Theme;
138
+ private readonly cache = new RenderCache();
137
139
 
138
140
  constructor(input: CheckResultInput, theme: Theme) {
139
141
  this.input = input;
@@ -144,12 +146,19 @@ export class CheckResultBlock implements Component {
144
146
  setInput(input: CheckResultInput, theme: Theme): void {
145
147
  this.input = input;
146
148
  this.theme = theme;
149
+ this.cache.invalidate();
147
150
  }
148
151
 
149
- invalidate(): void {}
152
+ invalidate(): void {
153
+ this.cache.invalidate();
154
+ }
150
155
 
151
156
  render(width: number): string[] {
152
157
  const w = Math.max(1, width);
158
+ return this.cache.lines(w, undefined, () => this.build(w));
159
+ }
160
+
161
+ private build(w: number): string[] {
153
162
  const theme = this.theme;
154
163
  const { details, expanded } = this.input;
155
164
  const role = statusRole(details.status);
@@ -2,16 +2,14 @@
2
2
  * Configuration for the browse extension.
3
3
  *
4
4
  * The `file-browse` section of the shared `bytefly.json` (global then project,
5
- * project wins). Everything
6
- * is validated and clamped so a typo cannot produce a nonsensical server.
5
+ * project wins). Everything is validated and clamped so a typo cannot produce a
6
+ * nonsensical page. The port is not here: the session serves every page on one, and
7
+ * `serve.port` is where that is pinned.
7
8
  */
8
9
 
9
- import { randomInt } from "node:crypto";
10
10
  import { asBoolean, clampInteger, loadConfigSection } from "../../../lib/config.ts";
11
11
 
12
12
  export interface ServeConfig {
13
- /** TCP port on 127.0.0.1; `0` picks one random port fixed for the session, a positive value pins it. */
14
- port: number;
15
13
  /** Open the default browser when the server starts. */
16
14
  autoOpen: boolean;
17
15
  /** Files larger than this are not rendered inline. */
@@ -43,7 +41,6 @@ export interface ServeConfig {
43
41
  }
44
42
 
45
43
  export const DEFAULT_CONFIG: ServeConfig = {
46
- port: 0,
47
44
  autoOpen: true,
48
45
  maxFileBytes: 1024 * 1024,
49
46
  maxThumbBytes: 5 * 1024 * 1024,
@@ -60,12 +57,6 @@ export const DEFAULT_CONFIG: ServeConfig = {
60
57
  liveIntervalMs: 5000,
61
58
  };
62
59
 
63
- /** Base of the per-session port range. */
64
- export const PORT_BASE = 4780;
65
-
66
- /** Number of ports in the per-session range. */
67
- export const PORT_RANGE = 1000;
68
-
69
60
  /** Fastest live-status cadence a page may be given: short enough to watch, and a
70
61
  * floor so a page cannot outrun the git reads behind each frame. */
71
62
  export const LIVE_INTERVAL_MIN = 250;
@@ -76,7 +67,7 @@ export const LIVE_INTERVAL_MAX = 60_000;
76
67
  /**
77
68
  * How long the server keeps listening once the last page has gone. A reload or a
78
69
  * navigation detaches the stream and re-attaches within a moment, so the grace has
79
- * to outlast one; a closed window never re-attaches, and the port is released
70
+ * to outlast one; a closed window never re-attaches, and the prefix is given back
80
71
  * instead of listening for nobody until the session ends.
81
72
  */
82
73
  export const IDLE_STOP_MS = 10_000;
@@ -90,11 +81,6 @@ export const IDLE_STOP_MS = 10_000;
90
81
  */
91
82
  export const LIVE_MAX_CLIENTS = 8;
92
83
 
93
- /** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
94
- export function randomPort(): number {
95
- return PORT_BASE + randomInt(PORT_RANGE);
96
- }
97
-
98
84
  /** Validate and clamp a raw config object over the defaults. */
99
85
  export function normalizeConfig(
100
86
  raw: Record<string, unknown> | undefined,
@@ -102,7 +88,6 @@ export function normalizeConfig(
102
88
  ): ServeConfig {
103
89
  if (!raw) return base;
104
90
  return {
105
- port: clampInteger(raw.port, base.port, 0, 65535),
106
91
  autoOpen: asBoolean(raw.autoOpen, base.autoOpen),
107
92
  maxFileBytes: clampInteger(raw.maxFileBytes, base.maxFileBytes, 0, 1024 * 1024 * 1024),
108
93
  maxThumbBytes: clampInteger(raw.maxThumbBytes, base.maxThumbBytes, 0, 1024 * 1024 * 1024),
@@ -14,9 +14,8 @@ 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";
17
+ import { requestMount } from "../../lib/http/index.ts";
18
+ import { loadConfig } from "./config/config.ts";
20
19
  import { createGitDiff, createGitStatus } from "./git/git.ts";
21
20
  import { openInBrowser } from "../../lib/browser.ts";
22
21
  import { HttpError, resolveRequestPath } from "./files/paths.ts";
@@ -27,8 +26,6 @@ export interface FileBrowserDeps {
27
26
  createServer?: typeof createFileServer;
28
27
  /** Override the browser opener (tests). */
29
28
  open?: typeof openInBrowser;
30
- /** Override session-port selection (tests). */
31
- pickPort?: () => number;
32
29
  }
33
30
 
34
31
  interface RunningServer {
@@ -40,40 +37,25 @@ interface RunningServer {
40
37
  export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}): void {
41
38
  if (!isExtensionEnabled("file-browse")) return;
42
39
  let running: RunningServer | undefined;
43
- /** Random port fixed for the session after the first successful start. */
44
- let sessionPort: number | undefined;
40
+ /** The session this page serves as; the mount lands on its one server. */
41
+ let sessionId = "";
45
42
 
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> => {
43
+ const stop = async (): Promise<boolean> => {
60
44
  const current = running;
61
45
  if (!current) return false;
62
46
  running = undefined;
63
47
  await current.server.close();
64
- if (ctx) setBrowseStatus(ctx, undefined);
65
48
  return true;
66
49
  };
67
50
 
68
51
  /**
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.
52
+ * Give the prefix back once the page that was its only reader has gone: nothing is
53
+ * left to serve. `/browse` starts it again on the session's port.
72
54
  */
73
55
  const stopWhenPageLeft = async (): Promise<void> => {
74
56
  const current = running;
75
57
  if (!current) return;
76
- if (await stop(current.ctx)) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
58
+ if (await stop()) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
77
59
  };
78
60
 
79
61
  const start = async (args: string, ctx: ExtensionContext): Promise<void> => {
@@ -86,15 +68,12 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
86
68
  return;
87
69
  }
88
70
  if (target === "stop") {
89
- ctx.ui.notify((await stop(ctx)) ? "browse: stopped" : "browse: not running", "info");
71
+ ctx.ui.notify((await stop()) ? "browse: stopped" : "browse: not running", "info");
90
72
  return;
91
73
  }
92
74
 
93
75
  const cwd = resolveEffectiveCwd(ctx.cwd);
94
76
  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
77
  let root = cwd;
99
78
  if (target) {
100
79
  try {
@@ -117,17 +96,19 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
117
96
  } catch {
118
97
  // Keep the unresolved root; the server factory reports a real failure.
119
98
  }
120
- if (running && running.server.root === realRoot && running.server.port === port) {
99
+ if (running && running.server.root === realRoot) {
121
100
  ctx.ui.notify(`browse: ${running.server.url} (already running)`, "info");
122
101
  return;
123
102
  }
124
103
 
125
- await stop(ctx);
104
+ await stop();
126
105
  const create = deps.createServer ?? createFileServer;
127
106
  try {
128
- const server = await create({
107
+ const outcome = await create({
129
108
  root,
130
- config: { ...config, port },
109
+ config,
110
+ mount: (spec) => requestMount(pi, spec),
111
+ session: sessionId,
131
112
  git: createGitStatus(pi, root),
132
113
  diff: createGitDiff(pi, root, {
133
114
  context: config.diffContext,
@@ -137,21 +118,16 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
137
118
  }),
138
119
  onIdle: () => void stopWhenPageLeft(),
139
120
  });
140
- if (unpinned) sessionPort = port;
121
+ if (outcome.kind === "refused") {
122
+ ctx.ui.notify(`browse: ${outcome.reason}`, "error");
123
+ return;
124
+ }
125
+ const server = outcome.server;
141
126
  running = { server, ctx };
142
- setBrowseStatus(ctx, server.port);
143
127
  ctx.ui.notify(`browse: ${server.root} at ${server.url}`, "info");
144
128
  if (config.autoOpen) (deps.open ?? openInBrowser)(server.url);
145
129
  } 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
- }
130
+ ctx.ui.notify(`browse: ${error instanceof Error ? error.message : String(error)}`, "error");
155
131
  }
156
132
  };
157
133
 
@@ -168,8 +144,11 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
168
144
  },
169
145
  });
170
146
 
171
- pi.on("session_shutdown", async (_event, ctx) => {
172
- await stop(ctx);
173
- sessionPort = undefined;
147
+ pi.on("session_start", async (_event, ctx) => {
148
+ sessionId = ctx.sessionManager.getSessionId();
149
+ });
150
+
151
+ pi.on("session_shutdown", async () => {
152
+ await stop();
174
153
  });
175
154
  }
@@ -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) {