@gavin-hu/my-pi-agent 0.12.0 → 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 (153) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +139 -67
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +36 -6
  7. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  8. package/extensions/bytefly/footer/config.ts +7 -1
  9. package/extensions/bytefly/footer/delegation.ts +100 -0
  10. package/extensions/bytefly/footer/register.ts +13 -2
  11. package/extensions/bytefly/footer/render/lines.ts +2 -0
  12. package/extensions/bytefly/footer/render/segments.ts +27 -0
  13. package/extensions/bytefly/footer/types.ts +11 -0
  14. package/extensions/bytefly/index.ts +4 -3
  15. package/extensions/bytefly/notify/config.ts +2 -0
  16. package/extensions/bytefly/notify/format.ts +19 -1
  17. package/extensions/bytefly/notify/register.ts +37 -13
  18. package/extensions/bytefly/notify/types.ts +2 -0
  19. package/extensions/bytefly/panel/commands.ts +34 -13
  20. package/extensions/bytefly/panel/settings.ts +98 -16
  21. package/extensions/bytefly/update/cache.ts +73 -0
  22. package/extensions/bytefly/update/format.ts +65 -10
  23. package/extensions/bytefly/update/register.ts +99 -13
  24. package/extensions/bytefly/update/state.ts +13 -1
  25. package/extensions/document/README.md +22 -12
  26. package/extensions/document/config.ts +4 -0
  27. package/extensions/document/deadline.ts +61 -0
  28. package/extensions/document/tool.ts +13 -2
  29. package/extensions/file-browse/README.md +12 -8
  30. package/extensions/file-browse/client.ts +6 -7
  31. package/extensions/file-browse/files.ts +0 -4
  32. package/extensions/file-browse/git.ts +9 -10
  33. package/extensions/file-browse/html.ts +17 -7
  34. package/extensions/file-browse/router.ts +24 -15
  35. package/extensions/goal/README.md +7 -1
  36. package/extensions/goal/schema.ts +6 -2
  37. package/extensions/goal/types.ts +6 -2
  38. package/extensions/job/README.md +16 -12
  39. package/extensions/job/runtime.ts +23 -6
  40. package/extensions/job/status.ts +4 -2
  41. package/extensions/memory/README.md +55 -13
  42. package/extensions/memory/commands.ts +20 -2
  43. package/extensions/memory/doctor.ts +137 -0
  44. package/extensions/memory/format.ts +24 -7
  45. package/extensions/memory/runtime.ts +49 -0
  46. package/extensions/memory/store.ts +31 -0
  47. package/extensions/memory/tui.ts +136 -12
  48. package/extensions/plan/README.md +60 -29
  49. package/extensions/plan/commands.ts +67 -8
  50. package/extensions/plan/index.ts +35 -11
  51. package/extensions/plan/plan-index.ts +234 -0
  52. package/extensions/plan/plan-text.ts +95 -0
  53. package/extensions/plan/plans.ts +90 -1
  54. package/extensions/plan/review.ts +12 -9
  55. package/extensions/plan/runtime.ts +72 -27
  56. package/extensions/plan/steps.ts +35 -25
  57. package/extensions/plan/todos.ts +24 -2
  58. package/extensions/plan/tools.ts +124 -24
  59. package/extensions/plan/tui.ts +7 -28
  60. package/extensions/plan/types.ts +9 -0
  61. package/extensions/plan/web-page.ts +2 -2
  62. package/extensions/plan/web.ts +12 -11
  63. package/extensions/rewind/README.md +28 -8
  64. package/extensions/rewind/commands.ts +1 -1
  65. package/extensions/rewind/format.ts +4 -4
  66. package/extensions/rewind/git.ts +56 -18
  67. package/extensions/rewind/index.ts +3 -5
  68. package/extensions/rewind/path-picker.ts +198 -0
  69. package/extensions/rewind/paths.ts +128 -0
  70. package/extensions/rewind/restore.ts +69 -28
  71. package/extensions/rewind/rewind.ts +80 -3
  72. package/extensions/rewind/runtime.ts +13 -5
  73. package/extensions/rewind/snapshot-run.ts +5 -2
  74. package/extensions/rewind/types.ts +2 -0
  75. package/extensions/subagent/README.md +7 -1
  76. package/extensions/subagent/accounting.ts +34 -0
  77. package/extensions/subagent/index.ts +23 -1
  78. package/extensions/subagent/stream.ts +2 -0
  79. package/extensions/subagent/types.ts +2 -0
  80. package/extensions/watch/README.md +8 -3
  81. package/extensions/watch/index.ts +19 -0
  82. package/extensions/web-access/README.md +43 -13
  83. package/extensions/web-access/charset.ts +58 -0
  84. package/extensions/web-access/fetch/find.ts +11 -7
  85. package/extensions/web-access/fetch/format.ts +64 -18
  86. package/extensions/web-access/fetch/page.ts +5 -1
  87. package/extensions/web-access/fetch/tool.ts +19 -18
  88. package/extensions/web-access/http.ts +49 -8
  89. package/extensions/web-access/search/facts.ts +38 -0
  90. package/extensions/web-access/search/tool.ts +13 -0
  91. package/extensions/web-access/text.ts +50 -11
  92. package/extensions/worktree/README.md +74 -15
  93. package/extensions/worktree/config.ts +11 -0
  94. package/extensions/worktree/exit.ts +35 -3
  95. package/extensions/worktree/gaps.ts +74 -0
  96. package/extensions/worktree/guard.ts +50 -4
  97. package/extensions/worktree/handoff.ts +55 -0
  98. package/extensions/worktree/index.ts +12 -2
  99. package/extensions/worktree/runtime.ts +20 -0
  100. package/extensions/worktree/status.ts +15 -2
  101. package/extensions/worktree/tools.ts +3 -2
  102. package/lib/README.md +10 -8
  103. package/lib/events.ts +86 -1
  104. package/lib/goal-state.ts +95 -0
  105. package/lib/requirements.ts +282 -62
  106. package/lib/tui/README.md +91 -0
  107. package/lib/tui/glyphs.ts +1 -0
  108. package/lib/web/layout.ts +12 -2
  109. package/package.json +5 -3
  110. package/themes/bytefly-dark.json +1 -1
  111. package/assets/chrome.png +0 -0
  112. package/assets/file-browse.png +0 -0
  113. package/assets/job.png +0 -0
  114. package/assets/plan-review.png +0 -0
  115. package/assets/question.png +0 -0
  116. package/assets/rewind.png +0 -0
  117. package/assets/todo.png +0 -0
  118. package/assets/tool-renderings.png +0 -0
  119. package/assets/tools/ask_user_question-cancelled.png +0 -0
  120. package/assets/tools/ask_user_question.png +0 -0
  121. package/assets/tools/bash.png +0 -0
  122. package/assets/tools/edit.png +0 -0
  123. package/assets/tools/enter_plan_mode.png +0 -0
  124. package/assets/tools/enter_worktree.png +0 -0
  125. package/assets/tools/exit_plan_mode.png +0 -0
  126. package/assets/tools/exit_worktree.png +0 -0
  127. package/assets/tools/find.png +0 -0
  128. package/assets/tools/goal.png +0 -0
  129. package/assets/tools/grep.png +0 -0
  130. package/assets/tools/job.png +0 -0
  131. package/assets/tools/list_worktrees.png +0 -0
  132. package/assets/tools/ls.png +0 -0
  133. package/assets/tools/memory-long-subject.png +0 -0
  134. package/assets/tools/memory.png +0 -0
  135. package/assets/tools/notice.png +0 -0
  136. package/assets/tools/open_path.png +0 -0
  137. package/assets/tools/prune_worktrees.png +0 -0
  138. package/assets/tools/read.png +0 -0
  139. package/assets/tools/read_doc-running.png +0 -0
  140. package/assets/tools/read_doc.png +0 -0
  141. package/assets/tools/run_doctor.png +0 -0
  142. package/assets/tools/send_wechat.png +0 -0
  143. package/assets/tools/set_plan_steps.png +0 -0
  144. package/assets/tools/subagent.png +0 -0
  145. package/assets/tools/todo.png +0 -0
  146. package/assets/tools/turn-rule.png +0 -0
  147. package/assets/tools/user-message.png +0 -0
  148. package/assets/tools/watch.png +0 -0
  149. package/assets/tools/web_fetch-rejected.png +0 -0
  150. package/assets/tools/web_fetch.png +0 -0
  151. package/assets/tools/web_search.png +0 -0
  152. package/assets/tools/write.png +0 -0
  153. package/assets/tools/write_plan.png +0 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The published-version answer, cached on disk with the time it was read.
3
+ *
4
+ * The check already fires once per session, which leaves two gaps: a session open
5
+ * for days never re-reads npm, and every new session pays for a request it made
6
+ * minutes earlier. A timestamp is what fixes the second, so the cache lives in the
7
+ * agent directory rather than in memory — one entry, one window, shared by every
8
+ * session that starts inside it.
9
+ *
10
+ * A cache can only ever be a miss. An absent, unreadable, or wrong-typed file
11
+ * reads as `undefined` and the caller fetches; nothing here throws, because a
12
+ * stale file must never become a session error.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { writeConfigFile } from "../../../lib/config.ts";
18
+ import { parseVersion } from "./version.ts";
19
+
20
+ /** The file name under `<agentDir>/bytefly/`. */
21
+ export const UPDATE_CACHE_FILE = "update.json";
22
+
23
+ /** How long a cached answer is reused before the next session re-reads npm. */
24
+ export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
25
+
26
+ /** One cached answer: the newest published version and when it was read. */
27
+ export interface UpdateCacheEntry {
28
+ /** The version npm's `latest` tag pointed at. */
29
+ latest: string;
30
+ /** Epoch milliseconds of the read that produced `latest`. */
31
+ checkedAt: number;
32
+ }
33
+
34
+ /** Where the cache lives for an agent directory. */
35
+ export function updateCachePath(agentDir: string): string {
36
+ return join(agentDir, "bytefly", UPDATE_CACHE_FILE);
37
+ }
38
+
39
+ /** Read the cache; a missing, corrupt, or mistyped entry is `undefined`. */
40
+ export function readUpdateCache(path: string): UpdateCacheEntry | undefined {
41
+ try {
42
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown> | undefined;
43
+ if (!raw || typeof raw !== "object") return undefined;
44
+ const latest = typeof raw.latest === "string" ? raw.latest.trim() : "";
45
+ const checkedAt = typeof raw.checkedAt === "number" && Number.isFinite(raw.checkedAt) ? raw.checkedAt : 0;
46
+ if (!parseVersion(latest) || checkedAt <= 0) return undefined;
47
+ return { latest, checkedAt };
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Write the cache atomically (a sibling temp file, then a rename). The caller
55
+ * treats a throw as a miss: a read-only agent directory must not break a session.
56
+ */
57
+ export function writeUpdateCache(path: string, entry: UpdateCacheEntry): void {
58
+ writeConfigFile(path, { latest: entry.latest, checkedAt: entry.checkedAt });
59
+ }
60
+
61
+ /**
62
+ * Whether an entry is still inside the window. The boundary is inclusive: an
63
+ * entry read exactly `ttlMs` ago is reused, so the window never flickers.
64
+ */
65
+ export function isFresh(
66
+ entry: UpdateCacheEntry | undefined,
67
+ now: number,
68
+ ttlMs: number = UPDATE_CACHE_TTL_MS,
69
+ ): boolean {
70
+ if (!entry) return false;
71
+ const age = now - entry.checkedAt;
72
+ return age >= 0 && age <= ttlMs;
73
+ }
@@ -1,32 +1,87 @@
1
1
  /**
2
- * Wording for the update check: the header marker and the one transcript notice.
2
+ * Wording for the update check: the header marker, the one transcript notice, and
3
+ * the answer an explicit re-check prints.
3
4
  *
4
- * Both read the same two facts — the version this session loaded and the version
5
- * the registry publishes — and neither adds a third. The marker is the shortest
6
- * honest form (`↑0.12.0`), and the notice names the command instead of printing
7
- * a changelog link the notice renderer cannot open.
5
+ * All three read the same two facts — the version this session loaded and the
6
+ * version the registry publishes — and none adds a third. The marker is the
7
+ * shortest honest form (`↑0.12.0`); the notice names the command and the release
8
+ * URL as text, because the notice renderer has no link support; and the re-check
9
+ * says what it just found, including the two cases it cannot answer (offline, and
10
+ * a registry it could not read).
8
11
  */
9
12
 
13
+ import pkg from "../../../package.json";
10
14
  import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
11
15
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
16
+ import type { UpdateOutcome } from "./state.ts";
12
17
 
13
18
  /** The header marker: an up arrow and the newest version, e.g. `↑0.12.0`. */
14
19
  export function updateMarker(latest: string): string {
15
20
  return `${GLYPHS.update}${latest}`;
16
21
  }
17
22
 
23
+ /**
24
+ * The release page for a version, or `undefined` when this checkout does not name
25
+ * a GitHub repository. Derived from `package.json` so a rename cannot leave a dead
26
+ * link behind in the notice.
27
+ */
28
+ export function releaseUrl(version: string): string | undefined {
29
+ const url = (pkg.repository as { url?: string } | undefined)?.url ?? "";
30
+ const base = url.replace(/\.git$/, "");
31
+ if (!/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(base)) return undefined;
32
+ return `${base}/releases/tag/v${version}`;
33
+ }
34
+
18
35
  /** The single notice a session posts when a newer version is published. */
19
36
  export function updateNotice(current: string, latest: string): NoticeMessage {
37
+ const release = releaseUrl(latest);
38
+ const command = "pi update --extensions";
20
39
  return noticeMessage({
21
- content: `bytefly ${latest} is available (current ${current}). Run \`pi update --extensions\` to update.`,
40
+ content:
41
+ `bytefly ${latest} is available (current ${current}). Run \`${command}\` to update.` +
42
+ (release ? ` Release notes: ${release}` : ""),
22
43
  details: {
23
44
  source: "bytefly",
24
45
  title: `${latest} is available`,
25
46
  severity: "info",
26
- items: [
27
- { label: "current", detail: `v${current}` },
28
- { label: "update", detail: "pi update --extensions" },
29
- ],
47
+ items: release
48
+ ? [
49
+ { label: "current", detail: `v${current}` },
50
+ { label: "update", detail: command },
51
+ { label: "release", detail: release },
52
+ ]
53
+ : [
54
+ { label: "current", detail: `v${current}` },
55
+ { label: "update", detail: command },
56
+ ],
30
57
  },
31
58
  });
32
59
  }
60
+
61
+ /**
62
+ * What `/bytefly update` prints. The background check is silent because nobody
63
+ * asked; here someone did, so a skip and a failed read are answers too.
64
+ */
65
+ export function updateOutcomeText(
66
+ outcome: UpdateOutcome,
67
+ current: string,
68
+ ): { text: string; severity: "info" | "warning" } {
69
+ switch (outcome.kind) {
70
+ case "offline":
71
+ return { text: "bytefly: the update check was skipped (PI_OFFLINE is set).", severity: "info" };
72
+ case "failed":
73
+ return {
74
+ text: "bytefly: could not read the published version — check the network and try again.",
75
+ severity: "warning",
76
+ };
77
+ case "current":
78
+ return { text: `bytefly: up to date — v${outcome.latest} is the newest published version.`, severity: "info" };
79
+ case "available": {
80
+ const release = releaseUrl(outcome.latest);
81
+ return {
82
+ text: `bytefly: v${outcome.latest} is available (current ${current}).${release ? ` ${release}` : ""}`,
83
+ severity: "info",
84
+ };
85
+ }
86
+ }
87
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The update check's lifecycle: read the published version once per interactive
3
- * session, record it when it is newer, and announce it once per load.
2
+ * The update check's lifecycle: reuse the cached answer or read the registry once
3
+ * per interactive session, record a newer version, and announce it once per load.
4
4
  *
5
5
  * The request is fired without awaiting. Pi awaits `session_start` handlers, so
6
6
  * a slow registry would otherwise hold the first prompt; Pi's own version check
@@ -8,49 +8,106 @@
8
8
  * version is silent, and a generation counter keeps a request that resolves
9
9
  * after shutdown from writing into the next session's state.
10
10
  *
11
- * `/bytefly` and `doctor` do not report this: an available update is not a
12
- * prerequisite problem, and the header plus one notice are the whole surface.
11
+ * A cached answer with a timestamp is what makes a long-lived session different
12
+ * from a fresh one: a session that starts inside the window reuses the last read
13
+ * instead of repeating it, and `recheck()` ignores the window so `/bytefly update`
14
+ * can look again without a restart. `/bytefly` and `doctor` do not report the
15
+ * version: an available update is not a prerequisite problem.
13
16
  */
14
17
 
15
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
20
  import pkg from "../../../package.json";
17
21
  import { isOffline } from "../../../lib/env.ts";
22
+ import { isFresh, readUpdateCache, updateCachePath, writeUpdateCache, type UpdateCacheEntry } from "./cache.ts";
18
23
  import { fetchLatestVersion } from "./check.ts";
19
24
  import { updateNotice } from "./format.ts";
20
- import type { UpdateSource } from "./state.ts";
25
+ import type { UpdateOutcome, UpdateSource } from "./state.ts";
21
26
  import { isNewerVersion } from "./version.ts";
22
27
 
23
- /** Test seams; production uses the registry read and `PI_OFFLINE`. */
28
+ /** The cache, as the lifecycle sees it: read on start, written after a read. */
29
+ export interface UpdateCache {
30
+ /** The last recorded answer, or `undefined` for a miss. */
31
+ read(): UpdateCacheEntry | undefined;
32
+ /** Record an answer; a throw is treated as a miss. */
33
+ write(entry: UpdateCacheEntry): void;
34
+ }
35
+
36
+ /** What `registerUpdate` returns, so the panel can re-run the check on demand. */
37
+ export interface UpdateHandle {
38
+ /** Read the registry now, ignoring the cache window. Never throws. */
39
+ recheck(): Promise<UpdateOutcome>;
40
+ }
41
+
42
+ /** Test seams; production uses the registry read, `PI_OFFLINE`, and the agent dir. */
24
43
  export interface UpdateDeps {
25
44
  /** Replace the registry read; a rejection counts as no answer. */
26
45
  fetchLatest?: () => Promise<string | undefined>;
27
46
  /** Replace the `PI_OFFLINE` check. */
28
47
  isOffline?: () => boolean;
48
+ /** Replace the disk cache. */
49
+ cache?: UpdateCache;
50
+ /** Replace the clock the cache timestamp is read from. */
51
+ now?: () => number;
29
52
  /** Called once a newer version is recorded, so a mounted surface repaints. */
30
53
  onUpdate?: () => void;
31
54
  }
32
55
 
56
+ /** The on-disk cache under the agent directory, built lazily so it is per call. */
57
+ function diskCache(): UpdateCache {
58
+ return {
59
+ read: () => readUpdateCache(updateCachePath(getAgentDir())),
60
+ write: (entry) => {
61
+ try {
62
+ writeUpdateCache(updateCachePath(getAgentDir()), entry);
63
+ } catch {
64
+ // A read-only agent directory costs the next session one request, nothing more.
65
+ }
66
+ },
67
+ };
68
+ }
69
+
33
70
  /**
34
71
  * Register the check. `updates` is the same store the header reads, so recording
35
72
  * a version and calling `onUpdate` is what puts the marker on screen.
36
73
  */
37
- export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}): void {
74
+ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}): UpdateHandle {
38
75
  const readLatest = deps.fetchLatest ?? (() => fetchLatestVersion());
39
76
  const offline = deps.isOffline ?? isOffline;
77
+ const cache = deps.cache ?? diskCache();
78
+ const now = deps.now ?? Date.now;
40
79
  // A session is announced at most once per load, even if `session_start` fires
41
80
  // again after a resume or a session replacement.
42
81
  let announced = false;
43
82
  let generation = 0;
44
83
 
84
+ /** Record a newer version and repaint the marker. */
85
+ const paint = (latest: string): void => {
86
+ if (!isNewerVersion(latest, pkg.version)) return;
87
+ updates.set(latest);
88
+ deps.onUpdate?.();
89
+ };
90
+
91
+ /** The once-per-load transcript notice, posted only for a strictly newer version. */
92
+ const announce = (latest: string): void => {
93
+ if (announced || !isNewerVersion(latest, pkg.version)) return;
94
+ announced = true;
95
+ pi.sendMessage(updateNotice(pkg.version, latest));
96
+ };
97
+
98
+ /** One answer, from the cache or the registry: paint the marker, announce it once. */
99
+ const record = (entry: UpdateCacheEntry): void => {
100
+ paint(entry.latest);
101
+ announce(entry.latest);
102
+ };
103
+
45
104
  const check = async (token: number): Promise<void> => {
46
105
  try {
47
106
  const latest = await readLatest();
48
107
  if (latest === undefined || token !== generation) return;
49
- if (announced || !isNewerVersion(latest, pkg.version)) return;
50
- announced = true;
51
- updates.set(latest);
52
- deps.onUpdate?.();
53
- pi.sendMessage(updateNotice(pkg.version, latest));
108
+ const entry = { latest, checkedAt: now() };
109
+ cache.write(entry);
110
+ record(entry);
54
111
  } catch {
55
112
  // A background check never surfaces an error: the session does not know it ran.
56
113
  }
@@ -60,7 +117,19 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
60
117
  // The marker lives in bytefly's header and the notice in a transcript, so a
61
118
  // non-interactive run has nothing to draw and makes no request.
62
119
  if (ctx.mode !== "tui") return;
63
- if (announced || offline()) return;
120
+ if (offline()) {
121
+ // No request, so the window does not matter: the cached answer is the best
122
+ // one available, and the marker is advisory.
123
+ const cached = cache.read();
124
+ if (cached) record(cached);
125
+ return;
126
+ }
127
+ const cached = cache.read();
128
+ if (cached && isFresh(cached, now())) {
129
+ record(cached);
130
+ return;
131
+ }
132
+ if (announced) return;
64
133
  void check(generation);
65
134
  });
66
135
 
@@ -69,4 +138,21 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
69
138
  generation++;
70
139
  updates.set();
71
140
  });
141
+
142
+ return {
143
+ async recheck() {
144
+ if (offline()) return { kind: "offline" };
145
+ let latest: string | undefined;
146
+ try {
147
+ latest = await readLatest();
148
+ } catch {
149
+ return { kind: "failed" };
150
+ }
151
+ if (latest === undefined) return { kind: "failed" };
152
+ cache.write({ latest, checkedAt: now() });
153
+ // The caller prints the answer, so this path never posts a notice.
154
+ paint(latest);
155
+ return isNewerVersion(latest, pkg.version) ? { kind: "available", latest } : { kind: "current", latest };
156
+ },
157
+ };
72
158
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Where a surface reads the newest published version this load found.
2
+ * Where a surface reads the newest published version this load found, and what an
3
+ * explicit re-check reported.
3
4
  *
4
5
  * The check resolves after the session started, so the header cannot be handed
5
6
  * the value up front: it reads through this store on every apply, and the
@@ -16,6 +17,17 @@ export interface UpdateSource {
16
17
  set(latest?: string): void;
17
18
  }
18
19
 
20
+ /**
21
+ * What one explicit re-check found. The background check reports nothing — nobody
22
+ * asked — so this vocabulary exists only for `/bytefly update`, where a failure or
23
+ * a skip is an answer rather than silence.
24
+ */
25
+ export type UpdateOutcome =
26
+ | { kind: "offline" }
27
+ | { kind: "failed" }
28
+ | { kind: "current"; latest: string }
29
+ | { kind: "available"; latest: string };
30
+
19
31
  /** A store holding at most one version. */
20
32
  export function createUpdateState(): UpdateSource {
21
33
  let latest: string | undefined;
@@ -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
@@ -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.",
@@ -101,8 +105,15 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
101
105
 
102
106
  const size = await statFile(abs, display, config.maxFileBytes);
103
107
  const extractor = deps.extractors?.[format.id] ?? format.extract;
108
+ const timeoutMs = deps.timeoutMs ?? config.extractTimeoutMs;
104
109
  startToolProgress(onUpdate);
105
- 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
+ });
106
117
 
107
118
  const startIndex = typeof args.startIndex === "number" ? Math.max(0, Math.round(args.startIndex)) : 0;
108
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