@gavin-hu/my-pi-agent 0.16.0 → 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 (135) hide show
  1. package/README.md +14 -8
  2. package/README.zh-CN.md +14 -9
  3. package/extensions/bytefly/README.md +174 -101
  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/panel/commands.ts +46 -25
  17. package/extensions/bytefly/panel/format.ts +49 -14
  18. package/extensions/bytefly/panel/join.ts +35 -9
  19. package/extensions/bytefly/panel/settings.ts +10 -27
  20. package/extensions/bytefly/panel/skills.ts +51 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  22. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  23. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  24. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  25. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  26. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  27. package/extensions/bytefly/panel/tui.ts +223 -331
  28. package/extensions/bytefly/panel/types.ts +26 -8
  29. package/extensions/bytefly/picker/discovery.ts +32 -27
  30. package/extensions/bytefly/picker/resources.ts +16 -8
  31. package/extensions/bytefly/picker/runtime.ts +62 -34
  32. package/extensions/bytefly/picker/toggle.ts +16 -10
  33. package/extensions/bytefly/picker/types.ts +31 -13
  34. package/extensions/bytefly/update/format.ts +66 -8
  35. package/extensions/bytefly/update/patch.ts +66 -0
  36. package/extensions/bytefly/update/register.ts +4 -11
  37. package/extensions/bytefly/update/release.ts +65 -0
  38. package/extensions/bytefly/update/state.ts +14 -0
  39. package/extensions/bytefly/update/suppress.ts +47 -29
  40. package/extensions/bytefly/update/types.ts +15 -5
  41. package/extensions/check/tui.ts +2 -0
  42. package/extensions/context/README.md +21 -11
  43. package/extensions/context/format.ts +10 -8
  44. package/extensions/context/report.ts +21 -8
  45. package/extensions/context/tui.ts +26 -10
  46. package/extensions/context/types.ts +5 -3
  47. package/extensions/file-browse/README.md +29 -1
  48. package/extensions/file-browse/client.ts +16 -463
  49. package/extensions/file-browse/html.ts +7 -5
  50. package/extensions/file-browse/palette.ts +224 -0
  51. package/extensions/file-browse/router.ts +2 -2
  52. package/extensions/file-browse/styles.ts +8 -3
  53. package/extensions/file-browse/tree.ts +381 -0
  54. package/extensions/file-read/README.md +4 -1
  55. package/extensions/file-read/extract/odt.ts +7 -5
  56. package/extensions/goal/tools.ts +6 -11
  57. package/extensions/job/index.ts +20 -45
  58. package/extensions/job/tools.ts +51 -77
  59. package/extensions/memory/tools.ts +4 -6
  60. package/extensions/memory/tui.ts +5 -1
  61. package/extensions/plan/README.md +88 -33
  62. package/extensions/plan/commands.ts +19 -4
  63. package/extensions/plan/config.ts +27 -0
  64. package/extensions/plan/plan-index.ts +3 -1
  65. package/extensions/plan/plans.ts +31 -13
  66. package/extensions/plan/review.ts +34 -14
  67. package/extensions/plan/runtime.ts +3 -1
  68. package/extensions/plan/steps.ts +43 -14
  69. package/extensions/plan/tools/cancel.ts +79 -0
  70. package/extensions/plan/tools/enter.ts +10 -10
  71. package/extensions/plan/tools/exit.ts +54 -43
  72. package/extensions/plan/tools/steps.ts +7 -19
  73. package/extensions/plan/tools/write.ts +22 -29
  74. package/extensions/plan/tools.ts +5 -2
  75. package/extensions/plan/tui.ts +9 -7
  76. package/extensions/plan/types.ts +8 -0
  77. package/extensions/plan/web-chrome.ts +101 -0
  78. package/extensions/plan/web-client.ts +98 -311
  79. package/extensions/plan/web-markdown.ts +222 -0
  80. package/extensions/plan/web-page.ts +28 -2
  81. package/extensions/plan/web-router.ts +2 -2
  82. package/extensions/rewind/checklist.ts +4 -0
  83. package/extensions/rewind/review-tui.ts +3 -1
  84. package/extensions/rewind/rewind-tui.ts +4 -0
  85. package/extensions/subagent/README.md +25 -12
  86. package/extensions/subagent/format.ts +72 -25
  87. package/extensions/subagent/index.ts +21 -47
  88. package/extensions/subagent/orchestrate.ts +18 -41
  89. package/extensions/subagent/render.ts +29 -42
  90. package/extensions/subagent/stream.ts +24 -18
  91. package/extensions/subagent/types.ts +7 -0
  92. package/extensions/todo/tools.ts +5 -6
  93. package/extensions/todo/tui.ts +5 -1
  94. package/extensions/watch/index.ts +10 -35
  95. package/extensions/watch/tools.ts +31 -59
  96. package/extensions/wechat/README.md +40 -18
  97. package/extensions/wechat/bridge.ts +38 -40
  98. package/extensions/wechat/client.ts +1 -4
  99. package/extensions/wechat/commands.ts +1 -1
  100. package/extensions/wechat/interaction.ts +25 -4
  101. package/extensions/wechat/login-screen.ts +2 -3
  102. package/extensions/wechat/notices.ts +7 -5
  103. package/extensions/wechat/prompt.ts +19 -4
  104. package/extensions/wechat/qrcode.ts +9 -9
  105. package/extensions/wechat/slash.ts +14 -0
  106. package/extensions/wechat/status-screen.ts +2 -2
  107. package/extensions/wechat/tools.ts +3 -11
  108. package/extensions/worktree/README.md +48 -12
  109. package/extensions/worktree/commands.ts +109 -65
  110. package/extensions/worktree/enter.ts +1 -1
  111. package/extensions/worktree/exit.ts +11 -1
  112. package/extensions/worktree/lifecycle.ts +9 -1
  113. package/extensions/worktree/screen.ts +263 -0
  114. package/extensions/worktree/status.ts +84 -38
  115. package/lib/README.md +7 -6
  116. package/lib/config.ts +12 -0
  117. package/lib/format.ts +6 -2
  118. package/lib/interaction.ts +18 -1
  119. package/lib/report.ts +75 -2
  120. package/lib/tool-result.ts +54 -4
  121. package/lib/tui/README.md +16 -8
  122. package/lib/tui/chrome.ts +27 -6
  123. package/lib/tui/dock-screen.ts +6 -2
  124. package/lib/tui/gauge.ts +9 -5
  125. package/lib/usage.ts +26 -0
  126. package/lib/web/README.md +131 -0
  127. package/lib/web/base.ts +19 -2
  128. package/lib/web/components.ts +10 -6
  129. package/lib/web/layout.ts +2 -0
  130. package/lib/web/markup.ts +2 -1
  131. package/lib/web/runtime.ts +103 -25
  132. package/lib/web/tokens.ts +23 -7
  133. package/package.json +3 -1
  134. package/skills/changelog/SKILL.md +57 -45
  135. package/skills/next/SKILL.md +184 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The `/worktree` list screen: one row per managed worktree, the active one
3
+ * marked, with a detail pane and three verbs.
4
+ *
5
+ * The frame, navigation, y/N confirmation, and pane come from the shared
6
+ * `DockListScreen`, so this module owns only the worktree vocabulary. Rows come
7
+ * from the same `worktreeSnapshot` the text status renders, so the screen and the
8
+ * `list_worktrees` tool cannot disagree about a worktree.
9
+ *
10
+ * Every verb but `p` closes the screen and hands the action back to the command:
11
+ * entering and exiting change the session binding and `exitWorktree` asks through
12
+ * `ctx.ui.select`, which cannot nest inside a mount. `p` never prompts, so it
13
+ * runs in place and refreshes the rows.
14
+ */
15
+
16
+ import { basename } from "node:path";
17
+ import type { Theme } from "@earendil-works/pi-coding-agent";
18
+ import { messageOf } from "../../lib/errors.ts";
19
+ import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
20
+ import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
21
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
22
+ import { statusRow } from "../../lib/tui/rows.ts";
23
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
24
+ import { gapLines } from "./gaps.ts";
25
+ import { handoffLines } from "./handoff.ts";
26
+ import type { CheckoutStatus } from "./registry.ts";
27
+ import { relativeDay, statusMarks, type WorktreeSnapshot, worktreeLabel } from "./status.ts";
28
+
29
+ /** What a verb asks the command to do once the screen is gone. */
30
+ export type WorktreeAction = { kind: "enter"; name: string } | { kind: "exit" };
31
+
32
+ /** One row: a managed worktree, or the active one when this session does not manage it. */
33
+ export interface WorktreeRow {
34
+ /** Canonical path, and the row's stable id. */
35
+ path: string;
36
+ /** Basename of the path: the name `enter` takes back. */
37
+ name: string;
38
+ branch: string | undefined;
39
+ /** Whether this row is the session's active worktree. */
40
+ current: boolean;
41
+ /** What git says about it; absent for a worktree entered by path. */
42
+ status: CheckoutStatus | undefined;
43
+ }
44
+
45
+ /** The rows for a snapshot, in git's order, with the active worktree always present. */
46
+ export function worktreeRows(snapshot: WorktreeSnapshot): WorktreeRow[] {
47
+ const rows: WorktreeRow[] = snapshot.managed.map((item) => ({
48
+ path: item.entry.path,
49
+ name: basename(item.entry.path),
50
+ branch: item.entry.branch,
51
+ current: snapshot.active?.path === item.entry.path,
52
+ status: item.status,
53
+ }));
54
+ const active = snapshot.active;
55
+ // A worktree entered by path is not in the managed list, but a reader looking
56
+ // for "where am I" must still see it.
57
+ if (active && !rows.some((row) => row.path === active.path)) {
58
+ rows.unshift({
59
+ path: active.path,
60
+ name: worktreeLabel(active),
61
+ branch: active.branch,
62
+ current: true,
63
+ status: undefined,
64
+ });
65
+ }
66
+ return rows;
67
+ }
68
+
69
+ /** The glyph and its role: a missing checkout is the error, dirty work the warning. */
70
+ function rowGlyph(status: CheckoutStatus | undefined): { char: string; role: TuiRole } {
71
+ if (!status) return { char: GLYPHS.worktree, role: "info" };
72
+ if (status.state === "missing") return { char: GLYPHS.cross, role: "bad" };
73
+ if (status.state === "dirty") return { char: GLYPHS.warn, role: "warn" };
74
+ if (status.merged) return { char: GLYPHS.check, role: "ok" };
75
+ return { char: GLYPHS.branch, role: "info" };
76
+ }
77
+
78
+ /** The row's right-hand text: its marks, or `clean` when it has none. */
79
+ function rowMarks(row: WorktreeRow): string {
80
+ const marks = row.status ? statusMarks(row.status, row.current) : ["current"];
81
+ if (marks.length === 0) marks.push("clean");
82
+ return marks.join(" ");
83
+ }
84
+
85
+ export interface WorktreeScreenOptions {
86
+ /** The rows to open with; the command has already read them. */
87
+ snapshot: WorktreeSnapshot;
88
+ theme: Theme;
89
+ requestRender(): void;
90
+ /** Close the screen; `undefined` when the user only closed it. */
91
+ onDone(action: WorktreeAction | undefined): void;
92
+ /** Read the rows again after a verb changed them. */
93
+ reload(): Promise<WorktreeSnapshot>;
94
+ /** Run a prune in place and return its report. */
95
+ prune(): Promise<string>;
96
+ /** Plan mode: the screen still shows the list, but no verb may change it. */
97
+ readOnly: boolean;
98
+ viewportRowsSource?: ViewportRowsSource;
99
+ }
100
+
101
+ /** The `/worktree` screen. */
102
+ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
103
+ private view: WorktreeSnapshot;
104
+ private pruning = false;
105
+
106
+ constructor(private readonly deps: WorktreeScreenOptions) {
107
+ super({
108
+ theme: deps.theme,
109
+ title: "Worktrees",
110
+ detailTitle: "Worktree",
111
+ emptyList: "No managed worktrees. /worktree enter <name> creates one.",
112
+ emptyDetail: "Nothing recorded for this worktree.",
113
+ requestRender: deps.requestRender,
114
+ onClose: () => deps.onDone(undefined),
115
+ viewportRowsSource: deps.viewportRowsSource,
116
+ });
117
+ this.view = deps.snapshot;
118
+ }
119
+
120
+ protected rows(): readonly WorktreeRow[] {
121
+ return worktreeRows(this.view);
122
+ }
123
+
124
+ protected idOf(row: WorktreeRow): string {
125
+ return row.path;
126
+ }
127
+
128
+ protected rowLine(row: WorktreeRow, width: number, selected: boolean): string {
129
+ const glyph = rowGlyph(row.status);
130
+ return statusRow(this.theme, width, {
131
+ selected,
132
+ glyph: glyph.char,
133
+ glyphRole: glyph.role,
134
+ label: row.name,
135
+ labelRole: selected ? "accent" : "label",
136
+ trailing: rowMarks(row),
137
+ trailingRole: row.current ? "accent" : "faint",
138
+ });
139
+ }
140
+
141
+ protected detailBlock(row: WorktreeRow): string[] {
142
+ const lines = [`${row.path} ${row.branch ?? "(detached)"}`];
143
+ const active = this.view.active;
144
+ // The gaps are the same rules `/worktree` status and `doctor` report: a gap
145
+ // the user cannot see is a gap they cannot weigh.
146
+ if (row.current && active) {
147
+ lines.push(`base: ${active.baseRef} (${active.baseRefMode}) main: ${active.repoRoot}`);
148
+ if (this.view.gaps.length > 0) lines.push("Not isolated:", ...gapLines(this.view.gaps));
149
+ }
150
+ return lines;
151
+ }
152
+
153
+ protected summaryCells(rows: readonly WorktreeRow[]): string[] {
154
+ const cells = [paint(this.theme, "meta", `${rows.length} worktree${rows.length === 1 ? "" : "s"}`)];
155
+ const dirty = rows.filter((row) => row.status?.state === "dirty").length;
156
+ if (dirty > 0) cells.push(paint(this.theme, "warn", `${dirty} dirty`));
157
+ const ahead = rows.filter((row) => (row.status?.ahead ?? 0) > 0).length;
158
+ if (ahead > 0) cells.push(paint(this.theme, "accent", `${ahead} ahead`));
159
+ const inactive = this.view.inactiveOverrides;
160
+ if (inactive.length > 0) cells.push(paint(this.theme, "warn", `not isolated: ${inactive.join(", ")}`));
161
+ return cells;
162
+ }
163
+
164
+ protected listHints(): ScreenHint[] {
165
+ return [
166
+ "Esc close",
167
+ "↑/↓ or j/k select",
168
+ { text: "Enter or l detail", color: "accent" },
169
+ { text: "e enter", color: "accent" },
170
+ { text: "x exit", color: "warning" },
171
+ { text: "p prune", color: "warning" },
172
+ ];
173
+ }
174
+
175
+ protected detailHints(): ScreenHint[] {
176
+ return ["Esc back", "↑/↓ scroll", "g/G ends"];
177
+ }
178
+
179
+ protected detailFor(row: WorktreeRow): DetailContents {
180
+ const lines = [`path ${row.path}`, `branch ${row.branch ?? "(detached)"}`];
181
+ const status = row.status;
182
+ if (!status) {
183
+ lines.push("state entered by path; this session does not manage it");
184
+ } else {
185
+ if (status.head) lines.push(`head ${status.head.slice(0, 7)}`);
186
+ const bits = [status.state === "dirty" ? `dirty (${status.changed} changed)` : "clean"];
187
+ if (status.ahead > 0) bits.push(`${status.ahead} ahead`);
188
+ if (status.behind > 0) bits.push(`${status.behind} behind`);
189
+ if (status.merged) bits.push("merged");
190
+ lines.push(`state ${bits.join(", ")}`);
191
+ if (status.locked) {
192
+ lines.push(`lock ${status.staleLock ? "stale (owner is gone)" : "held by a live session"}`);
193
+ }
194
+ if (status.lastUsedAt) lines.push(`used ${relativeDay(status.lastUsedAt)}`);
195
+ // The handoff is read, not performed: a merge lands in the main checkout.
196
+ if (status.ahead > 0 && row.branch) {
197
+ lines.push(
198
+ "",
199
+ ...handoffLines({
200
+ branch: row.branch,
201
+ base: this.view.baseBranch ?? null,
202
+ ahead: status.ahead,
203
+ merged: status.merged,
204
+ }),
205
+ );
206
+ }
207
+ }
208
+ return { title: row.name, lines, subtitle: row.branch ?? row.path };
209
+ }
210
+
211
+ protected handleVerb(key: string, row: WorktreeRow | undefined): void {
212
+ if (key !== "e" && key !== "x" && key !== "p") return;
213
+ if (this.deps.readOnly) {
214
+ this.notify("Plan mode is read-only. Approve the plan before changing worktrees.");
215
+ return;
216
+ }
217
+ if (key === "e") {
218
+ if (!row) {
219
+ this.notify("No worktree to enter.");
220
+ return;
221
+ }
222
+ if (row.current) {
223
+ this.notify(`${row.name} is already the current worktree.`);
224
+ return;
225
+ }
226
+ if (this.view.active) {
227
+ this.notify(`Still in ${worktreeLabel(this.view.active)}; press x to exit first.`);
228
+ return;
229
+ }
230
+ this.deps.onDone({ kind: "enter", name: row.name });
231
+ return;
232
+ }
233
+ if (key === "x") {
234
+ if (!this.view.active) {
235
+ this.notify("Not in a worktree.");
236
+ return;
237
+ }
238
+ this.deps.onDone({ kind: "exit" });
239
+ return;
240
+ }
241
+ if (this.pruning) {
242
+ this.notify("A prune is already running.");
243
+ return;
244
+ }
245
+ this.pruning = true;
246
+ this.notify("Pruning…");
247
+ void this.pruneInPlace();
248
+ }
249
+
250
+ /** Prune is the one verb that never prompts, so it runs without closing the screen. */
251
+ private async pruneInPlace(): Promise<void> {
252
+ try {
253
+ const report = await this.deps.prune();
254
+ this.view = await this.deps.reload();
255
+ this.resort();
256
+ this.notify(report.split("\n")[0] ?? "Pruned.");
257
+ } catch (error) {
258
+ this.notify(`Prune failed: ${messageOf(error)}`);
259
+ } finally {
260
+ this.pruning = false;
261
+ }
262
+ }
263
+ }
@@ -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,22 +18,22 @@ 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
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
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. |
30
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`. |
31
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`. |
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`), 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. |
33
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. |
34
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. |
35
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. |
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) (`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` 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
+ | [`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). |
37
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. |
38
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. |
39
39
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
@@ -48,8 +48,9 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
48
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. |
49
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. |
50
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. |
51
- | [`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. |
52
- | [`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. |
53
54
 
54
55
  ## Conventions
55
56
 
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/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
  }