@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
  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 +554 -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} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -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 +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -3,17 +3,21 @@
3
3
  *
4
4
  * A page that stays open holds one event stream; this module builds the snapshot
5
5
  * it is sent, the frame that carries it, and the channel that fans it out on a
6
- * tick. The git and diff providers already memoize, so a tick re-reads at most
7
- * one `git status`, and every read failure degrades to `null`: a status feed
8
- * must never be able to fail a page or the server that serves it.
6
+ * tick. A tick costs one `git status` and one `git diff --stat`: the providers'
7
+ * 2 s memo only coalesces a tick with a page render in the same window, so the
8
+ * cadence is what bounds the reads. Every read failure degrades to `null`: a
9
+ * status feed must never be able to fail a page or the server that serves it.
9
10
  *
10
11
  * The channel opens no socket of its own — the caller hands it a `write`/`end`
11
12
  * pair — so the whole feed is tested without one.
12
13
  */
13
14
 
14
- import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
15
- import { changeDirs } from "./diff.ts";
16
- 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";
20
+ import { changeRevision } from "./rev.ts";
17
21
 
18
22
  /**
19
23
  * The changed-path index the pages render from: the totals the changes chip
@@ -50,12 +54,27 @@ export interface LiveChanges {
50
54
  deletions: number;
51
55
  files: string[];
52
56
  dirs: string[];
57
+ /**
58
+ * Identity of this change set, counts included. A page that painted the same
59
+ * revision repaints nothing when the next frame arrives.
60
+ */
61
+ rev: string;
62
+ }
63
+
64
+ /** A change index as it goes over the wire, with its revision. */
65
+ function changesOf(index: ChangeIndex): LiveChanges {
66
+ const changes = {
67
+ count: index.count,
68
+ insertions: index.insertions,
69
+ deletions: index.deletions,
70
+ files: [...index.files].sort(),
71
+ dirs: [...index.dirs].sort(),
72
+ };
73
+ return { ...changes, rev: changeRevision(changes) };
53
74
  }
54
75
 
55
76
  /** One status frame's payload. */
56
77
  export interface LiveSnapshot {
57
- /** Server wall clock when the snapshot was taken, in ms. */
58
- at: number;
59
78
  /** How long the server has been listening, in ms. */
60
79
  uptimeMs: number;
61
80
  /** Requests the server has answered so far, this one included. */
@@ -112,15 +131,7 @@ export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
112
131
  uptimeMs: Math.max(0, at - options.startedAt),
113
132
  requests: options.requests(),
114
133
  git,
115
- changes: index
116
- ? {
117
- count: index.count,
118
- insertions: index.insertions,
119
- deletions: index.deletions,
120
- files: [...index.files].sort(),
121
- dirs: [...index.dirs].sort(),
122
- }
123
- : null,
134
+ changes: index ? changesOf(index) : null,
124
135
  };
125
136
  },
126
137
  };
@@ -131,61 +142,46 @@ export function frameOf(snapshot: LiveSnapshot): string {
131
142
  return `event: status\ndata: ${JSON.stringify(snapshot)}\n\n`;
132
143
  }
133
144
 
134
- /** A connected page: the two things a server can do to its stream. */
135
- export interface LiveClient {
136
- /** Write one frame. */
137
- write(frame: string): void;
138
- /** End the stream. */
139
- end(): void;
140
- }
141
-
142
- /** Starts a repeating tick and returns its cancel. */
143
- export type LiveTimer = (tick: () => void, intervalMs: number) => () => void;
144
-
145
- /** Starts a one-shot delay and returns its cancel. */
146
- export type LiveTimeout = (run: () => void, delayMs: number) => () => void;
147
-
148
- /** The default ticker: a repeating timer that cannot hold the process open. */
149
- const defaultTicker: LiveTimer = (tick, intervalMs) => defaultInterval(intervalMs, tick);
150
-
151
- /** The default grace timer: one shot, and it cannot hold the process open either. */
152
- 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;
153
147
 
154
148
  export interface LiveChannelOptions {
155
149
  /** What each frame carries. */
156
150
  status: LiveStatusSource;
157
151
  /** How far apart the frames are, in ms. */
158
152
  intervalMs: number;
159
- /** How long the last page's departure is held before `onIdle` is called, in ms. */
160
- idleMs: number;
161
- /** Called once the last page has been gone for `idleMs`; omitted, the channel only idles. */
162
- onIdle?: () => void;
153
+ /** How many pages may attach at once; defaults to `LIVE_MAX_CLIENTS`. */
154
+ maxClients?: number;
163
155
  /** Timer seam; defaults to an unref'd interval. */
164
- timer?: LiveTimer;
165
- /** Grace seam; defaults to an unref'd timeout. */
166
- timeout?: LiveTimeout;
156
+ timer?: RepeatTimer;
167
157
  }
168
158
 
169
159
  /** The fan-out for one server: pages attach, ticks publish, close ends them. */
170
160
  export interface LiveChannel {
171
161
  /** Add a connected page, send it the current status, and return its detach. */
172
162
  attach(client: LiveClient): () => void;
173
- /** End every connected page, stop the ticker, and drop a pending idle stop. */
163
+ /** End every connected page, and stop the ticker. */
174
164
  close(): void;
165
+ /**
166
+ * Whether another page must be refused. The caller asks before it opens the
167
+ * stream, so a refusal never has to take back a head it already wrote.
168
+ */
169
+ atCapacity(): boolean;
175
170
  }
176
171
 
177
172
  /**
178
- * Build a channel for one server. The ticker runs only while at least one page
179
- * is attached, so an idle server does no status work at all; the idle stop is one
180
- * timer armed when the last page leaves and cancelled by the next page, so a
181
- * 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.
182
179
  */
183
180
  export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
184
- const timer = options.timer ?? defaultTicker;
185
- const timeout = options.timeout ?? defaultTimeout;
181
+ const timer = options.timer ?? defaultInterval;
182
+ const maxClients = options.maxClients ?? LIVE_MAX_CLIENTS;
186
183
  const clients = new Set<LiveClient>();
187
184
  let cancel: (() => void) | undefined;
188
- let cancelIdle: (() => void) | undefined;
189
185
  let inFlight = false;
190
186
 
191
187
  const stop = (): void => {
@@ -193,34 +189,9 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
193
189
  cancel = undefined;
194
190
  };
195
191
 
196
- const cancelIdleStop = (): void => {
197
- cancelIdle?.();
198
- cancelIdle = undefined;
199
- };
200
-
201
- const armIdleStop = (): void => {
202
- if (!options.onIdle || cancelIdle) return;
203
- cancelIdle = timeout(() => {
204
- cancelIdle = undefined;
205
- options.onIdle?.();
206
- }, options.idleMs);
207
- };
208
-
209
- /** Nobody is reading a frame any more: park the ticker and start the grace. */
210
- const pageLeft = (): void => {
211
- stop();
212
- armIdleStop();
213
- };
214
-
215
192
  const send = (frame: string): void => {
216
- for (const client of clients) {
217
- try {
218
- client.write(frame);
219
- } catch {
220
- clients.delete(client);
221
- }
222
- }
223
- if (clients.size === 0) pageLeft();
193
+ for (const client of clients) client.write(frame);
194
+ if (clients.size === 0) stop();
224
195
  };
225
196
 
226
197
  // One snapshot serves every attached page, so a tick is one git read and one
@@ -237,20 +208,21 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
237
208
 
238
209
  return {
239
210
  attach(client) {
240
- cancelIdleStop();
241
211
  clients.add(client);
242
- cancel ??= timer(() => void publish(), options.intervalMs);
212
+ cancel ??= timer(options.intervalMs, () => void publish());
243
213
  void publish();
244
214
  return () => {
245
215
  if (!clients.delete(client)) return;
246
- if (clients.size === 0) pageLeft();
216
+ if (clients.size === 0) stop();
247
217
  };
248
218
  },
249
219
  close() {
250
- cancelIdleStop();
251
220
  stop();
252
221
  for (const client of clients) client.end();
253
222
  clients.clear();
254
223
  },
224
+ atCapacity() {
225
+ return clients.size >= maxClients;
226
+ },
255
227
  };
256
228
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Revisions for the browse live status.
3
+ *
4
+ * A revision is a short digest of something a page already drew — the change set
5
+ * a frame carries, or the names a listing holds — so a client can tell "nothing
6
+ * moved" from "paint again" without comparing the payload it was sent. A
7
+ * collision costs one skipped repaint and is corrected by the next frame or a
8
+ * reload, which is the trade a 12-character digest makes for a small frame.
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+
13
+ /**
14
+ * A digest of the parts, cut to 12 hex characters. The parts are joined with NUL,
15
+ * which no filename can contain, so two different lists cannot run together into
16
+ * the same text.
17
+ */
18
+ function digest(parts: readonly string[]): string {
19
+ return createHash("sha1").update(parts.join("\u0000")).digest("hex").slice(0, 12);
20
+ }
21
+
22
+ /** The change set a frame carries: its totals and paths, in a stable order. */
23
+ export interface RevisionInput {
24
+ count: number;
25
+ insertions: number;
26
+ deletions: number;
27
+ files: readonly string[];
28
+ dirs: readonly string[];
29
+ }
30
+
31
+ /**
32
+ * The revision of a change set. The totals are part of it, so an edit that moves
33
+ * no path still moves the revision and the chip's `+A −D` stays honest.
34
+ */
35
+ export function changeRevision(changes: RevisionInput): string {
36
+ return digest([`${changes.count}:${changes.insertions}:${changes.deletions}`, ...changes.files, ...changes.dirs]);
37
+ }
38
+
39
+ /** The revision of a listing's names, independent of the order they arrived in. */
40
+ export function listingRevision(names: readonly string[]): string {
41
+ return digest([...names].sort());
42
+ }
@@ -19,13 +19,14 @@ import {
19
19
  parseRequestUrl,
20
20
  scriptHeaders,
21
21
  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 { ServeConfig } from "./config.ts";
26
- import { isDiffMode, type DiffMode } from "./diff.ts";
27
- import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
28
- import { changeIndexOf, type LiveStatusSource } from "./live.ts";
22
+ } from "../../../lib/http/index.ts";
23
+ import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
24
+ import { CLIENT_JS } from "../view/client.ts";
25
+ import type { LivePage } from "../view/chrome.ts";
26
+ import type { ServeConfig } from "../config/config.ts";
27
+ import { isDiffMode, type DiffMode } from "../text/diff.ts";
28
+ import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "../git/git.ts";
29
+ import { changeIndexOf, type LiveStatusSource } from "../live/live.ts";
29
30
  import {
30
31
  buildFileIndex,
31
32
  buildTree,
@@ -37,12 +38,19 @@ import {
37
38
  type DirEntry,
38
39
  type FileIndexProvider,
39
40
  type TreeNode,
40
- } from "./files.ts";
41
- import { FOLDER_ICON, iconHref, languageOf } from "./icons.ts";
42
- import { fuzzySearch } from "./fuzzy.ts";
43
- import { renderDirectoryPage, renderDiffPage, renderErrorPage, renderFileDiffPage, renderFilePage } from "./html.ts";
44
- import type { ChangeSummary } from "./html.ts";
45
- import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "./paths.ts";
41
+ } from "../files/files.ts";
42
+ import { FOLDER_ICON, iconHref, languageOf } from "../view/icons.ts";
43
+ import { fuzzySearch } from "../text/fuzzy.ts";
44
+ import {
45
+ renderDirectoryPage,
46
+ renderDiffPage,
47
+ renderErrorPage,
48
+ renderFileDiffPage,
49
+ renderFilePage,
50
+ } from "../view/html.ts";
51
+ import type { ChangeSummary } from "../view/html.ts";
52
+ import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
53
+ import { listingRevision } from "../live/rev.ts";
46
54
 
47
55
  export interface ServeContext {
48
56
  root: string;
@@ -108,9 +116,13 @@ function summaryOf(list: DiffList): ChangeSummary {
108
116
  };
109
117
  }
110
118
 
111
- /** The live chrome a page renders, or `undefined` when live status is off. */
112
- function liveOf(ctx: ServeContext): { intervalMs: number } | undefined {
113
- return ctx.config.live && ctx.live ? { intervalMs: ctx.config.liveIntervalMs } : undefined;
119
+ /**
120
+ * The live chrome a page renders, or `undefined` when live status is off. A
121
+ * listing page also passes the revision of what it drew, which is what its
122
+ * reload control compares against; every other page has nothing to compare.
123
+ */
124
+ function liveOf(ctx: ServeContext, folder?: { dir: string; rev: string }): LivePage | undefined {
125
+ return ctx.config.live && ctx.live ? { intervalMs: ctx.config.liveIntervalMs, ...folder } : undefined;
114
126
  }
115
127
 
116
128
  /** The `head` change summary, for the header chip, tree markers, and file action. */
@@ -184,7 +196,10 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
184
196
  maxThumbBytes: ctx.config.maxThumbBytes,
185
197
  git,
186
198
  summary: changes,
187
- live: liveOf(ctx),
199
+ live: liveOf(
200
+ ctx,
201
+ listing.truncated ? undefined : { dir: rel, rev: listingRevision(listing.entries.map((entry) => entry.name)) },
202
+ ),
188
203
  }),
189
204
  );
190
205
  }
@@ -312,6 +327,17 @@ async function treeApi(ctx: ServeContext, decodedPath: string): Promise<ServeRes
312
327
  return jsonResponse(200, { path: rel, entries: listing.entries.map(treeApiEntry) });
313
328
  }
314
329
 
330
+ /** The revision of one folder's listing: what a page's reload control compares against. */
331
+ async function dirRevApi(ctx: ServeContext, decodedPath: string): Promise<ServeResponse> {
332
+ const abs = await resolveDecodedPath(ctx.root, decodedPath);
333
+ const rel = relativePath(ctx.root, abs);
334
+ const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
335
+ // A truncated listing is a prefix whose boundary moves, so its revision is not
336
+ // comparable with what the page drew: `null` says "cannot tell".
337
+ const names = listing.entries.map((entry) => entry.name);
338
+ return jsonResponse(200, { path: rel, rev: listing.truncated ? null : listingRevision(names) });
339
+ }
340
+
315
341
  /** Fuzzy file search for the quick-open palette, over the cached index. */
316
342
  async function filesApi(ctx: ServeContext, query: string): Promise<ServeResponse> {
317
343
  const index = ctx.index ? await ctx.index() : await buildFileIndex(ctx.root, ctx.config.maxIndexEntries);
@@ -355,6 +381,7 @@ export async function handleRequest(
355
381
  if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
356
382
  if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
357
383
  if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
384
+ if (pathname === "/api/dir-rev") return await dirRevApi(ctx, search.get("path") ?? "");
358
385
  if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
359
386
  if (pathname === "/events") return liveEvents(ctx);
360
387
  const rawMode = search.get("mode");
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The HTTP server for the browse extension.
3
+ *
4
+ * Wraps the pure `handleRequest` in `lib/http`'s loopback server: it resolves the
5
+ * root once, feeds the live status channel, and exposes an idempotent `close()`.
6
+ * The request handler is injectable so tests can avoid a socket when they only
7
+ * need routing.
8
+ *
9
+ * What stays here is this extension's own policy: the request handler, its
10
+ * request count, the catch that turns a thrown handler into a plain-text 500, the
11
+ * cap that refuses a page rather than opening a stream for it, and how long a
12
+ * server with nobody reading it is worth keeping.
13
+ */
14
+
15
+ import { realpathSync } from "node:fs";
16
+ import { type ServeResponse, serveLoopback, textResponse } from "../../../lib/http/index.ts";
17
+ import { IDLE_STOP_MS, type ServeConfig } from "../config/config.ts";
18
+ import { createFileIndex } from "../files/files.ts";
19
+ import type { GitDiffProvider, GitStatusProvider } from "../git/git.ts";
20
+ import { createLiveChannel, createLiveStatus } from "../live/live.ts";
21
+ import { handleRequest, type ServeContext } from "./router.ts";
22
+
23
+ export interface FileServer {
24
+ /** Origin URL, e.g. `http://127.0.0.1:49152/`. */
25
+ url: string;
26
+ /** The bound port. */
27
+ port: number;
28
+ /** Resolved absolute root that is actually served. */
29
+ root: string;
30
+ /** Stop listening. Safe to call more than once or before `listen` settles. */
31
+ close(): Promise<void>;
32
+ }
33
+
34
+ export interface FileServerOptions {
35
+ root: string;
36
+ config: ServeConfig;
37
+ /** Read-only git context for the page header; omitted when unavailable. */
38
+ git?: GitStatusProvider;
39
+ /** Read-only diff provider for the change and diff views; omitted when unavailable. */
40
+ diff?: GitDiffProvider;
41
+ /** Called once the last page has been gone for `idleStopMs`; omitted, the server waits for `close()`. */
42
+ onIdle?: () => void;
43
+ /** Grace before `onIdle`, in ms; defaults to `IDLE_STOP_MS`. */
44
+ idleStopMs?: number;
45
+ /** Override the request handler (tests). */
46
+ handler?: typeof handleRequest;
47
+ }
48
+
49
+ /** Start a read-only server rooted at `options.root` on 127.0.0.1. */
50
+ export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
51
+ const root = realpathSync.native(options.root);
52
+ const { config } = options;
53
+ // The status feed's two server-owned facts: how long it has been up, and how
54
+ // many requests it has answered. Both are read per frame, never cached.
55
+ let requests = 0;
56
+ const status = config.live
57
+ ? createLiveStatus({
58
+ git: options.git,
59
+ diff: config.gitDiff ? options.diff : undefined,
60
+ startedAt: Date.now(),
61
+ requests: () => requests,
62
+ })
63
+ : undefined;
64
+ const live = status ? createLiveChannel({ status, intervalMs: config.liveIntervalMs }) : undefined;
65
+ const context: ServeContext = {
66
+ root,
67
+ config,
68
+ git: options.git,
69
+ diff: options.diff,
70
+ index: createFileIndex(root, config.maxIndexEntries),
71
+ live: status,
72
+ };
73
+ const handler = options.handler ?? handleRequest;
74
+
75
+ const server = await serveLoopback({
76
+ ports: [config.port],
77
+ idle: options.onIdle === undefined ? undefined : { ms: options.idleStopMs ?? IDLE_STOP_MS, onIdle: options.onIdle },
78
+ handler: async (request) => {
79
+ requests += 1;
80
+ let result: ServeResponse;
81
+ try {
82
+ result = await handler(context, request.method, request.url, request.headers);
83
+ } catch {
84
+ return { result: textResponse(500, "Internal error") };
85
+ }
86
+ if (!result.stream || !live || request.method.toUpperCase() === "HEAD") return { result };
87
+ // Checked before the head is written: a refusal must not leave a stream the
88
+ // client would wait on. `204` is the one status an event source does not
89
+ // reconnect after, so a page beyond the cap goes quiet instead of retrying.
90
+ if (live.atCapacity()) {
91
+ return { result: { status: 204, headers: { "Cache-Control": "no-store" }, body: "" } };
92
+ }
93
+ return {
94
+ result,
95
+ openSink: (sink) => live.attach({ write: (frame) => sink.write(frame), end: () => sink.end() }),
96
+ };
97
+ },
98
+ });
99
+
100
+ return {
101
+ url: `${server.origin}/`,
102
+ port: server.port,
103
+ root,
104
+ close: async () => {
105
+ // End the streams first, so a connected page does not hold the close open.
106
+ live?.close();
107
+ await server.close();
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The header chrome for the browse pages.
3
+ *
4
+ * The chips the toolbar carries — the git branch, the change totals, the live
5
+ * connection state — and the footer's live line. Everything here is pure string
6
+ * building over an escaped view model: a branch name, a count, and a status word
7
+ * are all text a repository can choose, so each one crosses `escapeHtml` before
8
+ * it reaches the page. The page shell and the page bodies stay in `html.ts`.
9
+ */
10
+
11
+ import { escapeHtml } from "../../../lib/escape.ts";
12
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
13
+ import type { GitStatus } from "../git/git.ts";
14
+ import type { ChangeIndex } from "../live/live.ts";
15
+
16
+ /** The header's git chip: the branch, or the detached head; changes are the changes chip's job. */
17
+ export function renderGit(git: GitStatus | undefined): string {
18
+ if (!git) return "";
19
+ const name = git.branch ?? git.head;
20
+ if (!name) return "";
21
+ const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
22
+ const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
23
+ return `<span class="bf-chip browse-git" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span><span class="browse-git__name">${escapeHtml(name)}</span></span>`;
24
+ }
25
+
26
+ /** The live chrome a page renders: how often its stream is sent a frame. */
27
+ export interface LivePage {
28
+ intervalMs: number;
29
+ /** Root-relative folder the page listed; absent on a page with no listing to check. */
30
+ dir?: string;
31
+ /** Revision of the names that listing drew, which the reload control compares against. */
32
+ rev?: string;
33
+ }
34
+
35
+ /**
36
+ * The header's live controls: the connection chip, and the reload control the
37
+ * client reveals once the folder the page drew has changed on disk. Both start
38
+ * hidden, so a page with JS off — or with live status disabled — never shows a
39
+ * state or a control it cannot keep true; the reload control is not rendered at
40
+ * all when the page has no listing revision to compare.
41
+ *
42
+ * The chip is deliberately not a `role="status"` region: the header is where a
43
+ * reader looks for the page's state, not a place to announce every change to it.
44
+ */
45
+ export function renderLiveChrome(live: LivePage | undefined): string {
46
+ if (!live) return "";
47
+ const rev =
48
+ live.rev === undefined ? "" : ` data-dir="${escapeHtml(live.dir ?? "")}" data-rev="${escapeHtml(live.rev)}"`;
49
+ const chip = `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}"${rev} hidden><span class="browse-live__glyph" aria-hidden="true">${escapeHtml(GLYPHS.browse)}</span><span class="browse-live__text">connecting</span></span>`;
50
+ if (live.rev === undefined) return chip;
51
+ return `${chip}<button type="button" class="bf-chip bf-chip--warn browse-reload" id="reload-hint" title="This folder changed on disk" hidden>⟳ reload</button>`;
52
+ }
53
+
54
+ /**
55
+ * The footer's live line, hidden until the first frame: uptime and requests are
56
+ * server facts the rendered page does not know at build time.
57
+ */
58
+ export function renderLiveStatus(): string {
59
+ return `<span class="browse-live-line" id="live-status" hidden></span>`;
60
+ }
61
+
62
+ /**
63
+ * The header's changes chip, linking to the diff overview. With live status on,
64
+ * a hidden shell is always rendered so the chip can appear and disappear as the
65
+ * working tree changes, without the client inserting elements into the header.
66
+ */
67
+ export function renderChanges(summary: ChangeIndex | undefined, live: LivePage | undefined): string {
68
+ const count = summary?.count ?? 0;
69
+ if (count <= 0) {
70
+ return live
71
+ ? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
72
+ : "";
73
+ }
74
+ const label = `${count} change${count === 1 ? "" : "s"}`;
75
+ const insertions = summary?.insertions ?? 0;
76
+ const deletions = summary?.deletions ?? 0;
77
+ const totals =
78
+ insertions + deletions > 0
79
+ ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
80
+ : "";
81
+ const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${deletions} deletions` : ""}`;
82
+ return `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
83
+ }
@@ -12,7 +12,7 @@
12
12
  * and `bun build --no-bundle` needs no separate asset.
13
13
  */
14
14
 
15
- import { keysDialogScript, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
15
+ import { keysDialogScript, THEME_TOGGLE_JS } from "../../../lib/web/index.ts";
16
16
  import { LIVE_CLIENT_JS } from "./live-client.ts";
17
17
  import { PALETTE_CLIENT_JS } from "./palette.ts";
18
18
  import { TREE_CLIENT_JS } from "./tree.ts";
@@ -148,6 +148,65 @@ ${TREE_CLIENT_JS}
148
148
 
149
149
  ${PALETTE_CLIENT_JS}
150
150
 
151
+ // A code reference: the file's path and the lines the reader selected. A click on
152
+ // the button collapses the selection before its click handler runs, so a new
153
+ // selection replaces the stored one and a collapse leaves it alone.
154
+ var reference = null;
155
+
156
+ function lineNode(node) {
157
+ var element = node && node.nodeType === 3 ? node.parentElement : node;
158
+ return element && element.closest ? element.closest(".bf-code__line") : null;
159
+ }
160
+
161
+ function referenceText() {
162
+ if (!reference || !reference.path) return "";
163
+ var span = reference.start === reference.end ? String(reference.start) : reference.start + "-" + reference.end;
164
+ return reference.path + ":" + span;
165
+ }
166
+
167
+ function captureReference() {
168
+ var button = document.getElementById("copy-reference");
169
+ if (!button) return;
170
+ var selection = window.getSelection && window.getSelection();
171
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
172
+ var range = selection.getRangeAt(0);
173
+ var from = lineNode(range.startContainer);
174
+ var to = lineNode(range.endContainer);
175
+ if (!from || !to) return;
176
+ var pre = from.closest("pre[data-path]");
177
+ if (!pre || pre !== to.closest("pre[data-path]")) return;
178
+ var lines = Array.prototype.slice.call(pre.querySelectorAll(".bf-code__line"));
179
+ var first = lines.indexOf(from);
180
+ var last = lines.indexOf(to);
181
+ if (first < 0 || last < 0) return;
182
+ reference = {
183
+ path: pre.getAttribute("data-path") || "",
184
+ start: Math.min(first, last) + 1,
185
+ end: Math.max(first, last) + 1
186
+ };
187
+ button.disabled = false;
188
+ button.title = "Copy " + referenceText();
189
+ }
190
+
191
+ function copyReference() {
192
+ var text = referenceText();
193
+ var button = document.getElementById("copy-reference");
194
+ if (!text) {
195
+ var status = document.getElementById("copy-status");
196
+ if (status) status.textContent = "Select lines in the code to copy a reference.";
197
+ return;
198
+ }
199
+ copyText(text)
200
+ .then(function () { if (button) flash(button, "copied", "Copied " + text); })
201
+ .catch(function () { if (button) flash(button, "failed", "Copy failed"); });
202
+ }
203
+
204
+ if (document.getElementById("copy-reference")) {
205
+ document.getElementById("copy-reference").addEventListener("click", copyReference);
206
+ document.addEventListener("mouseup", captureReference);
207
+ document.addEventListener("keyup", captureReference);
208
+ }
209
+
151
210
  // Keyboard: the shared shortcut sheet, the per-page actions the server
152
211
  // rendered, and hunk-to-hunk navigation through a diff.
153
212
  ${keysDialogScript("keys-open")}
@@ -256,6 +315,9 @@ ${PALETTE_CLIENT_JS}
256
315
  } else if (event.key === "u" && wrapButton) {
257
316
  event.preventDefault();
258
317
  wrapButton.click();
318
+ } else if (event.key === "y" && document.getElementById("copy-reference")) {
319
+ event.preventDefault();
320
+ copyReference();
259
321
  } else if (event.key === "r" && document.querySelector(".bf-card__actions .bf-segmented")) {
260
322
  event.preventDefault();
261
323
  toggleRaw();