@gavin-hu/my-pi-agent 0.11.4 → 0.13.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 (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. package/assets/tools/write_plan.png +0 -0
@@ -44,7 +44,7 @@ them along. Then ask the model to read a document, or call the tool directly:
44
44
 
45
45
  ## Tool
46
46
 
47
- `read_doc` — `exposure: "direct"`, active by default, read-only, closed-world
47
+ `read_doc` — direct exposure, active by default, read-only, closed-world
48
48
  (no network).
49
49
 
50
50
  | Parameter | Type | Meaning |
@@ -124,11 +124,12 @@ merged over the defaults and clamped:
124
124
  |---|---|---|---|
125
125
  | `maxFileBytes` | `20971520` (20 MiB) | 1 KiB – 512 MiB | Reject larger files. |
126
126
  | `maxChars` | `40000` | 200 – 100000 | Default slice size. |
127
+ | `extractTimeoutMs` | `30000` (30 s) | 1 s – 5 min | Per-call extraction deadline; a parser that outlasts it is abandoned. |
127
128
  | `formats` | `{ "pdf": true, "docx": true, "doc": true, "odt": true, "rtf": true, "xlsx": true }` | — | Per-format on/off, merged over each registry entry's default; unknown keys are ignored. |
128
129
 
129
130
  ```jsonc
130
131
  // bytefly.json → "document"
131
- { "maxChars": 80000, "formats": { "docx": false } }
132
+ { "maxChars": 80000, "extractTimeoutMs": 60000, "formats": { "docx": false } }
132
133
  ```
133
134
 
134
135
  `doc.json` is not read, and neither is the old per-extension `document.json`;
@@ -142,8 +143,9 @@ run `bun run config:migrate` to move a `document.json` into the shared file.
142
143
  `resolveEffectiveCwd`; absolute paths are allowed only inside it. Containment
143
144
  is symlink-aware (`isInsideReal`), and the real, resolved path is what the
144
145
  parser receives.
145
- - **Size-capped.** Files larger than `maxFileBytes` are rejected before any
146
- parser runs.
146
+ - **Size-capped and time-bounded.** Files larger than `maxFileBytes` are rejected
147
+ before any parser runs, and a parser that outlasts `extractTimeoutMs` is
148
+ abandoned so the turn is not held open by one document.
147
149
  - **No subprocesses.** Extraction never spawns a process, so it grants no
148
150
  privileges beyond the current process.
149
151
  - **Untrusted text.** Extracted document text is treated as untrusted: it is
@@ -171,6 +173,10 @@ Each extractor builds a plain-text document from the format's bytes:
171
173
  The tool is pageable across the whole extracted text, so a word-processing
172
174
  document or workbook is read in the same `startIndex` / `maxChars` way as a PDF.
173
175
 
176
+ Each call is bounded by `extractTimeoutMs` (30 s by default). A parser that
177
+ outlasts the deadline is abandoned — the read fails with the limit named, and a
178
+ late result is discarded — so a pathological document cannot park the turn.
179
+
174
180
  ## Supported formats
175
181
 
176
182
  | Format | Extensions | Extractor | Enabled by default |
@@ -203,13 +209,17 @@ config defaults, and error listing all derive from it.
203
209
 
204
210
  - Parsing is best-effort and format-specific: output fidelity varies by library,
205
211
  and a malformed file fails with that library's message.
206
- - Legacy `.doc` relies on `word-extractor`; there is no fixture for the OLE
207
- format in the test suite, so that path is covered only for its error case.
212
+ - Legacy `.doc` relies on `word-extractor`, which now has a committed OLE fixture
213
+ (`test/helpers/fixtures/files/legacy.doc`, two paragraphs generated once by a
214
+ real word processor). Real Word 6/95 documents vary far more than one fixture
215
+ can show, so the format stays best-effort.
208
216
  - No OCR: scanned or image-only PDFs return little or no text.
209
217
  - Formatting, tables, and images are not preserved (XLSX keeps the row/column
210
218
  shape but drops cell styling and formulas; DOCX/ODT keep table cells as tabs).
211
- - No extraction timeout. `maxFileBytes` bounds the input, but a complex PDF can
212
- be CPU-heavy; `maxChars` limits the returned slice, not peak extraction memory.
219
+ - The deadline stops waiting and discards the result; it cannot preempt a parser
220
+ that never yields to the event loop, because JavaScript cannot interrupt a
221
+ synchronous parse. A worker thread could; the package does not use one.
222
+ - `maxChars` limits the returned slice, not peak extraction memory.
213
223
  - Only one file per call; legacy `.xls` gets a conversion hint.
214
224
 
215
225
  ## Non-goals
@@ -226,7 +236,7 @@ config defaults, and error listing all derive from it.
226
236
  | Aspect | Contract |
227
237
  |---|---|
228
238
  | Registration | `document(pi)` in `index.ts` calls `registerDocTool(pi)`, which registers `read_doc`. The factory only registers; no parser loads until a read needs it. |
229
- | Tool | `read_doc`, `exposure: "direct"`, `defaultActive: true`, default (`parallel`) execution, `annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }`, TypeBox `parameters` and `outputSchema`, and `renderCall`/`renderResult` (reuse `context.lastComponent`; read `context.cwd` and `context.isError`). |
239
+ | Tool | `read_doc`, direct exposure and active on registration (the defaults), default (`parallel`) execution, `annotations: { readOnlyHint: true, openWorldHint: false }`, TypeBox `parameters` and `outputSchema`, and `renderCall`/`renderResult` (reuse `context.lastComponent`; read `context.cwd` and `context.isError`). |
230
240
  | State | None persisted. Per-result state lives in the tool-result `details` / `structuredContent`; the renderer reads only that. |
231
241
  | Lifecycle | No `session_start` / `session_shutdown` hooks: each read imports a parser, runs it, and holds no long-lived resources. |
232
242
  | Host seams | `resolveEffectiveCwd` (`lib/env.ts`), `isInsideReal` / `realPathOfNearest` (`lib/path.ts`), `loadConfigSection` (`lib/config.ts`), `stripControlChars` / `sanitize` / `formatTokens` (`lib/format.ts`), `SEPARATORS` (`lib/tui/glyphs.ts`). |
@@ -285,8 +295,8 @@ bun test extensions/document
285
295
  ```
286
296
 
287
297
  Extractor tests run against real minimal files (`test/helpers/fixtures/documents.ts`
288
- builds a genuine PDF, DOCX, and ODT; XLSX is written with `exceljs`), so the
289
- libraries are exercised, not stubbed. `tool.test.ts` injects fake extractors for
290
- behavioural cases and reads one real PDF end to end;
291
- `test/helpers/fixtures/document.ts` provides the scratch root and an escape
292
- symlink.
298
+ builds a genuine PDF, DOCX, and ODT; XLSX is written with `exceljs`; legacy `.doc`
299
+ reads the committed `files/legacy.doc`), so the libraries are exercised, not
300
+ stubbed. `tool.test.ts` injects fake extractors for behavioural cases and reads a
301
+ real PDF and a real `.doc` end to end; `test/helpers/fixtures/document.ts`
302
+ provides the scratch root and an escape symlink.
@@ -15,6 +15,8 @@ export interface DocConfig {
15
15
  maxFileBytes: number;
16
16
  /** Default character budget for the returned slice. */
17
17
  maxChars: number;
18
+ /** Per-call extraction deadline; a parser that outlasts it is abandoned. */
19
+ extractTimeoutMs: number;
18
20
  /** Per-format on/off, merged over each registry entry's `defaultEnabled`. */
19
21
  formats: Record<FormatId, boolean>;
20
22
  }
@@ -22,6 +24,7 @@ export interface DocConfig {
22
24
  export const DEFAULT_CONFIG: DocConfig = {
23
25
  maxFileBytes: 20 * 1024 * 1024,
24
26
  maxChars: 40_000,
27
+ extractTimeoutMs: 30_000,
25
28
  formats: Object.fromEntries(FORMATS.map((format) => [format.id, format.defaultEnabled])) as Record<FormatId, boolean>,
26
29
  };
27
30
 
@@ -43,6 +46,7 @@ export function normalizeConfig(raw: Record<string, unknown> | undefined, base:
43
46
  return {
44
47
  maxFileBytes: clampInteger(raw.maxFileBytes, base.maxFileBytes, 1_024, 512 * 1024 * 1024),
45
48
  maxChars: clampInteger(raw.maxChars, base.maxChars, 200, 100_000),
49
+ extractTimeoutMs: clampInteger(raw.extractTimeoutMs, base.extractTimeoutMs, 1_000, 300_000),
46
50
  formats: normalizeFormats(raw.formats, base.formats),
47
51
  };
48
52
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A per-call deadline for one in-process extraction.
3
+ *
4
+ * A parser is a library call, not a subprocess: JavaScript cannot preempt a
5
+ * synchronous parse, so the deadline stops *waiting* for the work and discards
6
+ * its result rather than killing it. What it guarantees is the turn: a
7
+ * pathological file fails with the limit named instead of parking the session,
8
+ * and a late settle from the abandoned promise never surfaces.
9
+ *
10
+ * The timer is a plain `setTimeout` cleared in `finally` (unlike
11
+ * `AbortSignal.timeout`, whose timer cannot be cancelled and would stay pending
12
+ * after every fast read). The deadline is deliberately not passed into `work`:
13
+ * the caller's abort signal is the only abort an extractor sees, so a timeout
14
+ * can never be reported as a turn abort or the reverse.
15
+ */
16
+
17
+ /** How the deadline reports, and when to give up. */
18
+ export interface DeadlineOptions {
19
+ /** Milliseconds to wait before abandoning the work. */
20
+ timeoutMs: number;
21
+ /** The error message a timeout throws; the caller names its own limit. */
22
+ timeoutMessage: string;
23
+ /** The turn's abort signal; an abort wins over the deadline. */
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ /**
28
+ * Run `work` with a deadline. Resolves with its value, rethrows its error
29
+ * unchanged, or rejects with `timeoutMessage` (or the caller's abort reason).
30
+ */
31
+ export async function withDeadline<T>(work: () => Promise<T>, options: DeadlineOptions): Promise<T> {
32
+ const { timeoutMs, timeoutMessage, signal } = options;
33
+
34
+ let timer: ReturnType<typeof setTimeout> | undefined;
35
+ const expiry = new Promise<never>((_resolve, reject) => {
36
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
37
+ // A pending timer must not keep a short-lived process alive.
38
+ (timer as { unref?: () => void }).unref?.();
39
+ });
40
+
41
+ let onAbort: (() => void) | undefined;
42
+ const aborted = signal
43
+ ? new Promise<never>((_resolve, reject) => {
44
+ onAbort = () => reject(signal.reason ?? new Error("aborted"));
45
+ if (signal.aborted) onAbort();
46
+ else signal.addEventListener("abort", onAbort, { once: true });
47
+ })
48
+ : undefined;
49
+
50
+ const pending = work();
51
+ // One observed promise: if the deadline or the abort wins, a late resolve or
52
+ // rejection from the abandoned work has nobody left to reach.
53
+ pending.catch(() => {});
54
+
55
+ try {
56
+ return await Promise.race(aborted ? [pending, expiry, aborted] : [pending, expiry]);
57
+ } finally {
58
+ clearTimeout(timer);
59
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
60
+ }
61
+ }
@@ -18,6 +18,7 @@ import { isInsideReal, realPathOfNearest } from "../../lib/path.ts";
18
18
  import { reuseText } from "../../lib/tui/chrome.ts";
19
19
  import { elapsedSuffix, reuseCallText, startToolProgress, syncElapsedTimer } from "../../lib/tui/tool.ts";
20
20
  import { isFormatEnabled, loadConfig } from "./config.ts";
21
+ import { withDeadline } from "./deadline.ts";
21
22
  import type { DocExtractor } from "./extract/file.ts";
22
23
  import { detectFormat, supportedExtensions, unsupportedHint, type FormatId } from "./formats.ts";
23
24
  import { formatDoc } from "./paging.ts";
@@ -63,6 +64,8 @@ async function statFile(abs: string, display: string, maxFileBytes: number): Pro
63
64
  /** Per-format extractor overrides, for tests; the format table supplies the rest. */
64
65
  export interface DocToolDeps {
65
66
  extractors?: Partial<Record<FormatId, DocExtractor>>;
67
+ /** Test seam: replace the configured `document.extractTimeoutMs`. */
68
+ timeoutMs?: number;
66
69
  }
67
70
 
68
71
  export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void {
@@ -71,7 +74,8 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
71
74
  label: "Read document",
72
75
  description:
73
76
  "Read a local PDF, DOCX, DOC, ODT, RTF, or XLSX file and return its extracted text, pageable with " +
74
- "startIndex/maxChars. Extraction runs in process, so no external tools are needed.",
77
+ "startIndex/maxChars. Extraction runs in process, so no external tools are needed; a read that outlasts " +
78
+ "document.extractTimeoutMs fails with the limit named.",
75
79
  promptSnippet: "Read a PDF, DOCX, DOC, ODT, RTF, or XLSX as text, paged with startIndex/maxChars.",
76
80
  promptGuidelines: [
77
81
  "Use read_doc for .pdf/.docx/.doc/.odt/.rtf/.xlsx; the built-in read tool is for text and images.",
@@ -79,9 +83,7 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
79
83
  ],
80
84
  parameters: DocParams,
81
85
  outputSchema: DocOutput,
82
- exposure: "direct",
83
- defaultActive: true,
84
- annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
86
+ annotations: { readOnlyHint: true, openWorldHint: false },
85
87
 
86
88
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
87
89
  const args = params as DocArgs;
@@ -103,8 +105,15 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
103
105
 
104
106
  const size = await statFile(abs, display, config.maxFileBytes);
105
107
  const extractor = deps.extractors?.[format.id] ?? format.extract;
108
+ const timeoutMs = deps.timeoutMs ?? config.extractTimeoutMs;
106
109
  startToolProgress(onUpdate);
107
- const text = await extractor({ path: abs, signal });
110
+ const text = await withDeadline(() => extractor({ path: abs, signal }), {
111
+ timeoutMs,
112
+ signal,
113
+ timeoutMessage:
114
+ `Extraction of ${display} did not finish within ${timeoutMs} ms (document.extractTimeoutMs). ` +
115
+ `The ${format.label} parser was abandoned; raise the limit or read a smaller file.`,
116
+ });
108
117
 
109
118
  const startIndex = typeof args.startIndex === "number" ? Math.max(0, Math.round(args.startIndex)) : 0;
110
119
  const requested = typeof args.maxChars === "number" ? args.maxChars : config.maxChars;
@@ -22,8 +22,9 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
22
22
  opens the browser (unless `autoOpen: false`).
23
23
  - **Two-pane UI.** A server-rendered tree, directory listings, and file pages,
24
24
  progressively enhanced by one first-party script (filter, lazy tree
25
- expansion, thumbnail fallback). Below 860px the tree collapses into a
26
- `Files` disclosure above the listing. A hairline progress bar tracks whichever
25
+ expansion, thumbnail fallback). The tree scrolls in its own pane with quick
26
+ open and the filter pinned above it; below 860px it collapses into a `Files`
27
+ disclosure above the listing. A hairline progress bar tracks whichever
27
28
  column is scrolling (the card body for a file or diff, the main column
28
29
  otherwise), and the served folder names the root page.
29
30
  - **Rich previews.** Text, rendered Markdown, images (thumbnails), and
@@ -222,11 +223,14 @@ document and the project document (project wins):
222
223
  non-matching rows while keeping ancestors, disclosure triangles lazily
223
224
  `fetch("/api/tree")`, broken thumbnails fall back to the category icon, the
224
225
  listing headers sort in place, the wrap toggle is remembered, and the copy
225
- buttons use the clipboard. Without JS the page still browses and renders
226
- Markdown, and the JS-only controls (filter, disclosure, actions, quick open)
227
- are hidden; the sort buttons lose their pointer so they do not look live. The
228
- palette is the shared Bytefly web theme (`lib/web/tokens.ts`), derived from the
229
- `bytefly-dark`/`bytefly-light` terminal themes; it follows
226
+ buttons use the clipboard. On wide screens the tree is its own scroll pane
227
+ under the pinned filter and quick-open trigger; the sidebar is a `<details>`
228
+ whose content wrapper has no definite height, so the panel fills it
229
+ absolutely and the tree flexes inside. Without JS the page still browses and
230
+ renders Markdown, and the JS-only controls (filter, disclosure, actions, quick
231
+ open) are hidden; the sort buttons lose their pointer so they do not look
232
+ live. The palette is the shared Bytefly web theme (`lib/web/tokens.ts`),
233
+ derived from the `bytefly-dark`/`bytefly-light` terminal themes; it follows
230
234
  `prefers-color-scheme` until an explicit choice sets `data-theme` on the root:
231
235
  `/theme.js` applies the saved choice in the head before first paint, and the
232
236
  header toggle flips and stores it (`pi-web-theme` in `localStorage`, the key
@@ -273,7 +277,7 @@ document and the project document (project wins):
273
277
  `--cached` before the first commit), `staged` against the index, and
274
278
  `worktree` against the index from the other side. An untracked file renders as
275
279
  all-add through `readFileView`, so binary and over-size handling is shared
276
- with the file view. `git diff --shortstat` supplies the chip totals and
280
+ with the file view. `git diff --stat` supplies the chip totals and
277
281
  `changeDirs` derives the directory markers; the changed lines are highlighted
278
282
  per hunk by rebuilding the old and new sides for `lib/highlight`.
279
283
  - **Streaming responses.** The server streams `filePath` responses and
@@ -11,7 +11,7 @@
11
11
  * and `bun build --no-bundle` needs no separate asset.
12
12
  */
13
13
 
14
- import { THEME_TOGGLE_JS } from "../../lib/web/index.ts";
14
+ import { SIDEBAR_COLLAPSE_MAX, THEME_TOGGLE_JS } from "../../lib/web/index.ts";
15
15
 
16
16
  export const CLIENT_JS = `(function () {
17
17
  "use strict";
@@ -183,7 +183,7 @@ export const CLIENT_JS = `(function () {
183
183
 
184
184
  var panel = document.querySelector("details.bf-app__sidebar");
185
185
  if (panel && window.matchMedia) {
186
- var mq = window.matchMedia("(max-width: 860px)");
186
+ var mq = window.matchMedia("(max-width: ${SIDEBAR_COLLAPSE_MAX}px)");
187
187
  var syncSide = function () { panel.open = !mq.matches; };
188
188
  syncSide();
189
189
  if (mq.addEventListener) mq.addEventListener("change", syncSide);
@@ -289,11 +289,11 @@ export const CLIENT_JS = `(function () {
289
289
  });
290
290
  }
291
291
 
292
- function flash(button, label) {
292
+ function flash(button, label, message) {
293
293
  var original = button.textContent;
294
294
  button.textContent = label;
295
295
  var status = document.getElementById("copy-status");
296
- if (status) status.textContent = label === "copied" ? "Copied to clipboard" : "Copy failed";
296
+ if (status) status.textContent = message;
297
297
  window.setTimeout(function () { button.textContent = original; }, 1200);
298
298
  }
299
299
 
@@ -310,8 +310,8 @@ export const CLIENT_JS = `(function () {
310
310
  }
311
311
  source
312
312
  .then(function (text) { return copyText(text); })
313
- .then(function () { flash(button, "copied"); })
314
- .catch(function () { flash(button, "failed"); });
313
+ .then(function () { flash(button, "copied", "Copied to clipboard"); })
314
+ .catch(function () { flash(button, "failed", "Copy failed"); });
315
315
  });
316
316
  });
317
317
 
@@ -483,7 +483,6 @@ export const CLIENT_JS = `(function () {
483
483
  option.id = "palette-option-" + index;
484
484
  option.setAttribute("role", "option");
485
485
  option.setAttribute("data-index", String(index));
486
- option.setAttribute("aria-selected", index === 0 ? "true" : "false");
487
486
  appendHighlighted(option, result.path, result.positions);
488
487
  paletteList.appendChild(option);
489
488
  });
@@ -46,7 +46,6 @@ const TEXT_EXTS = new Set([
46
46
  ".ini",
47
47
  ".cfg",
48
48
  ".conf",
49
- ".env",
50
49
  ".xml",
51
50
  ".csv",
52
51
  ".tsv",
@@ -89,9 +88,6 @@ const TEXT_EXTS = new Set([
89
88
  ".vue",
90
89
  ".svelte",
91
90
  ".astro",
92
- ".gitignore",
93
- ".gitattributes",
94
- ".editorconfig",
95
91
  ]);
96
92
  const TEXT_NAMES = new Set([
97
93
  "license",
@@ -137,8 +137,8 @@ interface RepoState {
137
137
  const INSERTIONS = /(\d+) insertions?\(\+\)/;
138
138
  const DELETIONS = /(\d+) deletions?\(-\)/;
139
139
 
140
- /** A count from a `--shortstat` line, or `0` when the line does not carry it. */
141
- function shortstatCount(text: string, pattern: RegExp): number {
140
+ /** A count from a diffstat summary line, or `0` when the line does not carry it. */
141
+ function summaryCount(text: string, pattern: RegExp): number {
142
142
  const match = pattern.exec(text);
143
143
  return match ? Number(match[1]) : 0;
144
144
  }
@@ -218,17 +218,16 @@ export function createGitDiff(
218
218
  const current = await state();
219
219
  if (!current) return undefined;
220
220
  const args = diffArgs(effectiveMode(mode, current.hasCommits));
221
- const [stat, shortstat] = await Promise.all([
222
- run(["diff", "--no-color", "--stat", ...args, "--"], { cwd: root }),
223
- run(["diff", "--no-color", "--shortstat", ...args, "--"], { cwd: root }),
224
- ]);
225
- const summary = shortstat.code === 0 ? shortstat.stdout : "";
221
+ const stat = await run(["diff", "--no-color", "--stat", ...args, "--"], { cwd: root });
222
+ const output = stat.code === 0 ? stat.stdout : "";
223
+ const trimmed = output.trimEnd();
224
+ const summary = trimmed.slice(trimmed.lastIndexOf("\n") + 1);
226
225
  return {
227
226
  mode,
228
227
  files: current.files.filter((file) => inMode(file, mode)),
229
- stat: stat.code === 0 ? stat.stdout.trim() : "",
230
- insertions: shortstatCount(summary, INSERTIONS),
231
- deletions: shortstatCount(summary, DELETIONS),
228
+ stat: output.trim(),
229
+ insertions: summaryCount(summary, INSERTIONS),
230
+ deletions: summaryCount(summary, DELETIONS),
232
231
  };
233
232
  },
234
233
  async file(rel, mode) {
@@ -12,6 +12,7 @@ import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
12
  import {
13
13
  brandLockup,
14
14
  docHead,
15
+ SIDEBAR_COLLAPSE_MAX,
15
16
  WEB_BASE_CSS,
16
17
  WEB_COMPONENTS_CSS,
17
18
  WEB_LAYOUT_CSS,
@@ -61,7 +62,7 @@ export interface ChangeSummary {
61
62
  }
62
63
 
63
64
  /** Whether a path carries a change marker: a changed file, or a directory above one. */
64
- export function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
65
+ function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
65
66
  if (!summary || !rel) return false;
66
67
  return isDir ? summary.dirs.has(rel) : summary.files.has(rel);
67
68
  }
@@ -169,6 +170,16 @@ body.palette-open{overflow:hidden}
169
170
  .browse-keys dt{white-space:nowrap}
170
171
  .browse-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
171
172
  .browse-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
173
+ /* Wide screens: pin quick open and the filter, and scroll the tree on its own
174
+ box. The sidebar is a details element, whose content sits in an anonymous
175
+ wrapper with no definite height, so the panel fills the sidebar by absolute
176
+ positioning; below the sidebar breakpoint the disclosure keeps normal page
177
+ scroll. */
178
+ @media not (max-width:${SIDEBAR_COLLAPSE_MAX}px){
179
+ .browse-sidebar{position:relative;overflow:hidden}
180
+ .browse-side{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
181
+ .browse-side .bf-tree{flex:1 1 auto;min-height:0;overflow:auto}
182
+ }
172
183
  @media (max-width:820px){
173
184
  .browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
174
185
  }
@@ -238,7 +249,7 @@ ${SPRITE}
238
249
  <button type="button" class="bf-btn bf-btn--icon bf-theme-toggle" id="theme-toggle" aria-label="Toggle light and dark theme" title="Toggle light and dark theme"><span class="theme-glyph" aria-hidden="true">☾</span></button>
239
250
  </div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
240
251
  <div class="bf-app__body">
241
- <details class="bf-app__sidebar" open>
252
+ <details class="bf-app__sidebar browse-sidebar" open>
242
253
  <summary class="bf-panel__summary">Files</summary>
243
254
  <aside class="bf-panel browse-side">
244
255
  <button type="button" class="browse-quick-open" id="quick-open" aria-haspopup="dialog"><span>quick open</span><kbd>/</kbd></button>
@@ -252,7 +263,7 @@ ${SPRITE}
252
263
  ${layout.main}
253
264
  </main>
254
265
  </div>
255
- <footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${layout.footerNote}</footer>
266
+ <footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
256
267
  ${layout.keys && layout.keys.length > 0 ? renderKeysSheet(layout.keys) : ""}
257
268
  <script src="/app.js" defer></script>
258
269
  </body>
@@ -283,7 +294,7 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
283
294
  const expanded = node.children.length > 0;
284
295
  const isDir = node.isDir;
285
296
  const spec = isDir ? FOLDER_ICON : languageOf(node.name);
286
- const href = isDir ? (node.rel ? `/browse/${encodePath(node.rel)}` : "/browse/") : `/view/${encodePath(node.rel)}`;
297
+ const href = hrefFor(node);
287
298
  const current = node.rel === currentRel ? ` aria-current="page"` : "";
288
299
  const twisty = isDir
289
300
  ? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle" aria-expanded="${expanded}" aria-label="${escapeHtml(node.name)}">${expanded ? "▾" : "▸"}</button>`
@@ -495,7 +506,7 @@ export function renderFilePage(page: FilePage): string {
495
506
  mainClass: rendered ? "browse-page browse-measure" : "browse-page",
496
507
  keys,
497
508
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
498
- footerNote: escapeHtml(page.rel),
509
+ footerNote: page.rel,
499
510
  git: page.git,
500
511
  summary: page.summary,
501
512
  });
@@ -715,7 +726,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
715
726
  mainClass: "browse-page",
716
727
  keys,
717
728
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
718
- footerNote: `diff · ${escapeHtml(modeScope(page.mode))}`,
729
+ footerNote: `diff · ${modeScope(page.mode)}`,
719
730
  git: page.git,
720
731
  summary: page.summary,
721
732
  });
@@ -726,7 +737,6 @@ export interface ErrorPage {
726
737
  status: number;
727
738
  message: string;
728
739
  tree: TreeNode;
729
- rel: string;
730
740
  git?: GitStatus;
731
741
  }
732
742
 
@@ -7,6 +7,7 @@
7
7
  * tested without a socket.
8
8
  */
9
9
 
10
+ import type { Stats } from "node:fs";
10
11
  import { stat } from "node:fs/promises";
11
12
  import { THEME_JS } from "../../lib/web/index.ts";
12
13
  import { CLIENT_JS } from "./client.ts";
@@ -87,8 +88,8 @@ function jsonResponse(status: number, value: unknown): ServeResponse {
87
88
  };
88
89
  }
89
90
 
90
- async function treeFor(ctx: ServeContext, _currentRel: string): Promise<TreeNode> {
91
- return buildTree(ctx.root, _currentRel, ctx.config.maxDirEntries);
91
+ async function treeFor(ctx: ServeContext, currentRel: string): Promise<TreeNode> {
92
+ return buildTree(ctx.root, currentRel, ctx.config.maxDirEntries);
92
93
  }
93
94
 
94
95
  /** Resolve the header's git context, never failing the request. */
@@ -153,18 +154,30 @@ async function errorResponse(
153
154
  status,
154
155
  message,
155
156
  tree,
156
- rel: "",
157
157
  git: await gitFor(ctx),
158
158
  });
159
159
  return { status, headers: { ...HTML_SECURITY, ...extra }, body };
160
160
  }
161
161
 
162
- async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
162
+ /** Resolve a request path once and render the directory or file page it names. */
163
+ async function renderPath(ctx: ServeContext, rawRel: string, raw: boolean): Promise<ServeResponse> {
163
164
  const abs = await resolveRequestPath(ctx.root, rawRel);
164
165
  const rel = relativePath(ctx.root, abs);
165
- const info = await stat(abs).catch(() => undefined);
166
- if (!info || !info.isDirectory()) return view(ctx, rawRel, false);
166
+ const info: Stats | undefined = await stat(abs).catch(() => undefined);
167
+ if (info?.isDirectory()) return directoryResponse(ctx, rel);
168
+ if (!info) throw new HttpError(404, "That path does not exist.");
169
+ return fileResponse(ctx, rel, raw);
170
+ }
167
171
 
172
+ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
173
+ return renderPath(ctx, rawRel, false);
174
+ }
175
+
176
+ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<ServeResponse> {
177
+ return renderPath(ctx, rawRel, raw);
178
+ }
179
+
180
+ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeResponse> {
168
181
  const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
169
182
  const tree = await treeFor(ctx, rel);
170
183
  const git = await gitFor(ctx);
@@ -184,13 +197,7 @@ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse>
184
197
  );
185
198
  }
186
199
 
187
- async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<ServeResponse> {
188
- const abs = await resolveRequestPath(ctx.root, rawRel);
189
- const rel = relativePath(ctx.root, abs);
190
- const info = await stat(abs).catch(() => undefined);
191
- if (info?.isDirectory()) return browse(ctx, rawRel);
192
- if (!info) throw new HttpError(404, "That path does not exist.");
193
-
200
+ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promise<ServeResponse> {
194
201
  const file = await readFileView(ctx.root, rel, ctx.config.maxFileBytes, ctx.config.maxTextLines, ctx.config.markdown);
195
202
  const tree = await treeFor(ctx, rel);
196
203
  const git = await gitFor(ctx);
@@ -375,7 +382,9 @@ export async function handleRequest(
375
382
  if (pathname.startsWith("/raw/")) return await raw(ctx, pathname.slice("/raw/".length));
376
383
  return errorResponse(ctx, 404, "That page does not exist.");
377
384
  } catch (error) {
378
- if (error instanceof HttpError) return errorResponse(ctx, error.status, error.message);
379
- return errorResponse(ctx, 500, "Something went wrong reading that path.");
385
+ const status = error instanceof HttpError ? error.status : 500;
386
+ const message = error instanceof HttpError ? error.message : "Something went wrong reading that path.";
387
+ if (pathname.startsWith("/api/")) return jsonResponse(status, { error: message });
388
+ return errorResponse(ctx, status, message);
380
389
  }
381
390
  }