@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Reading a shell command line: its words, its segments, and its redirection targets.
3
+ *
4
+ * The isolation guards in `guard.ts` ask questions about a command through this module and
5
+ * never re-lex it themselves: one reader, so "where does this command go" has one answer.
6
+ * A quoted word keeps whether it began inside quotes, and anything that cannot be expanded
7
+ * statically is reported as unresolved rather than guessed at.
8
+ */
9
+
10
+ import { homedir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+ import { isInside } from "../../lib/path.ts";
13
+ import { isShellEscapable } from "../../lib/shell.ts";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Shell commands (checks 2-4)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** One shell word, with whether it began inside a quote (a quoted literal). */
20
+ export interface ShellWord {
21
+ text: string;
22
+ leadingQuoted: boolean;
23
+ }
24
+
25
+ /** Split a command on unquoted `;`, `&`, `|`, and newlines, preserving quotes. */
26
+ export function splitSegments(command: string): string[] {
27
+ const segments: string[] = [];
28
+ let current = "";
29
+ let quote: string | null = null;
30
+ for (let i = 0; i < command.length; i++) {
31
+ const ch = command[i];
32
+ if (quote) {
33
+ current += ch;
34
+ if (ch === "\\" && quote === '"' && i + 1 < command.length) {
35
+ const next = command[i + 1];
36
+ if (isShellEscapable(next)) current += command[++i];
37
+ } else if (ch === quote) {
38
+ quote = null;
39
+ }
40
+ continue;
41
+ }
42
+ if (ch === "'" || ch === '"') {
43
+ quote = ch;
44
+ current += ch;
45
+ } else if (ch === ";" || ch === "\n") {
46
+ segments.push(current);
47
+ current = "";
48
+ } else if (ch === "&") {
49
+ const prev = command[i - 1];
50
+ const next = command[i + 1];
51
+ if (next === "&") {
52
+ // `&&` is a command separator.
53
+ segments.push(current);
54
+ current = "";
55
+ i++;
56
+ } else if (prev === ">" || prev === "<" || next === ">") {
57
+ // `>&`, `<&`, `&>`: redirection, not a separator.
58
+ current += ch;
59
+ } else {
60
+ // Background job `&`.
61
+ segments.push(current);
62
+ current = "";
63
+ }
64
+ } else if (ch === "|") {
65
+ if (command[i + 1] === "&") i++; // `|&` is still a pipe separator.
66
+ segments.push(current);
67
+ current = "";
68
+ } else {
69
+ current += ch;
70
+ }
71
+ }
72
+ segments.push(current);
73
+ return segments;
74
+ }
75
+
76
+ /** Tokenize a shell segment into words, removing quotes and tracking quoted leads. */
77
+ export function tokenize(segment: string): ShellWord[] {
78
+ const words: ShellWord[] = [];
79
+ let current = "";
80
+ let leadingQuoted = false;
81
+ let started = false;
82
+ let quote: string | null = null;
83
+ const push = () => {
84
+ if (started) words.push({ text: current, leadingQuoted });
85
+ current = "";
86
+ leadingQuoted = false;
87
+ started = false;
88
+ };
89
+ for (let i = 0; i < segment.length; i++) {
90
+ const ch = segment[i];
91
+ if (quote) {
92
+ if (ch === "\\" && quote === '"' && i + 1 < segment.length) {
93
+ const next = segment[i + 1];
94
+ if (isShellEscapable(next)) current += segment[++i];
95
+ else current += ch;
96
+ } else if (ch === quote) {
97
+ quote = null;
98
+ } else {
99
+ current += ch;
100
+ }
101
+ continue;
102
+ }
103
+ if (ch === "'" || ch === '"') {
104
+ if (!started) {
105
+ started = true;
106
+ leadingQuoted = true;
107
+ }
108
+ quote = ch;
109
+ } else if (ch === " " || ch === "\t") {
110
+ push();
111
+ } else if (ch === "\\" && i + 1 < segment.length) {
112
+ started = true;
113
+ const next = segment[i + 1];
114
+ if (isShellEscapable(next)) current += segment[++i];
115
+ else current += ch;
116
+ } else {
117
+ started = true;
118
+ current += ch;
119
+ }
120
+ }
121
+ push();
122
+ return words;
123
+ }
124
+
125
+ /**
126
+ * Expand `~` and environment variables so a path that only leaves the worktree
127
+ * after shell expansion is still caught. `unresolved` marks a value that cannot
128
+ * be expanded statically (unknown variable, `$`, backtick, `~user`); callers
129
+ * treat it as escaping, because guessing could allow a bypass.
130
+ */
131
+ export function expandShellPath(raw: string): { path?: string; unresolved: boolean } {
132
+ let value = raw;
133
+ if (value === "~") value = homedir();
134
+ else if (value.startsWith("~/")) value = join(homedir(), value.slice(2));
135
+ else if (value.startsWith("~")) return { unresolved: true };
136
+
137
+ let unresolved = false;
138
+ value = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, plain) => {
139
+ const name = braced ?? plain;
140
+ const env = process.env[name];
141
+ if (env === undefined) {
142
+ unresolved = true;
143
+ return match;
144
+ }
145
+ return env;
146
+ });
147
+ if (value.includes("$") || value.includes("`")) unresolved = true;
148
+ return { path: value, unresolved };
149
+ }
150
+
151
+ /** Whether a shell path argument leaves the worktree once expanded. */
152
+ export function pathEscapes(root: string, raw: string): boolean {
153
+ const expanded = expandShellPath(raw);
154
+ if (expanded.unresolved || !expanded.path) return true;
155
+ return !isInside(root, resolve(root, expanded.path));
156
+ }
157
+
158
+ /**
159
+ * Constructs whose text does not spell out the git command that runs. Exported
160
+ * so the `tool_call` handler counts exactly the commands this check would have
161
+ * refused, instead of guessing at a second list.
162
+ */
163
+ export function isUnverifiable(command: string): boolean {
164
+ return /\$\(|`|\$\{!|\beval\b|\b(?:sh|bash|zsh|dash|ksh)\s+-c\b/.test(command);
165
+ }
166
+
167
+ /**
168
+ * Redirection targets: the paths a command writes to or reads from.
169
+ *
170
+ * The output and input operators name a path; descriptor duplication, a heredoc delimiter,
171
+ * and a here-string do not, so those are skipped rather than reported as an escape.
172
+ */
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Shell redirections (check 1b)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export interface Redirect {
179
+ target: string;
180
+ /** True for output/read-write redirections; false for input-only. */
181
+ write: boolean;
182
+ }
183
+
184
+ /** Read a shell word starting at `start`, skipping leading blanks, unquoting the result. */
185
+ export function readShellWord(text: string, start: number): { raw: string; end: number } {
186
+ let i = start;
187
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
188
+ let raw = "";
189
+ let quote: string | null = null;
190
+ while (i < text.length) {
191
+ const ch = text[i];
192
+ if (quote) {
193
+ if (ch === "\\" && quote === '"' && i + 1 < text.length) {
194
+ const next = text[i + 1];
195
+ if (isShellEscapable(next)) {
196
+ raw += next;
197
+ i += 2;
198
+ } else {
199
+ raw += ch;
200
+ i += 1;
201
+ }
202
+ continue;
203
+ }
204
+ if (ch === quote) {
205
+ quote = null;
206
+ i++;
207
+ continue;
208
+ }
209
+ raw += ch;
210
+ i++;
211
+ continue;
212
+ }
213
+ if (ch === "'" || ch === '"') {
214
+ quote = ch;
215
+ i++;
216
+ continue;
217
+ }
218
+ if (ch === "\\" && i + 1 < text.length) {
219
+ const next = text[i + 1];
220
+ if (isShellEscapable(next)) {
221
+ raw += next;
222
+ i += 2;
223
+ } else {
224
+ raw += ch;
225
+ i += 1;
226
+ }
227
+ continue;
228
+ }
229
+ if (ch === " " || ch === "\t") break;
230
+ raw += ch;
231
+ i++;
232
+ }
233
+ return { raw, end: i };
234
+ }
235
+
236
+ /**
237
+ * File redirection targets in one shell segment. File-descriptor duplications
238
+ * (`2>&1`), heredocs (`<<EOF`), and here-strings (`<<<x`) are skipped because
239
+ * their operand is not a path.
240
+ */
241
+ export function redirectionTargets(segment: string): Redirect[] {
242
+ const redirects: Redirect[] = [];
243
+ let quote: string | null = null;
244
+ for (let i = 0; i < segment.length; i++) {
245
+ const ch = segment[i];
246
+ if (quote) {
247
+ if (ch === "\\" && quote === '"' && i + 1 < segment.length) {
248
+ i++;
249
+ continue;
250
+ }
251
+ if (ch === quote) quote = null;
252
+ continue;
253
+ }
254
+ if (ch === "'" || ch === '"') {
255
+ quote = ch;
256
+ continue;
257
+ }
258
+ if (ch === "\\") {
259
+ i++;
260
+ continue;
261
+ }
262
+ if (ch !== ">" && ch !== "<") continue;
263
+
264
+ const two = segment.slice(i, i + 2);
265
+ const three = segment.slice(i, i + 3);
266
+ if (three === "<<<") {
267
+ i += 2;
268
+ continue;
269
+ }
270
+ if (two === "<<") {
271
+ // Heredoc delimiter: not a path.
272
+ i += 1;
273
+ continue;
274
+ }
275
+ if (two === ">&" || two === "<&") {
276
+ // File-descriptor duplication (`2>&1`, `>&-`) is not a path, but bash
277
+ // treats `>&file` as `&>file` (stdout+stderr to a file).
278
+ const operand = readShellWord(segment, i + 2);
279
+ if (operand.raw === "" || /^\d+$/.test(operand.raw) || operand.raw === "-") {
280
+ i += 1;
281
+ continue;
282
+ }
283
+ redirects.push({ target: operand.raw, write: ch === ">" });
284
+ i = operand.end - 1;
285
+ continue;
286
+ }
287
+ // `>`, `>>`, `>|`, `<`, `<>`: the next word is a path.
288
+ const write = ch === ">" || two === "<>";
289
+ if (two === ">>" || two === ">|") i += 1;
290
+ const word = readShellWord(segment, i + 1);
291
+ if (word.raw) redirects.push({ target: word.raw, write });
292
+ i = word.end - 1;
293
+ }
294
+ return redirects;
295
+ }
296
+
297
+ /** The wrappers and assignment shapes a policy reads through. */
298
+ /** Commands that wrap another command, so a following assignment is still a prefix. */
299
+ export const COMMAND_WRAPPERS = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
300
+
301
+ export function isAssignment(word: string): boolean {
302
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
303
+ }
@@ -12,7 +12,7 @@ const STATE_ENTRY = "worktree";
12
12
 
13
13
  export interface WorktreeState {
14
14
  active: boolean;
15
- /** Name of a managed worktree ("feature-auth"). Absent for foreign paths. */
15
+ /** Name of a managed worktree ("feature-auth"). Absent on an inherited checkout. */
16
16
  name?: string;
17
17
  /** Absolute path of the worktree. */
18
18
  path: string;
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * One snapshot feeds both surfaces: `formatWorktreeStatus` renders it as the text
6
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.
7
+ * rows as the `/worktree` list screen. They read the same git calls and word a row
8
+ * through the same `statusMarks`, so the two cannot disagree about a worktree.
9
9
  */
10
10
 
11
11
  import { basename } from "node:path";
@@ -13,7 +13,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
13
13
  import { mapConcurrent } from "../../lib/pool.ts";
14
14
  import { loadConfig } from "./config.ts";
15
15
  import { gapLines, type IsolationGap, isolationGaps } from "./gaps.ts";
16
- import { type CheckoutCheck, defaultBranch, listManagedWorktrees, type ManagedWorktree, repoRoot } from "./git.ts";
16
+ import { type CheckoutRefusal, defaultBranch, listManagedWorktrees, type ManagedWorktree, repoRoot } from "./git.ts";
17
17
  import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
18
18
  import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
19
19
  import type { WorktreeState } from "./state.ts";
@@ -22,6 +22,22 @@ export function worktreeLabel(state: WorktreeState): string {
22
22
  return state.name ?? basename(state.path);
23
23
  }
24
24
 
25
+ /**
26
+ * What entering a worktree is reported as, on every surface that reports it.
27
+ *
28
+ * The tool, the `/worktree` command, and the startup flag say the same sentence through
29
+ * this one function; the command adds the path on a line of its own, because a command's
30
+ * answer is read without a tool result beside it.
31
+ */
32
+ export function enteredWorktree(state: WorktreeState): string {
33
+ return `Entered worktree ${worktreeLabel(state)}`;
34
+ }
35
+
36
+ /** What leaving one is reported as, for the same reason. */
37
+ export function exitedWorktree(state: WorktreeState): string {
38
+ return `Exited worktree ${worktreeLabel(state)}`;
39
+ }
40
+
25
41
  /**
26
42
  * The worktree/path/branch/main fact block shared by the status summary and
27
43
  * the injected prompt section. `pathLabel` and `column` preserve each
@@ -47,9 +63,14 @@ export function stateSummary(state: WorktreeState): string {
47
63
  return `Worktree: ${worktreeLabel(state)}\n${worktreeFacts(state, "path", 8, [base])}`;
48
64
  }
49
65
 
50
- /** Actionable message for a worktree the session refuses to restore. */
51
- export function refusalMessage(check: CheckoutCheck, path: string, cwd: string): string {
52
- if (check.ok) return "";
66
+ /**
67
+ * Actionable message for a worktree the session refuses to restore.
68
+ *
69
+ * The parameter is the refusal half of `CheckoutCheck`, so "there is a message" is the
70
+ * type's claim rather than a guard's: its only caller is the branch that already knows the
71
+ * check failed.
72
+ */
73
+ export function refusalMessage(check: CheckoutRefusal, path: string, cwd: string): string {
53
74
  if (check.reason === "gone") {
54
75
  return `Worktree ${path} no longer exists; its binding was cleared and the session continues in ${cwd}.`;
55
76
  }
@@ -65,17 +86,29 @@ export function refusalMessage(check: CheckoutCheck, path: string, cwd: string):
65
86
  );
66
87
  }
67
88
 
68
- /** Compact marks for one managed worktree in the status list. */
89
+ /**
90
+ * Compact marks for one managed worktree, as both surfaces print them.
91
+ *
92
+ * A worktree with nothing to report says `clean` rather than nothing: a blank column reads
93
+ * as "not read yet", and the screen had to invent the word for itself before this owned it.
94
+ */
69
95
  export function statusMarks(status: CheckoutStatus, isCurrent: boolean): string[] {
70
96
  const marks: string[] = [];
71
- if (isCurrent) marks.push("current");
72
97
  if (status.state === "missing") marks.push("missing");
73
98
  if (status.locked) marks.push(status.staleLock ? "locked (stale)" : "locked");
74
99
  if (status.state === "dirty") marks.push(`dirty (${status.changed})`);
75
100
  if (status.ahead > 0) marks.push(`ahead ${status.ahead}`);
76
101
  if (status.behind > 0) marks.push(`behind ${status.behind}`);
77
- if (status.merged) marks.push("merged");
102
+ // A merged mark claims the work landed; with no commits of its own the worktree is an
103
+ // empty checkout, and saying so is the difference between "nothing here yet" and "done".
104
+ // How many commits landed is the number that mark is about, so it goes beside it.
105
+ if (status.commits === 0) marks.push("no commits");
106
+ else if (status.merged) marks.push(...(status.commits === undefined ? [] : [`commits ${status.commits}`]), "merged");
78
107
  if (status.lastUsedAt) marks.push(`used ${relativeDay(status.lastUsedAt)}`);
108
+ // `clean` answers "what is wrong with this checkout", so it does not stand in for the
109
+ // session's own mark: a worktree this session is in and has not touched is both.
110
+ if (marks.length === 0) marks.push("clean");
111
+ if (isCurrent) marks.unshift("current");
79
112
  return marks;
80
113
  }
81
114
 
@@ -4,12 +4,14 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
+ import { messageOf } from "../../lib/errors.ts";
7
8
  import {
8
9
  enterWorktree,
9
10
  exitWorktree,
10
11
  isWorktreeCancelled,
11
12
  pruneWorktrees,
12
- worktreeLabel,
13
+ enteredWorktree,
14
+ exitedWorktree,
13
15
  worktreeStatus,
14
16
  } from "./lifecycle.ts";
15
17
  import { getActive, getInactiveOverrides } from "./runtime.ts";
@@ -26,6 +28,18 @@ export const WORKTREE_TOOLS = {
26
28
  list: "list_worktrees",
27
29
  } as const;
28
30
 
31
+ /**
32
+ * The result a lifecycle tool answers when the user declined.
33
+ *
34
+ * Entering and exiting ask the same question and are refused the same way: the message is
35
+ * the refusal the user just chose, and `cancelled` is what tells a caller it was a choice
36
+ * rather than a failure. A cancelled call still throws — every other error is a real one.
37
+ */
38
+ function cancelledResult(error: unknown): { content: [{ type: "text"; text: string }]; details: { cancelled: true } } {
39
+ if (!isWorktreeCancelled(error)) throw error;
40
+ return { content: [{ type: "text", text: messageOf(error) }], details: { cancelled: true } };
41
+ }
42
+
29
43
  export function registerTools(pi: ExtensionAPI): void {
30
44
  pi.registerTool({
31
45
  name: WORKTREE_TOOLS.enter,
@@ -55,17 +69,13 @@ export function registerTools(pi: ExtensionAPI): void {
55
69
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
56
70
  try {
57
71
  const { state, summary } = await enterWorktree(pi, ctx, { name: params.name, path: params.path });
58
- ctx.ui.notify(`Entered worktree ${worktreeLabel(state)}`, "info");
72
+ ctx.ui.notify(enteredWorktree(state), "info");
59
73
  return {
60
74
  content: [{ type: "text", text: summary }],
61
75
  details: { state },
62
76
  };
63
77
  } catch (error) {
64
- if (isWorktreeCancelled(error)) {
65
- const message = error instanceof Error ? error.message : String(error);
66
- return { content: [{ type: "text", text: message }], details: { cancelled: true } };
67
- }
68
- throw error;
78
+ return cancelledResult(error);
69
79
  }
70
80
  },
71
81
  });
@@ -91,17 +101,13 @@ export function registerTools(pi: ExtensionAPI): void {
91
101
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
92
102
  try {
93
103
  const result = await exitWorktree(pi, ctx, { remove: params.remove, keepBranch: params.keepBranch });
94
- ctx.ui.notify(`Exited worktree ${worktreeLabel(result.state)}`, "info");
104
+ ctx.ui.notify(exitedWorktree(result.state), "info");
95
105
  return {
96
106
  content: [{ type: "text", text: result.output }],
97
107
  details: { state: result.state, removed: result.removed, handoff: result.handoff },
98
108
  };
99
109
  } catch (error) {
100
- if (isWorktreeCancelled(error)) {
101
- const message = error instanceof Error ? error.message : String(error);
102
- return { content: [{ type: "text", text: message }], details: { cancelled: true } };
103
- }
104
- throw error;
110
+ return cancelledResult(error);
105
111
  }
106
112
  },
107
113
  });
package/lib/README.md CHANGED
@@ -18,6 +18,7 @@ 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
+ | [`bus-request.ts`](./bus-request.ts) | The request/response handshake over `pi.events` for a question one extension asks another: `BusAnswer` (a value, or the promise of one an owner is still producing), `BusRequest` (the channel, the payload built around the helper's one-shot `respond`, the `unclaimed` sentence for a session where nothing listens, and an optional `failed`), and `requestOverBus`, which settles exactly once — on the claimant's answer, on the microtask fallback when nobody claims, or on `failed` when a claim's promise rejects. Shared by [`plan-entry.ts`](./plan-entry.ts) (enter plan mode), [`http/mount.ts`](./http/mount.ts) (a page's prefix), and [`session-tool.ts`](./session-tool.ts) (join the team), so one guarantee has one body. |
21
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. |
@@ -33,8 +34,8 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
33
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. |
34
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. |
35
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. |
36
- | [`http/`](./http/) | The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: [`http/serve.ts`](./http/serve.ts) (the one server entry, `serveLoopback` — bind with a port retry, the capped request body, `HEAD` of a stream, the SSE head, idle grace, and the idempotent close — so an extension supplies a handler and no extension builds a `node:http` server), [`http/sink.ts`](./http/sink.ts) (`EventSink`, the one thing a route writes, which owns liveness — a write after the end is a no-op — plus the open-stream registry and the idle grace armed when the last one goes), [`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/guard.ts`](./http/guard.ts) (the request guards a served page shares: `postOnly`, `checkOrigin`, `requireJsonBody`, and `envelopeRefusal` — the method allowlist, then the fail-closed `Host` rule, then the origin check on every state-changing method), [`http/token.ts`](./http/token.ts) (`mintToken`, 128 random bits as hex, and `tokenMatches`, the constant-time comparison a route trusts), [`http/server.ts`](./http/server.ts) (the socket primitives `serveLoopback` is built from — `bindHost` with the accept-error listener, `respond` for HEAD / streamed file / body, `openEventStream`, and the idempotent `createCloser` — layer-internal, so the barrel does not export them), and the [`http/index.ts`](./http/index.ts) barrel. |
37
- | [`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
+ | [`http/`](./http/) | The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: [`http/serve.ts`](./http/serve.ts) (the one server entry, `serveHttp` — one socket per session, dispatch to the longest matching mount prefix, the body cap of whichever mount was reached, `HEAD` of a stream, the SSE head, one idle grace per mount, and an idempotent close that the last mount's release reaches on its own — so an extension supplies routes and no extension binds a socket), [`http/mount.ts`](./http/mount.ts) (the contract a page and that server agree on: `HTTP_MOUNT_CHANNEL`, the `MountSpec` it asks with — its session, prefix, handler, cap and grace — the `HttpMount` it gets back (`base`, `origin`, `release`, and `share` for one LAN address), `MountOutcome`, and `requestMount`, whose handshake is *claimed* synchronously so a bind that is still starting is not mistaken for a session with no server), [`http/sink.ts`](./http/sink.ts) (`EventSink`, the one thing a route writes, which owns liveness — a write after the end is a no-op — plus the open-stream registry and the idle grace — armed when the last one goes, and, for a mount that asks, from the mount so a page no tab ever opened cannot hold its prefix), [`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 page chooses), [`http/guard.ts`](./http/guard.ts) (the request guards a served page shares: `postOnly`, `checkOrigin`, `requireJsonBody`, and `envelopeRefusal` — the method allowlist, then the fail-closed `Host` rule, then the origin check on every state-changing method), [`http/token.ts`](./http/token.ts) (`mintToken`, 128 random bits as hex, and `tokenMatches`, the constant-time comparison a route trusts), [`http/port.ts`](./http/port.ts) (`PORT_BASE`, `PORT_RANGE`, and `randomPort` — the per-session band `serve` draws the session's one port from, so the range is one number and every doc states it), [`http/server.ts`](./http/server.ts) (the socket primitives `serveHttp` is built from — `bindHost` with the accept-error listener, `respond` for HEAD / streamed file / body, `openEventStream`, and the idempotent `createCloser` — layer-internal, so the barrel does not export them), and the [`http/index.ts`](./http/index.ts) barrel. |
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 and `isTailResult` (the guard that puts back the row type `instanceof` erases, so an extension reuses its block without a cast), `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo — `lines()` for a component that derives its lines from another component's output, `keyed()` for one with no inner component that describes its own inputs — 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
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. |
39
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. |
40
41
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
@@ -44,19 +45,20 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
44
45
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
45
46
  | [`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). |
46
47
  | [`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. |
47
- | [`board/`](./board/) | The shared feature board. [`board/card.ts`](./board/card.ts) is the record: `CARD_VERSION`, `Card` / `CardStatus` / `CardOwner` / `CardNote`, the field caps `MAX_TITLE` / `MAX_PATHS` / `MAX_PLAN` / `MAX_NOTE` / `MAX_NOTES`, the `normalize*` boundary the model's arguments pass (`normalizeTitle`, `normalizePathHints` — repo-relative hints, never claims —, `normalizeNote`, `normalizePlanPath`, `normalizeCardId`, `normalizePriority`), the priority (`PRIORITIES`, `priorityOf`, `priorityRank`) and the links (`dependsOf`), `matchesQuery` — the rule `list`'s `query` applies, which the page's filter box answers in its own script — `cardRef`, `signatureOf`, and `isCard`, the one gate a file read passes. [`board/rules.ts`](./board/rules.ts) is the lifecycle: `Actor`, `CardOp` (including `edit`, whose fields are each optional because an omitted one must be left alone), `createCard`, the pure `applyOp` over the card *and* the board index (every transition, its owner rule, the dependency gates a claim, a move, and a drop pass, and the sentence a refusal carries, with a no-op reported as `changed: false`), and `holds`. [`board/links.ts`](./board/links.ts) is the dependency graph that index answers: `BoardIndex` / `indexOf`, `blockersOf` (what is unfinished), `dependentsOf` / `dependedOn` (what a drop would strand), `wouldCycle`, and `normalizeDepends` — one edge stored once, as an id in the waiting card's file. [`board/session.ts`](./board/session.ts) is the session a call works as (`BoardSession`, `openBoardSession` — one store per working directory, and the id/label pair a card records, shared by `board` and `team`). [`board/store.ts`](./board/store.ts) is the backend: `BoardStore` over `<agentDir>/board/<repo-key>/cards/*.json`, with `createStore(dir)` and the one construction point `openBoard(pi, cwd)` — `create` allocates the id by hard-linking a temp file onto the name (so two adds cannot share one), `mutate` re-reads the board and re-checks an operation whose card changed underneath instead of overwriting it, `list` orders a lane by priority before age, and nothing prunes. [`board/round.ts`](./board/round.ts) is the lead's rollup (`buildRound`, `IDLE_MS` — owners, unassigned, unplanned, blocked, idle, done, read from the cards alone so it works with the bus disabled), and [`board/round-text.ts`](./board/round-text.ts) is that rollup as the sentences a reader gets (`formatRound`, `plural`), so the tool's result and `team`'s `/team` cannot describe one board two ways. [`board/view.ts`](./board/view.ts) is the page's snapshot (`buildView` → lanes, counts, rollup, bytes, signature, where, and the session reading it), the one shape a client is allowed to know. [`board/page-pointer.ts`](./board/page-pointer.ts) is how a second session finds a page already serving (`PAGE_FILE`, `PagePointer`, `readPointer`, the exclusive `createPointer`, `clearPointer` for its own note only, `probePage` — the file is believed only while it answers, and it carries a view token, never the write one — and `settlePointer`, the ladder a starting page runs: write, attach to a winner that answers, take over a note proved dead, and name the file rather than blame a session when it cannot be read). |
48
- | [`session-roster.ts`](./session-roster.ts) | Who is on this repository's bus, read as data: `readRoster(dir)` returns `RosterSession` (`id`, `label`, `mode`, `lastSeen`, `mail`, and `watching` when the record says) or `undefined` when the bus has never been used here, `rosterStateOf` applies the liveness rule, and `BUS_DIR_NAME` / `SESSIONS_DIR_NAME` / `INBOX_DIR_NAME` / `SESSION_TTL_MS` are the one copy of the layout, imported by `session-bus` and read by `board`'s page so the Team tab can list teammates without the board depending on the bus extension. |
48
+ | [`board/`](./board/) | The shared feature board. [`board/card.ts`](./board/card.ts) is the record: `CARD_VERSION`, `Card` / `CardStatus` / `CardOwner` / `CardNote`, the field caps `MAX_TITLE` / `MAX_PATHS` / `MAX_PLAN` / `MAX_NOTE` / `MAX_NOTES`, the `normalize*` boundary the model's arguments pass (`normalizeTitle`, `normalizePathHints` — repo-relative hints, never claims —, `normalizeNote`, `normalizePlanPath`, `normalizeCardId`, `normalizePriority`), the priority (`PRIORITIES`, `priorityOf`, `priorityRank`), the lane clock (`CardTransition`, `MAX_LOG`, and `laneSince` — when the card entered the lane it is in, or nothing when its history does not say) and the links (`dependsOf`), `matchesQuery` — the rule `list`'s `query` and the page's filter box both answer, spliced into the bundle as `MATCHES_QUERY_JS` so it cannot become two rules — `cardRef`, `signatureOf`, and `isCard`, the one gate a file read passes. [`board/rules.ts`](./board/rules.ts) is the lifecycle: `Actor`, `CardOp` (including `edit`, whose fields are each optional because an omitted one must be left alone), `createCard`, the pure `applyOp` over the card *and* the board index (every transition, its owner rule, the dependency gates a claim, a move, and a drop pass, `archive` — the one op that moves no field, because the pile a finished card's file lies in is the whole of it — and the sentence a refusal carries, with a no-op reported as `changed: false`), and `holds`. [`board/links.ts`](./board/links.ts) is the dependency graph that index answers: `BoardIndex` / `indexOf`, `blockersOf` (what is unfinished) and `blockersText` (the same list as `#2 (doing)`, one wording for a refusal and for the page), `dependentsOf` / `dependedOn` (what a drop would strand), `wouldCycle`, and `normalizeDepends` — one edge stored once, as an id in the waiting card's file. [`board/session.ts`](./board/session.ts) is the session a call works as (`BoardSession`, `TeamStanding` — what the last join did, so a claim joins once and `/board status` can say — and `openBoardSession`: one store per working directory, the seat it serves under, the id and team name a card records, and one session per host so the seat `board` sets is the name `team` writes). [`board/store.ts`](./board/store.ts) is the backend: `BoardStore` over `<agentDir>/board/<repo-key>/cards/*.json`, with `createStore(dir, { root })` and the one construction point `openBoard(pi, cwd)` — `root()` and `name()` say which repository a hashed board belongs to, which is the one part of its identity a reader can say out loud — `create` allocates the id by hard-linking a temp file onto the name (so two adds cannot share one), `mutate` re-reads the board and re-checks an operation whose card changed underneath instead of overwriting it, `list` orders a lane by priority before age, `scan` is one pass over the pile answering both the cards and the files it refused — so a page's frame reads the board once, and `list` and `skipped` are its two halves —, `archive` renames a finished card's file into `archive/` beside the cards without rewriting it — so `read` finds a card in either pile and writes it back to that one, and `list` still reads the board alone —, `archived()` / `archivedCount()` are the pile on request and its count, `nextCardId` spends an archived number as well as an ended one, and neither pile is pruned by a janitor: `archive` and `drop` are the two ways a card leaves the board. [`board/round.ts`](./board/round.ts) is the lead's rollup (`buildRound`, `IDLE_MS` — owners, unassigned, unplanned, blocked, idle, done, read from the cards alone so it works with the bus disabled), and [`board/round-text.ts`](./board/round-text.ts) is that rollup as the sentences a reader gets (`formatRound`, `plural`), so the tool's result and `team`'s `/team` cannot describe one board two ways. [`board/view.ts`](./board/view.ts) is the page's snapshot (`buildView` → lanes, counts, rollup, bytes, signature, the repository's name and root, the store's path, and the session reading it), the one shape a client is allowed to know: each card arrives as a `ViewCard` carrying the priority it acts on, the moment it entered its lane (`since`), and, when something unfinished blocks it, `waits` — so the browser renders an answer instead of re-deciding one. [`board/page-pointer.ts`](./board/page-pointer.ts) is how a second session finds a page already serving (`PAGE_FILE`, the two routes a probe asks for (`PING_PATH`, `SNAPSHOT_PATH` — the router serves the same constants, so a route cannot move out from under the probe), `PagePointer`, `readPointer`, the exclusive `createPointer`, `clearPointer` for its own note only, `probePage` — the file is believed only while it answers, and it carries the page's one token, which reads and nothing else — and `settlePointer`, the ladder a starting page runs: write, attach to a winner that answers, take over a note proved dead, and name the file rather than blame a session when it cannot be read). |
49
+ | [`session-roster.ts`](./session-roster.ts) | Who is on this repository's bus, read as data: `readRoster(dir)` returns `RosterSession` (`id`, `label`, `mode`, `lastSeen`, `mail`, `pid` and `startToken` when the record names them, and `watching` when it says) or `undefined` when the bus has never been used here, `rosterStateOf` answers `live` / `quiet` / `gone` — a dead pid outranks a fresh heartbeat, and the start token settles a reused pid — and `BUS_DIR_NAME` / `SESSIONS_DIR_NAME` / `INBOX_DIR_NAME` / `SESSION_TTL_MS` are the one copy of the layout, imported by `session-bus` and read by `board`'s page so the Team tab can list teammates without the board depending on the bus extension. |
49
50
  | [`session-tool.ts`](./session-tool.ts) | The `session` tool as the other extensions call it: `join(ctx, {label?, wake?, listen?})`, `peers(ctx)`, and `send(ctx, {kind, to, text})`, each a typed result that never throws (`{ ok, error }` or `undefined` for a read the bus cannot answer), plus `MAX_MESSAGE_TEXT`, the send cap `session-bus` re-exports as `MAX_TEXT`. One copy, because the alternative is two extensions knowing the tool's details shape. |
50
- | [`session-label.ts`](./session-label.ts) | The label rules a session is addressed by and a card records: `MAX_LABEL`, `normalizeLabel` (one line, capped, with a fallback), `shortId`, and `defaultLabel` (the Pi session name, else the working directory's basename, else the id). Shared by `session-bus` (which settles the unique one) and `board` (which records it on a claimed card), so a card's owner names a target the bus answers to. |
51
+ | [`session-label.ts`](./session-label.ts) | The label rules a session is addressed by and a card records: `MAX_LABEL`, `normalizeLabel` (one line, capped, with a fallback), `shortId`, `defaultLabel` (the Pi session name, else the working directory's basename, else the id — the bus's own fallback for a session that asked for no name), and the team's vocabulary, `TEAM_AGENT_PREFIX` / `agentLabel` (the id's tail, so a name cannot drift on a rename or collide). Shared by `session-bus` (which settles the unique one) and `board` (which writes and joins under it), so a card's owner names a target the bus answers to. |
51
52
  | [`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. |
52
53
  | [`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`. |
53
- | [`spawn.ts`](./spawn.ts) | The process seam for extensions that run a shell command: `SpawnedProcess`, `SpawnFn`, `KillTreeFn`, the pure `spawnOptions` / `killPlan`, `applyKillPlan` over injected seams, `isPidAlive`, and the `defaultSpawn` / `defaultKillTree` that execute them (process group on POSIX, `taskkill /T` on Windows). Shared by `job`, `watch`, `check`, `worktree`, and `wechat`. |
54
+ | [`spawn.ts`](./spawn.ts) | The process seam for extensions that run a shell command: `SpawnedProcess`, `SpawnFn`, `KillTreeFn`, the pure `spawnOptions` / `killPlan`, `applyKillPlan` over injected seams, the liveness pair `isPidAlive` / `startTokenOf` (the second settles a pid reused by another process), and the `defaultSpawn` / `defaultKillTree` that execute them (process group on POSIX, `taskkill /T` on Windows). Shared by `job`, `watch`, `check`, `worktree`, `wechat`, and the session roster. |
54
55
  | [`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. |
55
56
  | [`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. |
57
+ | [`stray-fields.ts`](./stray-fields.ts) | The refusal for a tool call that carries a field its action does not act on: `ActionFields` is the per-action table the tool itself keeps, and `strayField(action, args, fields)` is the one sentence — `add does not act on dependsOn: \`edit\` takes it.` — that names every stray field and the action that does take it. Shared by `board` and `team`, whose flat parameter objects both used to drop such a field in silence. |
56
58
  | [`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. |
57
59
  | [`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. |
58
60
  | [`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. |
59
- | [`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. |
61
+ | [`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/client-runtime.ts`](./web/client-runtime.ts) (the browser runtime every page bundle splices in: `el`, `typing`, `paintStatus`, `postJson`, `ClientNode` — the node shape those answer — and `CLIENT_RUNTIME_JS`), [`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, the mark's one geometry, which `brandLockup` draws and `markIcon` links as the tab icon, and `tabTitle`, the one shape the served pages' tab titles take), [`web/names.ts`](./web/names.ts) (a page's class names as one table: `ClassNames`, the `KEBAB` / `IDENTIFIER` shape a name must have to survive being spliced into a bundle, and `namesScript`), and the [`web/index.ts`](./web/index.ts) barrel. |
60
62
 
61
63
  ## Conventions
62
64