@a-t-h-i/bot-lobby 0.6.33 → 0.6.35

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 CHANGED
@@ -124,3 +124,33 @@ Settings live in `~/.pi/bot-lobby/config.json` (`BOT_LOBBY_CONFIG_DIR` moves the
124
124
  `/bot-lobby config` shows what is in effect. Colour themes you import, under a name you choose,
125
125
  are kept in `themes.json` beside it, so every Pi session and project shows them. Tasks and
126
126
  project knowledge stay in `.pi/bot-lobby/` inside your project.
127
+
128
+
129
+ Requires **Pi 1.0.x** (1.0.1 or later). Every bot-lobby agent can use
130
+ `delegate_subtasks` for substantial, separable work. Each parent gets five child
131
+ slots total, shared across retries; children inherit its model, thinking, role,
132
+ permissions, worktree and remaining time. Children report to their parent, which
133
+ integrates and verifies the result. Children cannot delegate further. Active
134
+ children share `workflow.maxParallelWorkers`; waiting parents do not use those
135
+ child slots. Cancelling or finishing a parent stops its children and releases
136
+ their file claims. Full reports are saved under `.pi/bot-lobby/delegations/`.
137
+
138
+ Active task steps show the parent and active worker children, for example
139
+ **Step 6 – Implement large feature (6 devs working)**. Queued and finished agents
140
+ are excluded. A child finishing does not complete a checklist step.
141
+
142
+ The oracle, agents and children compact at settled turn boundaries before context
143
+ reaches 250,000 estimated tokens, or earlier at 80% of the model window or Pi's
144
+ native reserve threshold. Recent messages and summaries retain unfinished work;
145
+ raw session history remains available. A failed summary leaves context intact.
146
+ The boundary gives headroom below 300,000 tokens; a single very large input or tool
147
+ result can temporarily exceed it before the next boundary.
148
+
149
+ Pi's native MCP, `tool_search` and `codemode` are available. **MCP tools** in each
150
+ agent's Settings entry grants exact tool names, one per line, such as
151
+ `mcp__github__get_issue`. In `config.json` the same field is `mcpTools: ["mcp__github__get_issue"]`
152
+ on `master`, `agents.backend`, `agents.designer`, `agents.qa`, `scout`, `researcher`,
153
+ `quickFix` and `planner`. Grants default to empty and children inherit them.
154
+ Read-only roles also require the server's `readOnlyHint: true`; hidden tools and
155
+ ungranted calls are blocked, including calls nested inside codemode. Configure
156
+ server connections through Pi's native MCP settings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.33",
3
+ "version": "0.6.35",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -46,16 +46,16 @@
46
46
  "test": "node --test --test-reporter=dot test/*.test.ts"
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-ai": "*",
50
- "@earendil-works/pi-coding-agent": "*",
51
- "@earendil-works/pi-tui": "*",
49
+ "@earendil-works/pi-ai": ">=1.0.1 <1.1.0",
50
+ "@earendil-works/pi-coding-agent": ">=1.0.1 <1.1.0",
51
+ "@earendil-works/pi-tui": ">=1.0.1 <1.1.0",
52
52
  "typebox": "*"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@axe-core/playwright": "4.13.0",
56
- "@earendil-works/pi-ai": "0.87.0",
57
- "@earendil-works/pi-coding-agent": "0.87.0",
58
- "@earendil-works/pi-tui": "0.87.0",
56
+ "@earendil-works/pi-ai": "1.0.1",
57
+ "@earendil-works/pi-coding-agent": "1.0.1",
58
+ "@earendil-works/pi-tui": "1.0.1",
59
59
  "@fontsource-variable/inter": "5.3.0",
60
60
  "@playwright/test": "1.62.0",
61
61
  "@tailwindcss/vite": "4.3.3",
@@ -42,8 +42,8 @@ export function registerDeskClient(pi: ExtensionAPI, client?: DeskClient): void
42
42
  const path = editPath(event.input);
43
43
  if (!path) return undefined;
44
44
  const answer = await desk.request({ op: "check", path });
45
- // If the desk is gone the batch is over; never wedge the worker on it.
46
- if (!answer.ok || answer.allowed) return undefined;
45
+ // A vanished coordinator must not let an orphaned worker edit shared files.
46
+ if (answer.ok && answer.allowed) return undefined;
47
47
  return { block: true, reason: answer.text };
48
48
  });
49
49
 
package/src/desk/ipc.ts CHANGED
@@ -9,7 +9,7 @@ import { createConnection, createServer, type Socket } from "node:net";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
 
12
- export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait";
12
+ export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait" | "delegate" | "cancel_delegate";
13
13
 
14
14
  export interface DeskRequest {
15
15
  id: number;
@@ -17,6 +17,7 @@ export interface DeskRequest {
17
17
  op: DeskOp;
18
18
  path?: string;
19
19
  intent?: string;
20
+ briefs?: string[];
20
21
  note?: string;
21
22
  timeoutMs?: number;
22
23
  }
@@ -79,12 +80,12 @@ function serve(socket: Socket, handler: DeskHandler): void {
79
80
  }
80
81
 
81
82
  /** Start a desk server on a fresh private socket. */
82
- export async function startDeskServer(handler: DeskHandler): Promise<DeskServer> {
83
+ export async function startDeskServer(handler: DeskHandler, onDisconnect?: () => void): Promise<DeskServer> {
83
84
  const address = socketAddress();
84
85
  const sockets = new Set<Socket>();
85
86
  const server = createServer((socket) => {
86
87
  sockets.add(socket);
87
- socket.on("close", () => sockets.delete(socket));
88
+ socket.on("close", () => { sockets.delete(socket); onDisconnect?.(); });
88
89
  serve(socket, handler);
89
90
  });
90
91
  await new Promise<void>((done, fail) => {
@@ -63,6 +63,12 @@ export interface DeskSessionOptions {
63
63
  }
64
64
 
65
65
  /** Master-side desk for one batch of parallel workers. */
66
+ const sessions = new Map<string, DeskSession>();
67
+
68
+ export function deskSessionAt(address: string): DeskSession | undefined {
69
+ return sessions.get(address);
70
+ }
71
+
66
72
  export class DeskSession {
67
73
  readonly desk: FileDesk;
68
74
  private server?: DeskServer;
@@ -77,6 +83,7 @@ export class DeskSession {
77
83
 
78
84
  async open(): Promise<void> {
79
85
  this.server = await startDeskServer((request) => this.handle(request));
86
+ sessions.set(this.server.address, this);
80
87
  }
81
88
 
82
89
  /** Environment that points one worker's pi process at this desk. */
@@ -97,8 +104,13 @@ export class DeskSession {
97
104
 
98
105
  /** End of one attempt: hand everything it held to whoever is next, with `note` for them. */
99
106
  release(worker: WorkerId, note: (path: string, next: Claim) => string): void {
100
- this.handles.get(worker)?.annotate({ waitingFor: undefined });
107
+ this.releaseFiles(worker, note);
101
108
  this.handles.delete(worker);
109
+ }
110
+
111
+ /** A coordinating worker releases claims but still receives steering messages. */
112
+ releaseFiles(worker: WorkerId, note: (path: string, next: Claim) => string): void {
113
+ this.handles.get(worker)?.annotate({ waitingFor: undefined });
102
114
  for (const handover of this.desk.release(worker, note)) this.options.onHandover?.(handover);
103
115
  }
104
116
 
@@ -107,6 +119,7 @@ export class DeskSession {
107
119
  }
108
120
 
109
121
  async close(): Promise<void> {
122
+ if (this.server) sessions.delete(this.server.address);
110
123
  this.desk.close();
111
124
  await this.server?.close();
112
125
  this.server = undefined;
@@ -1,12 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createLiveRun } from "./live-run.ts";
1
3
  import type { Domain, Role } from "../schemas/agent.ts";
2
4
  import type { AgentRun } from "../schemas/findings.ts";
3
5
  import { roleSpec } from "../roles/registry.ts";
4
6
  import { compilePrompt } from "../prompts/compiler.ts";
5
- import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
6
- import { shortDuration, truncate } from "../text.ts";
7
7
  import { EditLog } from "../state/changes.ts";
8
8
  import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
- import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
9
+ import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
10
  import { ASK_ENV } from "../ask/relay.ts";
11
11
  import { ASK_TOOL } from "../ask/types.ts";
12
12
  import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
@@ -40,6 +40,8 @@ export interface AgentRequest {
40
40
  /** Prompt layers selected for this run. */
41
41
  context: AgentContext;
42
42
  model?: string;
43
+ mcpTools?: string[];
44
+ delegationQuota?: { used: number };
43
45
  thinking?: string;
44
46
  timeoutMs: number;
45
47
  cwd: string;
@@ -140,8 +142,9 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
140
142
  // A failed attempt may have edited files before the retry: the run owns every edit.
141
143
  const edits = new EditLog(request.cwd);
142
144
  let last: AgentRun | undefined;
145
+ const quota = request.delegationQuota ??= { used: 0 };
143
146
  for (let attempt = 1; attempt <= attempts; attempt++) {
144
- last = await runAgentOnce(request, run, attempt, startedAt, edits);
147
+ last = await runAgentOnce(request, run, attempt, startedAt, edits, quota);
145
148
  request.onAttemptEnd?.(last);
146
149
  if (!retryable(last)) break;
147
150
  // Under a budget a retry only uses what is left of the allotment.
@@ -187,8 +190,8 @@ function toolsFor(request: AgentRequest): readonly string[] | undefined {
187
190
  * Run one domain/role agent in an isolated pi process. The role's tool
188
191
  * allowlist comes from its spec, so read-only roles cannot modify anything.
189
192
  */
190
- async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog): Promise<AgentRun> {
191
- const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${Date.now().toString(36)}`, startedAt, attempt);
193
+ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog, quota: { used: number }): Promise<AgentRun> {
194
+ const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${randomUUID()}`, startedAt, attempt);
192
195
  request.onUpdate?.(base);
193
196
  const live = createLiveRun(base, request, edits);
194
197
 
@@ -203,6 +206,13 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
203
206
  const onTimeUp = time?.onTimeUp;
204
207
  const result = await runPiAgent({
205
208
  cwd: request.cwd,
209
+ agent: { runId: base.runId, taskId: request.taskId, domain: request.domain, role: request.role },
210
+ delegationQuota: quota,
211
+ onChildRun: (child) => {
212
+ for (const path of child.edited ?? []) edits.note("edit", { path });
213
+ request.onUpdate?.(child);
214
+ },
215
+ mcpTools: request.mcpTools,
206
216
  task: request.instruction,
207
217
  systemPrompt,
208
218
  tools: toolsFor(request),
@@ -270,92 +280,6 @@ function describeError(error: string, current: AgentRun, stalled: boolean): stri
270
280
  return `${error}${doing}`;
271
281
  }
272
282
 
273
- const THROTTLE_MS = 2000;
274
-
275
- /**
276
- * Tracks one attempt's live state from its stream and reports it through
277
- * `onUpdate`: activity changes, retries and notes immediately, counters and
278
- * heartbeats at most every couple of seconds.
279
- */
280
- function createLiveRun(base: AgentRun, request: AgentRequest, edits: EditLog) {
281
- let state: AgentRun = base;
282
- let lastEmit = 0;
283
- const emit = (patch: Partial<AgentRun>, force: boolean) => {
284
- state = { ...state, ...patch, lastEventAt: Date.now() };
285
- const now = Date.now();
286
- if (!force && now - lastEmit < THROTTLE_MS) return;
287
- lastEmit = now;
288
- request.onUpdate?.(state);
289
- };
290
- const changed = (activity: string, detail: string | undefined) => activity !== state.activity || detail !== state.detail;
291
- const onEvent = (event: PiStreamEvent) => {
292
- switch (event.type) {
293
- case "tool_execution_start": {
294
- edits.note(event.toolName, event.args);
295
- const activity = activityWord(event.toolName);
296
- const detail = activityDetail(event.toolName, event.args);
297
- const step = describeToolCall(event.toolName, event.args);
298
- emit({ activity, detail, step, tools: (state.tools ?? 0) + 1 }, changed(activity, detail) || step !== state.step);
299
- return;
300
- }
301
- case "thought":
302
- emit({ thought: event.text }, true);
303
- return;
304
- case "thinking":
305
- case "writing":
306
- emit({ activity: event.type, detail: undefined }, changed(event.type, undefined));
307
- return;
308
- case "turn_start":
309
- emit({ turns: (state.turns ?? 0) + 1 }, false);
310
- return;
311
- case "retry":
312
- emit({ note: `provider retry ${event.attempt}/${event.maxAttempts}: ${truncateLine(event.error)}`, noteKind: "warning" }, true);
313
- return;
314
- case "retry_end":
315
- emit({ note: undefined, noteKind: undefined }, true);
316
- return;
317
- case "compaction":
318
- emit({ note: "compacting context", noteKind: "info" }, true);
319
- return;
320
- case "wrap_up":
321
- emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
322
- return;
323
- case "time_up":
324
- emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
325
- return;
326
- case "extended":
327
- emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
328
- return;
329
- case "asking":
330
- emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
331
- return;
332
- case "answered":
333
- // Its time waited with it.
334
- if (request.time) request.time.endsAt += event.waitedMs;
335
- emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
336
- return;
337
- case "usage": {
338
- const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
339
- const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
340
- emit({ usage: next, model: event.model ?? state.model }, false);
341
- return;
342
- }
343
- default:
344
- emit({}, false);
345
- }
346
- };
347
- return {
348
- onEvent,
349
- annotate: (patch: LivePatch) => emit(patch, true),
350
- current: () => state,
351
- };
352
- }
353
-
354
- function truncateLine(text: string): string {
355
- const first = text.split("\n")[0] ?? "";
356
- return truncate(first, 48).split("\n")[0]!;
357
- }
358
-
359
283
  async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn) => Promise<TOut>): Promise<TOut[]> {
360
284
  const limit = Math.max(1, Math.min(concurrency, items.length));
361
285
  const results: TOut[] = new Array(items.length);
@@ -0,0 +1,193 @@
1
+ import { toolsOf } from "../excalidraw/sessions.ts";
2
+ import { appendMetrics, metricFromRun } from "../state/metrics.ts";
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
7
+ import type { AgentRun } from "../schemas/findings.ts";
8
+ import { agentProfile } from "../schemas/configuration.ts";
9
+ import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
10
+ import { EditLog } from "../state/changes.ts";
11
+ import { createLiveRun } from "./live-run.ts";
12
+ import { runPiAgent, type PiRunOptions, type ProcessRunner } from "./pi-runner.ts";
13
+ import { startDeskServer } from "../desk/ipc.ts";
14
+ import { DESK_ENV, DESK_TOOLS, DeskSession, deskSessionAt } from "../desk/session.ts";
15
+
16
+ export const MAX_CHILDREN = 5;
17
+ export const DELEGATE_TOOL = "delegate_subtasks";
18
+ export const AGENT_ENV = "BOT_LOBBY_AGENT_POLICY";
19
+ export const DELEGATION_ENV = "BOT_LOBBY_DELEGATION";
20
+
21
+ /** One shared child pool per task; parents waiting on children never hold its slots. */
22
+ const pools = new Map<string, { active: number; users: number; waiters: Array<() => void> }>();
23
+
24
+ async function inPool<T>(key: string, limit: number, signal: AbortSignal, work: () => Promise<T>): Promise<T> {
25
+ const pool = pools.get(key)!;
26
+ while (pool.active >= limit) {
27
+ signal.throwIfAborted();
28
+ await new Promise<void>((resolve, reject) => {
29
+ const wake = () => { signal.removeEventListener("abort", abort); resolve(); };
30
+ const abort = () => { pool.waiters = pool.waiters.filter((entry) => entry !== wake); reject(signal.reason); };
31
+ pool.waiters.push(wake);
32
+ signal.addEventListener("abort", abort, { once: true });
33
+ });
34
+ }
35
+ signal.throwIfAborted();
36
+ pool.active += 1;
37
+ try { return await work(); }
38
+ finally { pool.active -= 1; pool.waiters.shift()?.(); }
39
+ }
40
+
41
+ export function reserveChildren(quota: { used: number }, briefs: unknown): string[] {
42
+ if (!Array.isArray(briefs) || briefs.length === 0 || briefs.some((brief) => typeof brief !== "string" || !brief.trim() || brief.length > 32_000)) {
43
+ throw new Error("Provide non-empty self-contained briefs, each at most 32,000 characters.");
44
+ }
45
+ if (briefs.length > MAX_CHILDREN - quota.used) throw new Error(`Only ${MAX_CHILDREN - quota.used} of your five child slots remain.`);
46
+ quota.used += briefs.length;
47
+ return briefs.map((brief: string) => brief.trim());
48
+ }
49
+
50
+ export interface AgentPolicy {
51
+ tools: string[];
52
+ mcpTools: string[];
53
+ readOnly: boolean;
54
+ depth: number;
55
+ }
56
+
57
+ /** The host owns quotas, deadlines, file identities and child lifetimes, outside model context. */
58
+ export async function delegationHost(options: PiRunOptions, run: ProcessRunner) {
59
+ const config = loadConfig();
60
+ const id = options.agent?.runId ?? randomUUID();
61
+ const baseTools = options.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
62
+ const editable = baseTools.includes("edit") || baseTools.includes("write");
63
+ const agent = options.agent ?? { runId: id, taskId: id, domain: "backend" as const, role: editable ? "worker" as const : "reviewer" as const };
64
+ const depth = agent.depth ?? 1;
65
+ const quota = options.delegationQuota ?? { used: 0 };
66
+ const tools = [...new Set([...baseTools, ...(options.excalidraw ? toolsOf(options.excalidraw) : []), "codemode", "tool_search", ...(depth === 1 ? [DELEGATE_TOOL] : [])])];
67
+ const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
68
+ const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
69
+ if (depth > 1) return { tools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
70
+
71
+ const address = options.env?.[DESK_ENV.address];
72
+ let desk = address ? deskSessionAt(address) : undefined;
73
+ if (address && !desk) throw new Error("The inherited file desk is unavailable.");
74
+ const ownedDesk = editable && !desk ? new DeskSession({ cwd: options.cwd }) : undefined;
75
+ if (ownedDesk) { await ownedDesk.open(); desk = ownedDesk; }
76
+ const controller = new AbortController();
77
+ const lifetime = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
78
+ let timeController = new AbortController();
79
+ const childSignal = () => AbortSignal.any([lifetime, timeController.signal]);
80
+ const key = `${options.cwd}:${agent.taskId}`;
81
+ const pool = pools.get(key) ?? { active: 0, users: 0, waiters: [] };
82
+ pool.users += 1;
83
+ pools.set(key, pool);
84
+ let deadlineAt = Date.now() + (options.time?.upAtMs ?? options.timeoutMs);
85
+ let deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
86
+ const children = new Map<string, AgentRun>();
87
+ const pending = new Set<Promise<AgentRun>>();
88
+ const parentDeskId = options.env?.[DESK_ENV.worker] ?? id;
89
+ const update = (child: AgentRun) => {
90
+ children.set(child.runId, child);
91
+ options.onChildRun?.(child);
92
+ if (child.status === "running") return;
93
+ // Keep full child reports outside the parent's bounded tool result, including lobby jobs.
94
+ const root = detectProjectRoot(options.cwd, CONFIG_DIR_NAME);
95
+ const folder = join(dataRoot(root, CONFIG_DIR_NAME), "delegations");
96
+ mkdirSync(folder, { recursive: true });
97
+ writeFileSync(join(folder, `${child.runId}.json`), `${JSON.stringify(child)}\n`);
98
+ if (!options.agent || options.agent.taskId === options.agent.runId) appendMetrics(root, CONFIG_DIR_NAME, [metricFromRun(child)]);
99
+ };
100
+
101
+ const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
102
+ const childId = randomUUID();
103
+ const base: AgentRun = { runId: childId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: agent.role, instruction: brief, stepInstruction: options.task, status: "running", output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
104
+ const edits = new EditLog(options.cwd);
105
+ const live = createLiveRun(base, { onUpdate: update, timeoutMs: options.timeoutMs }, edits);
106
+ update(base);
107
+ try {
108
+ const left = deadlineAt - Date.now();
109
+ if (left <= 0) throw new Error("The parent's time is up.");
110
+ const result = await runPiAgent({
111
+ ...options,
112
+ agent: { ...agent, runId: childId, depth: 2 },
113
+ task: brief,
114
+ tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL), ...(desk ? DESK_TOOLS : [])])],
115
+ mcpTools,
116
+ timeoutMs: left,
117
+ signal,
118
+ time: undefined,
119
+ wrapUpAtMs: Math.floor(left * 0.85),
120
+ delegationQuota: undefined,
121
+ onChildRun: undefined,
122
+ onEvent: live.onEvent,
123
+ env: { ...options.env, ...(desk ? desk.env(childId) : {}) },
124
+ onStart: (handle) => desk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
125
+ }, run);
126
+ const final: AgentRun = { ...live.current(), ...result, edited: edits.list(), finishedAt: new Date().toISOString(), note: undefined, noteKind: undefined };
127
+ update(final);
128
+ return final;
129
+ } catch (error) {
130
+ const final: AgentRun = { ...live.current(), edited: edits.list(), status: signal.aborted ? "cancelled" : "failed", error: (error as Error).message, finishedAt: new Date().toISOString() };
131
+ update(final);
132
+ return final;
133
+ } finally {
134
+ desk?.release(childId, () => "Child finished; re-read the file and check its report before continuing.");
135
+ }
136
+ };
137
+
138
+ const token = randomUUID();
139
+ const server = await startDeskServer(async (request) => {
140
+ if (request.worker !== token) return { ok: false, text: "Unknown parent agent." };
141
+ if (request.op === "cancel_delegate") { controller.abort(); return { ok: true, text: "Children cancelled." }; }
142
+ if (request.op !== "delegate") return { ok: false, text: "Unknown delegation operation." };
143
+ const signal = childSignal();
144
+ signal.throwIfAborted();
145
+ const briefs = reserveChildren(quota, request.briefs);
146
+ // A coordinating parent must release claims before waiting for children that may need them.
147
+ desk?.releaseFiles(parentDeskId, () => "Delegating this work; re-read before editing.");
148
+ const runs = briefs.map((brief) => {
149
+ const promise = inPool(key, Math.max(1, config.workflow.maxParallelWorkers), signal, () => childRun(brief, signal));
150
+ pending.add(promise);
151
+ void promise.finally(() => pending.delete(promise)).catch(() => {});
152
+ return promise;
153
+ });
154
+ const results = await Promise.all(runs);
155
+ return { ok: true, text: JSON.stringify(results.map((child) => ({ runId: child.runId, status: child.status, report: child.output.slice(0, 12_000), reportPath: join(dataRoot(detectProjectRoot(options.cwd, CONFIG_DIR_NAME), CONFIG_DIR_NAME), "delegations", `${child.runId}.json`), error: child.error, usage: child.usage }))) };
156
+ }, () => controller.abort()).catch(async (error) => {
157
+ clearTimeout(deadline);
158
+ if (ownedDesk) await ownedDesk.close();
159
+ pool.users -= 1;
160
+ if (pool.users === 0) pools.delete(key);
161
+ throw error;
162
+ });
163
+
164
+ let closed = false;
165
+ return {
166
+ tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
167
+ env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
168
+ note: `For substantial, separable work, use ${DELEGATE_TOOL} with complete briefs (goal, files, contracts, constraints, done criteria). You have at most five children total; children cannot delegate. Integrate and verify their work yourself. Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
169
+ onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
170
+ desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
171
+ options.onStart?.(handle);
172
+ },
173
+ get children() { return [...children.values()]; },
174
+ pause() { clearTimeout(deadline); },
175
+ extend(ms: number, reset = false) {
176
+ deadlineAt = reset ? Date.now() + ms : deadlineAt + ms;
177
+ clearTimeout(deadline);
178
+ if (timeController.signal.aborted) timeController = new AbortController();
179
+ deadline = setTimeout(() => timeController.abort(), Math.max(1, deadlineAt - Date.now()));
180
+ },
181
+ async close() {
182
+ if (closed) return;
183
+ closed = true;
184
+ controller.abort();
185
+ clearTimeout(deadline);
186
+ await Promise.allSettled([...pending]);
187
+ await server.close();
188
+ if (ownedDesk) { desk?.release(parentDeskId, () => "Parent finished; re-read before editing."); await ownedDesk.close(); }
189
+ pool.users -= 1;
190
+ if (pool.users === 0) pools.delete(key);
191
+ },
192
+ };
193
+ }
@@ -0,0 +1,95 @@
1
+ import type { AgentRequest, LivePatch } from "./agent-runner.ts";
2
+ import type { AgentRun } from "../schemas/findings.ts";
3
+ import type { PiStreamEvent } from "./pi-runner.ts";
4
+ import { EditLog } from "../state/changes.ts";
5
+ import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
6
+ import { shortDuration, truncate } from "../text.ts";
7
+
8
+ const THROTTLE_MS = 2000;
9
+
10
+ /**
11
+ * Tracks one attempt's live state from its stream and reports it through
12
+ * `onUpdate`: activity changes, retries and notes immediately, counters and
13
+ * heartbeats at most every couple of seconds.
14
+ */
15
+ export function createLiveRun(base: AgentRun, request: Pick<AgentRequest, "onUpdate" | "timeoutMs" | "time">, edits: EditLog) {
16
+ let state: AgentRun = base;
17
+ let lastEmit = 0;
18
+ const emit = (patch: Partial<AgentRun>, force: boolean) => {
19
+ state = { ...state, ...patch, lastEventAt: Date.now() };
20
+ const now = Date.now();
21
+ if (!force && now - lastEmit < THROTTLE_MS) return;
22
+ lastEmit = now;
23
+ request.onUpdate?.(state);
24
+ };
25
+ const changed = (activity: string, detail: string | undefined) => activity !== state.activity || detail !== state.detail;
26
+ const onEvent = (event: PiStreamEvent) => {
27
+ switch (event.type) {
28
+ case "tool_execution_start": {
29
+ edits.note(event.toolName, event.args);
30
+ const activity = activityWord(event.toolName);
31
+ const detail = activityDetail(event.toolName, event.args);
32
+ const step = describeToolCall(event.toolName, event.args);
33
+ emit({ activity, detail, step, tools: (state.tools ?? 0) + 1 }, changed(activity, detail) || step !== state.step);
34
+ return;
35
+ }
36
+ case "thought":
37
+ emit({ thought: event.text }, true);
38
+ return;
39
+ case "thinking":
40
+ case "writing":
41
+ emit({ activity: event.type, detail: undefined }, changed(event.type, undefined));
42
+ return;
43
+ case "turn_start":
44
+ emit({ turns: (state.turns ?? 0) + 1 }, false);
45
+ return;
46
+ case "retry":
47
+ emit({ note: `provider retry ${event.attempt}/${event.maxAttempts}: ${truncateLine(event.error)}`, noteKind: "warning" }, true);
48
+ return;
49
+ case "retry_end":
50
+ emit({ note: undefined, noteKind: undefined }, true);
51
+ return;
52
+ case "compaction":
53
+ emit({ note: "compacting context", noteKind: "info" }, true);
54
+ return;
55
+ case "compaction_end":
56
+ emit({ note: event.error, noteKind: event.error ? "warning" : undefined }, true);
57
+ return;
58
+ case "wrap_up":
59
+ emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
60
+ return;
61
+ case "time_up":
62
+ emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
63
+ return;
64
+ case "extended":
65
+ emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
66
+ return;
67
+ case "asking":
68
+ emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
69
+ return;
70
+ case "answered":
71
+ // Its time waited with it.
72
+ if (request.time) request.time.endsAt += event.waitedMs;
73
+ emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
74
+ return;
75
+ case "usage": {
76
+ const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
77
+ const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
78
+ emit({ usage: next, model: event.model ?? state.model }, false);
79
+ return;
80
+ }
81
+ default:
82
+ emit({}, false);
83
+ }
84
+ };
85
+ return {
86
+ onEvent,
87
+ annotate: (patch: LivePatch) => emit(patch, true),
88
+ current: () => state,
89
+ };
90
+ }
91
+
92
+ function truncateLine(text: string): string {
93
+ const first = text.split("\n")[0] ?? "";
94
+ return truncate(first, 48).split("\n")[0]!;
95
+ }