@gavin-hu/my-pi-agent 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Directory listings and single-file reads: what a folder holds, capped and sorted, and
3
+ * what one file's bytes are.
4
+ *
5
+ * The filesystem reads are injected (`DirectoryFs`) so a test can count them: the property
6
+ * worth holding is that a directory is read once per entry, not once per row drawn.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { lstat, readdir, readFile, stat } from "node:fs/promises";
11
+ import type { Stats } from "node:fs";
12
+ import { mapConcurrent } from "../../../lib/pool.ts";
13
+ import { HttpError } from "./paths.ts";
14
+ import {
15
+ classifyByName,
16
+ contentTypeFor,
17
+ type EntryKind,
18
+ isImageName,
19
+ isMarkdownName,
20
+ isProbablyText,
21
+ } from "./classify.ts";
22
+
23
+ export interface DirEntry {
24
+ name: string;
25
+ /** Root-relative posix path. */
26
+ rel: string;
27
+ isDir: boolean;
28
+ isSymlink: boolean;
29
+ broken: boolean;
30
+ size: number;
31
+ mtimeMs: number;
32
+ kind: EntryKind;
33
+ isImage: boolean;
34
+ }
35
+
36
+ export interface Listing {
37
+ entries: DirEntry[];
38
+ total: number;
39
+ truncated: boolean;
40
+ }
41
+
42
+ export interface FileMeta {
43
+ size: number;
44
+ mtimeMs: number;
45
+ mime: string;
46
+ }
47
+
48
+ export type FileView =
49
+ | (FileMeta & { kind: "text"; lines: string[]; truncated: boolean; totalLines: number })
50
+ | (FileMeta & { kind: "markdown"; lines: string[]; truncated: boolean; totalLines: number })
51
+ | (FileMeta & { kind: "image" })
52
+ | (FileMeta & { kind: "binary" })
53
+ | (FileMeta & { kind: "tooLarge" });
54
+
55
+ /** Whether the name has an image extension. */
56
+ export function toAbs(root: string, rel: string): string {
57
+ return rel ? join(root, ...rel.split("/")) : root;
58
+ }
59
+
60
+ /** The `Dirent` fields a listing reads; a test's fake need not be a real `Dirent`. */
61
+ export interface DirectoryEntryInfo {
62
+ name: string;
63
+ isDirectory(): boolean;
64
+ isSymbolicLink(): boolean;
65
+ }
66
+
67
+ /** The `Stats` fields a listing reads. */
68
+ export interface DirectoryStats {
69
+ size: number;
70
+ mtimeMs: number;
71
+ isDirectory(): boolean;
72
+ }
73
+
74
+ /**
75
+ * The three filesystem reads a listing makes.
76
+ *
77
+ * Injected so a test can observe them: the interesting property — a directory is
78
+ * read once per entry, not once per entry *and* once per row drawn — is a count,
79
+ * not a value, and a wall-clock assertion would be flaky on a loaded machine.
80
+ */
81
+ export interface DirectoryFs {
82
+ readdir(path: string): Promise<DirectoryEntryInfo[]>;
83
+ lstat(path: string): Promise<DirectoryStats>;
84
+ stat(path: string): Promise<DirectoryStats>;
85
+ }
86
+
87
+ /** Production reads; a symlink's size and kind come from the target. */
88
+ const NODE_DIRECTORY_FS: DirectoryFs = {
89
+ readdir: (path) => readdir(path, { withFileTypes: true }),
90
+ lstat: (path) => lstat(path),
91
+ stat: (path) => stat(path),
92
+ };
93
+
94
+ /** Reads in flight per listing: enough to hide latency, bounded against a burst. */
95
+ const LISTING_CONCURRENCY = 32;
96
+
97
+ /** A row while it is still missing its metadata. */
98
+ interface Row {
99
+ name: string;
100
+ rel: string;
101
+ isDir: boolean;
102
+ isSymlink: boolean;
103
+ broken: boolean;
104
+ size: number;
105
+ mtimeMs: number;
106
+ }
107
+
108
+ /**
109
+ * Resolve one symlink: the link's own stat, then the target's.
110
+ *
111
+ * A symlink's directory-ness is the target's, so this cannot wait until after
112
+ * the sort; it is also the only entry whose classification needs a read.
113
+ */
114
+ async function resolveSymlink(fs: DirectoryFs, absDir: string, row: Row): Promise<void> {
115
+ const abs = join(absDir, row.name);
116
+ try {
117
+ const info = await fs.lstat(abs);
118
+ row.size = info.size;
119
+ row.mtimeMs = info.mtimeMs;
120
+ } catch {
121
+ // Vanished between readdir and lstat: the target read decides below.
122
+ }
123
+ try {
124
+ const target = await fs.stat(abs);
125
+ row.isDir = target.isDirectory();
126
+ row.size = target.size;
127
+ row.mtimeMs = target.mtimeMs;
128
+ } catch {
129
+ row.broken = true;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * List a directory, sorted (directories first, then case-insensitive name).
135
+ *
136
+ * A listing is latency per entry, not work per entry, so it is read in three
137
+ * passes instead of one entry at a time:
138
+ *
139
+ * 1. `readdir`, whose dirents already say whether each name is a directory;
140
+ * 2. the symlinks only, resolved through the pool, because their kind is the
141
+ * target's and the sort needs it;
142
+ * 3. the kept rows only, `stat`ed through the pool — so a directory of three
143
+ * thousand names pays for the two thousand it draws, not for every name, and
144
+ * pays them concurrently instead of in turn.
145
+ *
146
+ * `total` is still the directory's full entry count and `truncated` its `> limit`
147
+ * flip, so the page can say how many were dropped. Ties keep `readdir` order:
148
+ * `sort` is stable and the rows arrive unsorted.
149
+ */
150
+ export async function listDirectory(
151
+ root: string,
152
+ relDir: string,
153
+ limit: number,
154
+ fs: DirectoryFs = NODE_DIRECTORY_FS,
155
+ ): Promise<Listing> {
156
+ const absDir = toAbs(root, relDir);
157
+ let dirents: DirectoryEntryInfo[];
158
+ try {
159
+ dirents = await fs.readdir(absDir);
160
+ } catch {
161
+ throw new HttpError(404, "That folder could not be read.");
162
+ }
163
+
164
+ const links: Row[] = [];
165
+ const rows: Row[] = dirents.map((dirent) => {
166
+ const row: Row = {
167
+ name: dirent.name,
168
+ rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
169
+ isDir: dirent.isDirectory(),
170
+ isSymlink: dirent.isSymbolicLink(),
171
+ broken: false,
172
+ size: 0,
173
+ mtimeMs: 0,
174
+ };
175
+ if (row.isSymlink) links.push(row);
176
+ return row;
177
+ });
178
+
179
+ await mapConcurrent(links, LISTING_CONCURRENCY, (row) => resolveSymlink(fs, absDir, row));
180
+
181
+ // Directories first, then case-insensitive name. The sort runs before the cap,
182
+ // so the rows that survive it are the rows that would have been drawn.
183
+ rows.sort((a, b) => {
184
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
185
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
186
+ });
187
+
188
+ const total = rows.length;
189
+ const truncated = total > limit;
190
+ const kept = truncated ? rows.slice(0, limit) : rows;
191
+
192
+ await mapConcurrent(
193
+ kept.filter((row) => !row.isSymlink),
194
+ LISTING_CONCURRENCY,
195
+ async (row) => {
196
+ try {
197
+ const info = await fs.stat(join(absDir, row.name));
198
+ row.size = info.size;
199
+ row.mtimeMs = info.mtimeMs;
200
+ } catch {
201
+ // Directory entry vanished between readdir and stat.
202
+ }
203
+ },
204
+ );
205
+
206
+ const entries: DirEntry[] = kept.map((row) => {
207
+ const kind: EntryKind = row.isDir ? "directory" : classifyByName(row.name);
208
+ return {
209
+ name: row.name,
210
+ rel: row.rel,
211
+ isDir: row.isDir,
212
+ isSymlink: row.isSymlink,
213
+ broken: row.broken,
214
+ size: row.size,
215
+ mtimeMs: row.mtimeMs,
216
+ kind,
217
+ isImage: !row.isDir && kind === "image",
218
+ };
219
+ });
220
+
221
+ return { entries, total, truncated };
222
+ }
223
+
224
+ /** Read a file into a renderable view, honouring the size and line caps. */
225
+ export async function readFileView(
226
+ root: string,
227
+ rel: string,
228
+ maxFileBytes: number,
229
+ maxTextLines: number,
230
+ markdownEnabled = true,
231
+ ): Promise<FileView> {
232
+ const abs = toAbs(root, rel);
233
+ let info: Stats;
234
+ try {
235
+ info = await stat(abs);
236
+ } catch {
237
+ throw new HttpError(404, "That file could not be read.");
238
+ }
239
+ if (info.isDirectory()) throw new HttpError(404, "That path is a folder.");
240
+
241
+ const meta: FileMeta = { size: info.size, mtimeMs: info.mtimeMs, mime: contentTypeFor(rel) };
242
+ if (isImageName(rel)) return { kind: "image", ...meta };
243
+ if (info.size > maxFileBytes) return { kind: "tooLarge", ...meta };
244
+
245
+ let buffer: Buffer;
246
+ try {
247
+ buffer = await readFile(abs);
248
+ } catch {
249
+ throw new HttpError(404, "That file could not be read.");
250
+ }
251
+ if (!isProbablyText(buffer)) return { kind: "binary", ...meta };
252
+
253
+ const allLines = buffer.toString("utf-8").split(/\r\n|\r|\n/);
254
+ const truncated = allLines.length > maxTextLines;
255
+ const lines = truncated ? allLines.slice(0, maxTextLines) : allLines;
256
+ const kind = markdownEnabled && isMarkdownName(rel) ? "markdown" : "text";
257
+ if (kind === "markdown") {
258
+ return { kind: "markdown", ...meta, lines, truncated, totalLines: allLines.length };
259
+ }
260
+ return { kind: "text", ...meta, lines, truncated, totalLines: allLines.length };
261
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The sidebar tree: the current path's ancestors expanded, siblings collapsed.
3
+ *
4
+ * Its rows are the same `DirEntry` a listing produces, so the tree and the listing cannot
5
+ * disagree about what one name is.
6
+ */
7
+
8
+ import { basename } from "node:path";
9
+ import type { EntryKind } from "./classify.ts";
10
+ import { type Listing, listDirectory } from "./list.ts";
11
+
12
+ export interface TreeNode {
13
+ name: string;
14
+ rel: string;
15
+ isDir: boolean;
16
+ isImage: boolean;
17
+ /** True for a symlink whose target cannot be resolved. */
18
+ broken: boolean;
19
+ kind: EntryKind;
20
+ children: TreeNode[];
21
+ }
22
+
23
+ /**
24
+ * Build the sidebar tree: the current path's ancestors are expanded so the
25
+ * active item is always visible, and siblings are shown collapsed.
26
+ */
27
+ export async function buildTree(root: string, currentRel: string, limit: number): Promise<TreeNode> {
28
+ return buildNode(root, "", currentRel, basename(root) || root, limit);
29
+ }
30
+
31
+ /** A directory page's two views of one directory. */
32
+ export interface DirectoryView {
33
+ /** The current directory's own rows, for the page's listing. */
34
+ listing: Listing;
35
+ /** The sidebar tree, with the current path expanded. */
36
+ tree: TreeNode;
37
+ }
38
+
39
+ /**
40
+ * One walk for a directory page: the listing it draws, and the tree that shows
41
+ * where it sits.
42
+ *
43
+ * `listDirectory` is what decides whether the directory can be read at all, so it
44
+ * runs first and its 404 is the page's error. The walk then reuses that listing
45
+ * for the same directory instead of reading it a second time; the ancestors are
46
+ * still read once each, which is what the tree needs.
47
+ */
48
+ export async function directoryView(root: string, currentRel: string, limit: number): Promise<DirectoryView> {
49
+ const listing = await listDirectory(root, currentRel, limit);
50
+ const tree = await buildNode(root, "", currentRel, basename(root) || root, limit, { rel: currentRel, listing });
51
+ return { listing, tree };
52
+ }
53
+
54
+ async function buildNode(
55
+ root: string,
56
+ relDir: string,
57
+ currentRel: string,
58
+ label: string,
59
+ limit: number,
60
+ /** A listing the caller already read for this directory, so it is not re-read. */
61
+ known?: { rel: string; listing: Listing },
62
+ ): Promise<TreeNode> {
63
+ const node: TreeNode = {
64
+ name: label,
65
+ rel: relDir,
66
+ isDir: true,
67
+ isImage: false,
68
+ broken: false,
69
+ kind: "directory",
70
+ children: [],
71
+ };
72
+ let listing: Listing;
73
+ if (known && known.rel === relDir) {
74
+ listing = known.listing;
75
+ } else {
76
+ try {
77
+ listing = await listDirectory(root, relDir, limit);
78
+ } catch {
79
+ return node;
80
+ }
81
+ }
82
+ for (const entry of listing.entries) {
83
+ const child: TreeNode = {
84
+ name: entry.name,
85
+ rel: entry.rel,
86
+ isDir: entry.isDir,
87
+ isImage: entry.isImage,
88
+ broken: entry.broken,
89
+ kind: entry.kind,
90
+ children: [],
91
+ };
92
+ if (entry.isDir) {
93
+ const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
94
+ if (onPath) {
95
+ child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
96
+ }
97
+ }
98
+ node.children.push(child);
99
+ }
100
+ return node;
101
+ }
@@ -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 {
@@ -15,7 +15,6 @@ import {
15
15
  isLocalHostHeader,
16
16
  jsonError,
17
17
  jsonResponse,
18
- pageHeaders,
19
18
  parseRequestUrl,
20
19
  scriptHeaders,
21
20
  type ServeResponse,
@@ -47,13 +46,16 @@ import {
47
46
  renderErrorPage,
48
47
  renderFileDiffPage,
49
48
  renderFilePage,
49
+ hrefFor,
50
50
  } from "../view/html.ts";
51
51
  import type { ChangeSummary } from "../view/html.ts";
52
- import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
52
+ import { HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
53
53
  import { listingRevision } from "../live/rev.ts";
54
54
 
55
55
  export interface ServeContext {
56
56
  root: string;
57
+ /** The prefix this page answers under, e.g. `/files`; `""` for the origin's root. */
58
+ base: string;
57
59
  config: ServeConfig;
58
60
  /** Read-only git context for the page header; omitted when unavailable. */
59
61
  git?: GitStatusProvider;
@@ -153,6 +155,7 @@ async function errorPage(
153
155
  // Keep the minimal tree.
154
156
  }
155
157
  const body = renderErrorPage({
158
+ base: ctx.base,
156
159
  rootLabel: ctx.root,
157
160
  status,
158
161
  message,
@@ -188,6 +191,7 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
188
191
  return htmlResponse(
189
192
  200,
190
193
  renderDirectoryPage({
194
+ base: ctx.base,
191
195
  rootLabel: ctx.root,
192
196
  rel,
193
197
  listing,
@@ -212,6 +216,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
212
216
  return htmlResponse(
213
217
  200,
214
218
  renderFilePage({
219
+ base: ctx.base,
215
220
  rootLabel: ctx.root,
216
221
  rel,
217
222
  file,
@@ -236,6 +241,7 @@ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeRes
236
241
  return htmlResponse(
237
242
  200,
238
243
  renderDiffPage({
244
+ base: ctx.base,
239
245
  rootLabel: ctx.root,
240
246
  mode,
241
247
  files: list?.files ?? [],
@@ -268,6 +274,7 @@ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Prom
268
274
  return htmlResponse(
269
275
  200,
270
276
  renderFileDiffPage({
277
+ base: ctx.base,
271
278
  rootLabel: ctx.root,
272
279
  rel,
273
280
  mode,
@@ -304,12 +311,12 @@ async function raw(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
304
311
  };
305
312
  }
306
313
 
307
- function treeApiEntry(entry: DirEntry): Record<string, unknown> {
314
+ function treeApiEntry(entry: DirEntry, base: string): Record<string, unknown> {
308
315
  const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
309
316
  return {
310
317
  name: entry.name,
311
318
  path: entry.rel,
312
- href: entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`,
319
+ href: hrefFor(entry, base),
313
320
  isDir: entry.isDir,
314
321
  broken: entry.broken,
315
322
  iconHref: iconHref(spec.icon),
@@ -324,7 +331,7 @@ async function treeApi(ctx: ServeContext, decodedPath: string): Promise<ServeRes
324
331
  const info = await stat(abs).catch(() => undefined);
325
332
  if (info && !info.isDirectory()) throw new HttpError(400, "That path is not a folder.");
326
333
  const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
327
- 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)) });
328
335
  }
329
336
 
330
337
  /** The revision of one folder's listing: what a page's reload control compares against. */
@@ -379,7 +386,6 @@ export async function handleRequest(
379
386
  try {
380
387
  if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: CLIENT_JS };
381
388
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
382
- if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
383
389
  if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
384
390
  if (pathname === "/api/dir-rev") return await dirRevApi(ctx, search.get("path") ?? "");
385
391
  if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");