@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.
- package/README.md +10 -7
- package/README.zh-CN.md +8 -5
- package/extensions/bytefly/README.md +98 -116
- package/extensions/bytefly/notice/component.ts +6 -4
- package/extensions/bytefly/notice/config.ts +16 -2
- package/extensions/bytefly/panel/format.ts +3 -8
- package/extensions/bytefly/panel/join.ts +1 -6
- package/extensions/bytefly/panel/settings.ts +74 -377
- package/extensions/bytefly/panel/tui/entries.ts +16 -14
- package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
- package/extensions/bytefly/panel/tui/frame.ts +2 -2
- package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
- package/extensions/bytefly/panel/tui.ts +4 -6
- package/extensions/bytefly/picker/resources.ts +7 -3
- package/extensions/bytefly/working/register.ts +2 -8
- package/extensions/check/tools.ts +2 -11
- package/extensions/context/README.md +162 -0
- package/extensions/context/collect.ts +53 -0
- package/extensions/context/commands.ts +100 -0
- package/extensions/context/format.ts +82 -0
- package/extensions/context/index.ts +16 -0
- package/extensions/context/report.ts +319 -0
- package/extensions/context/tui.ts +268 -0
- package/extensions/context/types.ts +122 -0
- package/extensions/file-browse/README.md +38 -18
- package/extensions/file-browse/config.ts +8 -0
- package/extensions/file-browse/html.ts +2 -2
- package/extensions/file-browse/index.ts +17 -2
- package/extensions/file-browse/live-client.ts +13 -13
- package/extensions/file-browse/live.ts +44 -10
- package/extensions/file-browse/markdown.ts +3 -10
- package/extensions/file-browse/raw-html.ts +3 -10
- package/extensions/file-browse/server.ts +13 -2
- package/extensions/file-open/README.md +1 -2
- package/extensions/{document → file-read}/README.md +72 -56
- package/extensions/{document → file-read}/config.ts +12 -9
- package/extensions/{document → file-read}/extract/doc.ts +2 -2
- package/extensions/{document → file-read}/extract/docx.ts +3 -3
- package/extensions/{document → file-read}/extract/file.ts +9 -9
- package/extensions/{document → file-read}/extract/odt.ts +3 -3
- package/extensions/{document → file-read}/extract/pdf.ts +3 -3
- package/extensions/{document → file-read}/extract/rtf.ts +3 -3
- package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
- package/extensions/{document → file-read}/formats.ts +11 -11
- package/extensions/file-read/index.ts +17 -0
- package/extensions/{document → file-read}/paging.ts +15 -13
- package/extensions/{document → file-read}/render.ts +17 -13
- package/extensions/{document → file-read}/schema.ts +7 -7
- package/extensions/{document → file-read}/tool.ts +36 -28
- package/extensions/goal/tools.ts +10 -14
- package/extensions/job/commands.ts +32 -46
- package/extensions/job/runtime.ts +6 -8
- package/extensions/job/tools.ts +7 -26
- package/extensions/memory/commands.ts +30 -44
- package/extensions/memory/tools.ts +6 -14
- package/extensions/plan/tools/enter.ts +6 -7
- package/extensions/plan/tools/exit.ts +6 -7
- package/extensions/plan/tools/steps.ts +6 -7
- package/extensions/plan/tools/write.ts +8 -9
- package/extensions/plan/web.ts +21 -12
- package/extensions/question/tools.ts +5 -4
- package/extensions/subagent/background.ts +1 -7
- package/extensions/todo/tools.ts +10 -14
- package/extensions/watch/README.md +5 -5
- package/extensions/watch/commands.ts +33 -47
- package/extensions/watch/runtime.ts +5 -7
- package/extensions/watch/tools.ts +7 -18
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/search/search.ts +9 -18
- package/extensions/wechat/timer.ts +9 -30
- package/extensions/wechat/tools.ts +4 -7
- package/lib/README.md +9 -6
- package/lib/escape.ts +15 -2
- package/lib/pdf.ts +2 -2
- package/lib/requirements.ts +2 -2
- package/lib/screen-session.ts +85 -0
- package/lib/timer.ts +61 -0
- package/lib/tui/README.md +3 -3
- package/lib/tui/columns.ts +33 -8
- package/lib/tui/rows.ts +15 -6
- package/lib/tui/rules.ts +4 -8
- package/lib/tui/tool.ts +55 -2
- package/package.json +8 -4
- package/skills/changelog/SKILL.md +136 -0
- package/extensions/document/index.ts +0 -17
- /package/extensions/{document → file-read}/deadline.ts +0 -0
- /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
|
|
87
|
-
chip beside `read-only` says whether status is flowing (green,
|
|
88
|
-
|
|
89
|
-
the branch chip, the `● N changes +A −D` chip, and the change markers in
|
|
90
|
-
tree and the listing update in place; and the footer line reports `up 2m
|
|
91
|
-
37 requests`.
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
one, `◉ stale
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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.
|
|
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),
|
|
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
|
|
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
|
|
80
|
-
*
|
|
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
|
|
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
|
|
42
|
-
//
|
|
43
|
-
// missed frames on an open connection is a stalled feed, not a live one
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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.
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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 ??
|
|
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)
|
|
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)
|
|
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 =
|
|
759
|
-
else if (match[2] !== undefined) value =
|
|
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
|
|
357
|
-
if (hex !== undefined) return
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
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. |
|