@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -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
+ }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { realpathSync } from "node:fs";
11
11
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
12
- import { isInside } from "../../lib/path.ts";
12
+ import { isInside } from "../../../lib/path.ts";
13
13
 
14
14
  /** An HTTP-shaped failure the router turns into a status page. */
15
15
  export class HttpError extends Error {
@@ -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
+ }
@@ -9,7 +9,14 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { createExecRunner, currentBranch, hasCommits, repoRoot, revParse, type RunGit } from "../../lib/git/index.ts";
12
+ import {
13
+ createExecRunner,
14
+ currentBranch,
15
+ hasCommits,
16
+ repoRoot,
17
+ revParse,
18
+ type RunGit,
19
+ } from "../../../lib/git/index.ts";
13
20
  import {
14
21
  diffArgs,
15
22
  inMode,
@@ -18,8 +25,8 @@ import {
18
25
  type ChangedFile,
19
26
  type DiffLine,
20
27
  type DiffMode,
21
- } from "./diff.ts";
22
- import { readFileView } from "./files.ts";
28
+ } from "../text/diff.ts";
29
+ import { readFileView } from "../files/files.ts";
23
30
 
24
31
  export interface GitStatus {
25
32
  /** Checked-out branch, when HEAD is attached. */
@@ -16,11 +16,11 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
16
16
  import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
17
17
  import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
18
18
  import { paint } from "../../lib/tui/tokens.ts";
19
- import { loadConfig, randomPort } from "./config.ts";
20
- import { createGitDiff, createGitStatus } from "./git.ts";
19
+ import { loadConfig, randomPort } from "./config/config.ts";
20
+ import { createGitDiff, createGitStatus } from "./git/git.ts";
21
21
  import { openInBrowser } from "../../lib/browser.ts";
22
- import { HttpError, resolveRequestPath } from "./paths.ts";
23
- import { createFileServer, type FileServer } from "./server.ts";
22
+ import { HttpError, resolveRequestPath } from "./files/paths.ts";
23
+ import { createFileServer, type FileServer } from "./transport/server.ts";
24
24
 
25
25
  export interface FileBrowserDeps {
26
26
  /** Override the server factory (tests). */
@@ -12,10 +12,11 @@
12
12
  * pair — so the whole feed is tested without one.
13
13
  */
14
14
 
15
- import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
16
- import { LIVE_MAX_CLIENTS } from "./config.ts";
17
- import { changeDirs } from "./diff.ts";
18
- import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
15
+ import type { EventSink } from "../../../lib/http/index.ts";
16
+ import { defaultInterval, type RepeatTimer } from "../../../lib/timer.ts";
17
+ import { LIVE_MAX_CLIENTS } from "../config/config.ts";
18
+ import { changeDirs } from "../text/diff.ts";
19
+ import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "../git/git.ts";
19
20
  import { changeRevision } from "./rev.ts";
20
21
 
21
22
  /**
@@ -141,48 +142,25 @@ export function frameOf(snapshot: LiveSnapshot): string {
141
142
  return `event: status\ndata: ${JSON.stringify(snapshot)}\n\n`;
142
143
  }
143
144
 
144
- /** A connected page: the two things a server can do to its stream. */
145
- export interface LiveClient {
146
- /** Write one frame. */
147
- write(frame: string): void;
148
- /** End the stream. */
149
- end(): void;
150
- }
151
-
152
- /** Starts a repeating tick and returns its cancel. */
153
- export type LiveTimer = (tick: () => void, intervalMs: number) => () => void;
154
-
155
- /** Starts a one-shot delay and returns its cancel. */
156
- export type LiveTimeout = (run: () => void, delayMs: number) => () => void;
157
-
158
- /** The default ticker: a repeating timer that cannot hold the process open. */
159
- const defaultTicker: LiveTimer = (tick, intervalMs) => defaultInterval(intervalMs, tick);
160
-
161
- /** The default grace timer: one shot, and it cannot hold the process open either. */
162
- const defaultTimeout: LiveTimeout = (run, delayMs) => defaultTimer(delayMs, run);
145
+ /** A connected page, as the channel writes it: the sink's two verbs. */
146
+ export type LiveClient = EventSink;
163
147
 
164
148
  export interface LiveChannelOptions {
165
149
  /** What each frame carries. */
166
150
  status: LiveStatusSource;
167
151
  /** How far apart the frames are, in ms. */
168
152
  intervalMs: number;
169
- /** How long the last page's departure is held before `onIdle` is called, in ms. */
170
- idleMs: number;
171
153
  /** How many pages may attach at once; defaults to `LIVE_MAX_CLIENTS`. */
172
154
  maxClients?: number;
173
- /** Called once the last page has been gone for `idleMs`; omitted, the channel only idles. */
174
- onIdle?: () => void;
175
155
  /** Timer seam; defaults to an unref'd interval. */
176
- timer?: LiveTimer;
177
- /** Grace seam; defaults to an unref'd timeout. */
178
- timeout?: LiveTimeout;
156
+ timer?: RepeatTimer;
179
157
  }
180
158
 
181
159
  /** The fan-out for one server: pages attach, ticks publish, close ends them. */
182
160
  export interface LiveChannel {
183
161
  /** Add a connected page, send it the current status, and return its detach. */
184
162
  attach(client: LiveClient): () => void;
185
- /** End every connected page, stop the ticker, and drop a pending idle stop. */
163
+ /** End every connected page, and stop the ticker. */
186
164
  close(): void;
187
165
  /**
188
166
  * Whether another page must be refused. The caller asks before it opens the
@@ -192,18 +170,18 @@ export interface LiveChannel {
192
170
  }
193
171
 
194
172
  /**
195
- * Build a channel for one server. The ticker runs only while at least one page
196
- * is attached, so an idle server does no status work at all; the idle stop is one
197
- * timer armed when the last page leaves and cancelled by the next page, so a
198
- * reload keeps the server and a closed window releases it.
173
+ * Build a channel for one server. The ticker runs only while at least one page is
174
+ * attached, so an idle server does no status work at all; the last page leaving
175
+ * parks it and the next page starts it again. What happens to a server nobody is
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
178
+ * here.
199
179
  */
200
180
  export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
201
- const timer = options.timer ?? defaultTicker;
202
- const timeout = options.timeout ?? defaultTimeout;
181
+ const timer = options.timer ?? defaultInterval;
203
182
  const maxClients = options.maxClients ?? LIVE_MAX_CLIENTS;
204
183
  const clients = new Set<LiveClient>();
205
184
  let cancel: (() => void) | undefined;
206
- let cancelIdle: (() => void) | undefined;
207
185
  let inFlight = false;
208
186
 
209
187
  const stop = (): void => {
@@ -211,34 +189,9 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
211
189
  cancel = undefined;
212
190
  };
213
191
 
214
- const cancelIdleStop = (): void => {
215
- cancelIdle?.();
216
- cancelIdle = undefined;
217
- };
218
-
219
- const armIdleStop = (): void => {
220
- if (!options.onIdle || cancelIdle) return;
221
- cancelIdle = timeout(() => {
222
- cancelIdle = undefined;
223
- options.onIdle?.();
224
- }, options.idleMs);
225
- };
226
-
227
- /** Nobody is reading a frame any more: park the ticker and start the grace. */
228
- const pageLeft = (): void => {
229
- stop();
230
- armIdleStop();
231
- };
232
-
233
192
  const send = (frame: string): void => {
234
- for (const client of clients) {
235
- try {
236
- client.write(frame);
237
- } catch {
238
- clients.delete(client);
239
- }
240
- }
241
- if (clients.size === 0) pageLeft();
193
+ for (const client of clients) client.write(frame);
194
+ if (clients.size === 0) stop();
242
195
  };
243
196
 
244
197
  // One snapshot serves every attached page, so a tick is one git read and one
@@ -255,17 +208,15 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
255
208
 
256
209
  return {
257
210
  attach(client) {
258
- cancelIdleStop();
259
211
  clients.add(client);
260
- cancel ??= timer(() => void publish(), options.intervalMs);
212
+ cancel ??= timer(options.intervalMs, () => void publish());
261
213
  void publish();
262
214
  return () => {
263
215
  if (!clients.delete(client)) return;
264
- if (clients.size === 0) pageLeft();
216
+ if (clients.size === 0) stop();
265
217
  };
266
218
  },
267
219
  close() {
268
- cancelIdleStop();
269
220
  stop();
270
221
  for (const client of clients) client.end();
271
222
  clients.clear();
@@ -15,18 +15,17 @@ import {
15
15
  isLocalHostHeader,
16
16
  jsonError,
17
17
  jsonResponse,
18
- pageHeaders,
19
18
  parseRequestUrl,
20
19
  scriptHeaders,
21
20
  type ServeResponse,
22
- } from "../../lib/http/index.ts";
23
- import { THEME_JS, THEME_JS_PATH } from "../../lib/web/index.ts";
24
- import { CLIENT_JS } from "./client.ts";
25
- import type { LivePage } from "./chrome.ts";
26
- import type { ServeConfig } from "./config.ts";
27
- import { isDiffMode, type DiffMode } from "./diff.ts";
28
- import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
29
- import { changeIndexOf, type LiveStatusSource } from "./live.ts";
21
+ } from "../../../lib/http/index.ts";
22
+ import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
23
+ import { CLIENT_JS } from "../view/client.ts";
24
+ import type { LivePage } from "../view/chrome.ts";
25
+ import type { ServeConfig } from "../config/config.ts";
26
+ import { isDiffMode, type DiffMode } from "../text/diff.ts";
27
+ import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "../git/git.ts";
28
+ import { changeIndexOf, type LiveStatusSource } from "../live/live.ts";
30
29
  import {
31
30
  buildFileIndex,
32
31
  buildTree,
@@ -38,13 +37,19 @@ import {
38
37
  type DirEntry,
39
38
  type FileIndexProvider,
40
39
  type TreeNode,
41
- } from "./files.ts";
42
- import { FOLDER_ICON, iconHref, languageOf } from "./icons.ts";
43
- import { fuzzySearch } from "./fuzzy.ts";
44
- import { renderDirectoryPage, renderDiffPage, renderErrorPage, renderFileDiffPage, renderFilePage } from "./html.ts";
45
- import type { ChangeSummary } from "./html.ts";
46
- import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "./paths.ts";
47
- import { listingRevision } from "./rev.ts";
40
+ } from "../files/files.ts";
41
+ import { FOLDER_ICON, iconHref, languageOf } from "../view/icons.ts";
42
+ import { fuzzySearch } from "../text/fuzzy.ts";
43
+ import {
44
+ renderDirectoryPage,
45
+ renderDiffPage,
46
+ renderErrorPage,
47
+ renderFileDiffPage,
48
+ renderFilePage,
49
+ } from "../view/html.ts";
50
+ import type { ChangeSummary } from "../view/html.ts";
51
+ import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
52
+ import { listingRevision } from "../live/rev.ts";
48
53
 
49
54
  export interface ServeContext {
50
55
  root: string;
@@ -373,7 +378,6 @@ export async function handleRequest(
373
378
  try {
374
379
  if (pathname === "/app.js") return { status: 200, headers: scriptHeaders(), body: CLIENT_JS };
375
380
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
376
- if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
377
381
  if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
378
382
  if (pathname === "/api/dir-rev") return await dirRevApi(ctx, search.get("path") ?? "");
379
383
  if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");