@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  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/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -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,14 +1,18 @@
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";
7
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { realPathOfNearest } from "../../lib/path.ts";
9
13
  import { mapConcurrent } from "../../lib/pool.ts";
10
14
  import { loadConfig } from "./config.ts";
11
- import { gapLines, isolationGaps } from "./gaps.ts";
15
+ import { gapLines, type IsolationGap, isolationGaps } from "./gaps.ts";
12
16
  import { type CheckoutCheck, defaultBranch, listManagedWorktrees, type ManagedWorktree, repoRoot } from "./git.ts";
13
17
  import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
14
18
  import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
@@ -62,7 +66,7 @@ export function refusalMessage(check: CheckoutCheck, path: string, cwd: string):
62
66
  }
63
67
 
64
68
  /** Compact marks for one managed worktree in the status list. */
65
- function statusMarks(status: CheckoutStatus, isCurrent: boolean): string[] {
69
+ export function statusMarks(status: CheckoutStatus, isCurrent: boolean): string[] {
66
70
  const marks: string[] = [];
67
71
  if (isCurrent) marks.push("current");
68
72
  if (status.state === "missing") marks.push("missing");
@@ -75,60 +79,100 @@ function statusMarks(status: CheckoutStatus, isCurrent: boolean): string[] {
75
79
  return marks;
76
80
  }
77
81
 
78
- function relativeDay(at: number): string {
82
+ export function relativeDay(at: number): string {
79
83
  const days = Math.floor((Date.now() - at) / 86_400_000);
80
84
  if (days <= 0) return "today";
81
85
  if (days === 1) return "1d ago";
82
86
  return `${days}d ago`;
83
87
  }
84
88
 
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
- }
89
+ /** One managed worktree and what git says about it. */
90
+ export interface ManagedRow {
91
+ entry: ManagedWorktree;
92
+ status: CheckoutStatus;
93
+ }
94
+
95
+ /** Everything both status surfaces report, read once. */
96
+ export interface WorktreeSnapshot {
97
+ active: WorktreeState | null;
98
+ /** What isolation does not cover, from the same rules `doctor` reports. */
99
+ gaps: IsolationGap[];
100
+ /** Built-in tools with an inactive override. */
101
+ inactiveOverrides: string[];
102
+ /** Managed worktrees with their checkout status, in git's order. */
103
+ managed: ManagedRow[];
104
+ /** The merge-target branch name, when the repository has a remote. */
105
+ baseBranch: string | undefined;
106
+ }
111
107
 
112
- const root = current?.repoRoot ?? (await repoRoot(pi, ctx.cwd));
108
+ /** Read the session's worktree status once, for either surface to render. */
109
+ export async function worktreeSnapshot(pi: ExtensionAPI, ctx: ExtensionContext): Promise<WorktreeSnapshot> {
110
+ const active = getActive();
111
+ // A gap the user cannot see is a gap they cannot weigh, so they are computed for
112
+ // every read rather than cached from session start.
113
+ const gaps = active
114
+ ? isolationGaps({
115
+ platform: process.platform,
116
+ activeTools: pi.getActiveTools(),
117
+ config: configFor(active.repoRoot),
118
+ unverifiableCommands: getUnverifiableCommands(),
119
+ })
120
+ : [];
121
+
122
+ const managed: ManagedRow[] = [];
123
+ let baseBranch: string | undefined;
124
+ const root = active?.repoRoot ?? (await repoRoot(pi, ctx.cwd));
113
125
  if (root) {
114
126
  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);
127
+ const entries = await listManagedWorktrees(pi, root, config);
128
+ const { registry } = reconcileRegistry(root, config, entries, { persist: false });
129
+ baseBranch = await defaultBranch(pi, root);
118
130
  const baseRef = baseBranch ? `origin/${baseBranch}` : undefined;
119
131
  // Keyed by the canonical path the registry stores, so a row's record is one
120
132
  // lookup instead of a scan of every record.
121
133
  const records = new Map(registry.worktrees.map((record) => [record.path, record]));
122
- const row = async (entry: ManagedWorktree): Promise<string> => {
123
- 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();
134
+ const row = async (entry: ManagedWorktree): Promise<ManagedRow> => {
135
+ return { entry, status: await statusOf(pi, entry, { baseRef, record: records.get(entry.path) }) };
127
136
  };
128
137
  // Each row is up to four git reads; four at a time keeps the list in the order
129
138
  // git reports while overlapping the waits. `statusOf` passes no `env`, which is
130
139
  // what makes concurrent git calls safe here (see `RunGitOptions.env`).
131
- lines.push("", `Managed worktrees (${managed.length}):`, ...(await mapConcurrent(managed, 4, row)));
140
+ managed.push(...(await mapConcurrent(entries, 4, row)));
141
+ }
142
+
143
+ return { active, gaps, inactiveOverrides: getInactiveOverrides(), managed, baseBranch };
144
+ }
145
+
146
+ /** Human-readable status: current worktree, override conflicts, gaps, managed worktrees. */
147
+ export function formatWorktreeStatus(snapshot: WorktreeSnapshot): string {
148
+ const { active, gaps, inactiveOverrides, managed } = snapshot;
149
+ const lines: string[] = [];
150
+ if (active) {
151
+ lines.push(stateSummary(active));
152
+ if (active.borrowed) lines.push(" (inherited from the parent session)");
153
+ } else {
154
+ lines.push("Not in a worktree.");
155
+ }
156
+ if (inactiveOverrides.length > 0) {
157
+ lines.push(
158
+ "",
159
+ `Isolation not active for: ${inactiveOverrides.join(", ")} (their default implementation is in use).`,
160
+ );
161
+ }
162
+ const gapBlock = gapLines(gaps);
163
+ if (gapBlock.length > 0) lines.push("", "Not isolated:", ...gapBlock);
164
+
165
+ if (managed.length > 0) {
166
+ const row = (item: ManagedRow): string => {
167
+ const marks = statusMarks(item.status, active?.path === item.entry.path);
168
+ return ` ${item.entry.path} ${item.entry.branch ?? ""}${marks.length > 0 ? ` [${marks.join(", ")}]` : ""}`.trimEnd();
169
+ };
170
+ lines.push("", `Managed worktrees (${managed.length}):`, ...managed.map(row));
132
171
  }
133
172
  return lines.join("\n");
134
173
  }
174
+
175
+ /** Read the status and render it: what the `list_worktrees` tool and a headless `/worktree` print. */
176
+ export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
177
+ return formatWorktreeStatus(await worktreeSnapshot(pi, ctx));
178
+ }
package/lib/README.md CHANGED
@@ -18,29 +18,33 @@ 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
+ | [`claims/`](./claims/) | Claim overlap, shared by the two things that ask "do these paths collide?" so approval and claiming cannot disagree: [`claims/overlap.ts`](./claims/overlap.ts) holds `normalizeClaimPath` (repository-relative, posix, no trailing slash — a Windows spelling reads as the same path), `claimsConflict` (a claim covers everything below it, in either direction) and `findClaimOverlap` (the first colliding pair). Used by `session-bus` when a session claims, and by plan approval when a split names its units. |
22
+ | [`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
23
  | [`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
24
  | [`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
25
  | [`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
- | [`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
+ | [`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`, `watch`, and `session-bus`, rendered by bytefly — plus `burstText`, the one assembly of the model-facing text for a burst of pending outcomes (marker, counted headline, rows, an optional `… N more`, an optional hint). |
27
+ | [`report.ts`](./report.ts) | The pending-report channel: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `wakeRequested` / `source` / `onWoke`, returning the handlers that report at the next turn, raise at most one triggered turn per wake window, retry a wake the running turn suppressed once `agent_settled` reports an unaborted settle, 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. A `load` that resolves a repository may return a promise, which is awaited before the trigger is armed; a runtime that publishes no chip omits `setStatus`. `job`, `watch`, and `session-bus` supply only their own arming in `onSessionStart` / `onSessionShutdown` and their own `wakeRequested`. |
27
28
  | [`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
29
  | [`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
30
  | [`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
31
  | [`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
32
  | [`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. |
33
+ | [`qr.ts`](./qr.ts) | The QR seam shared by `wechat`'s login and `plan`'s shared review link: `qrMatrix` (the module matrix), `qrLines` (Unicode half-block terminal lines of a fixed width), and `qrSvg` (inline markup a page can paste); the `qrcode` package is imported on first use, never at registration. |
34
+ | [`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
35
  | [`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
36
  | [`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
37
  | [`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). |
38
+ | [`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
39
  | [`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
40
  | [`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
41
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
42
+ | [`plans/`](./plans/) | The plan document's shared shape, for plan mode and for whatever reads a plan without importing it: [`plans/sections.ts`](./plans/sections.ts) is the one fence-aware scan (`extractSections`, `replaceSection`, `teamSectionBody`, and the `HEADING` / `FENCE` / `normalizeHeadingText` vocabulary), [`plans/team.ts`](./plans/team.ts) parses and renders the `## Team` section (`PlanUnit`, `PlanTeam`, `renderTeam`, `parseTeam`, `readTeam`, `writeTeam`), [`plans/split.ts`](./plans/split.ts) decides whether a proposed split is one (`checkSplit`, its refusals by name, `SHARED_FILES`, `MAX_OWNERS`, `mergeOrder`), and [`plans/store.ts`](./plans/store.ts) holds the one directory rule (`plansDir`) the store writes to and the bus reads from. The switch lives apart in [`plans/setting.ts`](./plans/setting.ts): `SPLIT_SECTION` / `SPLIT_KEY` / `SPLIT_DEFAULT`, the `SPLIT_SETTING_ID` / `SPLIT_SETTING_LABEL` the settings row shows, `splitEnabled(cwd)` and `SPLIT_OFF_NOTE`, so the panel row, approval, and the bus read one key. |
40
43
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
41
44
  | [`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. |
42
45
  | [`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. |
43
46
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
47
+ | [`repo-key.ts`](./repo-key.ts) | The per-repository storage key that outlives one checkout: `repoKeyOf(value)` hashes an identity to `REPO_KEY_LENGTH` hex characters, and `repoKeyFor(pi, cwd)` returns `{ root, key, fromGit }` from the repository's *common* git directory, so every worktree of one repository files state under the same key, while a directory outside git keys on itself. Shared by `session-bus` (sessions, mail, claims) and `plan` (plans). |
44
48
  | [`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. |
45
49
  | [`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. |
46
50
  | [`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`. |
@@ -48,8 +52,9 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
48
52
  | [`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
53
  | [`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
54
  | [`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. |
55
+ | [`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. |
56
+ | [`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. |
57
+ | [`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
58
 
54
59
  ## Conventions
55
60
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Claim overlap (pure).
3
+ *
4
+ * Two things in this package need the same answer to "do these paths collide?":
5
+ * `session-bus` when a session claims a path, and plan approval when a proposed
6
+ * split names its units. They must agree, because a split that passes validation
7
+ * and then conflicts at claim time — or the reverse — teaches the user that a
8
+ * claim is arbitrary. So the predicate lives here once and both call it.
9
+ *
10
+ * A claim on a directory covers every file below it, in either direction, and a
11
+ * claim is always a repository-relative, posix-separated path: the caller
12
+ * normalizes first, because a Windows spelling (`a\b`) and a posix one (`a/b`)
13
+ * naming the same file must not read as two claims.
14
+ */
15
+
16
+ /** A repository-relative path with one separator and no trailing slash. */
17
+ export function normalizeClaimPath(raw: string): string {
18
+ const posix = raw.trim().replace(/\\/g, "/");
19
+ const trimmed = posix
20
+ .replace(/^\.\//, "")
21
+ .replace(/\/{2,}/g, "/")
22
+ .replace(/\/+$/, "");
23
+ return trimmed === "." ? "" : trimmed;
24
+ }
25
+
26
+ /** Whether one claim covers the other path in either direction. */
27
+ export function claimsConflict(a: string, b: string): boolean {
28
+ const left = normalizeClaimPath(a);
29
+ const right = normalizeClaimPath(b);
30
+ // The root claim (an empty path, or ".") covers everything below it.
31
+ if (left === "" || right === "") return true;
32
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
33
+ }
34
+
35
+ /** The first two prefixes in `paths` that collide, if any. */
36
+ export function findClaimOverlap(paths: readonly string[]): [string, string] | undefined {
37
+ for (let i = 0; i < paths.length; i++) {
38
+ for (let j = i + 1; j < paths.length; j++) {
39
+ if (claimsConflict(paths[i], paths[j])) return [paths[i], paths[j]];
40
+ }
41
+ }
42
+ return undefined;
43
+ }
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
  }