@gavin-hu/my-pi-agent 0.15.1 → 0.17.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 (189) hide show
  1. package/README.md +22 -13
  2. package/README.zh-CN.md +19 -11
  3. package/extensions/bytefly/README.md +255 -200
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notice/component.ts +6 -4
  17. package/extensions/bytefly/notice/config.ts +16 -2
  18. package/extensions/bytefly/panel/commands.ts +46 -25
  19. package/extensions/bytefly/panel/format.ts +52 -22
  20. package/extensions/bytefly/panel/join.ts +36 -15
  21. package/extensions/bytefly/panel/settings.ts +72 -392
  22. package/extensions/bytefly/panel/skills.ts +51 -0
  23. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  24. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -58
  25. package/extensions/bytefly/panel/tui/frame.ts +11 -3
  26. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  27. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  28. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  29. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  30. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  31. package/extensions/bytefly/panel/tui.ts +223 -333
  32. package/extensions/bytefly/panel/types.ts +26 -8
  33. package/extensions/bytefly/picker/discovery.ts +32 -27
  34. package/extensions/bytefly/picker/resources.ts +22 -10
  35. package/extensions/bytefly/picker/runtime.ts +62 -34
  36. package/extensions/bytefly/picker/toggle.ts +16 -10
  37. package/extensions/bytefly/picker/types.ts +31 -13
  38. package/extensions/bytefly/update/format.ts +66 -8
  39. package/extensions/bytefly/update/patch.ts +66 -0
  40. package/extensions/bytefly/update/register.ts +4 -11
  41. package/extensions/bytefly/update/release.ts +65 -0
  42. package/extensions/bytefly/update/state.ts +14 -0
  43. package/extensions/bytefly/update/suppress.ts +47 -29
  44. package/extensions/bytefly/update/types.ts +15 -5
  45. package/extensions/bytefly/working/register.ts +2 -8
  46. package/extensions/check/tools.ts +2 -11
  47. package/extensions/check/tui.ts +2 -0
  48. package/extensions/context/README.md +172 -0
  49. package/extensions/context/collect.ts +53 -0
  50. package/extensions/context/commands.ts +100 -0
  51. package/extensions/context/format.ts +84 -0
  52. package/extensions/context/index.ts +16 -0
  53. package/extensions/context/report.ts +332 -0
  54. package/extensions/context/tui.ts +284 -0
  55. package/extensions/context/types.ts +124 -0
  56. package/extensions/file-browse/README.md +67 -19
  57. package/extensions/file-browse/client.ts +16 -463
  58. package/extensions/file-browse/config.ts +8 -0
  59. package/extensions/file-browse/html.ts +9 -7
  60. package/extensions/file-browse/index.ts +17 -2
  61. package/extensions/file-browse/live-client.ts +13 -13
  62. package/extensions/file-browse/live.ts +44 -10
  63. package/extensions/file-browse/markdown.ts +3 -10
  64. package/extensions/file-browse/palette.ts +224 -0
  65. package/extensions/file-browse/raw-html.ts +3 -10
  66. package/extensions/file-browse/router.ts +2 -2
  67. package/extensions/file-browse/server.ts +13 -2
  68. package/extensions/file-browse/styles.ts +8 -3
  69. package/extensions/file-browse/tree.ts +381 -0
  70. package/extensions/file-open/README.md +1 -2
  71. package/extensions/{document → file-read}/README.md +76 -57
  72. package/extensions/{document → file-read}/config.ts +12 -9
  73. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  74. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  75. package/extensions/{document → file-read}/extract/file.ts +9 -9
  76. package/extensions/{document → file-read}/extract/odt.ts +10 -8
  77. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  78. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  79. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  80. package/extensions/{document → file-read}/formats.ts +11 -11
  81. package/extensions/file-read/index.ts +17 -0
  82. package/extensions/{document → file-read}/paging.ts +15 -13
  83. package/extensions/{document → file-read}/render.ts +17 -13
  84. package/extensions/{document → file-read}/schema.ts +7 -7
  85. package/extensions/{document → file-read}/tool.ts +36 -28
  86. package/extensions/goal/tools.ts +16 -25
  87. package/extensions/job/commands.ts +32 -46
  88. package/extensions/job/index.ts +20 -45
  89. package/extensions/job/runtime.ts +6 -8
  90. package/extensions/job/tools.ts +58 -103
  91. package/extensions/memory/commands.ts +30 -44
  92. package/extensions/memory/tools.ts +10 -20
  93. package/extensions/memory/tui.ts +5 -1
  94. package/extensions/plan/README.md +88 -33
  95. package/extensions/plan/commands.ts +19 -4
  96. package/extensions/plan/config.ts +27 -0
  97. package/extensions/plan/plan-index.ts +3 -1
  98. package/extensions/plan/plans.ts +31 -13
  99. package/extensions/plan/review.ts +34 -14
  100. package/extensions/plan/runtime.ts +3 -1
  101. package/extensions/plan/steps.ts +43 -14
  102. package/extensions/plan/tools/cancel.ts +79 -0
  103. package/extensions/plan/tools/enter.ts +16 -17
  104. package/extensions/plan/tools/exit.ts +60 -50
  105. package/extensions/plan/tools/steps.ts +13 -26
  106. package/extensions/plan/tools/write.ts +30 -38
  107. package/extensions/plan/tools.ts +5 -2
  108. package/extensions/plan/tui.ts +9 -7
  109. package/extensions/plan/types.ts +8 -0
  110. package/extensions/plan/web-chrome.ts +101 -0
  111. package/extensions/plan/web-client.ts +98 -311
  112. package/extensions/plan/web-markdown.ts +222 -0
  113. package/extensions/plan/web-page.ts +28 -2
  114. package/extensions/plan/web-router.ts +2 -2
  115. package/extensions/plan/web.ts +21 -12
  116. package/extensions/question/tools.ts +5 -4
  117. package/extensions/rewind/checklist.ts +4 -0
  118. package/extensions/rewind/review-tui.ts +3 -1
  119. package/extensions/rewind/rewind-tui.ts +4 -0
  120. package/extensions/subagent/README.md +25 -12
  121. package/extensions/subagent/background.ts +1 -7
  122. package/extensions/subagent/format.ts +72 -25
  123. package/extensions/subagent/index.ts +21 -47
  124. package/extensions/subagent/orchestrate.ts +18 -41
  125. package/extensions/subagent/render.ts +29 -42
  126. package/extensions/subagent/stream.ts +24 -18
  127. package/extensions/subagent/types.ts +7 -0
  128. package/extensions/todo/tools.ts +15 -20
  129. package/extensions/todo/tui.ts +5 -1
  130. package/extensions/watch/README.md +5 -5
  131. package/extensions/watch/commands.ts +33 -47
  132. package/extensions/watch/index.ts +10 -35
  133. package/extensions/watch/runtime.ts +5 -7
  134. package/extensions/watch/tools.ts +38 -77
  135. package/extensions/web-access/README.md +3 -3
  136. package/extensions/web-access/search/search.ts +9 -18
  137. package/extensions/wechat/README.md +40 -18
  138. package/extensions/wechat/bridge.ts +38 -40
  139. package/extensions/wechat/client.ts +1 -4
  140. package/extensions/wechat/commands.ts +1 -1
  141. package/extensions/wechat/interaction.ts +25 -4
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +7 -5
  144. package/extensions/wechat/prompt.ts +19 -4
  145. package/extensions/wechat/qrcode.ts +9 -9
  146. package/extensions/wechat/slash.ts +14 -0
  147. package/extensions/wechat/status-screen.ts +2 -2
  148. package/extensions/wechat/timer.ts +9 -30
  149. package/extensions/wechat/tools.ts +7 -18
  150. package/extensions/worktree/README.md +48 -12
  151. package/extensions/worktree/commands.ts +109 -65
  152. package/extensions/worktree/enter.ts +1 -1
  153. package/extensions/worktree/exit.ts +11 -1
  154. package/extensions/worktree/lifecycle.ts +9 -1
  155. package/extensions/worktree/screen.ts +263 -0
  156. package/extensions/worktree/status.ts +84 -38
  157. package/lib/README.md +15 -11
  158. package/lib/config.ts +12 -0
  159. package/lib/escape.ts +15 -2
  160. package/lib/format.ts +6 -2
  161. package/lib/interaction.ts +18 -1
  162. package/lib/pdf.ts +2 -2
  163. package/lib/report.ts +75 -2
  164. package/lib/requirements.ts +2 -2
  165. package/lib/screen-session.ts +85 -0
  166. package/lib/timer.ts +61 -0
  167. package/lib/tool-result.ts +54 -4
  168. package/lib/tui/README.md +19 -11
  169. package/lib/tui/chrome.ts +27 -6
  170. package/lib/tui/columns.ts +33 -8
  171. package/lib/tui/dock-screen.ts +6 -2
  172. package/lib/tui/gauge.ts +9 -5
  173. package/lib/tui/rows.ts +15 -6
  174. package/lib/tui/rules.ts +4 -8
  175. package/lib/tui/tool.ts +55 -2
  176. package/lib/usage.ts +26 -0
  177. package/lib/web/README.md +131 -0
  178. package/lib/web/base.ts +19 -2
  179. package/lib/web/components.ts +10 -6
  180. package/lib/web/layout.ts +2 -0
  181. package/lib/web/markup.ts +2 -1
  182. package/lib/web/runtime.ts +103 -25
  183. package/lib/web/tokens.ts +23 -7
  184. package/package.json +10 -4
  185. package/skills/changelog/SKILL.md +148 -0
  186. package/skills/next/SKILL.md +184 -0
  187. package/extensions/document/index.ts +0 -17
  188. /package/extensions/{document → file-read}/deadline.ts +0 -0
  189. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Worktree status and reporting: labels, summaries, refusal messages, and the
3
3
  * override/`list_worktrees` view.
4
+ *
5
+ * One snapshot feeds both surfaces: `formatWorktreeStatus` renders it as the text
6
+ * a tool result or a headless command prints, and `screen.ts` renders the same
7
+ * rows as the `/worktree` list screen. They read the same git calls, so they
8
+ * cannot disagree about a worktree.
4
9
  */
5
10
 
6
11
  import { basename } from "node:path";
@@ -8,7 +13,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
13
  import { realPathOfNearest } from "../../lib/path.ts";
9
14
  import { mapConcurrent } from "../../lib/pool.ts";
10
15
  import { loadConfig } from "./config.ts";
11
- import { gapLines, isolationGaps } from "./gaps.ts";
16
+ import { gapLines, type IsolationGap, isolationGaps } from "./gaps.ts";
12
17
  import { type CheckoutCheck, defaultBranch, listManagedWorktrees, type ManagedWorktree, repoRoot } from "./git.ts";
13
18
  import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
14
19
  import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
@@ -62,7 +67,7 @@ export function refusalMessage(check: CheckoutCheck, path: string, cwd: string):
62
67
  }
63
68
 
64
69
  /** Compact marks for one managed worktree in the status list. */
65
- function statusMarks(status: CheckoutStatus, isCurrent: boolean): string[] {
70
+ export function statusMarks(status: CheckoutStatus, isCurrent: boolean): string[] {
66
71
  const marks: string[] = [];
67
72
  if (isCurrent) marks.push("current");
68
73
  if (status.state === "missing") marks.push("missing");
@@ -75,60 +80,101 @@ function statusMarks(status: CheckoutStatus, isCurrent: boolean): string[] {
75
80
  return marks;
76
81
  }
77
82
 
78
- function relativeDay(at: number): string {
83
+ export function relativeDay(at: number): string {
79
84
  const days = Math.floor((Date.now() - at) / 86_400_000);
80
85
  if (days <= 0) return "today";
81
86
  if (days === 1) return "1d ago";
82
87
  return `${days}d ago`;
83
88
  }
84
89
 
85
- /** Human-readable status: current worktree, override conflicts, gaps, managed worktrees. */
86
- export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
87
- const current = getActive();
88
- const lines: string[] = [];
89
- if (current) {
90
- lines.push(stateSummary(current));
91
- if (current.borrowed) lines.push(" (inherited from the parent session)");
92
- } else {
93
- lines.push("Not in a worktree.");
94
- }
95
- const inactive = getInactiveOverrides();
96
- if (inactive.length > 0) {
97
- lines.push("", `Isolation not active for: ${inactive.join(", ")} (their default implementation is in use).`);
98
- }
99
- if (current) {
100
- // What isolation does not cover, from the same rules `doctor` reports: a gap
101
- // the user cannot see is a gap they cannot weigh.
102
- const gaps = isolationGaps({
103
- platform: process.platform,
104
- activeTools: pi.getActiveTools(),
105
- config: configFor(current.repoRoot),
106
- unverifiableCommands: getUnverifiableCommands(),
107
- });
108
- const gapBlock = gapLines(gaps);
109
- if (gapBlock.length > 0) lines.push("", "Not isolated:", ...gapBlock);
110
- }
90
+ /** One managed worktree and what git says about it. */
91
+ export interface ManagedRow {
92
+ entry: ManagedWorktree;
93
+ status: CheckoutStatus;
94
+ }
95
+
96
+ /** Everything both status surfaces report, read once. */
97
+ export interface WorktreeSnapshot {
98
+ active: WorktreeState | null;
99
+ /** What isolation does not cover, from the same rules `doctor` reports. */
100
+ gaps: IsolationGap[];
101
+ /** Built-in tools with an inactive override. */
102
+ inactiveOverrides: string[];
103
+ /** Managed worktrees with their checkout status, in git's order. */
104
+ managed: ManagedRow[];
105
+ /** The merge-target branch name, when the repository has a remote. */
106
+ baseBranch: string | undefined;
107
+ }
111
108
 
112
- const root = current?.repoRoot ?? (await repoRoot(pi, ctx.cwd));
109
+ /** Read the session's worktree status once, for either surface to render. */
110
+ export async function worktreeSnapshot(pi: ExtensionAPI, ctx: ExtensionContext): Promise<WorktreeSnapshot> {
111
+ const active = getActive();
112
+ // A gap the user cannot see is a gap they cannot weigh, so they are computed for
113
+ // every read rather than cached from session start.
114
+ const gaps = active
115
+ ? isolationGaps({
116
+ platform: process.platform,
117
+ activeTools: pi.getActiveTools(),
118
+ config: configFor(active.repoRoot),
119
+ unverifiableCommands: getUnverifiableCommands(),
120
+ })
121
+ : [];
122
+
123
+ const managed: ManagedRow[] = [];
124
+ let baseBranch: string | undefined;
125
+ const root = active?.repoRoot ?? (await repoRoot(pi, ctx.cwd));
113
126
  if (root) {
114
127
  const config = loadConfig(root);
115
- const managed = await listManagedWorktrees(pi, root, config);
116
- const { registry } = reconcileRegistry(root, config, managed, { persist: false });
117
- const baseBranch = await defaultBranch(pi, root);
128
+ const entries = await listManagedWorktrees(pi, root, config);
129
+ const { registry } = reconcileRegistry(root, config, entries, { persist: false });
130
+ baseBranch = await defaultBranch(pi, root);
118
131
  const baseRef = baseBranch ? `origin/${baseBranch}` : undefined;
119
132
  // Keyed by the canonical path the registry stores, so a row's record is one
120
133
  // lookup instead of a scan of every record.
121
134
  const records = new Map(registry.worktrees.map((record) => [record.path, record]));
122
- const row = async (entry: ManagedWorktree): Promise<string> => {
135
+ const row = async (entry: ManagedWorktree): Promise<ManagedRow> => {
123
136
  const path = realPathOfNearest(entry.path);
124
- const status = await statusOf(pi, entry, { baseRef, record: records.get(path) });
125
- const marks = statusMarks(status, current?.path === entry.path);
126
- return ` ${entry.path} ${entry.branch ?? ""}${marks.length > 0 ? ` [${marks.join(", ")}]` : ""}`.trimEnd();
137
+ return { entry, status: await statusOf(pi, entry, { baseRef, record: records.get(path) }) };
127
138
  };
128
139
  // Each row is up to four git reads; four at a time keeps the list in the order
129
140
  // git reports while overlapping the waits. `statusOf` passes no `env`, which is
130
141
  // what makes concurrent git calls safe here (see `RunGitOptions.env`).
131
- lines.push("", `Managed worktrees (${managed.length}):`, ...(await mapConcurrent(managed, 4, row)));
142
+ managed.push(...(await mapConcurrent(entries, 4, row)));
143
+ }
144
+
145
+ return { active, gaps, inactiveOverrides: getInactiveOverrides(), managed, baseBranch };
146
+ }
147
+
148
+ /** Human-readable status: current worktree, override conflicts, gaps, managed worktrees. */
149
+ export function formatWorktreeStatus(snapshot: WorktreeSnapshot): string {
150
+ const { active, gaps, inactiveOverrides, managed } = snapshot;
151
+ const lines: string[] = [];
152
+ if (active) {
153
+ lines.push(stateSummary(active));
154
+ if (active.borrowed) lines.push(" (inherited from the parent session)");
155
+ } else {
156
+ lines.push("Not in a worktree.");
157
+ }
158
+ if (inactiveOverrides.length > 0) {
159
+ lines.push(
160
+ "",
161
+ `Isolation not active for: ${inactiveOverrides.join(", ")} (their default implementation is in use).`,
162
+ );
163
+ }
164
+ const gapBlock = gapLines(gaps);
165
+ if (gapBlock.length > 0) lines.push("", "Not isolated:", ...gapBlock);
166
+
167
+ if (managed.length > 0) {
168
+ const row = (item: ManagedRow): string => {
169
+ const marks = statusMarks(item.status, active?.path === item.entry.path);
170
+ return ` ${item.entry.path} ${item.entry.branch ?? ""}${marks.length > 0 ? ` [${marks.join(", ")}]` : ""}`.trimEnd();
171
+ };
172
+ lines.push("", `Managed worktrees (${managed.length}):`, ...managed.map(row));
132
173
  }
133
174
  return lines.join("\n");
134
175
  }
176
+
177
+ /** Read the status and render it: what the `list_worktrees` tool and a headless `/worktree` print. */
178
+ export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
179
+ return formatWorktreeStatus(await worktreeSnapshot(pi, ctx));
180
+ }
package/lib/README.md CHANGED
@@ -18,36 +18,39 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
18
18
  | Module | Responsibility |
19
19
  |---|---|
20
20
  | [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
21
- | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `writeConfigValuesAt` / `unsetConfigValueAt` (the leaf writers preserve siblings and metadata, `writeConfigValuesAt` applies several leaves in one document pass, and the last removes one and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `optionalString` / `asBoolean` / `stringList` / `asRecord` / `readJson` / `readJsonResult`. |
21
+ | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `writeConfigValuesAt` / `unsetConfigValueAt` (the leaf writers preserve siblings and metadata, `writeConfigValuesAt` applies several leaves in one document pass, and the last removes one and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `optionalString` / `asBoolean` / `stringList` / `asRecord` / `asNumber` (the untrusted-JSON narrowing pair) / `readJson` / `readJsonResult`. |
22
22
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
23
- | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
23
+ | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters, plus `decodeCodePoint`, the other half of the same boundary — one numeric character reference as text, with a surrogate or out-of-range value decoded to U+FFFD. |
24
24
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
25
25
  | [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job` and `watch`, rendered by bytefly. |
26
- | [`report.ts`](./report.ts) | The pending-report coordinator: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `source` / `onWoke`, returning the lifecycle handlers that report at the next turn, raise at most one triggered turn per wake window, and prune only their own notices. Shared by `job` and `watch`. |
26
+ | [`report.ts`](./report.ts) | The pending-report channel: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `source` / `onWoke`, returning the handlers that report at the next turn, raise at most one triggered turn per wake window, and prune only their own notices; and `registerPendingChannel`, which binds those handlers to `session_start` / `session_tree` / `agent_start` / `agent_settled` / `before_agent_start` / `context` / `session_shutdown` around a `load` / `setStatus` / `shutdown` runtime, so the order a session starts and shuts down is written once. `job` and `watch` supply only their own arming in `onSessionStart` / `onSessionShutdown`. |
27
27
  | [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified). |
28
28
  | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
29
+ | [`timer.ts`](./timer.ts) | The three timer seams and their production defaults, each cancellable and each built so a pending timer never holds the process open: `ArmTimer` / `defaultTimer` (one shot), `RepeatTimer` / `defaultInterval` (repeating), and `abortableSleep` (resolve after a delay, reject `Error("aborted")` when a signal wins). Shared by `wechat`'s bridge and prompt channel, plan's review page, `job`'s and `watch`'s repaint clocks, `bytefly`'s working tick, the browse live channel, and the search throttle — so a test drives a deadline instead of waiting on one. |
29
30
  | [`lines.ts`](./lines.ts) | `LineSplitter`, the byte-safe splitter for a stream of program output: buffers bytes and decodes only at a newline, so a UTF-8 character split across two reads survives, and caps an over-long line instead of dropping it. Shared by `job`, `watch`, `check`, and `subagent`. |
30
31
  | [`pool.ts`](./pool.ts) | `mapConcurrent`: one async read per entry with at most `limit` calls in flight and results in input order, so a directory listing or a status sweep fans out without an unbounded burst or a per-entry round trip. Shared by `file-browse` and `worktree`. |
31
- | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, and digest preface) plus the shared “Other” label. |
32
+ | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`, plus an optional `setPromptTimeout` scope for a dialog the human must read something before answering), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, digest preface, and prompt `timeoutMs`) plus the shared “Other” label. |
32
33
  | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `gitCommonDir`, `revParse`, `revParseMany` for several revisions in one call, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
33
34
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
34
35
  | [`http/`](./http/) | The loopback HTTP layer shared by `file-browse` and `plan`: [`http/headers.ts`](./http/headers.ts) (the page policy `PAGE_CSP` and the `pageHeaders` / `scriptHeaders` / `jsonHeaders` sets, plus `eventHeaders` for the one long-lived stream), [`http/response.ts`](./http/response.ts) (`ServeResponse` — a buffered body, a `filePath` stream, or an SSE `stream` — plus `htmlResponse` / `jsonResponse` / `jsonError` / `textResponse`), [`http/router.ts`](./http/router.ts) (`parseRequestUrl`, and `isLocalHostHeader` with the `required` mode each server chooses), [`http/server.ts`](./http/server.ts) (`bindLoopback` on `127.0.0.1` with the accept-error listener, `respond` for HEAD / streamed file / body, `openEventStream` for the head and reconnect hint of an SSE response, and the idempotent `createCloser`), and the [`http/index.ts`](./http/index.ts) barrel. |
35
- | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame) with `detailBlockRows` (the rows its detail block costs), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
36
+ | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (the docked-list frame `dockListFrame`, the `screenFooter` that closes a screen's own rows, `screenHint`, `detailBlockRows` (the rows its detail block costs), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow` with its `align` option for a value held beside its label, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow` for two cells at the two edges, `inlineRow` for two cells held together, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, `callLine` — the one headline every renderer draws — `detailsOrLine` (a result's details, or the one `errorLine` / `rejectionLine` a failure shows), sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
36
37
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, doc, and plan guards share, and the canonical-path helper the worktree registry keys records by. |
37
- | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `document`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
38
+ | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `file-read`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
38
39
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
39
40
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
40
41
  | [`plan-state.ts`](./plan-state.ts) | The plan-mode flag replayed from the session branch: `PLAN_MODE_ENTRY_TYPE` and `isPlanModeEnabled(entries)`, so `rewind` can refuse a write while planning without importing the plan extension. |
41
42
  | [`plan-entry.ts`](./plan-entry.ts) | Plan-mode entry shared by the extension that owns the mode and the one that offers it: `READ_ONLY_SUMMARY` and `PLAN_CONSENT` (one wording for the read-only guarantee), the `PLAN_ENTER_REQUEST` channel with its `PlanEnterRequest` payload, and `requestPlanMode(pi, ctx)` — a request/response over `pi.events` that resolves `false` instead of hanging when `plan` is not loaded, so a command context (which has no `executeTool`) can still enter the mode after asking the user once. |
42
43
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
44
+ | [`screen-session.ts`](./screen-session.ts) | `withPolledScreen`: mount one `ctx.ui.custom` dock screen — rails suppressed for its lifetime, an optional repaint tick through an injected `RepeatTimer`, and one teardown — handing the mount the host's `tui`, `theme`, `requestRender`, `close`, and `viewportRows`. Shared by `/jobs`, `/watch`, and `/memory`, whose rails window and timer window were the same one. |
43
45
  | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
44
46
  | [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
45
47
  | [`spawn.ts`](./spawn.ts) | The process seam for extensions that run a shell command: `SpawnedProcess`, `SpawnFn`, `KillTreeFn`, the pure `spawnOptions` / `killPlan`, `applyKillPlan` over injected seams, `isPidAlive`, and the `defaultSpawn` / `defaultKillTree` that execute them (process group on POSIX, `taskkill /T` on Windows). Shared by `job`, `watch`, `check`, `worktree`, and `wechat`. |
46
48
  | [`goal-state.ts`](./goal-state.ts) | The published `goal` state replayed from the session branch: `PublishedGoal`, `PublishedGoalStatus` (`planning` / `active` / `achieved`), `GOAL_ENTRY_TYPE`, `MAX_GOAL_OBJECTIVE`, `isPursuedGoal(goal)` and `lastGoal(entries)` (the last valid goal, skipping a rejected call and honouring an explicit clear), so `plan` can restate a pursued goal while planning, promote a `planning` one at approval, and offer to record one when a plan is approved with none, and `goal` itself reads the branch through the same replay — with no extension importing another's modules. |
47
49
  | [`todo-state.ts`](./todo-state.ts) | The todo item contract, its parser, and the published state replayed from the session branch: `TODO_STATUSES` / `TodoStatus`, `ExistingTodo`, `MAX_TODOS` / `MAX_CONTENT`, `normalizeTodos`, and `lastTodos(entries)` (the last write that fully validates, skipping a rejected call; a malformed write leaves the previous list). `plan` merges steps into that list, `goal` counts the items still open, and `todo` itself replays through it — so one rule decides which writes are state writes, with no extension importing another's modules. |
48
50
  | [`tool-names.ts`](./tool-names.ts) | Tool names and one parameter name that more than one extension must agree on, so an orchestrating `ctx.executeTool()` call or schema probe breaks the build on a rename. |
49
- | [`tool-result.ts`](./tool-result.ts) | The rejected-tool-call shape: `toolError` builds the `isError: true` content plus the caller's `details`, and `errorResult` is the action-and-message form `job` and `watch` share. |
50
- | [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, and the `tok-*` map), [`web/base.ts`](./web/base.ts), [`web/components.ts`](./web/components.ts) (the `bf-*` kit, including the dialog, shortcut-sheet, and `bf-kbd` rules), [`web/layout.ts`](./web/layout.ts), [`web/runtime.ts`](./web/runtime.ts) (`WEB_THEME_STORAGE_KEY`, `THEME_JS`, `THEME_TOGGLE_JS`, and `keysDialogScript(triggerId)` — the shared shortcut-sheet wiring, whose contract is `window.bfKeys`), [`web/markup.ts`](./web/markup.ts) (the HTML atoms, including `keysSheet` and its `KeyHint` rows), and the [`web/index.ts`](./web/index.ts) barrel. |
51
+ | [`tool-result.ts`](./tool-result.ts) | The tool-result envelope: `textResult` builds the one text block, the caller's `details`, and the optional `structuredContent` mirror; `failedResult` is the same shape marked `isError`, keeping the previous state for a retry; `toolError` builds the rejected-call content plus the caller's details, and `errorResult` is the action-and-message form `job` and `watch` share. |
52
+ | [`usage.ts`](./usage.ts) | The usage counters an accumulator is measured in: `USAGE_COUNTERS`, `UsageCounter`, `UsageCounts`, and `zeroUsage` — shared by bytefly's session and `↳` roll-up accounting and by `subagent`'s fold of a child's `message_end` events, so two extensions cannot drift on which numbers a run is made of. `turns` and the context size are not counters and stay with each owner. |
53
+ | [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces, whose conventions are its own doc, [`web/README.md`](./web/README.md). Modules: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, the `tok-*` map, and the derived `--on-accent` / `--scrim` / `--shadow` overlays), [`web/base.ts`](./web/base.ts) (reset, one focus ring, the print and forced-colours layers), [`web/components.ts`](./web/components.ts) (the `bf-*` kit, including the dialog, shortcut-sheet, and `bf-kbd` rules), [`web/layout.ts`](./web/layout.ts) (the app shell and its one breakpoint), [`web/runtime.ts`](./web/runtime.ts) (theme storage and bootstrap, `THEME_JS_PATH`, `THEME_TOGGLE_JS`, `dialogScript`, and `keysDialogScript`), [`web/markup.ts`](./web/markup.ts) (the HTML atoms, including `keysSheet` and its `KeyHint` rows), and the [`web/index.ts`](./web/index.ts) barrel. |
51
54
 
52
55
  ## Conventions
53
56
 
@@ -62,9 +65,10 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
62
65
  one extension probes on another — live in `tool-names.ts`.
63
66
  - **A helper that exists here must be imported, not redefined.**
64
67
  `test/shared-helpers.test.ts` fails a top-level extension declaration whose
65
- name is also exported by `lib/`, and a body copied from `lib/` or from another
66
- extension. A deliberate wrapper goes on that test's reasoned exception list; a
67
- copy belongs here with one co-located test.
68
+ name is also exported by `lib/`, and a body copied from `lib/` or from any
69
+ other file — a second extension, a sibling module, or `lib/` itself. A
70
+ deliberate wrapper goes on that test's reasoned exception list; a copy belongs
71
+ here with its declared capability suite.
68
72
  - **A color is a role, not a token.** The rules and the primitives are in
69
73
  [`tui/README.md`](./tui/README.md#color-is-a-role): draw through
70
74
  [`tui/tokens.ts`](./tui/tokens.ts) (`paint(theme, role, text)`) so a theme swap
package/lib/config.ts CHANGED
@@ -85,6 +85,18 @@ export function asRecord(value: unknown): Record<string, unknown> | undefined {
85
85
  return value as Record<string, unknown>;
86
86
  }
87
87
 
88
+ /**
89
+ * A finite number from an untrusted JSON field, or `undefined`.
90
+ *
91
+ * The numeric companion to {@link asRecord}: a field a wire format, a child
92
+ * process, or a config file claims is a number is only a number when it is one —
93
+ * a string, `NaN`, or `null` reads as "not reported" so a caller can fall back
94
+ * instead of summing garbage into a total.
95
+ */
96
+ export function asNumber(value: unknown): number | undefined {
97
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
98
+ }
99
+
88
100
  /** Parse a JSON object from disk, ignoring missing/malformed/non-object files. */
89
101
  export function readJson(path: string): Record<string, unknown> | undefined {
90
102
  const result = readJsonResult(path);
package/lib/escape.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
- * HTML text escaping for the browse pages.
2
+ * HTML text escaping, and the inverse for one character reference.
3
3
  *
4
4
  * The single escape used by every page renderer and by the Markdown renderer:
5
5
  * model- and file-authored text becomes safe text nodes and double-quoted
6
6
  * attributes. Keeping it here (rather than in `html.ts`) lets `markdown.ts`
7
7
  * escape without importing the page renderer, so the module graph stays
8
- * acyclic.
8
+ * acyclic. `decodeCodePoint` is the other half of the same boundary and lives
9
+ * beside it for the same reason: the Markdown inline scan and the raw-HTML
10
+ * attribute decoder both resolve `&#N;` and `&#xN;` references, and one copy
11
+ * cannot drift from the other.
9
12
  */
10
13
 
11
14
  /** Escape text for an HTML text node or double-quoted attribute. */
@@ -25,3 +28,13 @@ export function escapeHtml(value: string): string {
25
28
  }
26
29
  });
27
30
  }
31
+
32
+ /**
33
+ * One numeric character reference as text: U+FFFD when it is a surrogate, zero,
34
+ * or out of range, so an entity cannot inject an unpaired half of a pair.
35
+ */
36
+ export function decodeCodePoint(value: number): string {
37
+ if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
38
+ return "\uFFFD";
39
+ return String.fromCodePoint(value);
40
+ }
package/lib/format.ts CHANGED
@@ -7,12 +7,16 @@
7
7
 
8
8
  import { homedir } from "node:os";
9
9
 
10
- /** Compact token count: `999`, `1.0k`, `12k`, `1.5M`, `12M`. `?` when not a number. */
10
+ /**
11
+ * Compact token count: `999`, `1.0k`, `12k`, `1.5M`, `12M`. `?` when not a
12
+ * number. The `k` → `M` switch sits below a million so a count that would round
13
+ * to `1000k` prints as `1.0M` instead.
14
+ */
11
15
  export function formatTokens(count: number): string {
12
16
  if (!Number.isFinite(count)) return "?";
13
17
  if (count < 1000) return count.toString();
14
18
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
15
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
19
+ if (count < 999500) return `${Math.round(count / 1000)}k`;
16
20
  if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
17
21
  return `${Math.round(count / 1000000)}M`;
18
22
  }
@@ -59,6 +59,12 @@ export interface InteractionChannel {
59
59
  post(text: string, signal?: AbortSignal): Promise<void>;
60
60
  /** Send a local file to the human; resolves true when delivered. */
61
61
  postFile(path: string, name: string, signal?: AbortSignal): Promise<boolean>;
62
+ /**
63
+ * Widen the bound on the prompts asked inside the returned scope: use it for a
64
+ * dialog the human must read something before answering. Returns the restore,
65
+ * and a transport with no such policy omits the method.
66
+ */
67
+ setPromptTimeout?(ms: number): () => void;
62
68
  }
63
69
 
64
70
  /** The slice of an extension context {@link remoteChannel} reads. */
@@ -105,6 +111,12 @@ export interface AskHumanOptions<T> {
105
111
  * attachment may never be opened (a phone client can hide a file bubble).
106
112
  */
107
113
  note?: string;
114
+ /**
115
+ * How long a remote channel may wait for the answer before dismissing the
116
+ * dialog. Raise it when the human has to read an artifact first; the channel's
117
+ * own bound applies when it is omitted or unsupported.
118
+ */
119
+ timeoutMs?: number;
108
120
  }
109
121
 
110
122
  /**
@@ -138,5 +150,10 @@ export async function askHuman<T>(
138
150
  // The preface is best-effort; the dialogs still run.
139
151
  }
140
152
  }
141
- return options.dialogs();
153
+ const restore = options.timeoutMs === undefined ? undefined : channel?.setPromptTimeout?.(options.timeoutMs);
154
+ try {
155
+ return await options.dialogs();
156
+ } finally {
157
+ restore?.();
158
+ }
142
159
  }
package/lib/pdf.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The optional `unpdf` seam: lazy load, module-shape handling, and text
3
- * extraction, shared by `web-access` and `document`.
3
+ * extraction, shared by `web-access` and `file-read`.
4
4
  *
5
5
  * `unpdf` bundles a server-friendly build of PDF.js, so it is imported lazily:
6
6
  * an extension registers on every session, and a parser should load only when a
7
7
  * read actually needs it. `web-access` treats the package as optional and
8
- * reports {@link pdfGuidance} when it is absent; `document` ships it as a
8
+ * reports {@link pdfGuidance} when it is absent; `file-read` ships it as a
9
9
  * dependency. Both get the same loader, the same merged-pages call, and the same
10
10
  * result unwrapping, so a PDF reads the same way through either surface.
11
11
  *
package/lib/report.ts CHANGED
@@ -14,11 +14,14 @@
14
14
  *
15
15
  * Delivery is one shared transcript notice per burst, and a burst is marked
16
16
  * reported only after the send succeeded, so a throw leaves it for the next
17
- * turn. Value-only: every flag lives in the returned closure, never in a module
18
- * variable, because each extension gets its own module instance.
17
+ * turn. `registerPendingChannel` is the other half: the seven host hooks that
18
+ * drive the machine, registered once for `job` and `watch` rather than copied
19
+ * into each factory. Value-only: every flag lives in the returned closure, never
20
+ * in a module variable, because each extension gets its own module instance.
19
21
  */
20
22
 
21
23
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
24
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
25
  import { pruneStaleNotices, type NoticeMessage } from "./notice.ts";
23
26
 
24
27
  export interface PendingReporterOptions<T> {
@@ -97,3 +100,73 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
97
100
  },
98
101
  };
99
102
  }
103
+
104
+ /** The session-scoped state a channel drives, narrowed to what its hooks need. */
105
+ export interface PendingChannelRuntime {
106
+ /** Load this session's records; called on every session start. */
107
+ load(ctx: ExtensionContext): void;
108
+ /** Repaint the status chip. */
109
+ setStatus(ctx: ExtensionContext): void;
110
+ /** Release the session's resources; idempotent. */
111
+ shutdown(): Promise<void>;
112
+ }
113
+
114
+ export interface PendingChannelOptions {
115
+ /** The reporter that turns pending outcomes into notices. */
116
+ reporter: PendingReporter;
117
+ /** The runtime whose pending outcomes the notices describe. */
118
+ runtime: PendingChannelRuntime;
119
+ /** Runs on every session start, after the runtime loads: arm the report trigger. */
120
+ onSessionStart?(ctx: ExtensionContext): void;
121
+ /** Runs before the runtime releases: clear what `onSessionStart` armed. */
122
+ onSessionShutdown?(): void;
123
+ }
124
+
125
+ /**
126
+ * Wire a reporter to the host's session lifecycle.
127
+ *
128
+ * The order inside `session_start` is the contract: the reporter forgets the
129
+ * previous session's busy window, the runtime loads the records, and only then
130
+ * is the report trigger armed — a reload or a replaced session reuses the same
131
+ * runtime. A shutdown clears the trigger before the runtime releases the table it
132
+ * was calling into.
133
+ */
134
+ export function registerPendingChannel(
135
+ pi: Pick<ExtensionAPI, "on">,
136
+ { reporter, runtime, onSessionStart, onSessionShutdown }: PendingChannelOptions,
137
+ ): void {
138
+ pi.on("session_start", (_event, ctx) => {
139
+ reporter.sessionStart();
140
+ runtime.load(ctx);
141
+ onSessionStart?.(ctx);
142
+ });
143
+
144
+ // Tree navigation does not change the pending records; only repaint.
145
+ pi.on("session_tree", (_event, ctx) => {
146
+ runtime.setStatus(ctx);
147
+ });
148
+
149
+ pi.on("agent_start", () => {
150
+ reporter.agentStart();
151
+ });
152
+ pi.on("agent_settled", () => {
153
+ reporter.agentSettled();
154
+ });
155
+
156
+ // Report what is queued at the start of the next turn, without triggering one.
157
+ pi.on("before_agent_start", (_event, ctx) => {
158
+ const reported = reporter.beforeAgentStart();
159
+ if (!reported) return undefined;
160
+ runtime.setStatus(ctx);
161
+ return reported;
162
+ });
163
+
164
+ // Keep stale notices out of later turns. Only this source's notices are
165
+ // pruned; another source's are left for its own handler.
166
+ pi.on("context", (event) => reporter.filterContext(event.messages));
167
+
168
+ pi.on("session_shutdown", async () => {
169
+ onSessionShutdown?.();
170
+ await runtime.shutdown();
171
+ });
172
+ }
@@ -255,7 +255,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
255
255
  optional: [gitCheck("the branch chip and the diff view")],
256
256
  notes: ["The default-browser opener (`open` / `xdg-open` / `cmd`) is best-effort; the URL is printed either way."],
257
257
  },
258
- document: {},
258
+ "file-read": {},
259
259
  "file-open": {
260
260
  notes: [
261
261
  "The `/open` command and its notification need an interactive UI; the `open_path` tool works without one.",
@@ -266,6 +266,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
266
266
  memory: {
267
267
  optional: [gitCheck("project-scope memory under the repository root")],
268
268
  },
269
+ context: {},
269
270
  wechat: {
270
271
  optional: [
271
272
  {
@@ -301,7 +302,6 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
301
302
  ],
302
303
  },
303
304
  };
304
-
305
305
  // --- Probing ----------------------------------------------------------------
306
306
 
307
307
  /** Run a command and report its exit code; production backs this with `pi.exec`. */
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Mounting a dock screen: rails suppressed, an optional repaint tick, and one
3
+ * teardown.
4
+ *
5
+ * Pi mounts a `ctx.ui.custom` screen in the editor slot the above-editor rails
6
+ * share, so every screen wraps itself in `withRailsSuppressed`. Three of them —
7
+ * `/jobs`, `/watch`, and `/memory` — also poll while they are open (a log pane,
8
+ * a line pane, a store another process can edit), and each re-derived the same
9
+ * `let timer` / `stopTimer` / `setInterval(...).unref?.()` / `finally` skeleton
10
+ * around its own component. The rails lifetime and the timer lifetime are the
11
+ * same window, so they are one helper rather than two wrappers each caller
12
+ * stacks and unwinds by hand.
13
+ *
14
+ * The timer is a seam (`RepeatTimer`), so a test drives the tick instead of
15
+ * waiting for one, and it is armed after the mount returns: a component whose
16
+ * constructor throws leaves no timer behind.
17
+ *
18
+ * Value-only: the timer handle lives in the caller's closure.
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
22
+ import type { Component, TUI } from "@earendil-works/pi-tui";
23
+ import { withRailsSuppressed } from "./rails.ts";
24
+ import { defaultInterval, type RepeatTimer } from "./timer.ts";
25
+
26
+ /** What the host accepts back from a screen's mount. */
27
+ export type ScreenComponent = Component & { dispose?(): void };
28
+
29
+ /** What a mounted screen is given: the host's mount parameters, narrowed. */
30
+ export interface PolledScreen {
31
+ /** The host's component handle; a screen that needs an `Editor` builds it from this. */
32
+ readonly tui: TUI;
33
+ /** The theme the host handed the mount. */
34
+ readonly theme: Theme;
35
+ /** Ask the host to repaint. */
36
+ requestRender(): void;
37
+ /** Close the screen, as its own close key would. */
38
+ close(): void;
39
+ /** The terminal's height in rows, or `undefined` when the host cannot say. */
40
+ viewportRows(): number | undefined;
41
+ }
42
+
43
+ export interface PolledScreenOptions<Screen> {
44
+ /** How often `poll` runs while the screen is open, in ms. */
45
+ pollMs: number;
46
+ /** Run once per tick; `screen` is what `mount` returned. */
47
+ poll(screen: Screen, handles: PolledScreen): void;
48
+ /** Timer seam; defaults to an unref'd interval. */
49
+ timer?: RepeatTimer;
50
+ }
51
+
52
+ /**
53
+ * Show one polled dock screen and resolve when it closes.
54
+ *
55
+ * The caller still owns the `ctx.mode !== "tui"` branch, because the text
56
+ * fallback differs per command; everything from the mount to the teardown is
57
+ * here.
58
+ */
59
+ export async function withPolledScreen<Screen extends ScreenComponent>(
60
+ pi: Pick<ExtensionAPI, "events">,
61
+ ctx: Pick<ExtensionContext, "ui">,
62
+ mount: (handles: PolledScreen) => Screen,
63
+ options: PolledScreenOptions<Screen>,
64
+ ): Promise<void> {
65
+ const { pollMs, poll, timer = defaultInterval } = options;
66
+ let stop: (() => void) | undefined;
67
+ try {
68
+ await withRailsSuppressed(pi, () =>
69
+ ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
70
+ const handles: PolledScreen = {
71
+ tui,
72
+ theme,
73
+ requestRender: () => tui.requestRender(),
74
+ close: () => done(),
75
+ viewportRows: () => tui.terminal?.rows,
76
+ };
77
+ const screen = mount(handles);
78
+ stop = timer(pollMs, () => poll(screen, handles));
79
+ return screen;
80
+ }),
81
+ );
82
+ } finally {
83
+ stop?.();
84
+ }
85
+ }