@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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/prompts/expert.md +17 -0
  4. package/prompts/goal-check.md +44 -0
  5. package/prompts/master.md +70 -32
  6. package/prompts/worker.md +25 -11
  7. package/src/desk/board-client.ts +65 -0
  8. package/src/desk/board.ts +200 -0
  9. package/src/desk/client-extension.ts +31 -1
  10. package/src/desk/ipc.ts +16 -4
  11. package/src/desk/session.ts +129 -1
  12. package/src/execution/agent-runner.ts +27 -132
  13. package/src/execution/delegation.ts +112 -38
  14. package/src/execution/live-run.ts +3 -14
  15. package/src/execution/pi-runner.ts +30 -163
  16. package/src/execution/worker-trees.ts +396 -0
  17. package/src/index.ts +6 -0
  18. package/src/lobby/feed.ts +1 -1
  19. package/src/lobby/host.ts +2 -0
  20. package/src/lobby/planner.ts +2 -2
  21. package/src/lobby/pr-review.ts +3 -3
  22. package/src/lobby/quickfix.ts +4 -3
  23. package/src/lobby/recovery.ts +2 -10
  24. package/src/lobby/runtime.ts +1 -1
  25. package/src/lobby/service.ts +3 -1
  26. package/src/master/goal-check.ts +114 -0
  27. package/src/master/master.ts +60 -24
  28. package/src/master/research.ts +3 -6
  29. package/src/pi/commands.ts +5 -45
  30. package/src/pi/delegation.ts +23 -4
  31. package/src/pi/events.ts +6 -19
  32. package/src/pi/grants.ts +89 -0
  33. package/src/pi/model-settings.ts +2 -4
  34. package/src/pi/model-support.ts +12 -5
  35. package/src/pi/route.ts +1 -1
  36. package/src/pi/run-summary.ts +3 -7
  37. package/src/pi/start-flags.ts +3 -3
  38. package/src/pi/start-task.ts +2 -8
  39. package/src/pi/tools.ts +10 -12
  40. package/src/roles/worker.ts +0 -18
  41. package/src/schemas/configuration.ts +81 -39
  42. package/src/schemas/findings.ts +0 -12
  43. package/src/schemas/task.ts +12 -1
  44. package/src/state/metrics.ts +1 -1
  45. package/src/state/work-clock.ts +140 -0
  46. package/src/state/work-time.ts +4 -7
  47. package/src/webui/api/index.ts +2 -1
  48. package/src/webui/api/settings.ts +6 -18
  49. package/src/webui/dev/fake-service.ts +14 -0
  50. package/src/webui/protocol.ts +23 -0
  51. package/src/workflow/workflow.ts +204 -344
  52. package/webui/dist/assets/{TerminalView-CbRc77C8.js → TerminalView-CIfjkb5H.js} +1 -1
  53. package/webui/dist/assets/index-BkXhtB6A.css +2 -0
  54. package/webui/dist/assets/index-DIM2-FUQ.js +114 -0
  55. package/webui/dist/build.json +10 -6
  56. package/webui/dist/index.html +2 -2
  57. package/src/state/budget.ts +0 -301
  58. package/src/state/step-limits.ts +0 -41
  59. package/webui/dist/assets/index-Cca5uEVs.js +0 -115
  60. 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" ? (run.wrappedUp ? "warning" : "success") : run.status === "cancelled" ? "warning" : "error";
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. */
@@ -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 time limit; the oracle's profile when absent. */
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
- timeoutMs: profile.timeoutMs,
1018
+ ...(profile.skills ? { skills: profile.skills } : {}),
1019
1019
  signal,
1020
1020
  stallTimeoutMs: this.deps.stallTimeoutMs,
1021
1021
  toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
@@ -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 time limit) gets the pull request's description, changed files
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 time limit, read as a review starts. */
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
- timeoutMs: profile.timeoutMs,
288
+ ...(profile.skills ? { skills: profile.skills } : {}),
289
289
  signal,
290
290
  stallTimeoutMs: this.deps.stallTimeoutMs,
291
291
  toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
@@ -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 time limit for the next job, read from settings at start time. */
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
- timeoutMs: profile.timeoutMs,
359
+ ...(profile.skills ? { skills: profile.skills } : {}),
359
360
  signal: controller.signal,
360
361
  stallTimeoutMs: this.deps.stallTimeoutMs,
361
362
  toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
@@ -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), the time given to the
63
- * steps it was running, and the runs it noted as active. A decision records
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
 
@@ -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 time limit.` : ""}`,
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");
@@ -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 time limit. */
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) => {