@gavin-hu/my-pi-agent 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +12 -11
  2. package/README.zh-CN.md +9 -7
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +83 -8
  26. package/extensions/file-browse/client.ts +3 -1
  27. package/extensions/file-browse/config.ts +23 -0
  28. package/extensions/file-browse/files.ts +183 -48
  29. package/extensions/file-browse/fuzzy.ts +65 -11
  30. package/extensions/file-browse/html.ts +78 -133
  31. package/extensions/file-browse/index.ts +17 -2
  32. package/extensions/file-browse/live-client.ts +158 -0
  33. package/extensions/file-browse/live.ts +256 -0
  34. package/extensions/file-browse/markdown.ts +3 -10
  35. package/extensions/file-browse/raw-html.ts +3 -10
  36. package/extensions/file-browse/router.ts +33 -8
  37. package/extensions/file-browse/server.ts +78 -13
  38. package/extensions/file-browse/styles.ts +124 -0
  39. package/extensions/file-open/README.md +1 -2
  40. package/extensions/{document → file-read}/README.md +72 -56
  41. package/extensions/{document → file-read}/config.ts +12 -9
  42. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  43. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  44. package/extensions/{document → file-read}/extract/file.ts +9 -9
  45. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  46. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  47. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  48. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  49. package/extensions/{document → file-read}/formats.ts +11 -11
  50. package/extensions/file-read/index.ts +17 -0
  51. package/extensions/{document → file-read}/paging.ts +15 -13
  52. package/extensions/{document → file-read}/render.ts +17 -13
  53. package/extensions/{document → file-read}/schema.ts +7 -7
  54. package/extensions/{document → file-read}/tool.ts +36 -28
  55. package/extensions/goal/tools.ts +10 -14
  56. package/extensions/job/commands.ts +32 -46
  57. package/extensions/job/runtime.ts +6 -8
  58. package/extensions/job/tools.ts +7 -26
  59. package/extensions/memory/commands.ts +30 -44
  60. package/extensions/memory/tools.ts +6 -14
  61. package/extensions/plan/tools/enter.ts +6 -7
  62. package/extensions/plan/tools/exit.ts +6 -7
  63. package/extensions/plan/tools/steps.ts +6 -7
  64. package/extensions/plan/tools/write.ts +8 -9
  65. package/extensions/plan/web-router.ts +2 -12
  66. package/extensions/plan/web.ts +30 -15
  67. package/extensions/question/tools.ts +5 -4
  68. package/extensions/rewind/checklist.ts +10 -3
  69. package/extensions/rewind/git.ts +27 -14
  70. package/extensions/rewind/hunk-picker.ts +1 -1
  71. package/extensions/rewind/path-picker.ts +1 -1
  72. package/extensions/rewind/review-tui.ts +3 -3
  73. package/extensions/rewind/snapshot.ts +13 -4
  74. package/extensions/subagent/background-tui.ts +1 -1
  75. package/extensions/subagent/background.ts +1 -7
  76. package/extensions/todo/tools.ts +10 -14
  77. package/extensions/watch/README.md +5 -5
  78. package/extensions/watch/commands.ts +33 -47
  79. package/extensions/watch/runtime.ts +5 -7
  80. package/extensions/watch/tools.ts +7 -18
  81. package/extensions/web-access/README.md +3 -3
  82. package/extensions/web-access/search/search.ts +9 -18
  83. package/extensions/wechat/timer.ts +9 -30
  84. package/extensions/wechat/tools.ts +4 -7
  85. package/extensions/worktree/status.ts +14 -9
  86. package/lib/README.md +12 -8
  87. package/lib/escape.ts +15 -2
  88. package/lib/git/read.ts +20 -0
  89. package/lib/http/headers.ts +20 -0
  90. package/lib/http/server.ts +22 -4
  91. package/lib/pdf.ts +2 -2
  92. package/lib/pool.ts +62 -0
  93. package/lib/requirements.ts +2 -2
  94. package/lib/screen-session.ts +85 -0
  95. package/lib/timer.ts +61 -0
  96. package/lib/tui/README.md +3 -3
  97. package/lib/tui/columns.ts +33 -8
  98. package/lib/tui/dock-screen.ts +12 -4
  99. package/lib/tui/rows.ts +15 -6
  100. package/lib/tui/rules.ts +4 -8
  101. package/lib/tui/tool.ts +55 -2
  102. package/package.json +8 -4
  103. package/skills/changelog/SKILL.md +136 -0
  104. package/extensions/document/index.ts +0 -17
  105. /package/extensions/{document → file-read}/deadline.ts +0 -0
  106. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The report's data contract.
3
+ *
4
+ * `ContextInputs` is what the host hands over: the effective system prompt, the
5
+ * tool surface, the host's usage estimate, the messages that will be sent, and
6
+ * the branch the compaction history is read from. `ContextReport` is what the
7
+ * pure builder derives from it and what the text detail and the screen render.
8
+ *
9
+ * Host types that the package root does not re-export are named through the
10
+ * extension surface (`ReturnType<…>`) rather than a deep import, so a peer
11
+ * version change fails to typecheck instead of resolving a second copy.
12
+ */
13
+
14
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+
17
+ /** The host's context-usage estimate (`ctx.getContextUsage()`). */
18
+ export type ContextUsage = NonNullable<ReturnType<ExtensionContext["getContextUsage"]>>;
19
+
20
+ /** The active branch entries (`sessionManager.getBranch()`). */
21
+ export type BranchEntries = ReturnType<ExtensionContext["sessionManager"]["getBranch"]>;
22
+
23
+ /** Merged global and project settings (`pi.getSettings()`). */
24
+ export type ContextSettings = ReturnType<ExtensionAPI["getSettings"]>;
25
+
26
+ /** Everything the pure report needs; collected once per open or refresh. */
27
+ export interface ContextInputs {
28
+ /** The model in effect, or `null` before one is selected. */
29
+ model: { provider: string; id: string } | null;
30
+ /** The active model's context window, or `0` when unknown. */
31
+ window: number;
32
+ /** The effective system prompt for this turn (`ctx.getSystemPrompt()`). */
33
+ systemPrompt: string;
34
+ /** The active tool surface, with the definition estimate already computed. */
35
+ tools: { active: number; total: number; tokens: number };
36
+ /** The host's estimate; `tokens` is `null` between a compaction and the next reply. */
37
+ usage: ContextUsage | undefined;
38
+ /** The model-visible messages for the next request. */
39
+ messages: readonly AgentMessage[];
40
+ /** The branch the latest compaction entry and its count are read from. */
41
+ branch: BranchEntries;
42
+ /** Merged settings, for the compaction threshold. */
43
+ settings: ContextSettings;
44
+ }
45
+
46
+ /** One row of the composition table. */
47
+ export interface ContextBucket {
48
+ /** Stable id; an injected child is `injected:<customType>`. */
49
+ id: string;
50
+ /** Display label, already plain text. */
51
+ label: string;
52
+ /** Estimated tokens. */
53
+ tokens: number;
54
+ /** Share of the composition estimate, `0..100`. */
55
+ share: number;
56
+ /** `0` for a top-level bucket, `1` for an injected child. */
57
+ depth: number;
58
+ /** A short factual suffix: `18 active of 24`, a message count. */
59
+ note?: string;
60
+ }
61
+
62
+ /** The latest compaction's effect. */
63
+ export interface CompactionSummary {
64
+ /** `YYYY-MM-DD HH:MMZ` from the entry's ISO timestamp, in UTC. */
65
+ at: string;
66
+ /** The estimate the host recorded before compacting. */
67
+ tokensBefore: number;
68
+ /** The summary's own estimate. */
69
+ summaryTokens: number;
70
+ /** `tokensBefore - summaryTokens`, floored at zero. */
71
+ freed: number;
72
+ }
73
+
74
+ /** What the branch remembers about compaction. */
75
+ export interface CompactionHistory {
76
+ /** How many compaction entries the branch holds. */
77
+ count: number;
78
+ /** The newest one, or `null` when the branch has never been compacted. */
79
+ last: CompactionSummary | null;
80
+ }
81
+
82
+ /** One of the largest single blocks in the projected context. */
83
+ export interface LargestBlock {
84
+ /** Estimated tokens. */
85
+ tokens: number;
86
+ /** What kind of message it is: `user`, `assistant`, `tool result`, … */
87
+ role: string;
88
+ /** Sanitized, clipped descriptor (a tool name, the first line, a custom type). */
89
+ label: string;
90
+ }
91
+
92
+ /** The finished report. */
93
+ export interface ContextReport {
94
+ /** The context window, `0` when unknown. */
95
+ window: number;
96
+ /** The host's estimate, `null` between a compaction and the next reply. */
97
+ tokens: number | null;
98
+ /** `tokens / window`, `null` when either is unknown. */
99
+ percent: number | null;
100
+ /** `window - tokens`, `null` when `tokens` is unknown. */
101
+ free: number | null;
102
+ /** `window - reserve - tokens`, `null` when `tokens` is unknown. */
103
+ headroom: number | null;
104
+ /** The resolved `reserveTokens` for the active model. */
105
+ reserve: number;
106
+ /** The resolved `keepRecentTokens` for the active model. */
107
+ keepRecentTokens: number;
108
+ /** Whether the host's automatic compaction is on. */
109
+ enabled: boolean;
110
+ /** `max(0, window - reserve)`: where the host would compact. */
111
+ autoCompactAt: number;
112
+ /** The composition rows, ordered for display. */
113
+ buckets: ContextBucket[];
114
+ /** Sum of the bucket estimates. */
115
+ estimate: number;
116
+ /** `tokens - estimate` when the host's estimate is known, else `null`. */
117
+ drift: number | null;
118
+ /** The branch's compaction history. */
119
+ compaction: CompactionHistory;
120
+ /** The largest projected blocks, biggest first. */
121
+ largest: LargestBlock[];
122
+ }
@@ -83,6 +83,16 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
83
83
  `worktree`; untracked files show as all-add, an unborn `HEAD` falls back to the
84
84
  index, and a non-repository is a plain empty state. Read-only, capped, and
85
85
  rendered with the shared diff colors.
86
+ - **Live status.** A page left open keeps up with the session: a `◉ live`
87
+ chip beside `read-only` says whether status is flowing (green), still
88
+ connecting (accent), stalled (amber, after three missed frames), or offline
89
+ (red); the branch chip, the `● N changes +A −D` chip, and the change markers in
90
+ the tree and the listing update in place; and the footer line reports `up 2m
91
+ 14s · 37 requests`. The chip names its state and never counts, so nothing is
92
+ repainted between frames. One Server-Sent Events stream per page carries it, on
93
+ the same loopback server, so nothing extra is opened and nothing is written;
94
+ that stream is also how the server notices the last page closing its window,
95
+ after which it stops listening.
86
96
  - **No dependencies.** `node:http` plus the inline sprite, CSS, and client
87
97
  script.
88
98
 
@@ -103,7 +113,10 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
103
113
  port; a busy port is reported, not silently changed.
104
114
  - Starting while already running with the same root reprints the URL without
105
115
  restarting; a different root (or a changed pinned port) restarts the server.
106
- - The server is closed on `session_shutdown`.
116
+ - The server is closed when its last page has been gone for ten seconds, so
117
+ closing the browser window ends the session's browse server and `/browse`
118
+ starts it again on the same port. A reload or a new tab inside that grace keeps
119
+ it. `session_shutdown` closes it too, and `/browse stop` closes it at once.
107
120
 
108
121
  ### Status
109
122
 
@@ -114,6 +127,26 @@ beside `⎇ branch` and `⑂ worktree`; it
114
127
  is cleared on stop and on `session_shutdown`. The chip is session-local: it
115
128
  reflects only this session's server, with no cross-session sharing.
116
129
 
130
+ The page has its own live status, which is what a left-open tab reads. The
131
+ header chip starts hidden and is painted by the client from `/events`: `◉ live`
132
+ in the success colour while frames arrive, `◉ connecting` before the first
133
+ one, `◉ stale` in the warning colour when three intervals pass without a frame
134
+ on an open connection, and `◉ offline` in the danger colour once the stream
135
+ errors. The chip names its state rather than counting, so it is repainted only
136
+ when that state changes. The same frame carries the branch (or detached head), the
137
+ uncommitted-change totals and paths, the server's uptime, and the requests it
138
+ has answered, so the branch chip, the changes chip, the markers, and the footer
139
+ line are painted from one snapshot rather than four requests. Losing the stream
140
+ leaves everything the page last showed in place: it is read-only, so a stale
141
+ label is never a stale action. `"live": false` turns the whole surface off — no
142
+ chip, no stream, no `/events` route — and so does JavaScript off.
143
+
144
+ The stream is also how the server knows a page is still there: when the last one
145
+ leaves, the port is released after ten seconds and the chip is cleared, because
146
+ nothing is left to serve. That is why a page with JavaScript off — or with live
147
+ status off — never counts as a reader, and the server then waits for
148
+ `/browse stop` or the end of the session.
149
+
117
150
  ## Routes
118
151
 
119
152
  | Route | Result |
@@ -126,6 +159,7 @@ reflects only this session's server, with no cross-session sharing.
126
159
  | `/diff/<rel>[?mode=…]` | one file's unified diff |
127
160
  | `/api/tree?path=<rel>` | JSON children for lazy tree expansion |
128
161
  | `/api/files?q=<query>` | JSON fuzzy path matches for the quick-open palette |
162
+ | `/events` | Server-Sent Events stream of `status` frames for the open page |
129
163
  | `/app.js` | the first-party client script |
130
164
  | `/theme.js` | the pre-paint theme bootstrap |
131
165
  | `/favicon.ico` | `204` (the icon is a data URI) |
@@ -150,7 +184,9 @@ document and the project document (project wins):
150
184
  "maxIndexEntries": 20000, // files collected for quick open before truncation
151
185
  "gitDiff": true, // offer the read-only diff view
152
186
  "diffContext": 3, // unified diff context lines
153
- "maxDiffLines": 4000 // diff lines rendered before truncation
187
+ "maxDiffLines": 4000, // diff lines rendered before truncation
188
+ "live": true, // stream live status to open pages
189
+ "liveIntervalMs": 3000 // ms between status frames while a page is connected
154
190
  }
155
191
  ```
156
192
 
@@ -185,6 +221,13 @@ document and the project document (project wins):
185
221
  - **Highlighting safety.** The highlighter escapes every literal before it
186
222
  wraps it in a token span, so a code file's content cannot inject markup or
187
223
  attributes; only the fixed `tok-*` class names reach the page.
224
+ - **Event stream.** `/events` is a `GET` on the same loopback server, behind
225
+ the same method and `Host` checks, and it only reads: the frame is the branch,
226
+ the change paths, an uptime, and a request count, all of which the page it
227
+ feeds already renders. It carries no page policy (`text/event-stream` is not a
228
+ document), sends `Cache-Control: no-store`, and cannot be cached or replayed.
229
+ A tick runs only while a page is attached, and the timer is `unref`ed, so an
230
+ idle server does no work and a forgotten tab cannot hold the process open.
188
231
  - **Caps.** `maxFileBytes` (inline rendering), `maxTextLines` (rendered lines),
189
232
  `maxThumbBytes` (thumbnails), and `maxDirEntries` (listings) bound every read.
190
233
  - **Git reads.** The header chip and the diff view run read-only git
@@ -200,6 +243,11 @@ document and the project document (project wins):
200
243
  type renders exactly as before. State that the tokenizer does not carry
201
244
  across lines (shell heredocs, interpolation inside a template literal) is
202
245
  approximated rather than parsed.
246
+ - Live status updates what the page already contains: the header chips, the
247
+ markers on the rows it rendered, and the footer line. A file that appears on
248
+ disk joins the listing on the next reload, the `/diff` table and a file page's
249
+ `changes` action stay page-load snapshots, and the branch chip is only patched
250
+ when the page was served inside a repository.
203
251
  - `/browse` has no auth; a malicious local page could probe the port. The `Host`
204
252
  allowlist and the absence of CORS headers mitigate reads, and the server is
205
253
  read-only.
@@ -221,7 +269,7 @@ document and the project document (project wins):
221
269
  |---|---|
222
270
  | Command | `/browse` registers a single command from the factory; no tools are exposed. |
223
271
  | State | The running server, its root, and the session port live in a module-local closure; nothing is persisted and no session entries are written. |
224
- | Lifecycle | The factory opens no socket; the server is created on `/browse` and closed idempotently by `/browse stop` and `session_shutdown`. |
272
+ | Lifecycle | The factory opens no socket; the server is created on `/browse` and closed idempotently by `/browse stop`, by `session_shutdown`, and once its last page has been gone for the idle grace, which also ends every attached status stream. |
225
273
 
226
274
  ## Design notes
227
275
 
@@ -232,7 +280,10 @@ document and the project document (project wins):
232
280
  wins, otherwise a random port is drawn and cached after the first successful
233
281
  bind, so later starts reuse it (a conflict is reported and the next `/browse`
234
282
  rolls another). `/browse stop` and `session_shutdown` await the idempotent
235
- `close()`; `session_shutdown` also clears the cached port.
283
+ `close()`; `session_shutdown` also clears the cached port. The page is the
284
+ server's only reader, so the channel reports the last page's departure after
285
+ `IDLE_STOP_MS` and the command closes the server and clears the chip — the
286
+ cached port survives, so the next `/browse` binds the same one.
236
287
  - **Pure routing.** `handleRequest(context, method, rawUrl, headers)` validates
237
288
  method and `Host`, parses the URL, matches a route, and runs every path
238
289
  through `paths.ts`. It returns a plain `ServeResponse`, so routing is tested
@@ -309,6 +360,21 @@ document and the project document (project wins):
309
360
  with the file view. `git diff --stat` supplies the chip totals and
310
361
  `changeDirs` derives the directory markers; the changed lines are highlighted
311
362
  per hunk by rebuilding the old and new sides for `lib/highlight`.
363
+ - **Live status.** `live.ts` builds one snapshot per tick — the git status,
364
+ the `head` change index, the server's uptime, and its request count — and
365
+ `frameOf` serializes it as one `status` event. `createLiveChannel` holds the
366
+ attached pages and starts the ticker on the first attach, so an idle server
367
+ does no work; it shares a single in-flight snapshot between pages, drops a
368
+ page whose write fails, and stops on the last detach and on `close()`, which
369
+ `server.ts` calls before closing the socket. The last detach also arms one
370
+ idle-stop timer — a reload re-attaches and cancels it — and `server.ts` turns
371
+ that into the extension's `close()` and a cleared chip. Both providers are already
372
+ TTL-cached, so a tick re-reads at most one `git status`, and every read
373
+ failure degrades to `null` rather than a failed frame. The event-stream
374
+ headers and the `retry:` preamble live in `lib/http` with the rest of the
375
+ loopback policy, shared with plan's review page. The client half is
376
+ `live-client.ts`: one `EventSource`, one snapshot paint (`textContent` only,
377
+ never markup), one staleness timeout armed per frame, and a `pagehide` cleanup.
312
378
  - **Streaming responses.** The server streams `filePath` responses and
313
379
  suppresses bodies for `HEAD`, so large `/raw` files are not buffered.
314
380
  - **Root confinement over feature breadth.** The root is deliberately confined
@@ -325,7 +391,7 @@ document and the project document (project wins):
325
391
  | [`../../lib/escape.ts`](../../lib/escape.ts) | `escapeHtml`, the shared HTML escaping used by every renderer |
326
392
  | [`../../lib/highlight.ts`](../../lib/highlight.ts) | First-party escape-first syntax tokenizer for the code view (shared with `plan`) |
327
393
  | [`../../lib/web/index.ts`](../../lib/web/index.ts) | The shared Bytefly web brand system: tokens, base, components, layout, runtime, and markup |
328
- | `files.ts` | Classification (incl. Markdown), listings, text reads, sidebar tree |
394
+ | `files.ts` | Classification (incl. Markdown), text reads, and one page-level walk (`directoryView`) that returns a directory's listing and its sidebar tree |
329
395
  | `diff.ts` | Pure unified-diff and porcelain-status parsing for the diff view |
330
396
  | `fuzzy.ts` | Fuzzy path matching and ranking for the quick-open palette |
331
397
  | `markdown.ts` | First-party escape-first Markdown-to-HTML renderer (fenced code highlighted; raw HTML through `raw-html.ts`) |
@@ -334,10 +400,13 @@ document and the project document (project wins):
334
400
  | `git.ts` | Read-only branch/dirty provider for the header chip |
335
401
  | `icons.ts` | Inline SVG sprite and the per-language mapping |
336
402
  | `client.ts` | The `/app.js` source (tree toggle, filter, sort, wrap, copy, quick open) |
337
- | `html.ts` | Page renderers, sortable headers, and the page-only `browse-*` CSS (the `bf-*` kit comes from `lib/web`) |
403
+ | `live-client.ts` | The client's live status block: the event stream, the chips, the markers, and the footer line (interpolated into `client.ts`) |
404
+ | `live.ts` | The live status snapshot, its frame, and the fan-out channel that ticks it |
405
+ | `styles.ts` | The page-only `browse-*` stylesheet (the `bf-*` kit comes from `lib/web`) |
406
+ | `html.ts` | Page renderers: the shared shell, the two-pane layout, the listing, the cards, and the diff body |
338
407
  | `router.ts` | Pure request handler: routes, the `/raw` sandbox policy, and the error page |
339
408
  | `server.ts` | `node:http` server: this extension's handler over the shared bind, writer, and close |
340
- | [`../../lib/http/index.ts`](../../lib/http/index.ts) | Loopback HTTP layer shared with `plan`: page/script/JSON headers, `ServeResponse`, the request-envelope helpers, bind/respond/close |
409
+ | [`../../lib/http/index.ts`](../../lib/http/index.ts) | Loopback HTTP layer shared with `plan`: page/script/JSON/event-stream headers, `ServeResponse`, the request-envelope helpers, bind/respond/close, and `openEventStream` |
341
410
  | [`../../lib/browser.ts`](../../lib/browser.ts) | Cross-platform browser opener (shared with `plan`) |
342
411
 
343
412
  ## Testing
@@ -360,5 +429,11 @@ on first start, reused, and re-rolled after a conflict), the git status provider
360
429
  and provider (line kinds, line numbers, status and rename parsing, mode
361
430
  filtering, untracked all-add, unborn-HEAD fallback, literal pathspec, and TTL
362
431
  cache), the diff renderers (highlighted changed lines, marker sets, and chip
363
- totals), and the command
432
+ totals), the live status feed (snapshot fields, degraded providers, the frame,
433
+ and the channel's ticker driven by an injected timer, plus the idle stop armed on
434
+ the last detach and cancelled by the next page or by `close()`), the `/events`
435
+ route and
436
+ its 404 when the channel is off, a real socket that reads a page's first frame
437
+ and the ticker's next one and sees the stream end when the server closes, the
438
+ live chrome a page renders when on and not when off, and the command
364
439
  lifecycle with injected seams.
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { keysDialogScript, SIDEBAR_COLLAPSE_MAX, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
15
+ import { LIVE_CLIENT_JS } from "./live-client.ts";
15
16
 
16
17
  export const CLIENT_JS = `(function () {
17
18
  "use strict";
@@ -716,5 +717,6 @@ export const CLIENT_JS = `(function () {
716
717
 
717
718
  // The control starts in the state the rendered tree implies.
718
719
  syncFoldButton();
719
- })();
720
+
721
+ ${LIVE_CLIENT_JS}})();
720
722
  `;
@@ -36,6 +36,10 @@ export interface ServeConfig {
36
36
  diffContext: number;
37
37
  /** Diff lines rendered before the view is truncated. */
38
38
  maxDiffLines: number;
39
+ /** Push live status to open pages over `/events`. */
40
+ live: boolean;
41
+ /** How often a connected page is sent a status frame, in ms. */
42
+ liveIntervalMs: number;
39
43
  }
40
44
 
41
45
  export const DEFAULT_CONFIG: ServeConfig = {
@@ -52,6 +56,8 @@ export const DEFAULT_CONFIG: ServeConfig = {
52
56
  gitDiff: true,
53
57
  diffContext: 3,
54
58
  maxDiffLines: 4000,
59
+ live: true,
60
+ liveIntervalMs: 3000,
55
61
  };
56
62
 
57
63
  /** Base of the per-session port range. */
@@ -60,6 +66,21 @@ export const PORT_BASE = 4780;
60
66
  /** Number of ports in the per-session range. */
61
67
  export const PORT_RANGE = 1000;
62
68
 
69
+ /** Fastest live-status cadence a page may be given, short enough to watch and
70
+ * long enough that the 2 s provider caches absorb most ticks. */
71
+ export const LIVE_INTERVAL_MIN = 250;
72
+
73
+ /** Slowest live-status cadence, so a page cannot look abandoned. */
74
+ export const LIVE_INTERVAL_MAX = 60_000;
75
+
76
+ /**
77
+ * How long the server keeps listening once the last page has gone. A reload or a
78
+ * navigation detaches the stream and re-attaches within a moment, so the grace has
79
+ * to outlast one; a closed window never re-attaches, and the port is released
80
+ * instead of listening for nobody until the session ends.
81
+ */
82
+ export const IDLE_STOP_MS = 10_000;
83
+
63
84
  /** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
64
85
  export function randomPort(): number {
65
86
  return PORT_BASE + randomInt(PORT_RANGE);
@@ -85,6 +106,8 @@ export function normalizeConfig(
85
106
  gitDiff: asBoolean(raw.gitDiff, base.gitDiff),
86
107
  diffContext: clampInteger(raw.diffContext, base.diffContext, 0, 100),
87
108
  maxDiffLines: clampInteger(raw.maxDiffLines, base.maxDiffLines, 1, 1_000_000),
109
+ live: asBoolean(raw.live, base.live),
110
+ liveIntervalMs: clampInteger(raw.liveIntervalMs, base.liveIntervalMs, LIVE_INTERVAL_MIN, LIVE_INTERVAL_MAX),
88
111
  };
89
112
  }
90
113
 
@@ -8,6 +8,7 @@
8
8
  import type { Dirent, Stats } from "node:fs";
9
9
  import { readFile, readdir, stat, lstat } from "node:fs/promises";
10
10
  import { basename, extname, join } from "node:path";
11
+ import { mapConcurrent } from "../../lib/pool.ts";
11
12
  import { HttpError } from "./paths.ts";
12
13
 
13
14
  export type EntryKind = "directory" | "text" | "image" | "binary";
@@ -188,63 +189,168 @@ function toAbs(root: string, rel: string): string {
188
189
  return rel ? join(root, ...rel.split("/")) : root;
189
190
  }
190
191
 
191
- /** List a directory, sorted (directories first, then case-insensitive name). */
192
- export async function listDirectory(root: string, relDir: string, limit: number): Promise<Listing> {
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> {
193
288
  const absDir = toAbs(root, relDir);
194
- let dirents: Dirent[];
289
+ let dirents: DirectoryEntryInfo[];
195
290
  try {
196
- dirents = await readdir(absDir, { withFileTypes: true });
291
+ dirents = await fs.readdir(absDir);
197
292
  } catch {
198
293
  throw new HttpError(404, "That folder could not be read.");
199
294
  }
200
295
 
201
- const entries: DirEntry[] = [];
202
- for (const dirent of dirents) {
203
- const abs = join(absDir, dirent.name);
204
- const isSymlink = dirent.isSymbolicLink();
205
- let isDir = dirent.isDirectory();
206
- let broken = false;
207
- let size = 0;
208
- let mtimeMs = 0;
209
- try {
210
- const info = isSymlink ? await lstat(abs) : await stat(abs);
211
- size = info.size;
212
- mtimeMs = info.mtimeMs;
213
- } catch {
214
- // Directory entry vanished between readdir and stat.
215
- }
216
- if (isSymlink) {
217
- try {
218
- const target = await stat(abs);
219
- isDir = target.isDirectory();
220
- size = target.size;
221
- mtimeMs = target.mtimeMs;
222
- } catch {
223
- broken = true;
224
- }
225
- }
226
- const kind: EntryKind = isDir ? "directory" : classifyByName(dirent.name);
227
- entries.push({
296
+ const links: Row[] = [];
297
+ const rows: Row[] = dirents.map((dirent) => {
298
+ const row: Row = {
228
299
  name: dirent.name,
229
300
  rel: relDir ? `${relDir}/${dirent.name}` : dirent.name,
230
- isDir,
231
- isSymlink,
232
- broken,
233
- size,
234
- mtimeMs,
235
- kind,
236
- isImage: !isDir && kind === "image",
237
- });
238
- }
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));
239
312
 
240
- entries.sort((a, b) => {
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) => {
241
316
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
242
317
  return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
243
318
  });
244
319
 
245
- const total = entries.length;
320
+ const total = rows.length;
246
321
  const truncated = total > limit;
247
- return { entries: truncated ? entries.slice(0, limit) : entries, total, truncated };
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 };
248
354
  }
249
355
 
250
356
  /** Read a file into a renderable view, honouring the size and line caps. */
@@ -305,12 +411,37 @@ export async function buildTree(root: string, currentRel: string, limit: number)
305
411
  return buildNode(root, "", currentRel, basename(root) || root, limit);
306
412
  }
307
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
+
308
437
  async function buildNode(
309
438
  root: string,
310
439
  relDir: string,
311
440
  currentRel: string,
312
441
  label: string,
313
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 },
314
445
  ): Promise<TreeNode> {
315
446
  const node: TreeNode = {
316
447
  name: label,
@@ -322,10 +453,14 @@ async function buildNode(
322
453
  children: [],
323
454
  };
324
455
  let listing: Listing;
325
- try {
326
- listing = await listDirectory(root, relDir, limit);
327
- } catch {
328
- return node;
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
+ }
329
464
  }
330
465
  for (const entry of listing.entries) {
331
466
  const child: TreeNode = {
@@ -340,7 +475,7 @@ async function buildNode(
340
475
  if (entry.isDir) {
341
476
  const onPath = currentRel === entry.rel || currentRel.startsWith(`${entry.rel}/`);
342
477
  if (onPath) {
343
- child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit)).children;
478
+ child.children = (await buildNode(root, entry.rel, currentRel, entry.name, limit, known)).children;
344
479
  }
345
480
  }
346
481
  node.children.push(child);