@gavin-hu/my-pi-agent 0.15.1 → 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 (87) hide show
  1. package/README.md +10 -7
  2. package/README.zh-CN.md +8 -5
  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 +38 -18
  26. package/extensions/file-browse/config.ts +8 -0
  27. package/extensions/file-browse/html.ts +2 -2
  28. package/extensions/file-browse/index.ts +17 -2
  29. package/extensions/file-browse/live-client.ts +13 -13
  30. package/extensions/file-browse/live.ts +44 -10
  31. package/extensions/file-browse/markdown.ts +3 -10
  32. package/extensions/file-browse/raw-html.ts +3 -10
  33. package/extensions/file-browse/server.ts +13 -2
  34. package/extensions/file-open/README.md +1 -2
  35. package/extensions/{document → file-read}/README.md +72 -56
  36. package/extensions/{document → file-read}/config.ts +12 -9
  37. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  38. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  39. package/extensions/{document → file-read}/extract/file.ts +9 -9
  40. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  41. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  42. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  43. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  44. package/extensions/{document → file-read}/formats.ts +11 -11
  45. package/extensions/file-read/index.ts +17 -0
  46. package/extensions/{document → file-read}/paging.ts +15 -13
  47. package/extensions/{document → file-read}/render.ts +17 -13
  48. package/extensions/{document → file-read}/schema.ts +7 -7
  49. package/extensions/{document → file-read}/tool.ts +36 -28
  50. package/extensions/goal/tools.ts +10 -14
  51. package/extensions/job/commands.ts +32 -46
  52. package/extensions/job/runtime.ts +6 -8
  53. package/extensions/job/tools.ts +7 -26
  54. package/extensions/memory/commands.ts +30 -44
  55. package/extensions/memory/tools.ts +6 -14
  56. package/extensions/plan/tools/enter.ts +6 -7
  57. package/extensions/plan/tools/exit.ts +6 -7
  58. package/extensions/plan/tools/steps.ts +6 -7
  59. package/extensions/plan/tools/write.ts +8 -9
  60. package/extensions/plan/web.ts +21 -12
  61. package/extensions/question/tools.ts +5 -4
  62. package/extensions/subagent/background.ts +1 -7
  63. package/extensions/todo/tools.ts +10 -14
  64. package/extensions/watch/README.md +5 -5
  65. package/extensions/watch/commands.ts +33 -47
  66. package/extensions/watch/runtime.ts +5 -7
  67. package/extensions/watch/tools.ts +7 -18
  68. package/extensions/web-access/README.md +3 -3
  69. package/extensions/web-access/search/search.ts +9 -18
  70. package/extensions/wechat/timer.ts +9 -30
  71. package/extensions/wechat/tools.ts +4 -7
  72. package/lib/README.md +9 -6
  73. package/lib/escape.ts +15 -2
  74. package/lib/pdf.ts +2 -2
  75. package/lib/requirements.ts +2 -2
  76. package/lib/screen-session.ts +85 -0
  77. package/lib/timer.ts +61 -0
  78. package/lib/tui/README.md +3 -3
  79. package/lib/tui/columns.ts +33 -8
  80. package/lib/tui/rows.ts +15 -6
  81. package/lib/tui/rules.ts +4 -8
  82. package/lib/tui/tool.ts +55 -2
  83. package/package.json +8 -4
  84. package/skills/changelog/SKILL.md +136 -0
  85. package/extensions/document/index.ts +0 -17
  86. /package/extensions/{document → file-read}/deadline.ts +0 -0
  87. /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,13 +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 · 2s`
87
- chip beside `read-only` says whether status is flowing (green, with the age of
88
- the last frame), still connecting (accent), stalled (amber), or offline (red);
89
- the branch chip, the `● N changes +A −D` chip, and the change markers in the
90
- tree and the listing update in place; and the footer line reports `up 2m 14s ·
91
- 37 requests`. One Server-Sent Events stream per page carries it, on the same
92
- loopback server, so nothing extra is opened and nothing is written.
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.
93
96
  - **No dependencies.** `node:http` plus the inline sprite, CSS, and client
94
97
  script.
95
98
 
@@ -110,7 +113,10 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
110
113
  port; a busy port is reported, not silently changed.
111
114
  - Starting while already running with the same root reprints the URL without
112
115
  restarting; a different root (or a changed pinned port) restarts the server.
113
- - 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.
114
120
 
115
121
  ### Status
116
122
 
@@ -122,11 +128,12 @@ is cleared on stop and on `session_shutdown`. The chip is session-local: it
122
128
  reflects only this session's server, with no cross-session sharing.
123
129
 
124
130
  The page has its own live status, which is what a left-open tab reads. The
125
- header chip starts hidden and is painted by the client from `/events`: `◉ live ·
126
- 2s` in the success colour while frames arrive, `◉ connecting` before the first
127
- one, `◉ stale · 9s` in the warning colour when three intervals pass without a
128
- frame on an open connection, and `◉ offline` in the danger colour once the
129
- stream errors. The same frame carries the branch (or detached head), 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
130
137
  uncommitted-change totals and paths, the server's uptime, and the requests it
131
138
  has answered, so the branch chip, the changes chip, the markers, and the footer
132
139
  line are painted from one snapshot rather than four requests. Losing the stream
@@ -134,6 +141,12 @@ leaves everything the page last showed in place: it is read-only, so a stale
134
141
  label is never a stale action. `"live": false` turns the whole surface off — no
135
142
  chip, no stream, no `/events` route — and so does JavaScript off.
136
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
+
137
150
  ## Routes
138
151
 
139
152
  | Route | Result |
@@ -256,7 +269,7 @@ document and the project document (project wins):
256
269
  |---|---|
257
270
  | Command | `/browse` registers a single command from the factory; no tools are exposed. |
258
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. |
259
- | Lifecycle | The factory opens no socket; the server is created on `/browse` and closed idempotently by `/browse stop` and `session_shutdown`, which also ends every attached status stream. |
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. |
260
273
 
261
274
  ## Design notes
262
275
 
@@ -267,7 +280,10 @@ document and the project document (project wins):
267
280
  wins, otherwise a random port is drawn and cached after the first successful
268
281
  bind, so later starts reuse it (a conflict is reported and the next `/browse`
269
282
  rolls another). `/browse stop` and `session_shutdown` await the idempotent
270
- `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.
271
287
  - **Pure routing.** `handleRequest(context, method, rawUrl, headers)` validates
272
288
  method and `Host`, parses the URL, matches a route, and runs every path
273
289
  through `paths.ts`. It returns a plain `ServeResponse`, so routing is tested
@@ -350,13 +366,15 @@ document and the project document (project wins):
350
366
  attached pages and starts the ticker on the first attach, so an idle server
351
367
  does no work; it shares a single in-flight snapshot between pages, drops a
352
368
  page whose write fails, and stops on the last detach and on `close()`, which
353
- `server.ts` calls before closing the socket. Both providers are already
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
354
372
  TTL-cached, so a tick re-reads at most one `git status`, and every read
355
373
  failure degrades to `null` rather than a failed frame. The event-stream
356
374
  headers and the `retry:` preamble live in `lib/http` with the rest of the
357
375
  loopback policy, shared with plan's review page. The client half is
358
376
  `live-client.ts`: one `EventSource`, one snapshot paint (`textContent` only,
359
- never markup), a one-second age ticker, and a `pagehide` cleanup.
377
+ never markup), one staleness timeout armed per frame, and a `pagehide` cleanup.
360
378
  - **Streaming responses.** The server streams `filePath` responses and
361
379
  suppresses bodies for `HEAD`, so large `/raw` files are not buffered.
362
380
  - **Root confinement over feature breadth.** The root is deliberately confined
@@ -412,7 +430,9 @@ and provider (line kinds, line numbers, status and rename parsing, mode
412
430
  filtering, untracked all-add, unborn-HEAD fallback, literal pathspec, and TTL
413
431
  cache), the diff renderers (highlighted changed lines, marker sets, and chip
414
432
  totals), the live status feed (snapshot fields, degraded providers, the frame,
415
- and the channel's ticker driven by an injected timer), the `/events` route and
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
416
436
  its 404 when the channel is off, a real socket that reads a page's first frame
417
437
  and the ticker's next one and sees the stream end when the server closes, the
418
438
  live chrome a page renders when on and not when off, and the command
@@ -73,6 +73,14 @@ export const LIVE_INTERVAL_MIN = 250;
73
73
  /** Slowest live-status cadence, so a page cannot look abandoned. */
74
74
  export const LIVE_INTERVAL_MAX = 60_000;
75
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
+
76
84
  /** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
77
85
  export function randomPort(): number {
78
86
  return PORT_BASE + randomInt(PORT_RANGE);
@@ -76,8 +76,8 @@ export interface LivePage {
76
76
  * client reveals and paints it from the event stream, so a page with JS off — or
77
77
  * with live status disabled — never shows a state it cannot keep true.
78
78
  *
79
- * It is deliberately not a `role="status"` region: the age in the label is
80
- * rewritten every second, and a live region would announce each rewrite.
79
+ * It is deliberately not a `role="status"` region: the header is where a reader
80
+ * looks for the page's state, not a place to announce every change to it.
81
81
  */
82
82
  function renderLiveChip(live: LivePage | undefined): string {
83
83
  if (!live) return "";
@@ -4,7 +4,8 @@
4
4
  * Registers the read-only `/browse` command. It starts a `node:http` server
5
5
  * bound to 127.0.0.1, rooted at the effective cwd, and opens the default
6
6
  * browser at a two-pane tree browser. No tool is registered; the server is
7
- * closed on `session_shutdown`.
7
+ * closed on `session_shutdown`, by `/browse stop`, and once its last page has
8
+ * been gone for the idle grace — a closed window is not a running server.
8
9
  *
9
10
  * Load with: pi --extension ./extensions/file-browse
10
11
  */
@@ -32,6 +33,8 @@ export interface FileBrowserDeps {
32
33
 
33
34
  interface RunningServer {
34
35
  server: FileServer;
36
+ /** The context of the command that started it, used to clear the chip later. */
37
+ ctx: ExtensionContext;
35
38
  }
36
39
 
37
40
  export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}): void {
@@ -62,6 +65,17 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
62
65
  return true;
63
66
  };
64
67
 
68
+ /**
69
+ * Close the server once the page that was its only reader has gone: nothing is
70
+ * left to serve, and the chip would otherwise advertise a port that answers no
71
+ * one. `/browse` starts it again on the same session port.
72
+ */
73
+ const stopWhenPageLeft = async (): Promise<void> => {
74
+ const current = running;
75
+ if (!current) return;
76
+ if (await stop(current.ctx)) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
77
+ };
78
+
65
79
  const start = async (args: string, ctx: ExtensionContext): Promise<void> => {
66
80
  const target = args.trim();
67
81
  if (target === "status") {
@@ -121,9 +135,10 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
121
135
  maxFileBytes: config.maxFileBytes,
122
136
  maxTextLines: config.maxTextLines,
123
137
  }),
138
+ onIdle: () => void stopWhenPageLeft(),
124
139
  });
125
140
  if (unpinned) sessionPort = port;
126
- running = { server };
141
+ running = { server, ctx };
127
142
  setBrowseStatus(ctx, server.port);
128
143
  ctx.ui.notify(`browse: ${server.root} at ${server.url}`, "info");
129
144
  if (config.autoOpen) (deps.open ?? openInBrowser)(server.url);
@@ -20,7 +20,7 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
20
20
  var liveGit = document.querySelector(".browse-git");
21
21
  var liveChanges = document.getElementById("browse-changes");
22
22
  var liveInterval = Number(liveChip.getAttribute("data-interval")) || 3000;
23
- var liveReceived = 0;
23
+ var liveStale = 0;
24
24
  var liveTone = "";
25
25
 
26
26
  function formatSpan(ms) {
@@ -38,14 +38,15 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
38
38
  if (liveText) liveText.textContent = label;
39
39
  }
40
40
 
41
- // The age is the local elapsed time since the last frame, so it stays right
42
- // even when the loopback server's clock and this browser's disagree. Three
43
- // missed frames on an open connection is a stalled feed, not a live one.
44
- function paintLiveAge() {
45
- if (!liveText || (liveTone !== "success" && liveTone !== "warn")) return;
46
- var idle = Date.now() - liveReceived;
47
- if (idle > liveInterval * 3) setLiveTone("warn", "stale \\u00B7 " + formatSpan(idle));
48
- else liveText.textContent = "live \\u00B7 " + formatSpan(idle);
41
+ // The chip names its state and never counts: nothing is repainted between
42
+ // frames, and the age of the last one is the footer's uptime to report. Three
43
+ // missed frames on an open connection is a stalled feed, not a live one, so
44
+ // one timeout is armed per frame instead of a second-by-second tick.
45
+ function armLiveStale() {
46
+ window.clearTimeout(liveStale);
47
+ liveStale = window.setTimeout(function () {
48
+ if (liveTone === "success") setLiveTone("warn", "stale");
49
+ }, liveInterval * 3);
49
50
  }
50
51
 
51
52
  function paintLiveGit(git) {
@@ -124,8 +125,8 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
124
125
  }
125
126
 
126
127
  function paintLiveSnapshot(snapshot) {
127
- liveReceived = Date.now();
128
- setLiveTone("success", "live \\u00B7 0s");
128
+ setLiveTone("success", "live");
129
+ armLiveStale();
129
130
  if (liveLine) {
130
131
  liveLine.hidden = false;
131
132
  var requests = Number(snapshot.requests) || 0;
@@ -137,7 +138,6 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
137
138
 
138
139
  if (window.EventSource) {
139
140
  var liveStream = new EventSource("/events");
140
- var liveTicker = window.setInterval(paintLiveAge, 1000);
141
141
  liveStream.addEventListener("open", function () {
142
142
  if (liveTone !== "success") setLiveTone("accent", "connecting");
143
143
  });
@@ -151,7 +151,7 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
151
151
  });
152
152
  window.addEventListener("pagehide", function () {
153
153
  liveStream.close();
154
- window.clearInterval(liveTicker);
154
+ window.clearTimeout(liveStale);
155
155
  });
156
156
  }
157
157
  }
@@ -11,6 +11,7 @@
11
11
  * pair — so the whole feed is tested without one.
12
12
  */
13
13
 
14
+ import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
14
15
  import { changeDirs } from "./diff.ts";
15
16
  import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
16
17
 
@@ -141,38 +142,50 @@ export interface LiveClient {
141
142
  /** Starts a repeating tick and returns its cancel. */
142
143
  export type LiveTimer = (tick: () => void, intervalMs: number) => () => void;
143
144
 
145
+ /** Starts a one-shot delay and returns its cancel. */
146
+ export type LiveTimeout = (run: () => void, delayMs: number) => () => void;
147
+
144
148
  /** The default ticker: a repeating timer that cannot hold the process open. */
145
- const defaultTimer: LiveTimer = (tick, intervalMs) => {
146
- const handle = setInterval(tick, intervalMs);
147
- (handle as { unref?: () => void }).unref?.();
148
- return () => clearInterval(handle);
149
- };
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);
150
153
 
151
154
  export interface LiveChannelOptions {
152
155
  /** What each frame carries. */
153
156
  status: LiveStatusSource;
154
157
  /** How far apart the frames are, in ms. */
155
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;
156
163
  /** Timer seam; defaults to an unref'd interval. */
157
164
  timer?: LiveTimer;
165
+ /** Grace seam; defaults to an unref'd timeout. */
166
+ timeout?: LiveTimeout;
158
167
  }
159
168
 
160
169
  /** The fan-out for one server: pages attach, ticks publish, close ends them. */
161
170
  export interface LiveChannel {
162
171
  /** Add a connected page, send it the current status, and return its detach. */
163
172
  attach(client: LiveClient): () => void;
164
- /** End every connected page and stop the ticker. */
173
+ /** End every connected page, stop the ticker, and drop a pending idle stop. */
165
174
  close(): void;
166
175
  }
167
176
 
168
177
  /**
169
178
  * Build a channel for one server. The ticker runs only while at least one page
170
- * is attached, so an idle server does no status work at all.
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.
171
182
  */
172
183
  export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
173
- const timer = options.timer ?? defaultTimer;
184
+ const timer = options.timer ?? defaultTicker;
185
+ const timeout = options.timeout ?? defaultTimeout;
174
186
  const clients = new Set<LiveClient>();
175
187
  let cancel: (() => void) | undefined;
188
+ let cancelIdle: (() => void) | undefined;
176
189
  let inFlight = false;
177
190
 
178
191
  const stop = (): void => {
@@ -180,6 +193,25 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
180
193
  cancel = undefined;
181
194
  };
182
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
+
183
215
  const send = (frame: string): void => {
184
216
  for (const client of clients) {
185
217
  try {
@@ -188,7 +220,7 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
188
220
  clients.delete(client);
189
221
  }
190
222
  }
191
- if (clients.size === 0) stop();
223
+ if (clients.size === 0) pageLeft();
192
224
  };
193
225
 
194
226
  // One snapshot serves every attached page, so a tick is one git read and one
@@ -205,15 +237,17 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
205
237
 
206
238
  return {
207
239
  attach(client) {
240
+ cancelIdleStop();
208
241
  clients.add(client);
209
242
  cancel ??= timer(() => void publish(), options.intervalMs);
210
243
  void publish();
211
244
  return () => {
212
245
  if (!clients.delete(client)) return;
213
- if (clients.size === 0) stop();
246
+ if (clients.size === 0) pageLeft();
214
247
  };
215
248
  },
216
249
  close() {
250
+ cancelIdleStop();
217
251
  stop();
218
252
  for (const client of clients) client.end();
219
253
  clients.clear();
@@ -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);
@@ -22,7 +22,7 @@ import {
22
22
  textResponse,
23
23
  type ServeResponse,
24
24
  } from "../../lib/http/index.ts";
25
- import type { ServeConfig } from "./config.ts";
25
+ import { IDLE_STOP_MS, type ServeConfig } from "./config.ts";
26
26
  import { createFileIndex } from "./files.ts";
27
27
  import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
28
28
  import { createLiveChannel, createLiveStatus } from "./live.ts";
@@ -46,6 +46,10 @@ export interface FileServerOptions {
46
46
  git?: GitStatusProvider;
47
47
  /** Read-only diff provider for the change and diff views; omitted when unavailable. */
48
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;
49
53
  /** Override the request handler (tests). */
50
54
  handler?: typeof handleRequest;
51
55
  }
@@ -65,7 +69,14 @@ export async function createFileServer(options: FileServerOptions): Promise<File
65
69
  requests: () => requests,
66
70
  })
67
71
  : undefined;
68
- const live = status ? createLiveChannel({ status, intervalMs: config.liveIntervalMs }) : 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;
69
80
  const context: ServeContext = {
70
81
  root,
71
82
  config,
@@ -152,5 +152,4 @@ of the model's active tool set unless a script explicitly reaches for it.
152
152
  | `index.ts` | The extension factory: registers `open_path` and the `/open` command, resolves the target, and routes a launch failure to `ctx.ui.notify`. |
153
153
  | `open.ts` | `normalizeTarget` and `buildCommand` (pure), `resolveTarget` (existence check), and `openTarget` with the injectable launcher, which reports a start failure and a meaningful non-zero exit. |
154
154
  | `schema.ts` | `TOOL_NAME`, the TypeBox `parameters` / `outputSchema`, and the `OpenPathArgs` / `OpenPathResult` types. |
155
- | `open.test.ts` | Pure-module tests for normalization, resolution, per-platform commands, and the launcher seam. |
156
- | `extension.test.ts` | Extension-surface tests: registration, disabling, the tool call, and the command. |
155
+ | `extension.test.ts` | The capability suite: normalization, resolution, per-platform commands, the launcher seam, registration, disabling, the tool call, and the command. |