@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
@@ -1,564 +0,0 @@
1
- /**
2
- * Filesystem reads for the browse extension: classification, capped directory
3
- * listings, capped text reads, and the small sidebar tree. All paths are
4
- * absolute and already vetted by `paths.ts`; this module never touches the raw
5
- * request URL.
6
- */
7
-
8
- import type { Dirent, Stats } from "node:fs";
9
- import { readFile, readdir, stat, lstat } from "node:fs/promises";
10
- import { basename, extname, join } from "node:path";
11
- import { mapConcurrent } from "../../lib/pool.ts";
12
- import { HttpError } from "./paths.ts";
13
-
14
- export type EntryKind = "directory" | "text" | "image" | "binary";
15
-
16
- /** Image extension (no dot) to MIME type; the single source for image classification. */
17
- const IMAGE_MIME: Record<string, string> = {
18
- png: "image/png",
19
- jpg: "image/jpeg",
20
- jpeg: "image/jpeg",
21
- gif: "image/gif",
22
- webp: "image/webp",
23
- svg: "image/svg+xml",
24
- bmp: "image/bmp",
25
- ico: "image/x-icon",
26
- avif: "image/avif",
27
- };
28
-
29
- /** Image extensions without a leading dot. */
30
- export const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set(Object.keys(IMAGE_MIME));
31
-
32
- const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdown", ".mkd", ".mkdn"]);
33
- const TEXT_EXTS = new Set([
34
- ".txt",
35
- ".md",
36
- ".markdown",
37
- ".rst",
38
- ".adoc",
39
- ".org",
40
- ".log",
41
- ".json",
42
- ".jsonc",
43
- ".json5",
44
- ".yaml",
45
- ".yml",
46
- ".toml",
47
- ".ini",
48
- ".cfg",
49
- ".conf",
50
- ".xml",
51
- ".csv",
52
- ".tsv",
53
- ".ts",
54
- ".tsx",
55
- ".js",
56
- ".jsx",
57
- ".mjs",
58
- ".cjs",
59
- ".py",
60
- ".go",
61
- ".rs",
62
- ".java",
63
- ".c",
64
- ".h",
65
- ".cc",
66
- ".cpp",
67
- ".hpp",
68
- ".cs",
69
- ".rb",
70
- ".php",
71
- ".sh",
72
- ".bash",
73
- ".zsh",
74
- ".ps1",
75
- ".swift",
76
- ".kt",
77
- ".kts",
78
- ".scala",
79
- ".lua",
80
- ".r",
81
- ".pl",
82
- ".sql",
83
- ".css",
84
- ".scss",
85
- ".sass",
86
- ".less",
87
- ".html",
88
- ".htm",
89
- ".vue",
90
- ".svelte",
91
- ".astro",
92
- ]);
93
- const TEXT_NAMES = new Set([
94
- "license",
95
- "licence",
96
- "makefile",
97
- "dockerfile",
98
- "readme",
99
- "changelog",
100
- "authors",
101
- "notice",
102
- ]);
103
-
104
- export interface DirEntry {
105
- name: string;
106
- /** Root-relative posix path. */
107
- rel: string;
108
- isDir: boolean;
109
- isSymlink: boolean;
110
- broken: boolean;
111
- size: number;
112
- mtimeMs: number;
113
- kind: EntryKind;
114
- isImage: boolean;
115
- }
116
-
117
- export interface Listing {
118
- entries: DirEntry[];
119
- total: number;
120
- truncated: boolean;
121
- }
122
-
123
- export interface FileMeta {
124
- size: number;
125
- mtimeMs: number;
126
- mime: string;
127
- }
128
-
129
- export type FileView =
130
- | (FileMeta & { kind: "text"; lines: string[]; truncated: boolean; totalLines: number })
131
- | (FileMeta & { kind: "markdown"; lines: string[]; truncated: boolean; totalLines: number })
132
- | (FileMeta & { kind: "image" })
133
- | (FileMeta & { kind: "binary" })
134
- | (FileMeta & { kind: "tooLarge" });
135
-
136
- /** Whether the name has an image extension. */
137
- export function isImageName(name: string): boolean {
138
- return IMAGE_EXTENSIONS.has(extname(name).slice(1).toLowerCase());
139
- }
140
-
141
- /** Whether the name has a Markdown extension. */
142
- export function isMarkdownName(name: string): boolean {
143
- return MARKDOWN_EXTS.has(extname(name).toLowerCase());
144
- }
145
-
146
- /** Extension-based classification for listings (no file contents read). */
147
- export function classifyByName(name: string): EntryKind {
148
- const ext = extname(name).toLowerCase();
149
- if (IMAGE_EXTENSIONS.has(ext.slice(1))) return "image";
150
- if (TEXT_EXTS.has(ext)) return "text";
151
- const lower = basename(name).toLowerCase();
152
- if (TEXT_NAMES.has(lower)) return "text";
153
- if (!ext && name.startsWith(".")) return "text";
154
- return "binary";
155
- }
156
-
157
- /** True when the sample looks like binary data (NUL or many control bytes). */
158
- export function isProbablyText(sample: Buffer): boolean {
159
- if (sample.length === 0) return true;
160
- if (sample.includes(0)) return false;
161
- let suspicious = 0;
162
- for (const byte of sample) {
163
- if (byte === 9 || byte === 10 || byte === 13 || byte === 12 || byte === 8) continue;
164
- if (byte < 32 || byte === 127) suspicious++;
165
- }
166
- return suspicious / sample.length < 0.3;
167
- }
168
-
169
- /** Compact byte size: `68 B`, `12.4 kB`, `1.5 MB`. */
170
- export function formatSize(bytes: number): string {
171
- if (bytes < 1024) return `${bytes} B`;
172
- const units = ["kB", "MB", "GB", "TB"];
173
- let value = bytes;
174
- let index = -1;
175
- do {
176
- value /= 1024;
177
- index++;
178
- } while (value >= 1024 && index < units.length - 1);
179
- return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[index]}`;
180
- }
181
-
182
- /** MIME type for a name; text is always served as plain text. */
183
- export function contentTypeFor(name: string): string {
184
- const ext = extname(name).slice(1).toLowerCase();
185
- return IMAGE_MIME[ext] ?? "application/octet-stream";
186
- }
187
-
188
- function toAbs(root: string, rel: string): string {
189
- return rel ? join(root, ...rel.split("/")) : root;
190
- }
191
-
192
- /** The `Dirent` fields a listing reads; a test's fake need not be a real `Dirent`. */
193
- export interface DirectoryEntryInfo {
194
- name: string;
195
- isDirectory(): boolean;
196
- isSymbolicLink(): boolean;
197
- }
198
-
199
- /** The `Stats` fields a listing reads. */
200
- export interface DirectoryStats {
201
- size: number;
202
- mtimeMs: number;
203
- isDirectory(): boolean;
204
- }
205
-
206
- /**
207
- * The three filesystem reads a listing makes.
208
- *
209
- * Injected so a test can observe them: the interesting property — a directory is
210
- * read once per entry, not once per entry *and* once per row drawn — is a count,
211
- * not a value, and a wall-clock assertion would be flaky on a loaded machine.
212
- */
213
- export interface DirectoryFs {
214
- readdir(path: string): Promise<DirectoryEntryInfo[]>;
215
- lstat(path: string): Promise<DirectoryStats>;
216
- stat(path: string): Promise<DirectoryStats>;
217
- }
218
-
219
- /** Production reads; a symlink's size and kind come from the target. */
220
- const NODE_DIRECTORY_FS: DirectoryFs = {
221
- readdir: (path) => readdir(path, { withFileTypes: true }),
222
- lstat: (path) => lstat(path),
223
- stat: (path) => stat(path),
224
- };
225
-
226
- /** Reads in flight per listing: enough to hide latency, bounded against a burst. */
227
- const LISTING_CONCURRENCY = 32;
228
-
229
- /** A row while it is still missing its metadata. */
230
- interface Row {
231
- name: string;
232
- rel: string;
233
- isDir: boolean;
234
- isSymlink: boolean;
235
- broken: boolean;
236
- size: number;
237
- mtimeMs: number;
238
- }
239
-
240
- /**
241
- * Resolve one symlink: the link's own stat, then the target's.
242
- *
243
- * A symlink's directory-ness is the target's, so this cannot wait until after
244
- * the sort; it is also the only entry whose classification needs a read.
245
- */
246
- async function resolveSymlink(fs: DirectoryFs, absDir: string, row: Row): Promise<void> {
247
- const abs = join(absDir, row.name);
248
- try {
249
- const info = await fs.lstat(abs);
250
- row.size = info.size;
251
- row.mtimeMs = info.mtimeMs;
252
- } catch {
253
- // Vanished between readdir and lstat: the target read decides below.
254
- }
255
- try {
256
- const target = await fs.stat(abs);
257
- row.isDir = target.isDirectory();
258
- row.size = target.size;
259
- row.mtimeMs = target.mtimeMs;
260
- } catch {
261
- row.broken = true;
262
- }
263
- }
264
-
265
- /**
266
- * List a directory, sorted (directories first, then case-insensitive name).
267
- *
268
- * A listing is latency per entry, not work per entry, so it is read in three
269
- * passes instead of one entry at a time:
270
- *
271
- * 1. `readdir`, whose dirents already say whether each name is a directory;
272
- * 2. the symlinks only, resolved through the pool, because their kind is the
273
- * target's and the sort needs it;
274
- * 3. the kept rows only, `stat`ed through the pool — so a directory of three
275
- * thousand names pays for the two thousand it draws, not for every name, and
276
- * pays them concurrently instead of in turn.
277
- *
278
- * `total` is still the directory's full entry count and `truncated` its `> limit`
279
- * flip, so the page can say how many were dropped. Ties keep `readdir` order:
280
- * `sort` is stable and the rows arrive unsorted.
281
- */
282
- export async function listDirectory(
283
- root: string,
284
- relDir: string,
285
- limit: number,
286
- fs: DirectoryFs = NODE_DIRECTORY_FS,
287
- ): Promise<Listing> {
288
- const absDir = toAbs(root, relDir);
289
- let dirents: DirectoryEntryInfo[];
290
- try {
291
- dirents = await fs.readdir(absDir);
292
- } catch {
293
- throw new HttpError(404, "That folder could not be read.");
294
- }
295
-
296
- const links: Row[] = [];
297
- const rows: Row[] = dirents.map((dirent) => {
298
- const row: Row = {
299
- name: dirent.name,
300
- rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
301
- isDir: dirent.isDirectory(),
302
- isSymlink: dirent.isSymbolicLink(),
303
- broken: false,
304
- size: 0,
305
- mtimeMs: 0,
306
- };
307
- if (row.isSymlink) links.push(row);
308
- return row;
309
- });
310
-
311
- await mapConcurrent(links, LISTING_CONCURRENCY, (row) => resolveSymlink(fs, absDir, row));
312
-
313
- // Directories first, then case-insensitive name. The sort runs before the cap,
314
- // so the rows that survive it are the rows that would have been drawn.
315
- rows.sort((a, b) => {
316
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
317
- return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
318
- });
319
-
320
- const total = rows.length;
321
- const truncated = total > limit;
322
- const kept = truncated ? rows.slice(0, limit) : rows;
323
-
324
- await mapConcurrent(
325
- kept.filter((row) => !row.isSymlink),
326
- LISTING_CONCURRENCY,
327
- async (row) => {
328
- try {
329
- const info = await fs.stat(join(absDir, row.name));
330
- row.size = info.size;
331
- row.mtimeMs = info.mtimeMs;
332
- } catch {
333
- // Directory entry vanished between readdir and stat.
334
- }
335
- },
336
- );
337
-
338
- const entries: DirEntry[] = kept.map((row) => {
339
- const kind: EntryKind = row.isDir ? "directory" : classifyByName(row.name);
340
- return {
341
- name: row.name,
342
- rel: row.rel,
343
- isDir: row.isDir,
344
- isSymlink: row.isSymlink,
345
- broken: row.broken,
346
- size: row.size,
347
- mtimeMs: row.mtimeMs,
348
- kind,
349
- isImage: !row.isDir && kind === "image",
350
- };
351
- });
352
-
353
- return { entries, total, truncated };
354
- }
355
-
356
- /** Read a file into a renderable view, honouring the size and line caps. */
357
- export async function readFileView(
358
- root: string,
359
- rel: string,
360
- maxFileBytes: number,
361
- maxTextLines: number,
362
- markdownEnabled = true,
363
- ): Promise<FileView> {
364
- const abs = toAbs(root, rel);
365
- let info: Stats;
366
- try {
367
- info = await stat(abs);
368
- } catch {
369
- throw new HttpError(404, "That file could not be read.");
370
- }
371
- if (info.isDirectory()) throw new HttpError(404, "That path is a folder.");
372
-
373
- const meta: FileMeta = { size: info.size, mtimeMs: info.mtimeMs, mime: contentTypeFor(rel) };
374
- if (isImageName(rel)) return { kind: "image", ...meta };
375
- if (info.size > maxFileBytes) return { kind: "tooLarge", ...meta };
376
-
377
- let buffer: Buffer;
378
- try {
379
- buffer = await readFile(abs);
380
- } catch {
381
- throw new HttpError(404, "That file could not be read.");
382
- }
383
- if (!isProbablyText(buffer)) return { kind: "binary", ...meta };
384
-
385
- const allLines = buffer.toString("utf-8").split(/\r\n|\r|\n/);
386
- const truncated = allLines.length > maxTextLines;
387
- const lines = truncated ? allLines.slice(0, maxTextLines) : allLines;
388
- const kind = markdownEnabled && isMarkdownName(rel) ? "markdown" : "text";
389
- if (kind === "markdown") {
390
- return { kind: "markdown", ...meta, lines, truncated, totalLines: allLines.length };
391
- }
392
- return { kind: "text", ...meta, lines, truncated, totalLines: allLines.length };
393
- }
394
-
395
- export interface TreeNode {
396
- name: string;
397
- rel: string;
398
- isDir: boolean;
399
- isImage: boolean;
400
- /** True for a symlink whose target cannot be resolved. */
401
- broken: boolean;
402
- kind: EntryKind;
403
- children: TreeNode[];
404
- }
405
-
406
- /**
407
- * Build the sidebar tree: the current path's ancestors are expanded so the
408
- * active item is always visible, and siblings are shown collapsed.
409
- */
410
- export async function buildTree(root: string, currentRel: string, limit: number): Promise<TreeNode> {
411
- return buildNode(root, "", currentRel, basename(root) || root, limit);
412
- }
413
-
414
- /** A directory page's two views of one directory. */
415
- export interface DirectoryView {
416
- /** The current directory's own rows, for the page's listing. */
417
- listing: Listing;
418
- /** The sidebar tree, with the current path expanded. */
419
- tree: TreeNode;
420
- }
421
-
422
- /**
423
- * One walk for a directory page: the listing it draws, and the tree that shows
424
- * where it sits.
425
- *
426
- * `listDirectory` is what decides whether the directory can be read at all, so it
427
- * runs first and its 404 is the page's error. The walk then reuses that listing
428
- * for the same directory instead of reading it a second time; the ancestors are
429
- * still read once each, which is what the tree needs.
430
- */
431
- export async function directoryView(root: string, currentRel: string, limit: number): Promise<DirectoryView> {
432
- const listing = await listDirectory(root, currentRel, limit);
433
- const tree = await buildNode(root, "", currentRel, basename(root) || root, limit, { rel: currentRel, listing });
434
- return { listing, tree };
435
- }
436
-
437
- async function buildNode(
438
- root: string,
439
- relDir: string,
440
- currentRel: string,
441
- label: string,
442
- limit: number,
443
- /** A listing the caller already read for this directory, so it is not re-read. */
444
- known?: { rel: string; listing: Listing },
445
- ): Promise<TreeNode> {
446
- const node: TreeNode = {
447
- name: label,
448
- rel: relDir,
449
- isDir: true,
450
- isImage: false,
451
- broken: false,
452
- kind: "directory",
453
- children: [],
454
- };
455
- let listing: Listing;
456
- if (known && known.rel === relDir) {
457
- listing = known.listing;
458
- } else {
459
- try {
460
- listing = await listDirectory(root, relDir, limit);
461
- } catch {
462
- return node;
463
- }
464
- }
465
- for (const entry of listing.entries) {
466
- const child: TreeNode = {
467
- name: entry.name,
468
- rel: entry.rel,
469
- isDir: entry.isDir,
470
- isImage: entry.isImage,
471
- broken: entry.broken,
472
- kind: entry.kind,
473
- children: [],
474
- };
475
- if (entry.isDir) {
476
- const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
477
- if (onPath) {
478
- child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
479
- }
480
- }
481
- node.children.push(child);
482
- }
483
- return node;
484
- }
485
-
486
- /** Directory names the quick-open index never walks. */
487
- export const INDEX_IGNORED_DIRS = new Set([".git", "node_modules"]);
488
-
489
- export interface FileIndex {
490
- files: string[];
491
- truncated: boolean;
492
- }
493
-
494
- /**
495
- * Walk `root` and collect file paths for the quick-open palette. Symlinks and
496
- * the ignored directories are skipped, and the walk stops at `limit` files so a
497
- * large tree cannot exhaust memory or time. Unreadable directories are skipped.
498
- */
499
- export async function buildFileIndex(root: string, limit: number): Promise<FileIndex> {
500
- const files: string[] = [];
501
- let truncated = false;
502
-
503
- async function walk(absDir: string, relDir: string): Promise<void> {
504
- let dirents: Dirent[];
505
- try {
506
- dirents = await readdir(absDir, { withFileTypes: true });
507
- } catch {
508
- return;
509
- }
510
- dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
511
- for (const dirent of dirents) {
512
- if (truncated) return;
513
- if (dirent.isSymbolicLink()) continue;
514
- const rel = relDir ? `${relDir}/${dirent.name}` : dirent.name;
515
- if (dirent.isDirectory()) {
516
- if (INDEX_IGNORED_DIRS.has(dirent.name)) continue;
517
- await walk(join(absDir, dirent.name), rel);
518
- } else if (dirent.isFile()) {
519
- if (files.length >= limit) {
520
- truncated = true;
521
- return;
522
- }
523
- files.push(rel);
524
- }
525
- }
526
- }
527
-
528
- await walk(root, "");
529
- files.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b));
530
- return { files, truncated };
531
- }
532
-
533
- export type FileIndexProvider = () => Promise<FileIndex>;
534
-
535
- export interface FileIndexOptions {
536
- /** How long a built index is reused, in ms (default 10000). */
537
- ttlMs?: number;
538
- /** Wall-clock seam; defaults to the real clock. */
539
- now?: () => number;
540
- }
541
-
542
- /** A cached `buildFileIndex` for `root`; concurrent calls share one walk. */
543
- export function createFileIndex(root: string, limit: number, options: FileIndexOptions = {}): FileIndexProvider {
544
- const ttlMs = options.ttlMs ?? 10_000;
545
- const now = options.now ?? (() => Date.now());
546
- let cached: FileIndex | undefined;
547
- let checkedAt = 0;
548
- let pending: Promise<FileIndex> | undefined;
549
- return async () => {
550
- const time = now();
551
- if (cached && time - checkedAt < ttlMs) return cached;
552
- if (pending) return pending;
553
- pending = buildFileIndex(root, limit)
554
- .then((index) => {
555
- cached = index;
556
- checkedAt = now();
557
- return index;
558
- })
559
- .finally(() => {
560
- pending = undefined;
561
- });
562
- return pending;
563
- };
564
- }
@@ -1,141 +0,0 @@
1
- /**
2
- * The HTTP server for the browse extension.
3
- *
4
- * Wraps `node:http` around the pure `handleRequest`: it resolves the root once,
5
- * binds `127.0.0.1`, streams `/raw` responses from disk, feeds the live status
6
- * channel, and exposes an idempotent `close()`. The request handler is injectable
7
- * so tests can avoid a socket when they only need routing.
8
- *
9
- * The bind, the response writer, the event-stream head, and the close come from
10
- * `lib/http`, which `plan` shares; what stays here is this server's own request
11
- * handler, its request count, and the catch that turns a thrown handler into a
12
- * plain-text 500.
13
- */
14
-
15
- import { realpathSync } from "node:fs";
16
- import { createServer } from "node:http";
17
- import {
18
- bindLoopback,
19
- createCloser,
20
- openEventStream,
21
- respond,
22
- textResponse,
23
- type ServeResponse,
24
- } from "../../lib/http/index.ts";
25
- import { IDLE_STOP_MS, type ServeConfig } from "./config.ts";
26
- import { createFileIndex } from "./files.ts";
27
- import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
28
- import { createLiveChannel, createLiveStatus } from "./live.ts";
29
- import { handleRequest, type ServeContext } from "./router.ts";
30
-
31
- export interface FileServer {
32
- /** Origin URL, e.g. `http://127.0.0.1:49152/`. */
33
- url: string;
34
- /** The bound port. */
35
- port: number;
36
- /** Resolved absolute root that is actually served. */
37
- root: string;
38
- /** Stop listening. Safe to call more than once or before `listen` settles. */
39
- close(): Promise<void>;
40
- }
41
-
42
- export interface FileServerOptions {
43
- root: string;
44
- config: ServeConfig;
45
- /** Read-only git context for the page header; omitted when unavailable. */
46
- git?: GitStatusProvider;
47
- /** Read-only diff provider for the change and diff views; omitted when unavailable. */
48
- diff?: GitDiffProvider;
49
- /** Called once the last page has been gone for `idleStopMs`; omitted, the server waits for `close()`. */
50
- onIdle?: () => void;
51
- /** Grace before `onIdle`, in ms; defaults to `IDLE_STOP_MS`. */
52
- idleStopMs?: number;
53
- /** Override the request handler (tests). */
54
- handler?: typeof handleRequest;
55
- }
56
-
57
- /** Start a read-only server rooted at `options.root` on 127.0.0.1. */
58
- export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
59
- const root = realpathSync.native(options.root);
60
- const { config } = options;
61
- // The status feed's two server-owned facts: how long it has been up, and how
62
- // many requests it has answered. Both are read per frame, never cached.
63
- let requests = 0;
64
- const status = config.live
65
- ? createLiveStatus({
66
- git: options.git,
67
- diff: config.gitDiff ? options.diff : undefined,
68
- startedAt: Date.now(),
69
- requests: () => requests,
70
- })
71
- : undefined;
72
- const live = status
73
- ? createLiveChannel({
74
- status,
75
- intervalMs: config.liveIntervalMs,
76
- idleMs: options.idleStopMs ?? IDLE_STOP_MS,
77
- onIdle: options.onIdle,
78
- })
79
- : undefined;
80
- const context: ServeContext = {
81
- root,
82
- config,
83
- git: options.git,
84
- diff: options.diff,
85
- index: createFileIndex(root, config.maxIndexEntries),
86
- live: status,
87
- };
88
- const handler = options.handler ?? handleRequest;
89
-
90
- const server = createServer((request, response) => {
91
- requests += 1;
92
- const answer = async (): Promise<ServeResponse> => {
93
- try {
94
- return await handler(context, request.method ?? "GET", request.url ?? "/", request.headers);
95
- } catch {
96
- return textResponse(500, "Internal error");
97
- }
98
- };
99
- void answer().then((result) => {
100
- // A HEAD of the stream is answered as a plain response: there is no body to
101
- // hold open, and the client would otherwise wait on frames it asked not to get.
102
- if (!result.stream || !live || (request.method ?? "GET").toUpperCase() === "HEAD") {
103
- respond(request, response, result);
104
- return;
105
- }
106
- // Checked before the head is written: a refusal must not leave a stream the
107
- // client would wait on. `204` is the one status an event source does not
108
- // reconnect after, so a page beyond the cap goes quiet instead of retrying.
109
- if (live.atCapacity()) {
110
- respond(request, response, { status: 204, headers: { "Cache-Control": "no-store" }, body: "" });
111
- return;
112
- }
113
- openEventStream(response, result);
114
- const detach = live.attach({
115
- write: (frame) => {
116
- response.write(frame);
117
- },
118
- end: () => {
119
- if (!response.writableEnded) response.end();
120
- },
121
- });
122
- request.on("close", () => {
123
- detach();
124
- if (!response.writableEnded) response.end();
125
- });
126
- });
127
- });
128
-
129
- const port = await bindLoopback(server, config.port);
130
- const closeServer = createCloser(server);
131
- return {
132
- url: `http://127.0.0.1:${port}/`,
133
- port,
134
- root,
135
- close: async () => {
136
- // End the streams first, so a connected page does not hold the close open.
137
- live?.close();
138
- await closeServer();
139
- },
140
- };
141
- }