@a-t-h-i/bot-lobby 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,12 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, join } from "node:path";
5
5
  import { activityWord } from "../pi/activity.ts";
6
+ import { shortDuration } from "../text.ts";
7
+
8
+ /** Live control over one running subagent: queue a steering message for its next turn. */
9
+ export interface RunHandle {
10
+ steer(text: string): void;
11
+ }
6
12
 
7
13
  export interface PiRunOptions {
8
14
  cwd: string;
@@ -15,6 +21,18 @@ export interface PiRunOptions {
15
21
  signal?: AbortSignal;
16
22
  /** Reports the current one-word tool activity while the agent works. */
17
23
  onActivity?: (activity: string) => void;
24
+ /** Every streamed agent event (tools, turns, thinking/writing, retries, usage). */
25
+ onEvent?: (event: PiStreamEvent) => void;
26
+ /** Extra environment for the subagent process. */
27
+ env?: Record<string, string>;
28
+ /** Kill the agent after this long without any output; 0 disables. */
29
+ stallTimeoutMs?: number;
30
+ /** Silence allowed while one tool call is in flight (tests, builds); 0 disables. */
31
+ toolStallTimeoutMs?: number;
32
+ /** Ask the agent to wrap up and report at this elapsed time; 0 disables. */
33
+ wrapUpAtMs?: number;
34
+ /** Receives the steering handle once the process is running. */
35
+ onStart?: (handle: RunHandle) => void;
18
36
  }
19
37
 
20
38
  export interface ProcessOutcome {
@@ -23,20 +41,44 @@ export interface ProcessOutcome {
23
41
  stderr: string;
24
42
  killed: boolean;
25
43
  timedOut: boolean;
44
+ /** Killed by the watchdog after going silent. */
45
+ stalled?: boolean;
46
+ /** The agent was asked to wrap up before its deadline. */
47
+ wrappedUp?: boolean;
48
+ /** The RPC host rejected the prompt itself. */
49
+ protocolError?: string;
26
50
  }
27
51
 
28
52
  /** Streaming agent events forwarded as they arrive on stdout. */
29
- export interface PiStreamEvent {
30
- type: "tool_execution_start";
31
- toolName: string;
32
- }
53
+ export type PiStreamEvent =
54
+ | { type: "tool_execution_start"; toolName: string; args?: unknown }
55
+ | { type: "tool_execution_end"; toolName: string; isError: boolean }
56
+ | { type: "turn_start" }
57
+ | { type: "thinking" }
58
+ | { type: "writing" }
59
+ | { type: "retry"; attempt: number; maxAttempts: number; delayMs: number; error: string }
60
+ | { type: "retry_end"; success: boolean }
61
+ | { type: "compaction" }
62
+ | { type: "usage"; input: number; output: number; cost: number; model?: string }
63
+ | { type: "wrap_up" }
64
+ | { type: "heartbeat" };
33
65
 
34
66
  export interface ProcessRunOptions {
35
67
  cwd: string;
36
68
  signal?: AbortSignal;
37
69
  timeoutMs: number;
38
- /** Optional sink for streaming agent events (tool starts). */
70
+ /** The task message sent to the agent over RPC stdin. */
71
+ prompt?: string;
72
+ /** Optional sink for streaming agent events. */
39
73
  onEvent?: (event: PiStreamEvent) => void;
74
+ env?: Record<string, string>;
75
+ stallTimeoutMs?: number;
76
+ toolStallTimeoutMs?: number;
77
+ wrapUpAtMs?: number;
78
+ wrapUpMessage?: string;
79
+ onStart?: (handle: RunHandle) => void;
80
+ /** Grace between the deadline abort and the hard kill. */
81
+ graceMs?: number;
40
82
  }
41
83
 
42
84
  export type ProcessRunner = (
@@ -50,6 +92,8 @@ export interface PiRunResult {
50
92
  error?: string;
51
93
  usage: { input: number; output: number; cost: number; turns: number };
52
94
  model?: string;
95
+ stalled?: boolean;
96
+ wrappedUp?: boolean;
53
97
  }
54
98
 
55
99
  export interface ParsedStream {
@@ -61,6 +105,31 @@ export interface ParsedStream {
61
105
  }
62
106
 
63
107
  const MAX_STREAM_CHARS = 5_000_000;
108
+ const HEARTBEAT_MS = 2000;
109
+ const SETTLE_FALLBACK_MS = 3000;
110
+ const EXIT_DRAIN_MS = 1500;
111
+ const DEADLINE_GRACE_MS = 5000;
112
+ const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
113
+
114
+ /** The steering message sent at the wrap-up point. */
115
+ export const WRAP_UP_MESSAGE = [
116
+ "bot-lobby: you are close to your time limit.",
117
+ "Stop exploring now. Finish or revert any half-done edit so every file is consistent,",
118
+ "then write your final report in the required output format.",
119
+ "List anything unfinished under Blockers or Notes.",
120
+ ].join(" ");
121
+
122
+ /** RPC protocol lines the process host reacts to (never forwarded to the caller). */
123
+ export interface ControlEvent {
124
+ type: string;
125
+ id?: string;
126
+ command?: string;
127
+ success?: boolean;
128
+ error?: string;
129
+ method?: string;
130
+ willRetry?: boolean;
131
+ delayMs?: number;
132
+ }
64
133
 
65
134
  /** Buffers one JSON-mode stream, keeping only lines parsePiStream consumes. */
66
135
  export interface StreamCollector {
@@ -68,21 +137,88 @@ export interface StreamCollector {
68
137
  finish(): string;
69
138
  }
70
139
 
140
+ const INTERESTING = [
141
+ '"tool_execution_start"',
142
+ '"tool_execution_end"',
143
+ '"message_end"',
144
+ '"turn_start"',
145
+ '"auto_retry_',
146
+ '"compaction_start"',
147
+ '"agent_settled"',
148
+ '"agent_end"',
149
+ '"extension_ui_request"',
150
+ '"response"',
151
+ ];
152
+
153
+ function num(value: unknown): number {
154
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
155
+ }
156
+
157
+ interface RawEvent {
158
+ type?: string;
159
+ toolName?: string;
160
+ args?: unknown;
161
+ isError?: boolean;
162
+ attempt?: number;
163
+ maxAttempts?: number;
164
+ delayMs?: number;
165
+ errorMessage?: string;
166
+ success?: boolean;
167
+ message?: MessageLike;
168
+ }
169
+
170
+ /** Map one parsed stream line to the caller-facing event, if it is one. */
171
+ function streamEvent(event: RawEvent): PiStreamEvent | undefined {
172
+ switch (event.type) {
173
+ case "tool_execution_start":
174
+ return typeof event.toolName === "string" ? { type: "tool_execution_start", toolName: event.toolName, args: event.args } : undefined;
175
+ case "tool_execution_end":
176
+ return { type: "tool_execution_end", toolName: String(event.toolName ?? ""), isError: event.isError === true };
177
+ case "turn_start":
178
+ return { type: "turn_start" };
179
+ case "auto_retry_start":
180
+ return { type: "retry", attempt: num(event.attempt), maxAttempts: num(event.maxAttempts), delayMs: num(event.delayMs), error: String(event.errorMessage ?? "") };
181
+ case "auto_retry_end":
182
+ return { type: "retry_end", success: event.success === true };
183
+ case "compaction_start":
184
+ return { type: "compaction" };
185
+ case "message_end": {
186
+ const message = event.message;
187
+ if (message?.role !== "assistant") return undefined;
188
+ return { type: "usage", input: num(message.usage?.input), output: num(message.usage?.output), cost: num(message.usage?.cost?.total), model: message.model };
189
+ }
190
+ default:
191
+ return undefined;
192
+ }
193
+ }
194
+
71
195
  /**
72
- * Buffers one JSON-mode stream, keeping only lines parsePiStream consumes and
73
- * forwarding tool-start events to an optional sink. Keeping only assistant
74
- * `message_end` events means a tool-heavy run cannot exhaust memory or
75
- * truncate the final report. Events split across chunk boundaries are
76
- * reassembled; malformed lines are ignored exactly as parsePiStream ignores
77
- * them.
196
+ * Buffers one JSON-lines stream, keeping only assistant `message_end` lines for
197
+ * parsePiStream and forwarding agent events to `onEvent` and RPC protocol lines
198
+ * to `onControl`. Per-token `message_update` deltas are never parsed: a cheap
199
+ * prefix check reports only thinking/writing phase changes, so a chatty run
200
+ * cannot exhaust memory. Events split across chunks are reassembled; malformed
201
+ * lines are ignored exactly as parsePiStream ignores them.
78
202
  */
79
- export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void): StreamCollector {
203
+ export function createStreamCollector(
204
+ onEvent?: (event: PiStreamEvent) => void,
205
+ onControl?: (event: ControlEvent) => void,
206
+ ): StreamCollector {
80
207
  const partial: string[] = [];
81
208
  const kept: string[] = [];
209
+ let phase: "thinking" | "writing" | undefined;
210
+ let lastBeat = 0;
211
+ const deltaPhase = (line: string) => {
212
+ if (!line.startsWith('{"type":"message_update"')) return;
213
+ const next = line.includes('"thinking_delta"') ? "thinking" : line.includes('"text_delta"') ? "writing" : undefined;
214
+ if (next && next !== phase) {
215
+ phase = next;
216
+ onEvent?.({ type: next });
217
+ }
218
+ };
82
219
  const keep = (line: string) => {
83
- // Most lines are per-token `message_update` deltas or large tool results;
84
- // skip the parse for any line that cannot be one of the two kept events.
85
- if (!line.includes('"tool_execution_start"') && !line.includes('"message_end"')) return;
220
+ deltaPhase(line);
221
+ if (!INTERESTING.some((marker) => line.includes(marker))) return;
86
222
  let raw: unknown;
87
223
  try {
88
224
  raw = JSON.parse(line);
@@ -90,12 +226,19 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
90
226
  return; // Malformed lines never parse; drop them.
91
227
  }
92
228
  if (raw === null || typeof raw !== "object") return;
93
- const event = raw as { type?: string; toolName?: string; message?: { role?: string } };
94
- if (event.type === "tool_execution_start" && typeof event.toolName === "string") {
95
- onEvent?.({ type: "tool_execution_start", toolName: event.toolName });
96
- }
229
+ const event = raw as RawEvent;
230
+ if (event.type === "tool_execution_start" || event.type === "turn_start") phase = undefined;
231
+ const forwarded = streamEvent(event);
232
+ if (forwarded) onEvent?.(forwarded);
233
+ onControl?.(event as ControlEvent);
97
234
  if (event.type === "message_end" && event.message?.role === "assistant") kept.push(line);
98
235
  };
236
+ const beat = () => {
237
+ const now = Date.now();
238
+ if (now - lastBeat < HEARTBEAT_MS) return;
239
+ lastBeat = now;
240
+ onEvent?.({ type: "heartbeat" });
241
+ };
99
242
  return {
100
243
  // Scan only the new chunk for line breaks; a long line split over many
101
244
  // chunks is joined once, when its newline finally arrives.
@@ -109,6 +252,7 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
109
252
  newline = chunk.indexOf("\n", start);
110
253
  }
111
254
  if (start < chunk.length) partial.push(chunk.slice(start));
255
+ beat();
112
256
  },
113
257
  finish() {
114
258
  if (partial.length > 0) keep(partial.splice(0).join(""));
@@ -117,14 +261,13 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
117
261
  };
118
262
  }
119
263
 
120
- /** Build the `pi` argv for one isolated, non-interactive agent run. */
121
- export function buildPiArgs(options: PiRunOptions & { systemPromptFile?: string }): string[] {
122
- const args = ["--mode", "json", "-p", "--no-session"];
264
+ /** Build the `pi` argv for one isolated, headless RPC agent run; the task goes over stdin. */
265
+ export function buildPiArgs(options: Omit<PiRunOptions, "task"> & { systemPromptFile?: string }): string[] {
266
+ const args = ["--mode", "rpc", "--no-session", "--no-prompt-templates", "--no-themes"];
123
267
  if (options.model && options.model !== "inherit") args.push("--model", options.model);
124
268
  if (options.thinking) args.push("--thinking", options.thinking);
125
269
  if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
126
270
  if (options.systemPromptFile) args.push("--append-system-prompt", options.systemPromptFile);
127
- args.push(`Task: ${options.task}`);
128
271
  return args;
129
272
  }
130
273
 
@@ -148,7 +291,7 @@ function messageText(content: unknown): string {
148
291
  .join("");
149
292
  }
150
293
 
151
- /** Parse the JSON-mode stream defensively; malformed lines are ignored. */
294
+ /** Parse the JSON-lines stream defensively; malformed lines are ignored. */
152
295
  export function parsePiStream(stdout: string): ParsedStream {
153
296
  const parsed: ParsedStream = { text: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
154
297
  for (const line of stdout.split("\n")) {
@@ -194,80 +337,214 @@ export function isPiLauncher(script: string): boolean {
194
337
  return base === "pi" || base === "pi.js" || script.includes("pi-coding-agent");
195
338
  }
196
339
 
197
- function terminate(proc: ChildProcess): void {
198
- proc.kill("SIGTERM");
199
- const escalation = setTimeout(() => {
200
- if (proc.exitCode === null) proc.kill("SIGKILL");
201
- }, 5000);
340
+ const POSIX = process.platform !== "win32";
341
+
342
+ /** Signal the child's whole process group (POSIX), so grandchildren go too. */
343
+ function signalGroup(proc: ChildProcess, signal: NodeJS.Signals): void {
344
+ if (proc.pid === undefined) return;
345
+ if (POSIX) {
346
+ try {
347
+ process.kill(-proc.pid, signal);
348
+ return;
349
+ } catch {
350
+ // The group is already gone; fall through to the child itself.
351
+ }
352
+ }
353
+ try {
354
+ proc.kill(signal);
355
+ } catch {
356
+ // Already exited.
357
+ }
358
+ }
359
+
360
+ function terminate(proc: ChildProcess, graceMs = 5000): void {
361
+ signalGroup(proc, "SIGTERM");
362
+ const escalation = setTimeout(() => signalGroup(proc, "SIGKILL"), graceMs);
202
363
  escalation.unref();
203
364
  }
204
365
 
205
- function wireOutput(
206
- proc: ChildProcess,
207
- buffers: { stdout: string; stderr: string },
208
- onEvent?: (event: PiStreamEvent) => void,
209
- ): () => void {
210
- const collector = createStreamCollector(onEvent);
211
- // Decode as UTF-8 streams so a multi-byte character split across chunks survives.
212
- proc.stdout?.setEncoding("utf8");
213
- proc.stderr?.setEncoding("utf8");
214
- proc.stdout?.on("data", (chunk: string) => collector.push(chunk));
215
- proc.stderr?.on("data", (chunk: string) => {
216
- // Keep stderr head-bounded: it only feeds exit-code error messages.
217
- if (buffers.stderr.length < MAX_STREAM_CHARS) buffers.stderr += chunk;
366
+ /** Line-oriented stdin writer for RPC commands; a closed pipe silently drops writes. */
367
+ function rpcInput(proc: ChildProcess): { send(command: object): void; close(): void } {
368
+ let open = true;
369
+ proc.stdin?.on("error", () => {
370
+ open = false;
218
371
  });
219
- return () => {
220
- buffers.stdout = collector.finish();
372
+ return {
373
+ send(command) {
374
+ if (!open || !proc.stdin?.writable) return;
375
+ try {
376
+ proc.stdin.write(`${JSON.stringify(command)}\n`);
377
+ } catch {
378
+ open = false;
379
+ }
380
+ },
381
+ close() {
382
+ if (!open) return;
383
+ open = false;
384
+ try {
385
+ proc.stdin?.end();
386
+ } catch {
387
+ // Already closed.
388
+ }
389
+ },
221
390
  };
222
391
  }
223
392
 
224
- /** Default runner: spawn the same pi binary in JSON mode and collect output. */
393
+ interface RunState {
394
+ killed: boolean;
395
+ timedOut: boolean;
396
+ stalled: boolean;
397
+ wrappedUp: boolean;
398
+ settled: boolean;
399
+ protocolError?: string;
400
+ }
401
+
402
+ /** Smallest positive limit, used to pick a watchdog cadence that can see it. */
403
+ function watchInterval(...limits: Array<number | undefined>): number {
404
+ const positive = limits.filter((limit): limit is number => typeof limit === "number" && limit > 0);
405
+ const smallest = positive.length > 0 ? Math.min(...positive) : 4000;
406
+ return Math.max(25, Math.min(1000, Math.floor(smallest / 4)));
407
+ }
408
+
409
+ /**
410
+ * Default runner: spawn pi in RPC mode, send the task over stdin, and watch it.
411
+ * The child leads its own process group so a stall, deadline or cancel takes
412
+ * its grandchildren (dev servers, watch-mode tests) with it, and the run ends
413
+ * on process exit rather than waiting for a pipe a grandchild may hold open.
414
+ * Near the deadline the agent is steered to wrap up and report; at the
415
+ * deadline it is aborted; after prolonged silence it is killed as stalled.
416
+ */
225
417
  export function spawnPiProcess(args: string[], options: ProcessRunOptions): Promise<ProcessOutcome> {
226
418
  return new Promise((resolve) => {
227
419
  const invocation = resolvePiInvocation(args);
228
420
  const proc = spawn(invocation.command, invocation.args, {
229
421
  cwd: options.cwd,
230
- // Marks subagent processes so bot-lobby skips master-only quiet wiring.
231
- env: { ...process.env, BOT_LOBBY_SUBAGENT: "1" },
422
+ // Marks subagent processes so bot-lobby skips master-only wiring, and
423
+ // skips pi's startup network work (update checks, package refresh).
424
+ env: { ...process.env, BOT_LOBBY_SUBAGENT: "1", PI_SKIP_VERSION_CHECK: "1", PI_OFFLINE: "1", ...options.env },
232
425
  shell: false,
233
- stdio: ["ignore", "pipe", "pipe"],
426
+ detached: POSIX,
427
+ windowsHide: true,
428
+ stdio: ["pipe", "pipe", "pipe"],
429
+ });
430
+ const state: RunState = { killed: false, timedOut: false, stalled: false, wrappedUp: false, settled: false };
431
+ const input = rpcInput(proc);
432
+ const started = Date.now();
433
+ let lastOutput = started;
434
+ let quietGraceUntil = 0;
435
+ let toolsInFlight = 0;
436
+ let stderr = "";
437
+ let exitCode: number | undefined;
438
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
439
+ let done = false;
440
+
441
+ const onEvent = (event: PiStreamEvent) => {
442
+ if (event.type === "tool_execution_start") toolsInFlight += 1;
443
+ if (event.type === "tool_execution_end") toolsInFlight = Math.max(0, toolsInFlight - 1);
444
+ if (event.type === "retry") quietGraceUntil = Date.now() + event.delayMs;
445
+ options.onEvent?.(event);
446
+ };
447
+ const onControl = (event: ControlEvent) => {
448
+ if (event.type === "response" && event.command === "prompt" && event.success === false) {
449
+ state.protocolError = event.error ?? "the prompt was rejected";
450
+ input.close();
451
+ } else if (event.type === "extension_ui_request" && event.method && DIALOG_METHODS.has(event.method)) {
452
+ // Nobody can answer a dialog inside a subagent; cancel it instead of blocking forever.
453
+ input.send({ type: "extension_ui_response", id: event.id, cancelled: true });
454
+ } else if (event.type === "agent_settled") {
455
+ state.settled = true;
456
+ input.close();
457
+ } else if (event.type === "agent_end" && event.willRetry !== true && !settleTimer) {
458
+ settleTimer = setTimeout(() => input.close(), SETTLE_FALLBACK_MS);
459
+ }
460
+ };
461
+ const collector = createStreamCollector(onEvent, onControl);
462
+ proc.stdout?.setEncoding("utf8");
463
+ proc.stderr?.setEncoding("utf8");
464
+ proc.stdout?.on("data", (chunk: string) => {
465
+ lastOutput = Date.now();
466
+ collector.push(chunk);
467
+ });
468
+ proc.stderr?.on("data", (chunk: string) => {
469
+ // Keep stderr head-bounded: it only feeds exit-code error messages.
470
+ if (stderr.length < MAX_STREAM_CHARS) stderr += chunk;
234
471
  });
235
- const buffers = { stdout: "", stderr: "" };
236
- const state = { killed: false, timedOut: false };
237
- const flush = wireOutput(proc, buffers, options.onEvent);
238
472
 
239
- const finish = (exitCode: number) => {
240
- clearTimeout(timer);
473
+ const finish = (code: number) => {
474
+ if (done) return;
475
+ done = true;
476
+ clearTimeout(deadline);
477
+ clearTimeout(settleTimer);
478
+ clearInterval(watch);
241
479
  options.signal?.removeEventListener("abort", onAbort);
242
- flush();
243
- resolve({ exitCode, ...buffers, ...state });
480
+ input.close();
481
+ // Reap anything the agent left running in its group.
482
+ if (POSIX) signalGroup(proc, "SIGTERM");
483
+ const { killed, timedOut, stalled, wrappedUp, protocolError } = state;
484
+ resolve({ exitCode: code, stdout: collector.finish(), stderr, killed, timedOut, stalled, wrappedUp, protocolError });
244
485
  };
245
486
  const onAbort = () => {
246
487
  state.killed = true;
247
- terminate(proc);
488
+ input.send({ type: "abort" });
489
+ input.close();
490
+ terminate(proc, 3000);
248
491
  };
249
- const timer = setTimeout(() => {
492
+ const deadline = setTimeout(() => {
250
493
  state.killed = true;
251
494
  state.timedOut = true;
252
- terminate(proc);
495
+ input.send({ type: "abort" });
496
+ input.close();
497
+ setTimeout(() => terminate(proc), options.graceMs ?? DEADLINE_GRACE_MS).unref();
253
498
  }, options.timeoutMs);
499
+ const watch = setInterval(() => {
500
+ if (state.killed || done) return;
501
+ const now = Date.now();
502
+ if (!state.wrappedUp && !state.settled && options.wrapUpAtMs && options.wrapUpAtMs > 0 && now - started >= options.wrapUpAtMs) {
503
+ state.wrappedUp = true;
504
+ input.send({ type: "steer", message: options.wrapUpMessage ?? WRAP_UP_MESSAGE });
505
+ options.onEvent?.({ type: "wrap_up" });
506
+ }
507
+ const limit = toolsInFlight > 0 ? options.toolStallTimeoutMs : options.stallTimeoutMs;
508
+ if (limit && limit > 0 && now - Math.max(lastOutput, quietGraceUntil) >= limit) {
509
+ state.killed = true;
510
+ state.stalled = true;
511
+ input.close();
512
+ terminate(proc, 3000);
513
+ }
514
+ }, watchInterval(options.stallTimeoutMs, options.toolStallTimeoutMs, options.wrapUpAtMs));
515
+ watch.unref();
254
516
 
255
- proc.on("close", (code) => finish(code ?? 0));
517
+ proc.on("exit", (code, signal) => {
518
+ exitCode = code ?? (signal ? 1 : 0);
519
+ // A grandchild may hold stdout open forever; give the pipe a moment to drain, then move on.
520
+ setTimeout(() => finish(exitCode ?? 0), EXIT_DRAIN_MS);
521
+ });
522
+ proc.on("close", (code) => finish(exitCode ?? code ?? 0));
256
523
  proc.on("error", (error: Error) => {
257
- buffers.stderr += error.message;
524
+ stderr += error.message;
258
525
  finish(1);
259
526
  });
527
+ if (options.prompt !== undefined) input.send({ id: "task", type: "prompt", message: options.prompt });
528
+ options.onStart?.({ steer: (text) => input.send({ type: "steer", message: text }) });
260
529
  if (options.signal?.aborted) onAbort();
261
530
  else options.signal?.addEventListener("abort", onAbort, { once: true });
262
531
  });
263
532
  }
264
533
 
265
- function toResult(parsed: ParsedStream, outcome: ProcessOutcome, aborted: boolean): PiRunResult {
266
- const base = { output: parsed.text, usage: parsed.usage, model: parsed.model };
267
- if (outcome.timedOut) return { ...base, status: "timeout", error: "agent timed out" };
534
+ function toResult(parsed: ParsedStream, outcome: ProcessOutcome, aborted: boolean, options: PiRunOptions): PiRunResult {
535
+ const base = { output: parsed.text, usage: parsed.usage, model: parsed.model, wrappedUp: outcome.wrappedUp === true };
536
+ if (outcome.stalled) {
537
+ const quiet = shortDuration(options.stallTimeoutMs ?? 0);
538
+ return { ...base, status: "timeout", stalled: true, error: `stalled: no output for ${quiet}` };
539
+ }
540
+ if (outcome.timedOut) {
541
+ const note = outcome.wrappedUp ? " after a wrap-up request" : "";
542
+ return { ...base, status: "timeout", error: `hit the ${shortDuration(options.timeoutMs)} time limit${note}` };
543
+ }
268
544
  if (outcome.killed || aborted || parsed.stopReason === "aborted") {
269
545
  return { ...base, status: "cancelled", error: "agent was cancelled" };
270
546
  }
547
+ if (outcome.protocolError) return { ...base, status: "failed", error: outcome.protocolError };
271
548
  if (outcome.exitCode !== 0) {
272
549
  const detail = parsed.errorMessage ?? outcome.stderr.trim();
273
550
  return { ...base, status: "failed", error: detail || `pi exited with code ${outcome.exitCode}` };
@@ -295,9 +572,18 @@ export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spa
295
572
  cwd: options.cwd,
296
573
  signal: options.signal,
297
574
  timeoutMs: options.timeoutMs,
298
- onEvent: (event) => options.onActivity?.(activityWord(event.toolName)),
575
+ prompt: `Task: ${options.task}`,
576
+ env: options.env,
577
+ stallTimeoutMs: options.stallTimeoutMs,
578
+ toolStallTimeoutMs: options.toolStallTimeoutMs,
579
+ wrapUpAtMs: options.wrapUpAtMs,
580
+ onStart: options.onStart,
581
+ onEvent: (event) => {
582
+ if (event.type === "tool_execution_start") options.onActivity?.(activityWord(event.toolName));
583
+ options.onEvent?.(event);
584
+ },
299
585
  });
300
- return toResult(parsePiStream(outcome.stdout), outcome, options.signal?.aborted === true);
586
+ return toResult(parsePiStream(outcome.stdout), outcome, options.signal?.aborted === true, options);
301
587
  } finally {
302
588
  prompt?.cleanup();
303
589
  }
package/src/index.ts CHANGED
@@ -4,10 +4,14 @@ import { registerLifecycle } from "./pi/events.ts";
4
4
  import { registerOrchestrateTool } from "./pi/tools.ts";
5
5
  import { onTransition } from "./state/task-state.ts";
6
6
  import { ping } from "./pi/notify.ts";
7
+ import { isSubagentProcess } from "./pi/quiet.ts";
8
+ import { registerDeskClient } from "./desk/client-extension.ts";
7
9
 
8
10
  export default function (pi: ExtensionAPI): void {
9
11
  registerLifecycle(pi, CONFIG_DIR_NAME);
10
12
  onTransition((task) => ping(task.state, task.title));
11
13
  registerCommands(pi, CONFIG_DIR_NAME);
12
14
  registerOrchestrateTool(pi, CONFIG_DIR_NAME);
15
+ // Parallel workers share files through the master's file desk.
16
+ if (isSubagentProcess()) registerDeskClient(pi);
13
17
  }
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { BotLobbyConfig } from "../schemas/configuration.ts";
3
+ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
4
4
  import type { Domain } from "../schemas/agent.ts";
5
5
  import type { AgentRun, ReviewResult, ScoutResult, WorkerResult } from "../schemas/findings.ts";
6
6
  import { domainSpec } from "../agents/registry.ts";
7
- import { runAgent, runParallel, type AgentRequest } from "../execution/agent-runner.ts";
7
+ import { runAgent, runParallel, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
8
8
  import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
9
9
  import { readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
@@ -32,14 +32,16 @@ export interface ScoutRequest {
32
32
  dataRoots: readonly string[];
33
33
  taskDir: string;
34
34
  config: BotLobbyConfig;
35
+ /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
36
+ profile?: ProfileResolver;
35
37
  signal?: AbortSignal;
36
38
  onUpdate?: (run: AgentRun) => void;
37
39
  }
38
40
 
39
- /** Models are inherited from the session unless the config pins one. */
40
- export function resolveModel(config: BotLobbyConfig, domain: Domain): string | undefined {
41
- const model = config.agents[domain].model;
42
- return model === "inherit" ? undefined : model;
41
+ /** Model, thinking and time limit for one run, from settings. */
42
+ function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
43
+ const profile = profileFor(config, resolver, domain, role);
44
+ return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
43
45
  }
44
46
 
45
47
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {
@@ -82,13 +84,11 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
82
84
  role: "scout",
83
85
  instruction: scoutInstruction(request, domain),
84
86
  context: scoutContext(request, domain),
85
- model: resolveModel(request.config, domain),
86
- thinking: request.config.agents[domain].thinking,
87
- timeoutMs: request.config.workflow.agentTimeoutMs,
87
+ ...profileFields(request.config, request.profile, domain, "scout"),
88
88
  cwd: request.cwd,
89
89
  signal: request.signal,
90
90
  onUpdate: request.onUpdate,
91
- retries: request.config.workflow.maxAgentRetries,
91
+ ...watchdogOptions(request.config.workflow),
92
92
  }));
93
93
  const runs = await runParallel(requests, request.config.workflow.maxParallelScouts, run);
94
94
  const outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
@@ -136,6 +136,8 @@ export interface WorkerOutcome {
136
136
  }
137
137
 
138
138
  export interface WorkerRequest {
139
+ /** Extra run wiring for parallel batches: the file desk's env, tools and hooks. */
140
+ agent?: Pick<AgentRequest, "env" | "extraTools" | "onStart" | "onAttemptEnd">;
139
141
  taskId: string;
140
142
  domain: Domain;
141
143
  /** What the Master wants implemented. */
@@ -146,6 +148,8 @@ export interface WorkerRequest {
146
148
  cwd: string;
147
149
  dataRoots: readonly string[];
148
150
  config: BotLobbyConfig;
151
+ /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
152
+ profile?: ProfileResolver;
149
153
  signal?: AbortSignal;
150
154
  onUpdate?: (run: AgentRun) => void;
151
155
  }
@@ -184,13 +188,12 @@ export async function runWorker(
184
188
  instructions: request.config.agents[request.domain].instructions,
185
189
  workflowContext: workerWorkflowContext(request),
186
190
  },
187
- model: resolveModel(request.config, request.domain),
188
- thinking: request.config.agents[request.domain].thinking,
189
- timeoutMs: request.config.workflow.agentTimeoutMs,
191
+ ...profileFields(request.config, request.profile, request.domain, "worker"),
190
192
  cwd: request.cwd,
191
193
  signal: request.signal,
192
194
  onUpdate: request.onUpdate,
193
- retries: request.config.workflow.maxAgentRetries,
195
+ ...watchdogOptions(request.config.workflow),
196
+ ...request.agent,
194
197
  },
195
198
  run,
196
199
  );
@@ -219,6 +222,8 @@ export interface ReviewerRequest {
219
222
  cwd: string;
220
223
  dataRoots: readonly string[];
221
224
  config: BotLobbyConfig;
225
+ /** Settings-derived model/thinking/time limit, clamped to the model; plain settings when absent. */
226
+ profile?: ProfileResolver;
222
227
  signal?: AbortSignal;
223
228
  onUpdate?: (run: AgentRun) => void;
224
229
  }
@@ -262,12 +267,11 @@ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelec
262
267
  instructions: request.config.agents[request.domain].instructions,
263
268
  workflowContext: reviewerContext(request),
264
269
  },
265
- model: resolveModel(request.config, request.domain),
266
- thinking: request.config.agents[request.domain].thinking,
267
- timeoutMs: request.config.workflow.agentTimeoutMs,
270
+ ...profileFields(request.config, request.profile, request.domain, "reviewer"),
268
271
  cwd: request.cwd,
269
272
  signal: request.signal,
270
273
  onUpdate: request.onUpdate,
274
+ ...watchdogOptions(request.config.workflow),
271
275
  retries: 0,
272
276
  };
273
277
  }