@a-t-h-i/bot-lobby 0.6.53 → 0.6.55
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 +1 -1
- package/package.json +1 -1
- package/prompts/expert.md +17 -0
- package/prompts/goal-check.md +44 -0
- package/prompts/master.md +70 -32
- package/prompts/worker.md +25 -11
- package/src/desk/board-client.ts +65 -0
- package/src/desk/board.ts +200 -0
- package/src/desk/client-extension.ts +31 -1
- package/src/desk/ipc.ts +16 -4
- package/src/desk/session.ts +129 -1
- package/src/execution/agent-runner.ts +27 -132
- package/src/execution/delegation.ts +112 -38
- package/src/execution/live-run.ts +3 -14
- package/src/execution/pi-runner.ts +30 -163
- package/src/execution/worker-trees.ts +396 -0
- package/src/index.ts +6 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +2 -0
- package/src/lobby/planner.ts +2 -2
- package/src/lobby/pr-review.ts +3 -3
- package/src/lobby/quickfix.ts +4 -3
- package/src/lobby/recovery.ts +2 -10
- package/src/lobby/runtime.ts +1 -1
- package/src/lobby/service.ts +3 -1
- package/src/master/goal-check.ts +114 -0
- package/src/master/master.ts +60 -24
- package/src/master/research.ts +3 -6
- package/src/pi/commands.ts +5 -45
- package/src/pi/delegation.ts +23 -4
- package/src/pi/events.ts +6 -19
- package/src/pi/grants.ts +89 -0
- package/src/pi/model-settings.ts +2 -4
- package/src/pi/model-support.ts +12 -5
- package/src/pi/route.ts +1 -1
- package/src/pi/run-summary.ts +3 -7
- package/src/pi/start-flags.ts +3 -3
- package/src/pi/start-task.ts +2 -8
- package/src/pi/tools.ts +10 -12
- package/src/roles/worker.ts +0 -18
- package/src/schemas/configuration.ts +81 -39
- package/src/schemas/findings.ts +0 -12
- package/src/schemas/task.ts +12 -1
- package/src/state/metrics.ts +1 -1
- package/src/state/work-clock.ts +140 -0
- package/src/state/work-time.ts +4 -7
- package/src/webui/api/index.ts +2 -1
- package/src/webui/api/settings.ts +6 -18
- package/src/webui/dev/fake-service.ts +14 -0
- package/src/webui/protocol.ts +23 -0
- package/src/workflow/workflow.ts +204 -344
- package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
- package/webui/dist/assets/index-BkXhtB6A.css +2 -0
- package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
- package/webui/dist/build.json +10 -6
- package/webui/dist/index.html +2 -2
- package/src/state/budget.ts +0 -301
- package/src/state/step-limits.ts +0 -41
- package/webui/dist/assets/index-Cca5uEVs.js +0 -115
- package/webui/dist/assets/index-DsVdXmh5.css +0 -2
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A worktree per worker for a parallel batch. Each worker gets its own
|
|
3
|
+
* checkout of the task's working tree as it stood when the batch began
|
|
4
|
+
* (uncommitted changes included), so workers never queue for each other's
|
|
5
|
+
* files; when the batch ends, each worker's change is applied back to the
|
|
6
|
+
* task's tree in turn. A change that no longer applies (two workers touched
|
|
7
|
+
* the same lines) is kept as a patch and a git ref for the oracle to hand
|
|
8
|
+
* back to its owner, never forced in: a patch that fails part way is rolled
|
|
9
|
+
* back file by file.
|
|
10
|
+
*
|
|
11
|
+
* Ignored folders (`node_modules`, virtualenvs, build caches) are linked
|
|
12
|
+
* into each worktree from the main checkout, so workers can build and test.
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
const TIMEOUT_MS = 120_000;
|
|
20
|
+
/** The batch's own commits are never on a branch, so they carry the engine's name rather than needing the user's. */
|
|
21
|
+
const IDENTITY = { GIT_AUTHOR_NAME: "bot-lobby", GIT_AUTHOR_EMAIL: "bot-lobby@localhost", GIT_COMMITTER_NAME: "bot-lobby", GIT_COMMITTER_EMAIL: "bot-lobby@localhost" };
|
|
22
|
+
/** Ignored folders linked into a worktree, at most. */
|
|
23
|
+
const MAX_LINKS = 200;
|
|
24
|
+
|
|
25
|
+
interface GitResult {
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
code: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Run git, resolving with its output and exit code (never rejecting on a non-zero exit). */
|
|
32
|
+
export function git(cwd: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<GitResult> {
|
|
33
|
+
return new Promise((done) => {
|
|
34
|
+
const child = execFile("git", [...args], { cwd, timeout: TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, windowsHide: true, env: { ...process.env, ...options.env } }, (error, stdout, stderr) => {
|
|
35
|
+
const code = error ? (typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : 1) : 0;
|
|
36
|
+
done({ stdout: String(stdout), stderr: String(stderr), code });
|
|
37
|
+
});
|
|
38
|
+
if (options.input !== undefined) child.stdin?.end(options.input);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function gitOk(cwd: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<string> {
|
|
43
|
+
const result = await git(cwd, args, options);
|
|
44
|
+
if (result.code !== 0) throw new Error(result.stderr.trim().split("\n")[0]?.replace(/^(fatal|error):\s*/i, "") || `git ${args[0]} failed`);
|
|
45
|
+
return result.stdout.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WorkerTree {
|
|
49
|
+
/** The worktree's own folder. */
|
|
50
|
+
dir: string;
|
|
51
|
+
/** Where the worker runs: the task's folder inside the worktree. */
|
|
52
|
+
cwd: string;
|
|
53
|
+
/** Ignored folders linked in from the main checkout, relative to the worktree. */
|
|
54
|
+
links: string[];
|
|
55
|
+
/** Left on disk when the batch closes, because its change could not be saved any other way. */
|
|
56
|
+
keep?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TreeBatch {
|
|
60
|
+
/** The task tree's repository top, where changes are applied back. */
|
|
61
|
+
top: string;
|
|
62
|
+
/** The same top as the task's folder names it (through any symlink), for paths reported back. */
|
|
63
|
+
shownTop: string;
|
|
64
|
+
/** The commit (never on any branch) holding the tree as the batch began. */
|
|
65
|
+
base: string;
|
|
66
|
+
/** Where the batch's worktrees live, removed when it closes. */
|
|
67
|
+
home: string;
|
|
68
|
+
trees: Map<string, WorkerTree>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The working tree as a commit, untracked files included (ignored ones are
|
|
73
|
+
* not), built in a private index so the user's own index is never touched.
|
|
74
|
+
*/
|
|
75
|
+
export async function snapshotTree(top: string): Promise<string> {
|
|
76
|
+
const head = await gitOk(top, ["rev-parse", "--verify", "HEAD"]);
|
|
77
|
+
const scratch = mkdtempSync(join(tmpdir(), "bl-index-"));
|
|
78
|
+
const env = { GIT_INDEX_FILE: join(scratch, "index") };
|
|
79
|
+
try {
|
|
80
|
+
await gitOk(top, ["read-tree", head], { env });
|
|
81
|
+
await gitOk(top, ["add", "-A"], { env });
|
|
82
|
+
const tree = await gitOk(top, ["write-tree"], { env });
|
|
83
|
+
return await gitOk(top, ["commit-tree", tree, "-p", head, "-m", "bot-lobby: parallel batch base"], { env: { ...env, ...IDENTITY } });
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Ignored folders of the main checkout, relative to its top (`node_modules`, `webui/node_modules`). */
|
|
90
|
+
async function ignoredFolders(top: string): Promise<string[]> {
|
|
91
|
+
const listed = await gitOk(top, ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"]).catch(() => "");
|
|
92
|
+
return listed
|
|
93
|
+
.split("\n")
|
|
94
|
+
.map((line) => line.trim())
|
|
95
|
+
.filter((line) => line.endsWith("/"))
|
|
96
|
+
.map((line) => line.slice(0, -1))
|
|
97
|
+
.filter((line) => line && !line.startsWith(".git"))
|
|
98
|
+
.slice(0, MAX_LINKS);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Link each ignored folder of the main checkout into a worktree, where its parent exists and nothing is there yet. */
|
|
102
|
+
function linkIgnored(top: string, dir: string, folders: readonly string[]): string[] {
|
|
103
|
+
const linked: string[] = [];
|
|
104
|
+
for (const folder of folders) {
|
|
105
|
+
const target = join(dir, folder);
|
|
106
|
+
if (existsSync(target) || !existsSync(dirname(target))) continue;
|
|
107
|
+
try {
|
|
108
|
+
symlinkSync(join(top, folder), target, process.platform === "win32" ? "junction" : "dir");
|
|
109
|
+
linked.push(folder);
|
|
110
|
+
} catch {
|
|
111
|
+
// A folder that cannot be linked is left out: the worker finds it missing and says so.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return linked;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One worktree per worker, each a detached checkout of the tree as it stands
|
|
119
|
+
* now. Returns why not (not a repository, no commits yet) as one line when it
|
|
120
|
+
* cannot, and the batch then runs on the file desk.
|
|
121
|
+
*/
|
|
122
|
+
export async function openWorkerTrees(cwd: string, workers: readonly string[]): Promise<TreeBatch | string> {
|
|
123
|
+
let top: string;
|
|
124
|
+
try {
|
|
125
|
+
top = realpathSync(await gitOk(cwd, ["rev-parse", "--show-toplevel"]));
|
|
126
|
+
} catch {
|
|
127
|
+
return "this folder is not a git repository";
|
|
128
|
+
}
|
|
129
|
+
const realCwd = realpathSync(cwd);
|
|
130
|
+
const inside = relative(top, realCwd);
|
|
131
|
+
const folders = await ignoredFolders(top);
|
|
132
|
+
// A task folder inside a borrowed folder would put every worker back in the main checkout.
|
|
133
|
+
const borrowed = folders.find((folder) => inside === folder || inside.startsWith(`${folder}/`) || inside.startsWith(`${folder}\\`));
|
|
134
|
+
if (borrowed) return `the task's folder is inside the ignored folder ${borrowed}`;
|
|
135
|
+
let base: string;
|
|
136
|
+
try {
|
|
137
|
+
base = await snapshotTree(top);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return `git could not snapshot the working tree (${(error as Error).message})`;
|
|
140
|
+
}
|
|
141
|
+
const home = mkdtempSync(join(tmpdir(), "bl-trees-"));
|
|
142
|
+
const shownTop = resolve(cwd, relative(realCwd, top));
|
|
143
|
+
const batch: TreeBatch = { top, shownTop, base, home, trees: new Map() };
|
|
144
|
+
try {
|
|
145
|
+
for (const worker of workers) {
|
|
146
|
+
const dir = join(home, worker.replace(/[^A-Za-z0-9_-]/g, "-"));
|
|
147
|
+
await gitOk(top, ["worktree", "add", "--detach", dir, base]);
|
|
148
|
+
const links = linkIgnored(top, dir, folders);
|
|
149
|
+
const real = realpathSync(dir);
|
|
150
|
+
const workerCwd = inside && !inside.startsWith("..") ? join(real, inside) : real;
|
|
151
|
+
// An empty or untracked-only task folder is not in the snapshot; the worker still starts there.
|
|
152
|
+
mkdirSync(workerCwd, { recursive: true });
|
|
153
|
+
batch.trees.set(worker, { dir: real, cwd: workerCwd, links });
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
await closeWorkerTrees(batch);
|
|
157
|
+
return `git could not add a worktree (${(error as Error).message})`;
|
|
158
|
+
}
|
|
159
|
+
return batch;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Pathspecs leaving out a worktree's borrowed folders, matched literally from the top. */
|
|
163
|
+
function linkExcludes(tree: WorkerTree): string[] {
|
|
164
|
+
return tree.links.map((link) => `:(exclude,top,literal)${link}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What one worker changed, as a commit on top of the batch's base (never on
|
|
169
|
+
* a branch). Staged from everything in its worktree except the borrowed
|
|
170
|
+
* folders, including anything the worker staged or committed itself.
|
|
171
|
+
*/
|
|
172
|
+
export async function workerCommit(batch: TreeBatch, worker: string): Promise<string> {
|
|
173
|
+
const tree = batch.trees.get(worker);
|
|
174
|
+
if (!tree) return batch.base;
|
|
175
|
+
await gitOk(tree.dir, ["add", "-A", "--", ".", ...linkExcludes(tree)]);
|
|
176
|
+
// A borrowed folder the worker staged itself is the main checkout's, never its change.
|
|
177
|
+
if (tree.links.length > 0) await gitOk(tree.dir, ["rm", "-r", "--cached", "--quiet", "--ignore-unmatch", "--", ...tree.links.map((link) => `:(top,literal)${link}`)]);
|
|
178
|
+
const written = await gitOk(tree.dir, ["write-tree"]);
|
|
179
|
+
const baseTree = await gitOk(tree.dir, ["rev-parse", `${batch.base}^{tree}`]);
|
|
180
|
+
if (written === baseTree) return batch.base;
|
|
181
|
+
return gitOk(tree.dir, ["commit-tree", written, "-p", batch.base, "-m", `bot-lobby: ${worker}'s change`], { env: IDENTITY });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Paths a commit changes against the batch's base, each as it stands in the tree. */
|
|
185
|
+
async function changedPaths(batch: TreeBatch, commit: string): Promise<string[]> {
|
|
186
|
+
const listed = await gitOk(batch.top, ["diff", "--name-only", "-z", "--no-renames", batch.base, commit]);
|
|
187
|
+
return listed.split("\0").filter(Boolean);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Nested repositories the commit records (mode 160000): their files are not in it, only a pointer. */
|
|
191
|
+
async function gitlinks(batch: TreeBatch, commit: string): Promise<string[]> {
|
|
192
|
+
const raw = await gitOk(batch.top, ["diff", "--raw", "-z", "--no-renames", batch.base, commit]);
|
|
193
|
+
const fields = raw.split("\0");
|
|
194
|
+
const found: string[] = [];
|
|
195
|
+
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
196
|
+
const [, , newMode] = /^:(\d+) (\d+)/.exec(fields[index] ?? "") ?? [];
|
|
197
|
+
if (newMode === "160000") found.push(fields[index + 1]!);
|
|
198
|
+
}
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
type Saved = { path: string; kind: "file"; data: Buffer; mode: number } | { path: string; kind: "link"; target: string } | { path: string; kind: "other" } | { path: string; kind: "none"; madeDirs: string[] };
|
|
203
|
+
|
|
204
|
+
/** Folders that would be made to hold `path`, nearest last, so a rollback can take them away again. */
|
|
205
|
+
function missingParents(top: string, path: string): string[] {
|
|
206
|
+
const missing: string[] = [];
|
|
207
|
+
for (let dir = dirname(join(top, path)); dir.length > top.length && !existsSync(dir); dir = dirname(dir)) missing.unshift(dir);
|
|
208
|
+
return missing;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The current contents of each path a patch is about to touch. */
|
|
212
|
+
function saveFiles(top: string, paths: readonly string[]): Saved[] {
|
|
213
|
+
return paths.map((path): Saved => {
|
|
214
|
+
const full = join(top, path);
|
|
215
|
+
let stat;
|
|
216
|
+
try {
|
|
217
|
+
stat = lstatSync(full);
|
|
218
|
+
} catch {
|
|
219
|
+
return { path, kind: "none", madeDirs: missingParents(top, path) };
|
|
220
|
+
}
|
|
221
|
+
if (stat.isSymbolicLink()) return { path, kind: "link", target: readlinkSync(full) };
|
|
222
|
+
if (stat.isFile()) return { path, kind: "file", data: readFileSync(full), mode: stat.mode };
|
|
223
|
+
return { path, kind: "other" };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sameAsSaved(top: string, saved: Saved): boolean {
|
|
228
|
+
const full = join(top, saved.path);
|
|
229
|
+
try {
|
|
230
|
+
const stat = lstatSync(full);
|
|
231
|
+
if (saved.kind === "none") return false;
|
|
232
|
+
if (saved.kind === "link") return stat.isSymbolicLink() && readlinkSync(full) === saved.target;
|
|
233
|
+
if (saved.kind === "file") return stat.isFile() && (stat.mode & 0o777) === (saved.mode & 0o777) && readFileSync(full).equals(saved.data);
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return saved.kind === "none";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Put each saved path back as it was; returns the paths that could not be. */
|
|
241
|
+
function restoreFiles(top: string, saved: readonly Saved[]): string[] {
|
|
242
|
+
for (const entry of saved) {
|
|
243
|
+
const full = join(top, entry.path);
|
|
244
|
+
try {
|
|
245
|
+
if (sameAsSaved(top, entry) || entry.kind === "other") continue;
|
|
246
|
+
if (existsSync(full) || isLink(full)) rmSync(full, { force: true });
|
|
247
|
+
if (entry.kind === "none") {
|
|
248
|
+
for (const dir of [...entry.madeDirs].reverse()) {
|
|
249
|
+
if (existsSync(dir) && readdirSync(dir).length === 0) rmdirSync(dir);
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
254
|
+
if (entry.kind === "link") symlinkSync(entry.target, full);
|
|
255
|
+
else {
|
|
256
|
+
writeFileSync(full, entry.data);
|
|
257
|
+
chmodSync(full, entry.mode & 0o7777);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// Reported below by the check.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return saved.filter((entry) => !sameAsSaved(top, entry)).map((entry) => entry.path);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isLink(path: string): boolean {
|
|
267
|
+
try {
|
|
268
|
+
return lstatSync(path).isSymbolicLink();
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export type MergeOutcome =
|
|
275
|
+
| { worker: string; status: "applied" | "empty" }
|
|
276
|
+
| {
|
|
277
|
+
worker: string;
|
|
278
|
+
/** `conflict`: none of it is in the tree; `partial`: applying failed and some files could not be put back. */
|
|
279
|
+
status: "conflict" | "partial";
|
|
280
|
+
/** The change as a patch file ("" when it could not be written). */
|
|
281
|
+
patch: string;
|
|
282
|
+
/** A git ref holding the change as a commit, or the worktree kept on disk when not even that could be made. */
|
|
283
|
+
saved: string;
|
|
284
|
+
reason: string;
|
|
285
|
+
/** For `partial`: the files left changed. */
|
|
286
|
+
leftChanged?: string[];
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
function firstLines(text: string): string {
|
|
290
|
+
return text.trim().split("\n").slice(0, 3).join(" ");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Apply each worker's change back to the task's tree, in order. A change
|
|
295
|
+
* that does not apply cleanly is saved (a patch under `patchDir` and a ref
|
|
296
|
+
* under `refs/bot-lobby/`) and reported; when applying fails part way, the
|
|
297
|
+
* files it touched are put back as they were. A worker whose change could not
|
|
298
|
+
* even be read keeps its worktree, which `closeWorkerTrees` then leaves alone.
|
|
299
|
+
*/
|
|
300
|
+
export async function mergeWorkerTrees(batch: TreeBatch, order: readonly string[], patchDir: string): Promise<MergeOutcome[]> {
|
|
301
|
+
const outcomes: MergeOutcome[] = [];
|
|
302
|
+
const stamp = Date.now();
|
|
303
|
+
for (const worker of order) {
|
|
304
|
+
const tree = batch.trees.get(worker);
|
|
305
|
+
let commit: string;
|
|
306
|
+
try {
|
|
307
|
+
commit = await workerCommit(batch, worker);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (tree) tree.keep = true;
|
|
310
|
+
outcomes.push({ worker, status: "conflict", patch: "", saved: tree?.dir ?? "", reason: `its change could not be read (${(error as Error).message}); its worktree is kept as it was` });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (commit === batch.base) {
|
|
314
|
+
outcomes.push({ worker, status: "empty" });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const ref = `refs/bot-lobby/${worker.replace(/[^A-Za-z0-9_-]/g, "-")}-${stamp}`;
|
|
318
|
+
const keep = async (reason: string, status: "conflict" | "partial" = "conflict", leftChanged?: string[]): Promise<void> => {
|
|
319
|
+
let saved = ref;
|
|
320
|
+
if ((await git(batch.top, ["update-ref", ref, commit])).code !== 0 && tree) {
|
|
321
|
+
tree.keep = true;
|
|
322
|
+
saved = tree.dir;
|
|
323
|
+
}
|
|
324
|
+
let patch = "";
|
|
325
|
+
try {
|
|
326
|
+
mkdirSync(patchDir, { recursive: true });
|
|
327
|
+
const file = join(patchDir, `${worker}-${stamp}.patch`);
|
|
328
|
+
if ((await git(batch.top, ["diff", "--binary", "--no-renames", `--output=${file}`, batch.base, commit])).code === 0) patch = file;
|
|
329
|
+
} catch {
|
|
330
|
+
// The ref still holds it.
|
|
331
|
+
}
|
|
332
|
+
outcomes.push({ worker, status, patch, saved, reason, ...(leftChanged ? { leftChanged } : {}) });
|
|
333
|
+
};
|
|
334
|
+
try {
|
|
335
|
+
const nested = await gitlinks(batch, commit);
|
|
336
|
+
if (nested.length > 0) {
|
|
337
|
+
if (tree) tree.keep = true;
|
|
338
|
+
await keep(`it made a nested git repository (${nested.slice(0, 3).join(", ")}), whose files cannot be carried over as a change; its worktree is kept`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const paths = await changedPaths(batch, commit);
|
|
342
|
+
const scratch = mkdtempSync(join(tmpdir(), "bl-patch-"));
|
|
343
|
+
const file = join(scratch, "change.patch");
|
|
344
|
+
try {
|
|
345
|
+
await gitOk(batch.top, ["diff", "--binary", "--no-renames", `--output=${file}`, batch.base, commit]);
|
|
346
|
+
const check = await git(batch.top, ["apply", "--check", "--binary", "--whitespace=nowarn", file]);
|
|
347
|
+
if (check.code !== 0) {
|
|
348
|
+
await keep(firstLines(check.stderr) || "it does not apply to the tree as the other workers left it");
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const before = saveFiles(batch.top, paths);
|
|
352
|
+
const applied = await git(batch.top, ["apply", "--binary", "--whitespace=nowarn", file]);
|
|
353
|
+
if (applied.code === 0) {
|
|
354
|
+
outcomes.push({ worker, status: "applied" });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const left = restoreFiles(batch.top, before);
|
|
358
|
+
const reason = firstLines(applied.stderr) || "git could not apply it";
|
|
359
|
+
if (left.length === 0) await keep(reason);
|
|
360
|
+
else await keep(`${reason}; ${left.length} file(s) it touched could not be put back`, "partial", left);
|
|
361
|
+
} finally {
|
|
362
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
await keep(`its change could not be applied (${(error as Error).message})`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return outcomes;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A path a worker edited inside its worktree, as the same path in the task's tree. */
|
|
372
|
+
export function mapToTop(batch: TreeBatch, worker: string, path: string): string {
|
|
373
|
+
const tree = batch.trees.get(worker);
|
|
374
|
+
if (!tree) return path;
|
|
375
|
+
const rel = relative(tree.dir, path);
|
|
376
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? join(batch.shownTop, rel) : path;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Remove every worktree of the batch and its folder, except one kept to save its change. Never throws. */
|
|
380
|
+
export async function closeWorkerTrees(batch: TreeBatch): Promise<void> {
|
|
381
|
+
let kept = false;
|
|
382
|
+
for (const tree of batch.trees.values()) {
|
|
383
|
+
// Unlink the borrowed folders first, so removing the worktree can never reach into the main checkout's.
|
|
384
|
+
for (const link of tree.links) {
|
|
385
|
+
try {
|
|
386
|
+
unlinkSync(join(tree.dir, link));
|
|
387
|
+
} catch {
|
|
388
|
+
// Already gone.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (tree.keep) kept = true;
|
|
392
|
+
else await git(batch.top, ["worktree", "remove", "--force", tree.dir]);
|
|
393
|
+
}
|
|
394
|
+
if (!kept) rmSync(batch.home, { recursive: true, force: true });
|
|
395
|
+
await git(batch.top, ["worktree", "prune"]);
|
|
396
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { TERMINAL_STATES } from "./schemas/task.ts";
|
|
|
12
12
|
import { pingTransition } from "./pi/notify.ts";
|
|
13
13
|
import { isSubagentProcess } from "./pi/quiet.ts";
|
|
14
14
|
import { registerDeskClient } from "./desk/client-extension.ts";
|
|
15
|
+
import { registerBoardClient } from "./desk/board-client.ts";
|
|
15
16
|
import { registerLobbyEvents } from "./lobby/runtime.ts";
|
|
16
17
|
import { registerOwner } from "./pi/owner.ts";
|
|
17
18
|
import { registerClassifier } from "./classifier/instance.ts";
|
|
@@ -20,11 +21,14 @@ import { registerFreshContext } from "./pi/fresh-context.ts";
|
|
|
20
21
|
import { registerAskTool } from "./ask/tool.ts";
|
|
21
22
|
import { registerWebTools } from "./web/tools.ts";
|
|
22
23
|
import { registerToolBudget } from "./pi/tool-budget.ts";
|
|
24
|
+
import { bindGrants } from "./pi/grants.ts";
|
|
23
25
|
|
|
24
26
|
export default function (pi: ExtensionAPI): void {
|
|
25
27
|
registerAgentPermissions(pi);
|
|
26
28
|
registerDelegation(pi);
|
|
27
29
|
registerAutoCompaction(pi);
|
|
30
|
+
// Settings lists this session's MCP tools and skills to grant, and agents find their granted skills through it.
|
|
31
|
+
if (!isSubagentProcess()) bindGrants(pi);
|
|
28
32
|
let root = detectProjectRoot(process.cwd(), CONFIG_DIR_NAME);
|
|
29
33
|
pi.on("session_start", (_event, ctx) => { root = detectProjectRoot(ctx.cwd, CONFIG_DIR_NAME); });
|
|
30
34
|
// First, so every session_start handler below finds the classifier bound to this session's keys.
|
|
@@ -50,6 +54,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
50
54
|
registerWebTools(pi);
|
|
51
55
|
// Parallel workers share files through the master's file desk.
|
|
52
56
|
if (isSubagentProcess()) registerDeskClient(pi);
|
|
57
|
+
// Workers on a task post contracts, decisions and blockers to its board and read the others'.
|
|
58
|
+
if (isSubagentProcess()) registerBoardClient(pi);
|
|
53
59
|
// Subagents look files up with the classifier (the engine allows the tool only while file hints are on).
|
|
54
60
|
if (isSubagentProcess()) registerClassifierTools(pi);
|
|
55
61
|
// A subagent given a tool budget (scouts) is warned, then refused, so it reports instead of searching on.
|
package/src/lobby/feed.ts
CHANGED
|
@@ -266,7 +266,7 @@ export class LobbyFeed {
|
|
|
266
266
|
if (run.status !== "running") this.settleThought(key);
|
|
267
267
|
if (run.status !== "running" && before !== run.status) {
|
|
268
268
|
this.settle(undefined, key);
|
|
269
|
-
const kind: ActivityKind = run.status === "success" ?
|
|
269
|
+
const kind: ActivityKind = run.status === "success" ? "success" : run.status === "cancelled" ? "warning" : "error";
|
|
270
270
|
this.log(source, `${run.role} ${run.status === "success" ? "finished" : run.status}${run.error ? ` — ${run.error.split("\n")[0]}` : ""}`, kind, at);
|
|
271
271
|
}
|
|
272
272
|
}
|
package/src/lobby/host.ts
CHANGED
|
@@ -148,6 +148,8 @@ export interface LobbyService {
|
|
|
148
148
|
configChanged?(): void;
|
|
149
149
|
/** The models Pi offers, for the settings page's picker. */
|
|
150
150
|
models?(): ModelChoice[];
|
|
151
|
+
/** The MCP tools and skills pi offers to grant agents, for the settings page. */
|
|
152
|
+
grants?(): { mcpTools: Array<{ name: string; server: string; description: string; readOnly: boolean }>; skills: Array<{ name: string; description: string; path: string }> };
|
|
151
153
|
/** The classifier models pi can call with its logins, for the classifier's picker. */
|
|
152
154
|
classifierModels?(): Array<{ id: string; label: string }>;
|
|
153
155
|
/** The model this window's session runs on (`provider/id`), when it has one. */
|
package/src/lobby/planner.ts
CHANGED
|
@@ -139,7 +139,7 @@ export interface PlannerDeps {
|
|
|
139
139
|
configDir: string;
|
|
140
140
|
/** The oracle chairing the panel (the Planner settings entry). */
|
|
141
141
|
profile: () => QuickFixProfile;
|
|
142
|
-
/** A seat's model, thinking and
|
|
142
|
+
/** A seat's model, thinking and grants; the oracle's profile when absent. */
|
|
143
143
|
memberProfile?: (member: PanelMember) => QuickFixProfile;
|
|
144
144
|
/** Seats on the panel when the session starts; every seat when absent. */
|
|
145
145
|
panel?: readonly PanelMember[];
|
|
@@ -1015,7 +1015,7 @@ export class PlanningSession {
|
|
|
1015
1015
|
if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
|
|
1016
1016
|
this.deps.onChange?.();
|
|
1017
1017
|
},
|
|
1018
|
-
|
|
1018
|
+
...(profile.skills ? { skills: profile.skills } : {}),
|
|
1019
1019
|
signal,
|
|
1020
1020
|
stallTimeoutMs: this.deps.stallTimeoutMs,
|
|
1021
1021
|
toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
|
package/src/lobby/pr-review.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
/**
|
|
3
3
|
* Reviewing pull requests from the Git tab. A read-only agent (on QA's model,
|
|
4
|
-
* thinking and
|
|
4
|
+
* thinking and grants) gets the pull request's description, changed files
|
|
5
5
|
* and diff from `gh`, may read the repository for context, and writes a
|
|
6
6
|
* review: verdict, summary, findings, tests, questions. Jev can read a pull
|
|
7
7
|
* request first, in a moment, and say whether a full review is worth its
|
|
@@ -74,7 +74,7 @@ export interface PullReviewDeps {
|
|
|
74
74
|
root: string;
|
|
75
75
|
configDir: string;
|
|
76
76
|
exec: Exec;
|
|
77
|
-
/** QA's model, thinking and
|
|
77
|
+
/** QA's model, thinking and grants, read as a review starts. */
|
|
78
78
|
profile: () => QuickFixProfile;
|
|
79
79
|
stallTimeoutMs?: number;
|
|
80
80
|
toolStallTimeoutMs?: number;
|
|
@@ -285,7 +285,7 @@ export class PullReviews {
|
|
|
285
285
|
if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
|
|
286
286
|
this.deps.onChange?.();
|
|
287
287
|
},
|
|
288
|
-
|
|
288
|
+
...(profile.skills ? { skills: profile.skills } : {}),
|
|
289
289
|
signal,
|
|
290
290
|
stallTimeoutMs: this.deps.stallTimeoutMs,
|
|
291
291
|
toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -89,9 +89,10 @@ export interface SubmitOptions {
|
|
|
89
89
|
export interface QuickFixProfile {
|
|
90
90
|
model?: string;
|
|
91
91
|
thinking: string;
|
|
92
|
-
timeoutMs: number;
|
|
93
92
|
instructions?: string;
|
|
94
93
|
mcpTools?: string[];
|
|
94
|
+
/** Skill names it may load; absent keeps every skill pi finds. */
|
|
95
|
+
skills?: string[];
|
|
95
96
|
/** False when its settings turn the agent's auto-compaction off. */
|
|
96
97
|
autoCompact?: boolean;
|
|
97
98
|
/** Where the job goes when its model runs out of usage or is unavailable. */
|
|
@@ -101,7 +102,7 @@ export interface QuickFixDeps {
|
|
|
101
102
|
cwd: string;
|
|
102
103
|
root: string;
|
|
103
104
|
configDir: string;
|
|
104
|
-
/** Model, thinking and
|
|
105
|
+
/** Model, thinking and grants for the next job, read from settings at start time. */
|
|
105
106
|
profile: () => QuickFixProfile;
|
|
106
107
|
stallTimeoutMs?: number;
|
|
107
108
|
toolStallTimeoutMs?: number;
|
|
@@ -355,7 +356,7 @@ export class QuickFixQueue {
|
|
|
355
356
|
if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
|
|
356
357
|
this.changed();
|
|
357
358
|
},
|
|
358
|
-
|
|
359
|
+
...(profile.skills ? { skills: profile.skills } : {}),
|
|
359
360
|
signal: controller.signal,
|
|
360
361
|
stallTimeoutMs: this.deps.stallTimeoutMs,
|
|
361
362
|
toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
|
package/src/lobby/recovery.ts
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
17
17
|
import { loadTask, ownedTask, saveTask } from "../state/persistence.ts";
|
|
18
18
|
import { resolvePhaseTiming } from "../state/phase-timing.ts";
|
|
19
|
-
import { updateBudget } from "../state/budget.ts";
|
|
20
19
|
import { clearStaleRuns } from "../state/work-time.ts";
|
|
21
20
|
import { livePresence } from "../state/presence.ts";
|
|
22
21
|
import {
|
|
@@ -59,8 +58,8 @@ const INTERRUPTED_DECISION = "pi stopped unexpectedly; the task carried on in th
|
|
|
59
58
|
|
|
60
59
|
/**
|
|
61
60
|
* Clear what the stopped process left marked as under way on a task: the
|
|
62
|
-
* questions it was waiting on (they no longer wait)
|
|
63
|
-
*
|
|
61
|
+
* questions it was waiting on (they no longer wait) and the runs it noted as
|
|
62
|
+
* active. A decision records
|
|
64
63
|
* the stop, so the task's history says what happened.
|
|
65
64
|
*/
|
|
66
65
|
export function settleInterrupted(root: string, configDir: string, taskId: string, now = new Date(), note = INTERRUPTED_DECISION): void {
|
|
@@ -71,13 +70,6 @@ export function settleInterrupted(root: string, configDir: string, taskId: strin
|
|
|
71
70
|
task.decisions.push({ domain: "master", text: note, createdAt: at });
|
|
72
71
|
task.updatedAt = at;
|
|
73
72
|
saveTask(root, configDir, task);
|
|
74
|
-
updateBudget(root, configDir, taskId, (budget) => {
|
|
75
|
-
for (const allotment of budget.allotments) {
|
|
76
|
-
if (allotment.endedAt) continue;
|
|
77
|
-
allotment.endedAt = at;
|
|
78
|
-
allotment.outcome = "stopped";
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
73
|
clearStaleRuns(root, configDir, taskId);
|
|
82
74
|
}
|
|
83
75
|
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -126,7 +126,7 @@ function handToQuickFix(state: Runtime, request: string, builder: Domain | undef
|
|
|
126
126
|
const job = state.quickfix.submit(request, Date.now(), {
|
|
127
127
|
force: true,
|
|
128
128
|
...(profile ? { profile } : {}),
|
|
129
|
-
note: `The oracle sent your request here: ${reason.replace(/[.\s]+$/, "")}.${builder ? ` A quick feature: it runs on ${builder === "designer" ? "DESIGN" : "DEV"}'s model, thinking and
|
|
129
|
+
note: `The oracle sent your request here: ${reason.replace(/[.\s]+$/, "")}.${builder ? ` A quick feature: it runs on ${builder === "designer" ? "DESIGN" : "DEV"}'s model, thinking and grants.` : ""}`,
|
|
130
130
|
});
|
|
131
131
|
lobbyTopics.bump("quickfix");
|
|
132
132
|
pushNotice("The oracle sent your request to the quick-fix agent.", "info");
|
package/src/lobby/service.ts
CHANGED
|
@@ -47,6 +47,7 @@ import { settleInterrupted, type Relaunch } from "./recovery.ts";
|
|
|
47
47
|
import { carryOnMessage, resumeRoute, RESUMED_DECISION, type Drivers } from "./resume.ts";
|
|
48
48
|
import { archivePlan as archivePlanOnDisk, deletePlan as deletePlanOnDisk, isPlanId, previousPlans, readPlan, type PlanRecord, type PlanSummary } from "../state/plan-history.ts";
|
|
49
49
|
import { applyMasterModel } from "../pi/model-settings.ts";
|
|
50
|
+
import { grantableSkills, grantableTools } from "../pi/grants.ts";
|
|
50
51
|
import { lobbyTopics } from "./topics.ts";
|
|
51
52
|
import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
|
|
52
53
|
import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
|
|
@@ -719,7 +720,7 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
|
|
|
719
720
|
return `deleted ${taskId} for good`;
|
|
720
721
|
}
|
|
721
722
|
|
|
722
|
-
/** What a pull request review runs on: QA's model, thinking and
|
|
723
|
+
/** What a pull request review runs on: QA's model, thinking and grants. */
|
|
723
724
|
export function reviewProfile(state: Runtime) {
|
|
724
725
|
return resolveReviewProfile(loadConfig(), {
|
|
725
726
|
lookup: modelLookup(state.ctx),
|
|
@@ -988,6 +989,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
988
989
|
}
|
|
989
990
|
},
|
|
990
991
|
classifierModels: () => availableClassifiers().map((model) => ({ id: model.id, label: model.label })),
|
|
992
|
+
grants: () => ({ mcpTools: grantableTools(state.pi), skills: grantableSkills(state.root, state.pi) }),
|
|
991
993
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
992
994
|
recovered: () => state.recovered,
|
|
993
995
|
seatLabel: (member) => {
|