@gavin-hu/my-pi-agent 0.19.0 → 0.20.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.
- package/README.md +17 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- 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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
@@ -33,7 +33,7 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
|
|
|
33
33
|
| [`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
34
|
| [`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
35
|
| [`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. |
|
|
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, and, for a server that asks, from the bind so a page no tab ever opened cannot hold its port), [`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
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). |
|
|
38
38
|
| [`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
39
|
| [`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. |
|
|
@@ -44,19 +44,20 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
|
|
|
44
44
|
| [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
|
|
45
45
|
| [`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
46
|
| [`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`
|
|
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 `
|
|
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`), 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`, `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, `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, where, 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`, `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). |
|
|
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`, `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
49
|
| [`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
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
51
|
| [`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
52
|
| [`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
|
|
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, 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
54
|
| [`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
55
|
| [`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. |
|
|
56
|
+
| [`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
57
|
| [`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
58
|
| [`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
59
|
| [`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. |
|
|
60
|
+
| [`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
61
|
|
|
61
62
|
## Conventions
|
|
62
63
|
|