@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,256 @@
1
+ /**
2
+ * Live status for the browse pages.
3
+ *
4
+ * A page that stays open holds one event stream; this module builds the snapshot
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.
9
+ *
10
+ * The channel opens no socket of its own — the caller hands it a `write`/`end`
11
+ * pair — so the whole feed is tested without one.
12
+ */
13
+
14
+ import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
15
+ import { changeDirs } from "./diff.ts";
16
+ import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
17
+
18
+ /**
19
+ * The changed-path index the pages render from: the totals the changes chip
20
+ * prints and the paths the tree and listing mark.
21
+ */
22
+ export interface ChangeIndex {
23
+ /** Changed paths in the scope. */
24
+ count: number;
25
+ /** Added lines in the scope. */
26
+ insertions: number;
27
+ /** Removed lines in the scope. */
28
+ deletions: number;
29
+ /** Changed files, root-relative. */
30
+ files: ReadonlySet<string>;
31
+ /** Directories that contain a changed file. */
32
+ dirs: ReadonlySet<string>;
33
+ }
34
+
35
+ /** The marker/totals index a change list yields. */
36
+ export function changeIndexOf(list: DiffList): ChangeIndex {
37
+ return {
38
+ count: list.files.length,
39
+ insertions: list.insertions,
40
+ deletions: list.deletions,
41
+ files: new Set(list.files.map((file) => file.rel)),
42
+ dirs: changeDirs(list.files),
43
+ };
44
+ }
45
+
46
+ /** A change index as it goes over the wire: sets become ordered lists. */
47
+ export interface LiveChanges {
48
+ count: number;
49
+ insertions: number;
50
+ deletions: number;
51
+ files: string[];
52
+ dirs: string[];
53
+ }
54
+
55
+ /** One status frame's payload. */
56
+ export interface LiveSnapshot {
57
+ /** Server wall clock when the snapshot was taken, in ms. */
58
+ at: number;
59
+ /** How long the server has been listening, in ms. */
60
+ uptimeMs: number;
61
+ /** Requests the server has answered so far, this one included. */
62
+ requests: number;
63
+ /** Branch or detached head, or `null` outside a repository. */
64
+ git: GitStatus | null;
65
+ /** Uncommitted changes against `HEAD`, or `null` when unavailable. */
66
+ changes: LiveChanges | null;
67
+ }
68
+
69
+ /** The snapshot a streamed frame is built from. */
70
+ export interface LiveStatusSource {
71
+ snapshot(): Promise<LiveSnapshot>;
72
+ }
73
+
74
+ export interface LiveStatusOptions {
75
+ /** Read-only git context; omitted when the page has none. */
76
+ git?: GitStatusProvider;
77
+ /** Read-only diff provider; omitted when the diff view is off. */
78
+ diff?: GitDiffProvider;
79
+ /** When the server started listening, in ms. */
80
+ startedAt: number;
81
+ /** Requests answered so far, read per snapshot. */
82
+ requests: () => number;
83
+ /** Wall-clock seam; defaults to the real clock. */
84
+ now?: () => number;
85
+ }
86
+
87
+ /** Read a provider, turning a rejection or an absent value into `null`. */
88
+ async function settled<T>(read: () => Promise<T | undefined>): Promise<T | null> {
89
+ try {
90
+ return (await read()) ?? null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Build the status source for one server. `snapshot` never rejects: a
98
+ * non-repository, a failed git call, or a timeout simply leaves that field
99
+ * `null`, and the page keeps whatever it last showed.
100
+ */
101
+ export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
102
+ const now = options.now ?? (() => Date.now());
103
+ const diff = options.diff;
104
+ return {
105
+ async snapshot() {
106
+ const at = now();
107
+ const git = options.git ? await settled(options.git) : null;
108
+ const list = diff ? await settled(() => diff.list("head")) : null;
109
+ const index = list ? changeIndexOf(list) : null;
110
+ return {
111
+ at,
112
+ uptimeMs: Math.max(0, at - options.startedAt),
113
+ requests: options.requests(),
114
+ 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,
124
+ };
125
+ },
126
+ };
127
+ }
128
+
129
+ /** One server-sent event carrying a snapshot. */
130
+ export function frameOf(snapshot: LiveSnapshot): string {
131
+ return `event: status\ndata: ${JSON.stringify(snapshot)}\n\n`;
132
+ }
133
+
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);
153
+
154
+ export interface LiveChannelOptions {
155
+ /** What each frame carries. */
156
+ status: LiveStatusSource;
157
+ /** How far apart the frames are, in ms. */
158
+ 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;
163
+ /** Timer seam; defaults to an unref'd interval. */
164
+ timer?: LiveTimer;
165
+ /** Grace seam; defaults to an unref'd timeout. */
166
+ timeout?: LiveTimeout;
167
+ }
168
+
169
+ /** The fan-out for one server: pages attach, ticks publish, close ends them. */
170
+ export interface LiveChannel {
171
+ /** Add a connected page, send it the current status, and return its detach. */
172
+ attach(client: LiveClient): () => void;
173
+ /** End every connected page, stop the ticker, and drop a pending idle stop. */
174
+ close(): void;
175
+ }
176
+
177
+ /**
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.
182
+ */
183
+ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
184
+ const timer = options.timer ?? defaultTicker;
185
+ const timeout = options.timeout ?? defaultTimeout;
186
+ const clients = new Set<LiveClient>();
187
+ let cancel: (() => void) | undefined;
188
+ let cancelIdle: (() => void) | undefined;
189
+ let inFlight = false;
190
+
191
+ const stop = (): void => {
192
+ cancel?.();
193
+ cancel = undefined;
194
+ };
195
+
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
+ 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();
224
+ };
225
+
226
+ // One snapshot serves every attached page, so a tick is one git read and one
227
+ // serialization however many tabs are open.
228
+ const publish = async (): Promise<void> => {
229
+ if (inFlight) return;
230
+ inFlight = true;
231
+ try {
232
+ send(frameOf(await options.status.snapshot()));
233
+ } finally {
234
+ inFlight = false;
235
+ }
236
+ };
237
+
238
+ return {
239
+ attach(client) {
240
+ cancelIdleStop();
241
+ clients.add(client);
242
+ cancel ??= timer(() => void publish(), options.intervalMs);
243
+ void publish();
244
+ return () => {
245
+ if (!clients.delete(client)) return;
246
+ if (clients.size === 0) pageLeft();
247
+ };
248
+ },
249
+ close() {
250
+ cancelIdleStop();
251
+ stop();
252
+ for (const client of clients) client.end();
253
+ clients.clear();
254
+ },
255
+ };
256
+ }
@@ -25,7 +25,7 @@
25
25
  * policy and raw HTML do not.
26
26
  */
27
27
 
28
- import { escapeHtml } from "../../lib/escape.ts";
28
+ import { decodeCodePoint, escapeHtml } from "../../lib/escape.ts";
29
29
  import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
30
30
  import {
31
31
  closesSuppressed,
@@ -739,13 +739,6 @@ interface Entity {
739
739
  length: number;
740
740
  }
741
741
 
742
- /** A code point, or U+FFFD when the reference is a surrogate or out of range. */
743
- function codePoint(value: number): string {
744
- if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
745
- return "\uFFFD";
746
- return String.fromCodePoint(value);
747
- }
748
-
749
742
  /**
750
743
  * Match a numeric or named character reference at `start`. Decoding happens in
751
744
  * the inline scanner rather than a pre-pass so an entity cannot become Markdown
@@ -755,8 +748,8 @@ function matchEntity(text: string, start: number): Entity | undefined {
755
748
  const match = /^&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/.exec(text.slice(start));
756
749
  if (!match) return undefined;
757
750
  let value: string | undefined;
758
- if (match[1] !== undefined) value = codePoint(Number(match[1]));
759
- else if (match[2] !== undefined) value = codePoint(Number.parseInt(match[2], 16));
751
+ if (match[1] !== undefined) value = decodeCodePoint(Number(match[1]));
752
+ else if (match[2] !== undefined) value = decodeCodePoint(Number.parseInt(match[2], 16));
760
753
  else if (match[3] !== undefined && Object.hasOwn(NAMED_ENTITIES, match[3])) value = NAMED_ENTITIES[match[3]];
761
754
  if (value === undefined) return undefined;
762
755
  return { value, length: match[0].length };
@@ -25,7 +25,7 @@
25
25
  * how `<details>` wraps Markdown.
26
26
  */
27
27
 
28
- import { escapeHtml } from "../../lib/escape.ts";
28
+ import { decodeCodePoint, escapeHtml } from "../../lib/escape.ts";
29
29
  import { resolveImage, resolveLink } from "./urls.ts";
30
30
 
31
31
  /** Elements re-emitted with sanitized attributes. */
@@ -353,21 +353,14 @@ function decodeAttribute(value: string): string {
353
353
  return value.replace(
354
354
  ATTRIBUTE_ENTITY,
355
355
  (match, decimal: string | undefined, hex: string | undefined, named: string | undefined) => {
356
- if (decimal !== undefined) return codePoint(Number(decimal));
357
- if (hex !== undefined) return codePoint(Number.parseInt(hex, 16));
356
+ if (decimal !== undefined) return decodeCodePoint(Number(decimal));
357
+ if (hex !== undefined) return decodeCodePoint(Number.parseInt(hex, 16));
358
358
  if (named !== undefined && Object.hasOwn(ATTRIBUTE_ENTITIES, named)) return ATTRIBUTE_ENTITIES[named];
359
359
  return match;
360
360
  },
361
361
  );
362
362
  }
363
363
 
364
- /** A code point, or U+FFFD when the reference is a surrogate or out of range. */
365
- function codePoint(value: number): string {
366
- if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
367
- return "\uFFFD";
368
- return String.fromCodePoint(value);
369
- }
370
-
371
364
  /** The first value for `name`, decoded, so a duplicate attribute cannot override it. */
372
365
  function attribute(tag: RawTag, name: string): string | undefined {
373
366
  const found = tag.attrs.find((candidate) => candidate.name === name);
@@ -10,6 +10,7 @@
10
10
  import type { Stats } from "node:fs";
11
11
  import { stat } from "node:fs/promises";
12
12
  import {
13
+ eventHeaders,
13
14
  htmlResponse,
14
15
  isLocalHostHeader,
15
16
  jsonError,
@@ -22,12 +23,14 @@ import {
22
23
  import { THEME_JS } from "../../lib/web/index.ts";
23
24
  import { CLIENT_JS } from "./client.ts";
24
25
  import type { ServeConfig } from "./config.ts";
25
- import { changeDirs, isDiffMode, type DiffMode } from "./diff.ts";
26
+ import { isDiffMode, type DiffMode } from "./diff.ts";
26
27
  import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
28
+ import { changeIndexOf, type LiveStatusSource } from "./live.ts";
27
29
  import {
28
30
  buildFileIndex,
29
31
  buildTree,
30
32
  contentTypeFor,
33
+ directoryView,
31
34
  isImageName,
32
35
  listDirectory,
33
36
  readFileView,
@@ -50,6 +53,8 @@ export interface ServeContext {
50
53
  diff?: GitDiffProvider;
51
54
  /** Cached quick-open file index; built on demand when omitted. */
52
55
  index?: FileIndexProvider;
56
+ /** Snapshot source for the live status channel; omitted when live status is off. */
57
+ live?: LiveStatusSource;
53
58
  }
54
59
 
55
60
  /** Most palette rows the client will render for one query. */
@@ -93,15 +98,21 @@ async function diffFor(ctx: ServeContext, mode: DiffMode) {
93
98
 
94
99
  /** The marker/totals index derived from a change list. */
95
100
  function summaryOf(list: DiffList): ChangeSummary {
101
+ const index = changeIndexOf(list);
96
102
  return {
97
- count: list.files.length,
98
- insertions: list.insertions,
99
- deletions: list.deletions,
100
- files: new Set(list.files.map((file) => file.rel)),
101
- dirs: changeDirs(list.files),
103
+ count: index.count,
104
+ insertions: index.insertions,
105
+ deletions: index.deletions,
106
+ files: index.files,
107
+ dirs: index.dirs,
102
108
  };
103
109
  }
104
110
 
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;
114
+ }
115
+
105
116
  /** The `head` change summary, for the header chip, tree markers, and file action. */
106
117
  async function changed(ctx: ServeContext): Promise<ChangeSummary | undefined> {
107
118
  const list = await diffFor(ctx, "head");
@@ -135,6 +146,7 @@ async function errorPage(
135
146
  message,
136
147
  tree,
137
148
  git: await gitFor(ctx),
149
+ live: liveOf(ctx),
138
150
  });
139
151
  return htmlResponse(status, body, extra);
140
152
  }
@@ -158,8 +170,7 @@ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<Se
158
170
  }
159
171
 
160
172
  async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeResponse> {
161
- const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
162
- const tree = await treeFor(ctx, rel);
173
+ const { listing, tree } = await directoryView(ctx.root, rel, ctx.config.maxDirEntries);
163
174
  const git = await gitFor(ctx);
164
175
  const changes = await changed(ctx);
165
176
  return htmlResponse(
@@ -173,6 +184,7 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
173
184
  maxThumbBytes: ctx.config.maxThumbBytes,
174
185
  git,
175
186
  summary: changes,
187
+ live: liveOf(ctx),
176
188
  }),
177
189
  );
178
190
  }
@@ -195,6 +207,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
195
207
  markdownHtml: ctx.config.markdownHtml,
196
208
  changed: changes?.files.has(rel),
197
209
  summary: changes,
210
+ live: liveOf(ctx),
198
211
  }),
199
212
  );
200
213
  }
@@ -215,6 +228,7 @@ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeRes
215
228
  tree,
216
229
  git,
217
230
  summary: list ? summaryOf(list) : undefined,
231
+ live: liveOf(ctx),
218
232
  }),
219
233
  );
220
234
  }
@@ -246,6 +260,7 @@ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Prom
246
260
  tree,
247
261
  git,
248
262
  summary: changes,
263
+ live: liveOf(ctx),
249
264
  }),
250
265
  );
251
266
  }
@@ -304,6 +319,15 @@ async function filesApi(ctx: ServeContext, query: string): Promise<ServeResponse
304
319
  return jsonResponse(200, { query, total: index.files.length, truncated: index.truncated, results });
305
320
  }
306
321
 
322
+ /**
323
+ * The live status stream: a marker response the server in `server.ts` keeps open
324
+ * and feeds from the same source.
325
+ */
326
+ function liveEvents(ctx: ServeContext): ServeResponse {
327
+ if (!ctx.config.live || !ctx.live) throw new HttpError(404, "Live status is not available.");
328
+ return { status: 200, headers: eventHeaders(), stream: true };
329
+ }
330
+
307
331
  /**
308
332
  * Handle one request. `method` and `headers` are the raw HTTP values; `rawUrl`
309
333
  * is the request target (path plus optional query).
@@ -332,6 +356,7 @@ export async function handleRequest(
332
356
  if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
333
357
  if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
334
358
  if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
359
+ if (pathname === "/events") return liveEvents(ctx);
335
360
  const rawMode = search.get("mode");
336
361
  if (rawMode !== null && !isDiffMode(rawMode)) return errorPage(ctx, 400, "Unknown diff mode.");
337
362
  const mode: DiffMode = isDiffMode(rawMode) ? rawMode : "head";
@@ -2,21 +2,30 @@
2
2
  * The HTTP server for the browse extension.
3
3
  *
4
4
  * Wraps `node:http` around the pure `handleRequest`: it resolves the root once,
5
- * binds `127.0.0.1`, streams `/raw` responses from disk, and exposes an
6
- * idempotent `close()`. The request handler is injectable so tests can avoid a
7
- * socket when they only need routing.
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
8
  *
9
- * The bind, the response writer, and the close come from `lib/http`, which `plan`
10
- * shares; what stays here is this server's own request handler and the catch that
11
- * turns a thrown handler into a plain-text 500.
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.
12
13
  */
13
14
 
14
15
  import { realpathSync } from "node:fs";
15
16
  import { createServer } from "node:http";
16
- import { bindLoopback, createCloser, respond, textResponse, type ServeResponse } from "../../lib/http/index.ts";
17
- import type { ServeConfig } from "./config.ts";
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";
18
26
  import { createFileIndex } from "./files.ts";
19
27
  import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
28
+ import { createLiveChannel, createLiveStatus } from "./live.ts";
20
29
  import { handleRequest, type ServeContext } from "./router.ts";
21
30
 
22
31
  export interface FileServer {
@@ -37,6 +46,10 @@ export interface FileServerOptions {
37
46
  git?: GitStatusProvider;
38
47
  /** Read-only diff provider for the change and diff views; omitted when unavailable. */
39
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;
40
53
  /** Override the request handler (tests). */
41
54
  handler?: typeof handleRequest;
42
55
  }
@@ -44,16 +57,38 @@ export interface FileServerOptions {
44
57
  /** Start a read-only server rooted at `options.root` on 127.0.0.1. */
45
58
  export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
46
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;
47
80
  const context: ServeContext = {
48
81
  root,
49
- config: options.config,
82
+ config,
50
83
  git: options.git,
51
84
  diff: options.diff,
52
- index: createFileIndex(root, options.config.maxIndexEntries),
85
+ index: createFileIndex(root, config.maxIndexEntries),
86
+ live: status,
53
87
  };
54
88
  const handler = options.handler ?? handleRequest;
55
89
 
56
90
  const server = createServer((request, response) => {
91
+ requests += 1;
57
92
  const answer = async (): Promise<ServeResponse> => {
58
93
  try {
59
94
  return await handler(context, request.method ?? "GET", request.url ?? "/", request.headers);
@@ -61,9 +96,39 @@ export async function createFileServer(options: FileServerOptions): Promise<File
61
96
  return textResponse(500, "Internal error");
62
97
  }
63
98
  };
64
- void answer().then((result) => respond(request, response, result));
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
+ openEventStream(response, result);
107
+ const detach = live.attach({
108
+ write: (frame) => {
109
+ response.write(frame);
110
+ },
111
+ end: () => {
112
+ if (!response.writableEnded) response.end();
113
+ },
114
+ });
115
+ request.on("close", () => {
116
+ detach();
117
+ if (!response.writableEnded) response.end();
118
+ });
119
+ });
65
120
  });
66
121
 
67
- const port = await bindLoopback(server, options.config.port);
68
- return { url: `http://127.0.0.1:${port}/`, port, root, close: createCloser(server) };
122
+ const port = await bindLoopback(server, config.port);
123
+ const closeServer = createCloser(server);
124
+ return {
125
+ url: `http://127.0.0.1:${port}/`,
126
+ port,
127
+ root,
128
+ close: async () => {
129
+ // End the streams first, so a connected page does not hold the close open.
130
+ live?.close();
131
+ await closeServer();
132
+ },
133
+ };
69
134
  }