@a-t-h-i/bot-lobby 0.6.32 → 0.6.34

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 (39) hide show
  1. package/README.md +30 -0
  2. package/package.json +7 -7
  3. package/src/desk/client-extension.ts +2 -2
  4. package/src/desk/ipc.ts +4 -3
  5. package/src/desk/session.ts +14 -1
  6. package/src/execution/agent-runner.ts +16 -92
  7. package/src/execution/delegation.ts +193 -0
  8. package/src/execution/live-run.ts +95 -0
  9. package/src/execution/pi-runner.ts +35 -9
  10. package/src/index.ts +5 -0
  11. package/src/lobby/planner.ts +9 -0
  12. package/src/lobby/pr-review.ts +9 -0
  13. package/src/lobby/quickfix.ts +16 -0
  14. package/src/master/master.ts +3 -1
  15. package/src/master/research.ts +1 -0
  16. package/src/pi/auto-compaction.ts +72 -0
  17. package/src/pi/delegation.ts +68 -0
  18. package/src/pi/model-support.ts +1 -1
  19. package/src/pi/plan-checklist.ts +18 -7
  20. package/src/pi/run-summary.ts +9 -0
  21. package/src/schemas/configuration.ts +19 -7
  22. package/src/schemas/findings.ts +4 -0
  23. package/src/schemas/task.ts +4 -0
  24. package/src/state/work-time.ts +4 -2
  25. package/src/webui/api/index.ts +2 -2
  26. package/src/webui/api/lobby.ts +1 -1
  27. package/src/webui/api/metrics.ts +41 -8
  28. package/src/webui/api/plan-facts.ts +4 -3
  29. package/src/webui/api/tasks.ts +1 -1
  30. package/src/webui/dev/fake-service.ts +1 -1
  31. package/src/webui/dev/fixtures.ts +1 -1
  32. package/src/webui/protocol.ts +4 -3
  33. package/src/workflow/workflow.ts +4 -1
  34. package/webui/dist/assets/index-B4zpEv7K.js +109 -0
  35. package/webui/dist/assets/index-C8hgfw6m.css +2 -0
  36. package/webui/dist/build.json +39 -27
  37. package/webui/dist/index.html +2 -2
  38. package/webui/dist/assets/index-BtKUZnAE.css +0 -2
  39. package/webui/dist/assets/index-CmNcHve-.js +0 -108
@@ -1,3 +1,7 @@
1
+ import type { AgentRun } from "../schemas/findings.ts";
2
+ import type { Domain, Role } from "../schemas/agent.ts";
3
+ import { delegationHost } from "./delegation.ts";
4
+ import { fileURLToPath } from "node:url";
1
5
  import { spawn, type ChildProcess } from "node:child_process";
2
6
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
7
  import { tmpdir } from "node:os";
@@ -6,7 +10,7 @@ import { activityWord } from "../pi/activity.ts";
6
10
  import { shortDuration } from "../text.ts";
7
11
  import { readRelayRequest } from "../ask/relay.ts";
8
12
  import type { AskQuestion, AskResult } from "../ask/types.ts";
9
- import { grantEnv, grantNote, toolsOf, type Grant } from "../excalidraw/sessions.ts";
13
+ import { grantEnv, grantNote, type Grant } from "../excalidraw/sessions.ts";
10
14
 
11
15
  /** Live control over one running subagent: queue a steering message for its next turn. */
12
16
  export interface RunHandle {
@@ -20,6 +24,10 @@ export interface RunHandle {
20
24
  export type RelayAsk = (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>;
21
25
 
22
26
  export interface PiRunOptions {
27
+ agent?: { runId: string; taskId: string; domain: Domain; role: Role; depth?: number };
28
+ mcpTools?: string[];
29
+ delegationQuota?: { used: number };
30
+ onChildRun?: (run: AgentRun) => void;
23
31
  cwd: string;
24
32
  task: string;
25
33
  systemPrompt?: string;
@@ -96,6 +104,7 @@ export type PiStreamEvent =
96
104
  | { type: "retry"; attempt: number; maxAttempts: number; delayMs: number; error: string }
97
105
  | { type: "retry_end"; success: boolean }
98
106
  | { type: "compaction" }
107
+ | { type: "compaction_end"; error?: string }
99
108
  | { type: "usage"; input: number; output: number; cost: number; model?: string }
100
109
  | { type: "wrap_up" }
101
110
  /** Its allotted time is up: it was asked to stop and report where it left off. */
@@ -139,6 +148,7 @@ export type ProcessRunner = (
139
148
  ) => Promise<ProcessOutcome>;
140
149
 
141
150
  export interface PiRunResult {
151
+ children?: AgentRun[];
142
152
  status: "success" | "failed" | "cancelled" | "timeout";
143
153
  output: string;
144
154
  error?: string;
@@ -186,6 +196,7 @@ export interface ControlEvent {
186
196
  /** extension_ui_request: the dialog's title and (editor) prefilled text. */
187
197
  title?: string;
188
198
  prefill?: string;
199
+ message?: string;
189
200
  }
190
201
 
191
202
  /** Buffers one JSON-mode stream, keeping only lines parsePiStream consumes. */
@@ -203,6 +214,7 @@ const INTERESTING = [
203
214
  '"turn_start"',
204
215
  '"auto_retry_',
205
216
  '"compaction_start"',
217
+ '"compaction_end"',
206
218
  '"agent_settled"',
207
219
  '"agent_end"',
208
220
  '"extension_ui_request"',
@@ -241,6 +253,8 @@ function streamEvent(event: RawEvent): PiStreamEvent | undefined {
241
253
  return { type: "retry_end", success: event.success === true };
242
254
  case "compaction_start":
243
255
  return { type: "compaction" };
256
+ case "compaction_end":
257
+ return { type: "compaction_end", ...(typeof event.errorMessage === "string" ? { error: event.errorMessage } : {}) };
244
258
  case "message_end": {
245
259
  const message = event.message;
246
260
  if (message?.role !== "assistant") return undefined;
@@ -345,7 +359,7 @@ export function finishedThought(line: string): string | undefined {
345
359
 
346
360
  /** Build the `pi` argv for one isolated, headless RPC agent run; the task goes over stdin. */
347
361
  export function buildPiArgs(options: Omit<PiRunOptions, "task"> & { systemPromptFile?: string }): string[] {
348
- const args = ["--mode", "rpc", "--no-session", "--no-prompt-templates", "--no-themes"];
362
+ const args = ["--mode", "rpc", "--no-session", "--no-prompt-templates", "--no-themes", "--extension", fileURLToPath(new URL("../index.ts", import.meta.url))];
349
363
  if (options.model && options.model !== "inherit") args.push("--model", options.model);
350
364
  if (options.thinking) args.push("--thinking", options.thinking);
351
365
  if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
@@ -608,6 +622,11 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
608
622
  });
609
623
  };
610
624
  const onControl = (event: ControlEvent) => {
625
+ if (event.type === "extension_ui_request" && event.method === "notify") {
626
+ if (event.message === "Compacting agent context") options.onEvent?.({ type: "compaction" });
627
+ else if (event.message === "Agent context compacted") options.onEvent?.({ type: "compaction_end" });
628
+ else if (event.message?.startsWith("Context compaction failed:")) options.onEvent?.({ type: "compaction_end", error: event.message });
629
+ }
611
630
  const relayed = event.type === "extension_ui_request" && event.method === "editor" && options.onAsk ? readRelayRequest(event.title, event.prefill) : undefined;
612
631
  if (event.type === "response" && event.command === "prompt" && event.success === false) {
613
632
  state.protocolError = event.error ?? "the prompt was rejected";
@@ -744,31 +763,38 @@ function writePromptFile(prompt: string): { file: string; cleanup: () => void }
744
763
 
745
764
  /** Run one isolated subagent and normalise its outcome. */
746
765
  export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spawnPiProcess): Promise<PiRunResult> {
747
- const prompt = options.systemPrompt ? writePromptFile(options.systemPrompt) : undefined;
766
+ const host = await delegationHost(options, run);
767
+ let prompt: ReturnType<typeof writePromptFile> | undefined;
748
768
  const grant = options.excalidraw;
749
- // An agent with an allowlist gets the Excalidraw tools added to it; one without is not restricted, so already has them.
750
- const tools = grant && options.tools ? [...new Set([...options.tools, ...toolsOf(grant)])] : options.tools;
751
769
  try {
752
- const args = buildPiArgs({ ...options, ...(tools ? { tools } : {}), systemPromptFile: prompt?.file });
770
+ prompt = writePromptFile([options.systemPrompt, host.note].filter(Boolean).join("\n\n"));
771
+ if (options.signal?.aborted) return { status: "cancelled", output: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
772
+ const args = buildPiArgs({ ...options, tools: host.tools, systemPromptFile: prompt.file });
753
773
  const outcome = await run(args, {
754
774
  cwd: options.cwd,
755
775
  signal: options.signal,
756
776
  timeoutMs: options.timeoutMs,
757
777
  prompt: `Task: ${options.task}${grant ? `\n\n${grantNote(grant)}` : ""}`,
758
- env: grant ? { ...options.env, ...grantEnv(grant) } : options.env,
778
+ env: grant ? { ...host.env, ...grantEnv(grant) } : host.env,
759
779
  stallTimeoutMs: options.stallTimeoutMs,
760
780
  toolStallTimeoutMs: options.toolStallTimeoutMs,
761
781
  wrapUpAtMs: options.wrapUpAtMs,
762
782
  ...(options.time ? { time: options.time } : {}),
763
783
  ...(options.onAsk ? { onAsk: options.onAsk } : {}),
764
- onStart: options.onStart,
784
+ onStart: host.onStart,
765
785
  onEvent: (event) => {
786
+ if (event.type === "asking") host.pause?.();
787
+ if (event.type === "answered") host.extend?.(event.waitedMs);
788
+ if (event.type === "extended") host.extend?.(event.ms, true);
766
789
  if (event.type === "tool_execution_start") options.onActivity?.(activityWord(event.toolName));
767
790
  options.onEvent?.(event);
768
791
  },
769
792
  });
770
- return toResult(parsePiStream(outcome.stdout), outcome, options.signal?.aborted === true, options);
793
+ const result = toResult(parsePiStream(outcome.stdout), outcome, options.signal?.aborted === true, options);
794
+ await host.close();
795
+ return host.children.length ? { ...result, children: host.children } : result;
771
796
  } finally {
772
797
  prompt?.cleanup();
798
+ await host.close();
773
799
  }
774
800
  }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { registerAgentPermissions, registerDelegation } from "./pi/delegation.ts";
2
+ import { registerAutoCompaction } from "./pi/auto-compaction.ts";
1
3
  import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
4
  import { registerCommands } from "./pi/commands.ts";
3
5
  import { registerLifecycle } from "./pi/events.ts";
@@ -20,6 +22,9 @@ import { registerWebTools } from "./web/tools.ts";
20
22
  import { registerExcalidrawTools } from "./excalidraw/tools.ts";
21
23
 
22
24
  export default function (pi: ExtensionAPI): void {
25
+ registerAgentPermissions(pi);
26
+ registerDelegation(pi);
27
+ registerAutoCompaction(pi);
23
28
  let root = detectProjectRoot(process.cwd(), CONFIG_DIR_NAME);
24
29
  pi.on("session_start", (_event, ctx) => { root = detectProjectRoot(ctx.cwd, CONFIG_DIR_NAME); });
25
30
  // First, so every session_start handler below finds the classifier bound to this session's keys.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  /**
2
3
  * Task planning mode: a planning panel. The user describes an idea (or picks a
3
4
  * GitHub issue) and every seat questions them from its own domain — DEV,
@@ -998,14 +999,22 @@ export class PlanningSession {
998
999
 
999
1000
  private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[]; excalidraw?: Grant }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
1000
1001
  const startedAt = Date.now();
1002
+ const runId = randomUUID();
1001
1003
  let outcome: RunOutcome;
1002
1004
  try {
1003
1005
  const attempt = (model: string | undefined, thinking: string) => runPiAgent(
1004
1006
  {
1005
1007
  cwd: this.deps.cwd,
1008
+ agent: { runId, taskId: runId, domain: label === "DESIGN" ? "designer" : label === "QA" ? "qa" : "backend", role: label === "RESEARCH" ? "researcher" : "reviewer" },
1006
1009
  ...request,
1007
1010
  model,
1008
1011
  thinking,
1012
+ mcpTools: profile.mcpTools,
1013
+ onChildRun: (child) => {
1014
+ this.deps.feed?.step(label, `child ${child.runId.slice(0, 8)}: ${child.step ?? child.status}`, child.runId);
1015
+ if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
1016
+ this.deps.onChange?.();
1017
+ },
1009
1018
  timeoutMs: profile.timeoutMs,
1010
1019
  signal,
1011
1020
  stallTimeoutMs: this.deps.stallTimeoutMs,
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  /**
2
3
  * Reviewing pull requests from the Git tab. A read-only agent (on QA's model,
3
4
  * thinking and time limit) gets the pull request's description, changed files
@@ -264,17 +265,25 @@ export class PullReviews {
264
265
 
265
266
  private async run(review: PullReview, task: string, signal: AbortSignal): Promise<RunUsage> {
266
267
  const profile = this.deps.profile();
268
+ const runId = randomUUID();
267
269
  if (profile.model) review.model = profile.model;
268
270
  review.thinking = profile.thinking;
269
271
  this.step(review, "reading the diff");
270
272
  const attempt = (model: string | undefined, thinking: string) => runPiAgent(
271
273
  {
272
274
  cwd: this.deps.cwd,
275
+ agent: { runId, taskId: runId, domain: "qa", role: "reviewer" },
273
276
  task,
274
277
  systemPrompt: reviewPrompt(profile.instructions),
275
278
  tools: REVIEW_TOOLS,
276
279
  model,
277
280
  thinking,
281
+ mcpTools: profile.mcpTools,
282
+ onChildRun: (child) => {
283
+ this.deps.feed?.step(REVIEW_SOURCE, `child ${child.runId.slice(0, 8)}: ${child.step ?? child.status}`, child.runId);
284
+ if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
285
+ this.deps.onChange?.();
286
+ },
278
287
  timeoutMs: profile.timeoutMs,
279
288
  signal,
280
289
  stallTimeoutMs: this.deps.stallTimeoutMs,
@@ -1,3 +1,4 @@
1
+ import type { AgentRun } from "../schemas/findings.ts";
1
2
  /**
2
3
  * Quick fixes: a direct prompt from the lobby goes straight to one coding
3
4
  * subagent — no scouting, proposal, plan or QA gate — while any bot-lobby task
@@ -38,6 +39,7 @@ export interface QuickFixStep {
38
39
  }
39
40
 
40
41
  export interface QuickFixJob {
42
+ children?: AgentRun[];
41
43
  id: string;
42
44
  prompt: string;
43
45
  status: QuickFixStatus;
@@ -83,6 +85,7 @@ export interface QuickFixProfile {
83
85
  thinking: string;
84
86
  timeoutMs: number;
85
87
  instructions?: string;
88
+ mcpTools?: string[];
86
89
  /** Where the job goes when its model runs out of usage or is unavailable. */
87
90
  }
88
91
 
@@ -295,6 +298,7 @@ export class QuickFixQueue {
295
298
  this.edits.set(job.id, new EditLog(this.deps.cwd));
296
299
  try {
297
300
  const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
301
+ const quota = { used: 0 };
298
302
  const attempt = (model: string | undefined, thinking: string) => runPiAgent(
299
303
  {
300
304
  cwd: this.deps.cwd,
@@ -304,6 +308,18 @@ export class QuickFixQueue {
304
308
  ...grantOption("quickfix"),
305
309
  model,
306
310
  thinking,
311
+ mcpTools: profile.mcpTools,
312
+ delegationQuota: quota,
313
+ agent: { runId: job.id, taskId: job.id, domain: "backend", role: "worker" },
314
+ onChildRun: (child) => {
315
+ const edits = this.edits.get(job.id);
316
+ for (const path of child.edited ?? []) edits?.note("edit", { path });
317
+ if (edits) job.files = edits.shown();
318
+ job.children = [...(job.children ?? []).filter((entry) => entry.runId !== child.runId), child];
319
+ this.deps.feed?.step(QUICK_FIX_SOURCE, `child ${child.runId.slice(0, 8)}: ${child.step ?? child.status}`, child.runId);
320
+ if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
321
+ this.changed();
322
+ },
307
323
  timeoutMs: profile.timeoutMs,
308
324
  signal: controller.signal,
309
325
  stallTimeoutMs: this.deps.stallTimeoutMs,
@@ -52,7 +52,7 @@ export interface ScoutRequest {
52
52
  /** Model, thinking and time limit for one run, from settings. */
53
53
  function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
54
54
  const profile = profileFor(config, resolver, domain, role);
55
- return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
55
+ return { model: profile.model, mcpTools: profile.mcpTools, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
56
56
  }
57
57
 
58
58
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {
@@ -141,6 +141,7 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
141
141
  ]);
142
142
  const extraTools = request.hints?.tools() ?? [];
143
143
  const requests: AgentRequest[] = request.domains.map((domain, index) => ({
144
+ delegationQuota: { used: 0 },
144
145
  taskId: request.taskId,
145
146
  domain,
146
147
  role: "scout",
@@ -286,6 +287,7 @@ export async function runWorker(
286
287
  const hintTools = request.hints?.tools() ?? [];
287
288
  const extraTools = [...(request.agent?.extraTools ?? []), ...hintTools];
288
289
  const base: AgentRequest = {
290
+ delegationQuota: { used: 0 },
289
291
  taskId: request.taskId,
290
292
  domain: request.domain,
291
293
  role: "worker",
@@ -70,6 +70,7 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
70
70
  instruction: request.instruction,
71
71
  context: researchContext(request),
72
72
  model: profile.model,
73
+ mcpTools: profile.mcpTools,
73
74
  thinking: profile.thinking,
74
75
  timeoutMs: profile.timeoutMs,
75
76
  ...(request.time ? { time: request.time } : {}),
@@ -0,0 +1,72 @@
1
+ import { compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, type ExtensionAPI, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
2
+ import { contextMarks, freshContextOn } from "./fresh-context.ts";
3
+ import { isSubagentProcess } from "./quiet.ts";
4
+
5
+ export const CONTEXT_TARGET = 250_000;
6
+
7
+ export function compactionThreshold(window: number, reserve = DEFAULT_COMPACTION_SETTINGS.reserveTokens): number {
8
+ return Math.max(1, Math.min(CONTEXT_TARGET, Math.floor(window * 0.8), window - Math.min(reserve, Math.floor(window / 2))));
9
+ }
10
+
11
+ /** Compact at a settled tool boundary; ctx.compact() would abort the turn we need to continue. */
12
+ export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compact = compact): void {
13
+ let busy = false;
14
+ let lastLeaf: string | null | undefined;
15
+ pi.on("session_start", () => { busy = false; lastLeaf = undefined; });
16
+ pi.on("turn_end", async (event: TurnEndEvent, ctx) => {
17
+ if (busy || !ctx.model) return;
18
+ const leaf = ctx.sessionManager.getLeafId();
19
+ if (leaf === lastLeaf) return;
20
+ const configured = pi.getSettings().compaction;
21
+ const override = configured?.modelOverrides?.[`${ctx.model.provider}/${ctx.model.id}`];
22
+ const reserve = Math.min(override?.reserveTokens ?? configured?.reserveTokens ?? DEFAULT_COMPACTION_SETTINGS.reserveTokens, Math.floor(ctx.model.contextWindow / 2));
23
+ const threshold = compactionThreshold(ctx.model.contextWindow, reserve);
24
+ const estimate = event.context.contextMessages.reduce((total, message) => total + estimateTokens(message), 0) + Math.ceil(ctx.getSystemPrompt().length / 4);
25
+ const usage = ctx.getContextUsage();
26
+ if (Math.max(estimate, usage?.tokens ?? 0) < threshold) return;
27
+ let entries = ctx.sessionManager.buildContextEntries();
28
+ if (!isSubagentProcess() && freshContextOn()) {
29
+ const boundary = contextMarks(ctx.sessionManager.getBranch()).at(-1)?.at;
30
+ if (boundary !== undefined) entries = entries.filter((entry) => Date.parse(entry.timestamp) >= boundary);
31
+ }
32
+ if (entries.length < 2) return;
33
+ const keepRecentTokens = Math.min(override?.keepRecentTokens ?? configured?.keepRecentTokens ?? DEFAULT_COMPACTION_SETTINGS.keepRecentTokens, Math.floor(threshold / 4));
34
+ const cut = findCutPoint(entries, 0, entries.length, keepRecentTokens);
35
+ if (cut.firstKeptEntryIndex <= 0) return;
36
+ const prefix = entries.slice(0, cut.firstKeptEntryIndex);
37
+ const messages = prefix.flatMap((entry) => entry.type === "message" ? [entry.message] : []);
38
+ if (!messages.length) return;
39
+ const previous = [...entries].reverse().find((entry) => entry.type === "compaction");
40
+ const firstKeptEntryId = entries[cut.firstKeptEntryIndex]!.id;
41
+ const fileOps = { read: new Set<string>(), written: new Set<string>(), edited: new Set<string>() };
42
+ const details = previous?.type === "compaction" ? previous.details as { readFiles?: string[]; modifiedFiles?: string[] } | undefined : undefined;
43
+ for (const path of details?.readFiles ?? []) fileOps.read.add(path);
44
+ for (const path of details?.modifiedFiles ?? []) fileOps.edited.add(path);
45
+ for (const message of messages) {
46
+ const calls = message.role === "assistant" ? message.content.filter((part) => part.type === "toolCall") : message.role === "toolResult" ? message.nestedCalls?.calls ?? [] : [];
47
+ for (const call of calls) {
48
+ const path = call.arguments?.path;
49
+ if (typeof path !== "string") continue;
50
+ if (call.name === "read") fileOps.read.add(path);
51
+ if (call.name === "write") fileOps.written.add(path);
52
+ if (call.name === "edit") fileOps.edited.add(path);
53
+ }
54
+ }
55
+ lastLeaf = leaf;
56
+ busy = true;
57
+ ctx.ui.notify("Compacting agent context", "info");
58
+ try {
59
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
60
+ if (!auth.ok) throw new Error(auth.error);
61
+ const headers = auth.headers ? Object.fromEntries(Object.entries(auth.headers).filter((entry): entry is [string, string] => entry[1] !== null)) : undefined;
62
+ const retry = pi.getSettings().retry;
63
+ const retryPolicy = { enabled: retry?.enabled ?? true, maxRetries: retry?.maxRetries ?? 3, baseDelayMs: retry?.baseDelayMs ?? 2_000, maxAgentDelayMs: retry?.maxAgentDelayMs };
64
+ const result = await summarize({ firstKeptEntryId, messagesToSummarize: messages, turnPrefixMessages: [], isSplitTurn: false, tokensBefore: estimate, previousSummary: previous?.type === "compaction" ? previous.summary : undefined, fileOps, settings: { enabled: true, reserveTokens: reserve, keepRecentTokens } }, auth.baseUrl ? { ...ctx.model, baseUrl: auth.baseUrl } : ctx.model, auth.apiKey, headers, "Preserve goals, constraints, decisions, file references, verification evidence, unfinished work and outstanding delegated subtasks. The parent remains responsible for integration and verification.", ctx.signal, undefined, undefined, auth.env, retryPolicy);
65
+ ctx.signal?.throwIfAborted();
66
+ ctx.ui.notify("Agent context compacted", "info");
67
+ return { entries: [...event.entries, { type: "compaction" as const, summary: result.summary, firstKeptEntryId, usage: result.usage, details: result.details }] };
68
+ } catch (error) {
69
+ ctx.ui.notify(`Context compaction failed: ${(error as Error).message}`, "warning");
70
+ } finally { busy = false; }
71
+ });
72
+ }
@@ -0,0 +1,68 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { createDeskClient } from "../desk/ipc.ts";
4
+ import { AGENT_ENV, DELEGATION_ENV, DELEGATE_TOOL, MAX_CHILDREN, type AgentPolicy } from "../execution/delegation.ts";
5
+ import { loadConfig } from "../state/project.ts";
6
+ import { isSubagentProcess } from "./quiet.ts";
7
+
8
+ export function allowedAgentTool(policy: AgentPolicy, name: string, readOnlyHint?: boolean, hidden = false): boolean {
9
+ if (hidden) return false;
10
+ if (name.startsWith("mcp__")) return policy.mcpTools.includes(name) && (!policy.readOnly || readOnlyHint === true);
11
+ if (name === DELEGATE_TOOL && policy.depth > 1) return false;
12
+ return policy.tools.includes(name);
13
+ }
14
+
15
+ /** Enforce the same permissions on direct, discovered and codemode-nested calls. */
16
+ export function registerAgentPermissions(pi: ExtensionAPI): void {
17
+ const raw = process.env[AGENT_ENV];
18
+ let policy: AgentPolicy | undefined;
19
+ if (raw) {
20
+ try { policy = JSON.parse(raw) as AgentPolicy; }
21
+ catch { throw new Error("Invalid bot-lobby agent policy."); }
22
+ if (!Array.isArray(policy?.tools) || !Array.isArray(policy?.mcpTools) || typeof policy.readOnly !== "boolean" || ![1, 2].includes(policy.depth)) throw new Error("Invalid bot-lobby agent policy.");
23
+ }
24
+ pi.on("tool_call", (event) => {
25
+ const info = pi.getAllTools().find((tool) => tool.name === event.toolName);
26
+ if (!info) return { block: true, reason: `Unknown tool ${event.toolName}.` };
27
+ if (["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"].includes(event.toolName)) {
28
+ const server = (event.input as { server?: unknown }).server;
29
+ const grants = policy?.mcpTools ?? loadConfig().master.mcpTools ?? [];
30
+ if (typeof server === "string" && grants.some((tool) => tool.startsWith(`mcp__${server.replace(/[^A-Za-z0-9_]/g, "_")}__`)) && info?.exposure !== "hidden") return;
31
+ return { block: true, reason: "Specify an MCP server with an explicit tool grant to read its resources." };
32
+ }
33
+ if (policy) {
34
+ if (allowedAgentTool(policy, event.toolName, info?.annotations?.readOnlyHint, info?.exposure === "hidden")) return;
35
+ return { block: true, reason: `This agent is not granted ${event.toolName}.` };
36
+ }
37
+ if (event.toolName.startsWith("mcp__")) {
38
+ if ((loadConfig().master.mcpTools ?? []).includes(event.toolName) && info?.exposure !== "hidden") return;
39
+ return { block: true, reason: `Grant ${event.toolName} to the master in bot-lobby settings first.` };
40
+ }
41
+ if (event.parentToolCallId && !pi.getActiveTools().includes(event.toolName)) return { block: true, reason: `${event.toolName} is not active for this agent.` };
42
+ });
43
+ }
44
+
45
+ export function registerDelegation(pi: ExtensionAPI): void {
46
+ if (!isSubagentProcess() || !process.env[DELEGATION_ENV]) return;
47
+ const target = JSON.parse(process.env[DELEGATION_ENV]!) as { address: string; token: string; timeoutMs: number };
48
+ const client = createDeskClient(target.address, target.token);
49
+ pi.on("session_shutdown", () => client.close());
50
+ pi.registerTool({
51
+ name: DELEGATE_TOOL,
52
+ label: "Delegate subtasks",
53
+ description: "Split substantial, separable work among at most five children total. Supply complete briefs with goals, files, contracts, constraints and done criteria. Wait for their reports, integrate changes and verify the whole task yourself. Children cannot delegate further.",
54
+ parameters: Type.Object({ briefs: Type.Array(Type.String({ minLength: 1, maxLength: 32_000 }), { minItems: 1, maxItems: MAX_CHILDREN }) }),
55
+ executionMode: "sequential",
56
+ async execute(_id, params, signal) {
57
+ signal?.throwIfAborted();
58
+ const cancel = () => { void client.request({ op: "cancel_delegate" }); };
59
+ signal?.addEventListener("abort", cancel, { once: true });
60
+ try {
61
+ const answer = await client.request({ op: "delegate", briefs: params.briefs }, target.timeoutMs + 10_000);
62
+ if (!answer.ok) throw new Error(answer.text);
63
+ const children = JSON.parse(answer.text) as Array<{ runId: string; status: string; report: string; reportPath?: string; error?: string }>;
64
+ return { content: [{ type: "text", text: children.map((child) => `Child ${child.runId}: ${child.status}\n${child.report}${child.reportPath ? `\nFull report: ${child.reportPath}` : ""}${child.error ? `\nError: ${child.error}` : ""}`).join("\n\n") }], details: { children } };
65
+ } finally { signal?.removeEventListener("abort", cancel); }
66
+ },
67
+ });
68
+ }
@@ -90,7 +90,7 @@ export function createProfileResolver(config: BotLobbyConfig, options: ResolverO
90
90
  };
91
91
  }
92
92
 
93
- type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string };
93
+ type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string; mcpTools?: string[] };
94
94
 
95
95
  /** Unset models run on the session's; thinking is clamped to the model, with a one-time warning. */
96
96
  function resolveRunProfile(profile: RunProfile, label: string, options: ResolverOptions): RunProfile {
@@ -315,7 +315,7 @@ export function targetStep(steps: readonly string[], instruction: string | undef
315
315
  export function latestWorkerRun(runs: readonly AgentRun[]): AgentRun | undefined {
316
316
  let latest: AgentRun | undefined;
317
317
  for (const run of runs) {
318
- if (run.role !== "worker" || !run.instruction) continue;
318
+ if (run.role !== "worker" || run.parentRunId || !run.instruction) continue;
319
319
  if (!latest || Date.parse(run.startedAt) >= Date.parse(latest.startedAt)) latest = run;
320
320
  }
321
321
  return latest;
@@ -360,7 +360,7 @@ function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { com
360
360
  const latestRun = latestWorkerRun(runs);
361
361
  let latest = -1;
362
362
  for (const run of byStart(runs)) {
363
- if (run.role !== "worker") continue;
363
+ if (run.role !== "worker" || run.parentRunId) continue;
364
364
  const matched = targetStep(steps, run.instruction, completed);
365
365
  if (run === latestRun) latest = matched >= 0 && run.status === "success" ? matched + 1 : matched;
366
366
  if (run.status !== "success") continue;
@@ -401,6 +401,7 @@ export interface StepWork {
401
401
  ms: number;
402
402
  /** A worker is on it right now. */
403
403
  active: boolean;
404
+ activeAgentCount: number;
404
405
  }
405
406
 
406
407
  /** The steps a run works on: every step of a range it names, else the step it targets, else the next one still open. */
@@ -419,19 +420,29 @@ function runSteps(steps: readonly string[], instruction: string | undefined, com
419
420
  * completes them, so a later run that names no step goes to the next one.
420
421
  */
421
422
  export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now()): StepWork[] {
422
- const work = steps.map(() => ({ ms: 0, active: false }));
423
+ const work = steps.map(() => ({ ms: 0, active: false, activeAgentCount: 0 }));
424
+ const assigned = new Map<string, number[]>();
425
+ const counted = new Set<string>();
423
426
  const completed = new Set<number>();
424
427
  for (const run of byStart(runs)) {
425
428
  if (run.role !== "worker") continue;
426
- const covered = runSteps(steps, run.instruction, completed);
429
+ if (counted.has(run.runId)) continue;
430
+ counted.add(run.runId);
431
+ const covered = run.parentRunId
432
+ ? assigned.get(run.parentRunId) ?? runSteps(steps, run.stepInstruction, completed)
433
+ : runSteps(steps, run.instruction, completed);
434
+ assigned.set(run.runId, covered);
427
435
  const start = Date.parse(run.startedAt);
428
436
  const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
429
437
  const ms = Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
430
438
  for (const index of covered) {
431
- work[index]!.ms += ms;
432
- if (run.status === "running") work[index]!.active = true;
439
+ if (!run.parentRunId) work[index]!.ms += ms;
440
+ if (run.status === "running") {
441
+ work[index]!.active = true;
442
+ work[index]!.activeAgentCount += 1;
443
+ }
433
444
  }
434
- if (run.status === "success" && covered.length > 0) markThrough(completed, covered[covered.length - 1]!);
445
+ if (!run.parentRunId && run.status === "success" && covered.length > 0) markThrough(completed, covered[covered.length - 1]!);
435
446
  }
436
447
  return work;
437
448
  }
@@ -69,6 +69,7 @@ export function runFlags(run: Pick<AgentRun, "status" | "stalled" | "wrappedUp"
69
69
  */
70
70
  export function describeRun(run: AgentRun, now = Date.now()): string {
71
71
  const parts = [`${STATUS_ICONS[run.status]} ${agentName(run)} ${run.role}`, shortDuration(durationOf(run, now))];
72
+ if (run.parentRunId) parts.push(`child of ${run.parentRunId}`);
72
73
  const turns = run.turns ?? run.usage?.turns;
73
74
  if (turns) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
74
75
  if (run.tools) parts.push(`${run.tools} tool${run.tools === 1 ? "" : "s"}`);
@@ -84,6 +85,10 @@ export function describeRun(run: AgentRun, now = Date.now()): string {
84
85
  export function runLogEntry(run: AgentRun): RunLogEntry {
85
86
  return {
86
87
  runId: run.runId,
88
+ ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}),
89
+ ...(run.depth ? { depth: run.depth } : {}),
90
+ ...(run.instruction ? { instruction: run.instruction } : {}),
91
+ ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}),
87
92
  domain: run.domain,
88
93
  role: run.role,
89
94
  status: run.status,
@@ -106,6 +111,10 @@ export function runLogEntry(run: AgentRun): RunLogEntry {
106
111
  export function runFromLog(entry: RunLogEntry, taskId: string): AgentRun {
107
112
  return {
108
113
  runId: entry.runId,
114
+ ...(entry.parentRunId ? { parentRunId: entry.parentRunId } : {}),
115
+ ...(entry.depth ? { depth: entry.depth } : {}),
116
+ ...(entry.instruction ? { instruction: entry.instruction } : {}),
117
+ ...(entry.stepInstruction ? { stepInstruction: entry.stepInstruction } : {}),
109
118
  taskId,
110
119
  domain: entry.domain,
111
120
  role: entry.role,
@@ -27,12 +27,15 @@ export interface AgentModelConfig {
27
27
  thinking: string;
28
28
  /** Free-form instructions layered on top of this agent's built-in prompt. */
29
29
  instructions?: string;
30
+ /** Exact native MCP tool names this profile may call; empty grants none. */
31
+ mcpTools?: string[];
30
32
  /** Time limit per run; falls back to `workflow.agentTimeoutMs`. */
31
33
  timeoutMs?: number;
32
34
  }
33
35
 
34
36
  /** Scouts pick a model and a time limit only; their thinking is fixed at `SCOUT_THINKING`. */
35
37
  export interface ScoutConfig {
38
+ mcpTools?: string[];
36
39
  model: ModelRef;
37
40
  timeoutMs: number;
38
41
  }
@@ -320,20 +323,27 @@ function positive(value: unknown): number | undefined {
320
323
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
321
324
  }
322
325
 
326
+ export function normalizeMcpTools(value: unknown): string[] {
327
+ return Array.isArray(value) ? [...new Set(value.filter((tool): tool is string => typeof tool === "string" && /^mcp__[A-Za-z0-9_]+__[A-Za-z0-9_]+$/.test(tool)))] : [];
328
+ }
329
+
323
330
  /** Merge one agent's override over its default; legacy `inherit` or unknown thinking falls back to the default level. */
324
331
  function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
325
332
  const merged = { ...base, ...(override ?? {}) };
326
333
  const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
327
334
  const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
328
- return { model: merged.model, thinking, ...(typeof merged.instructions === "string" ? { instructions: merged.instructions } : {}), ...(timeoutMs ? { timeoutMs } : {}) };
335
+ const mcpTools = normalizeMcpTools(merged.mcpTools);
336
+ return { model: merged.model, thinking, ...(mcpTools.length ? { mcpTools } : {}), ...(typeof merged.instructions === "string" ? { instructions: merged.instructions } : {}), ...(timeoutMs ? { timeoutMs } : {}) };
329
337
  }
330
338
 
331
339
  /** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
332
340
  function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
333
341
  const base = DEFAULT_CONFIG.scout;
342
+ const mcpTools = normalizeMcpTools(override?.mcpTools);
334
343
  return {
335
344
  model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
336
345
  timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
346
+ ...(mcpTools.length ? { mcpTools } : {}),
337
347
  };
338
348
  }
339
349
 
@@ -479,6 +489,8 @@ export interface AgentProfile {
479
489
  thinking: string;
480
490
  timeoutMs: number;
481
491
  instructions?: string;
492
+ /** Exact native MCP tool names this profile may call; empty grants none. */
493
+ mcpTools?: string[];
482
494
  }
483
495
 
484
496
  /** The settings entry a domain/role run draws from. */
@@ -503,10 +515,10 @@ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role)
503
515
  const instructions = config.agents[domain].instructions;
504
516
  const fallback = config.workflow.agentTimeoutMs;
505
517
  if (kind === "scout") {
506
- return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions };
518
+ return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions, ...(config.scout.mcpTools?.length ? { mcpTools: config.scout.mcpTools } : {}) };
507
519
  }
508
520
  const entry = kind === "researcher" ? config.researcher : config.agents[kind];
509
- return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
521
+ return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions, ...(entry.mcpTools?.length ? { mcpTools: entry.mcpTools } : {}) };
510
522
  }
511
523
 
512
524
  /** The settings entry of a lobby agent (quick fix or planner). */
@@ -515,19 +527,19 @@ export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind):
515
527
  }
516
528
 
517
529
  /** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
518
- export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
530
+ export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string; mcpTools?: string[] } {
519
531
  const entry = lobbyAgentConfig(config, kind);
520
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions };
532
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions, ...(entry.mcpTools?.length ? { mcpTools: entry.mcpTools } : {}) };
521
533
  }
522
534
 
523
535
  /**
524
536
  * A planning panel seat's profile: the domain's (or the researcher's) model,
525
537
  * thinking and custom instructions, bounded by the planner's per-turn limit.
526
538
  */
527
- export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
539
+ export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string; mcpTools?: string[] } {
528
540
  const entry = member === "researcher" ? config.researcher : config.agents[member];
529
541
  const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
530
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions };
542
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions, ...(entry.mcpTools?.length ? { mcpTools: entry.mcpTools } : {}) };
531
543
  }
532
544
 
533
545
  /** Resolves the model, thinking and time limit one subagent run uses. */
@@ -100,6 +100,10 @@ export interface ResearchResult {
100
100
 
101
101
  export interface AgentRun {
102
102
  runId: string;
103
+ parentRunId?: string;
104
+ depth?: number;
105
+ /** Parent instruction used to assign children to plan steps. */
106
+ stepInstruction?: string;
103
107
  taskId: string;
104
108
  domain: Domain;
105
109
  role: Role;