@a-t-h-i/bot-lobby 0.3.0 → 0.5.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.
Files changed (61) hide show
  1. package/README.md +266 -35
  2. package/package.json +1 -1
  3. package/prompts/backend.md +46 -1
  4. package/prompts/designer.md +94 -15
  5. package/prompts/master.md +70 -1
  6. package/prompts/panel.md +39 -0
  7. package/prompts/planner.md +64 -0
  8. package/prompts/qa.md +35 -2
  9. package/prompts/quickfix.md +41 -0
  10. package/prompts/researcher.md +6 -0
  11. package/prompts/reviewer.md +16 -0
  12. package/prompts/scout.md +11 -2
  13. package/prompts/worker.md +35 -2
  14. package/src/desk/client-extension.ts +101 -0
  15. package/src/desk/desk.ts +249 -0
  16. package/src/desk/ipc.ts +178 -0
  17. package/src/desk/session.ts +214 -0
  18. package/src/execution/agent-runner.ts +165 -14
  19. package/src/execution/pi-runner.ts +376 -65
  20. package/src/index.ts +7 -0
  21. package/src/lobby/feed.ts +253 -0
  22. package/src/lobby/issues.ts +227 -0
  23. package/src/lobby/layout.ts +174 -0
  24. package/src/lobby/planner.ts +474 -0
  25. package/src/lobby/quickfix.ts +227 -0
  26. package/src/lobby/runtime.ts +440 -0
  27. package/src/lobby/tabs/home.ts +164 -0
  28. package/src/lobby/tabs/issues.ts +72 -0
  29. package/src/lobby/tabs/metrics.ts +162 -0
  30. package/src/lobby/tabs/plan.ts +160 -0
  31. package/src/lobby/tabs/quickfix.ts +101 -0
  32. package/src/lobby/tabs/tasks.ts +209 -0
  33. package/src/lobby/view.ts +855 -0
  34. package/src/master/master.ts +21 -17
  35. package/src/master/research.ts +8 -7
  36. package/src/pi/activity.ts +165 -0
  37. package/src/pi/commands.ts +46 -59
  38. package/src/pi/events.ts +5 -2
  39. package/src/pi/expressions.ts +43 -12
  40. package/src/pi/kaomoji.ts +227 -0
  41. package/src/pi/mascot-art.ts +5 -15
  42. package/src/pi/model-support.ts +135 -0
  43. package/src/pi/run-summary.ts +172 -0
  44. package/src/pi/settings-ui.ts +162 -60
  45. package/src/pi/start-task.ts +63 -0
  46. package/src/pi/tools.ts +51 -8
  47. package/src/pi/ui.ts +151 -49
  48. package/src/pi/zen-large.ts +41 -4
  49. package/src/pi/zen-metrics.ts +47 -7
  50. package/src/pi/zen.ts +29 -8
  51. package/src/roles/reviewer.ts +24 -4
  52. package/src/roles/worker.ts +7 -1
  53. package/src/schemas/configuration.ts +177 -18
  54. package/src/schemas/findings.ts +26 -0
  55. package/src/schemas/task.ts +27 -1
  56. package/src/state/backlog.ts +106 -0
  57. package/src/state/comments.ts +136 -0
  58. package/src/state/metrics.ts +305 -0
  59. package/src/state/project.ts +9 -0
  60. package/src/text.ts +9 -0
  61. package/src/workflow/workflow.ts +161 -12
@@ -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,49 @@ 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
+ /** One finished thinking block, bounded to `MAX_THOUGHT_CHARS`. */
65
+ | { type: "thought"; text: string }
66
+ | { type: "heartbeat" };
67
+
68
+ /** Longest thought forwarded from a subagent stream. */
69
+ export const MAX_THOUGHT_CHARS = 1500;
33
70
 
34
71
  export interface ProcessRunOptions {
35
72
  cwd: string;
36
73
  signal?: AbortSignal;
37
74
  timeoutMs: number;
38
- /** Optional sink for streaming agent events (tool starts). */
75
+ /** The task message sent to the agent over RPC stdin. */
76
+ prompt?: string;
77
+ /** Optional sink for streaming agent events. */
39
78
  onEvent?: (event: PiStreamEvent) => void;
79
+ env?: Record<string, string>;
80
+ stallTimeoutMs?: number;
81
+ toolStallTimeoutMs?: number;
82
+ wrapUpAtMs?: number;
83
+ wrapUpMessage?: string;
84
+ onStart?: (handle: RunHandle) => void;
85
+ /** Grace between the deadline abort and the hard kill. */
86
+ graceMs?: number;
40
87
  }
41
88
 
42
89
  export type ProcessRunner = (
@@ -50,6 +97,8 @@ export interface PiRunResult {
50
97
  error?: string;
51
98
  usage: { input: number; output: number; cost: number; turns: number };
52
99
  model?: string;
100
+ stalled?: boolean;
101
+ wrappedUp?: boolean;
53
102
  }
54
103
 
55
104
  export interface ParsedStream {
@@ -61,6 +110,31 @@ export interface ParsedStream {
61
110
  }
62
111
 
63
112
  const MAX_STREAM_CHARS = 5_000_000;
113
+ const HEARTBEAT_MS = 2000;
114
+ const SETTLE_FALLBACK_MS = 3000;
115
+ const EXIT_DRAIN_MS = 1500;
116
+ const DEADLINE_GRACE_MS = 5000;
117
+ const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
118
+
119
+ /** The steering message sent at the wrap-up point. */
120
+ export const WRAP_UP_MESSAGE = [
121
+ "bot-lobby: you are close to your time limit.",
122
+ "Stop exploring now. Finish or revert any half-done edit so every file is consistent,",
123
+ "then write your final report in the required output format.",
124
+ "List anything unfinished under Blockers or Notes.",
125
+ ].join(" ");
126
+
127
+ /** RPC protocol lines the process host reacts to (never forwarded to the caller). */
128
+ export interface ControlEvent {
129
+ type: string;
130
+ id?: string;
131
+ command?: string;
132
+ success?: boolean;
133
+ error?: string;
134
+ method?: string;
135
+ willRetry?: boolean;
136
+ delayMs?: number;
137
+ }
64
138
 
65
139
  /** Buffers one JSON-mode stream, keeping only lines parsePiStream consumes. */
66
140
  export interface StreamCollector {
@@ -68,21 +142,93 @@ export interface StreamCollector {
68
142
  finish(): string;
69
143
  }
70
144
 
145
+ const INTERESTING = [
146
+ '"tool_execution_start"',
147
+ '"tool_execution_end"',
148
+ '"message_end"',
149
+ '"turn_start"',
150
+ '"auto_retry_',
151
+ '"compaction_start"',
152
+ '"agent_settled"',
153
+ '"agent_end"',
154
+ '"extension_ui_request"',
155
+ '"response"',
156
+ ];
157
+
158
+ function num(value: unknown): number {
159
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
160
+ }
161
+
162
+ interface RawEvent {
163
+ type?: string;
164
+ toolName?: string;
165
+ args?: unknown;
166
+ isError?: boolean;
167
+ attempt?: number;
168
+ maxAttempts?: number;
169
+ delayMs?: number;
170
+ errorMessage?: string;
171
+ success?: boolean;
172
+ message?: MessageLike;
173
+ }
174
+
175
+ /** Map one parsed stream line to the caller-facing event, if it is one. */
176
+ function streamEvent(event: RawEvent): PiStreamEvent | undefined {
177
+ switch (event.type) {
178
+ case "tool_execution_start":
179
+ return typeof event.toolName === "string" ? { type: "tool_execution_start", toolName: event.toolName, args: event.args } : undefined;
180
+ case "tool_execution_end":
181
+ return { type: "tool_execution_end", toolName: String(event.toolName ?? ""), isError: event.isError === true };
182
+ case "turn_start":
183
+ return { type: "turn_start" };
184
+ case "auto_retry_start":
185
+ return { type: "retry", attempt: num(event.attempt), maxAttempts: num(event.maxAttempts), delayMs: num(event.delayMs), error: String(event.errorMessage ?? "") };
186
+ case "auto_retry_end":
187
+ return { type: "retry_end", success: event.success === true };
188
+ case "compaction_start":
189
+ return { type: "compaction" };
190
+ case "message_end": {
191
+ const message = event.message;
192
+ if (message?.role !== "assistant") return undefined;
193
+ return { type: "usage", input: num(message.usage?.input), output: num(message.usage?.output), cost: num(message.usage?.cost?.total), model: message.model };
194
+ }
195
+ default:
196
+ return undefined;
197
+ }
198
+ }
199
+
71
200
  /**
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.
201
+ * Buffers one JSON-lines stream, keeping only assistant `message_end` lines for
202
+ * parsePiStream and forwarding agent events to `onEvent` and RPC protocol lines
203
+ * to `onControl`. Per-token `message_update` deltas are never parsed: a cheap
204
+ * prefix check reports only thinking/writing phase changes, so a chatty run
205
+ * cannot exhaust memory. Events split across chunks are reassembled; malformed
206
+ * lines are ignored exactly as parsePiStream ignores them.
78
207
  */
79
- export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void): StreamCollector {
208
+ export function createStreamCollector(
209
+ onEvent?: (event: PiStreamEvent) => void,
210
+ onControl?: (event: ControlEvent) => void,
211
+ ): StreamCollector {
80
212
  const partial: string[] = [];
81
213
  const kept: string[] = [];
214
+ let phase: "thinking" | "writing" | undefined;
215
+ let lastBeat = 0;
216
+ const deltaPhase = (line: string) => {
217
+ if (!line.startsWith('{"type":"message_update"')) return;
218
+ const next = line.includes('"thinking_delta"') ? "thinking" : line.includes('"text_delta"') ? "writing" : undefined;
219
+ if (next && next !== phase) {
220
+ phase = next;
221
+ onEvent?.({ type: next });
222
+ }
223
+ // One parse per finished thinking block (not per token) forwards the thought itself.
224
+ if (onEvent && line.includes('"thinking_end"')) {
225
+ const thought = finishedThought(line);
226
+ if (thought) onEvent({ type: "thought", text: thought });
227
+ }
228
+ };
82
229
  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;
230
+ deltaPhase(line);
231
+ if (!INTERESTING.some((marker) => line.includes(marker))) return;
86
232
  let raw: unknown;
87
233
  try {
88
234
  raw = JSON.parse(line);
@@ -90,12 +236,19 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
90
236
  return; // Malformed lines never parse; drop them.
91
237
  }
92
238
  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
- }
239
+ const event = raw as RawEvent;
240
+ if (event.type === "tool_execution_start" || event.type === "turn_start") phase = undefined;
241
+ const forwarded = streamEvent(event);
242
+ if (forwarded) onEvent?.(forwarded);
243
+ onControl?.(event as ControlEvent);
97
244
  if (event.type === "message_end" && event.message?.role === "assistant") kept.push(line);
98
245
  };
246
+ const beat = () => {
247
+ const now = Date.now();
248
+ if (now - lastBeat < HEARTBEAT_MS) return;
249
+ lastBeat = now;
250
+ onEvent?.({ type: "heartbeat" });
251
+ };
99
252
  return {
100
253
  // Scan only the new chunk for line breaks; a long line split over many
101
254
  // chunks is joined once, when its newline finally arrives.
@@ -109,6 +262,7 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
109
262
  newline = chunk.indexOf("\n", start);
110
263
  }
111
264
  if (start < chunk.length) partial.push(chunk.slice(start));
265
+ beat();
112
266
  },
113
267
  finish() {
114
268
  if (partial.length > 0) keep(partial.splice(0).join(""));
@@ -117,14 +271,28 @@ export function createStreamCollector(onEvent?: (event: PiStreamEvent) => void):
117
271
  };
118
272
  }
119
273
 
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"];
274
+ /** The text of a `thinking_end` message update, trimmed and bounded; undefined for anything else. */
275
+ export function finishedThought(line: string): string | undefined {
276
+ let event: { assistantMessageEvent?: { type?: string; content?: unknown } };
277
+ try {
278
+ event = JSON.parse(line) as typeof event;
279
+ } catch {
280
+ return undefined;
281
+ }
282
+ const update = event?.assistantMessageEvent;
283
+ if (update?.type !== "thinking_end" || typeof update.content !== "string") return undefined;
284
+ const text = update.content.trim();
285
+ if (!text) return undefined;
286
+ return text.length > MAX_THOUGHT_CHARS ? `${text.slice(0, MAX_THOUGHT_CHARS - 1)}…` : text;
287
+ }
288
+
289
+ /** Build the `pi` argv for one isolated, headless RPC agent run; the task goes over stdin. */
290
+ export function buildPiArgs(options: Omit<PiRunOptions, "task"> & { systemPromptFile?: string }): string[] {
291
+ const args = ["--mode", "rpc", "--no-session", "--no-prompt-templates", "--no-themes"];
123
292
  if (options.model && options.model !== "inherit") args.push("--model", options.model);
124
293
  if (options.thinking) args.push("--thinking", options.thinking);
125
294
  if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
126
295
  if (options.systemPromptFile) args.push("--append-system-prompt", options.systemPromptFile);
127
- args.push(`Task: ${options.task}`);
128
296
  return args;
129
297
  }
130
298
 
@@ -148,7 +316,7 @@ function messageText(content: unknown): string {
148
316
  .join("");
149
317
  }
150
318
 
151
- /** Parse the JSON-mode stream defensively; malformed lines are ignored. */
319
+ /** Parse the JSON-lines stream defensively; malformed lines are ignored. */
152
320
  export function parsePiStream(stdout: string): ParsedStream {
153
321
  const parsed: ParsedStream = { text: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
154
322
  for (const line of stdout.split("\n")) {
@@ -194,80 +362,214 @@ export function isPiLauncher(script: string): boolean {
194
362
  return base === "pi" || base === "pi.js" || script.includes("pi-coding-agent");
195
363
  }
196
364
 
197
- function terminate(proc: ChildProcess): void {
198
- proc.kill("SIGTERM");
199
- const escalation = setTimeout(() => {
200
- if (proc.exitCode === null) proc.kill("SIGKILL");
201
- }, 5000);
365
+ const POSIX = process.platform !== "win32";
366
+
367
+ /** Signal the child's whole process group (POSIX), so grandchildren go too. */
368
+ function signalGroup(proc: ChildProcess, signal: NodeJS.Signals): void {
369
+ if (proc.pid === undefined) return;
370
+ if (POSIX) {
371
+ try {
372
+ process.kill(-proc.pid, signal);
373
+ return;
374
+ } catch {
375
+ // The group is already gone; fall through to the child itself.
376
+ }
377
+ }
378
+ try {
379
+ proc.kill(signal);
380
+ } catch {
381
+ // Already exited.
382
+ }
383
+ }
384
+
385
+ function terminate(proc: ChildProcess, graceMs = 5000): void {
386
+ signalGroup(proc, "SIGTERM");
387
+ const escalation = setTimeout(() => signalGroup(proc, "SIGKILL"), graceMs);
202
388
  escalation.unref();
203
389
  }
204
390
 
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;
391
+ /** Line-oriented stdin writer for RPC commands; a closed pipe silently drops writes. */
392
+ function rpcInput(proc: ChildProcess): { send(command: object): void; close(): void } {
393
+ let open = true;
394
+ proc.stdin?.on("error", () => {
395
+ open = false;
218
396
  });
219
- return () => {
220
- buffers.stdout = collector.finish();
397
+ return {
398
+ send(command) {
399
+ if (!open || !proc.stdin?.writable) return;
400
+ try {
401
+ proc.stdin.write(`${JSON.stringify(command)}\n`);
402
+ } catch {
403
+ open = false;
404
+ }
405
+ },
406
+ close() {
407
+ if (!open) return;
408
+ open = false;
409
+ try {
410
+ proc.stdin?.end();
411
+ } catch {
412
+ // Already closed.
413
+ }
414
+ },
221
415
  };
222
416
  }
223
417
 
224
- /** Default runner: spawn the same pi binary in JSON mode and collect output. */
418
+ interface RunState {
419
+ killed: boolean;
420
+ timedOut: boolean;
421
+ stalled: boolean;
422
+ wrappedUp: boolean;
423
+ settled: boolean;
424
+ protocolError?: string;
425
+ }
426
+
427
+ /** Smallest positive limit, used to pick a watchdog cadence that can see it. */
428
+ function watchInterval(...limits: Array<number | undefined>): number {
429
+ const positive = limits.filter((limit): limit is number => typeof limit === "number" && limit > 0);
430
+ const smallest = positive.length > 0 ? Math.min(...positive) : 4000;
431
+ return Math.max(25, Math.min(1000, Math.floor(smallest / 4)));
432
+ }
433
+
434
+ /**
435
+ * Default runner: spawn pi in RPC mode, send the task over stdin, and watch it.
436
+ * The child leads its own process group so a stall, deadline or cancel takes
437
+ * its grandchildren (dev servers, watch-mode tests) with it, and the run ends
438
+ * on process exit rather than waiting for a pipe a grandchild may hold open.
439
+ * Near the deadline the agent is steered to wrap up and report; at the
440
+ * deadline it is aborted; after prolonged silence it is killed as stalled.
441
+ */
225
442
  export function spawnPiProcess(args: string[], options: ProcessRunOptions): Promise<ProcessOutcome> {
226
443
  return new Promise((resolve) => {
227
444
  const invocation = resolvePiInvocation(args);
228
445
  const proc = spawn(invocation.command, invocation.args, {
229
446
  cwd: options.cwd,
230
- // Marks subagent processes so bot-lobby skips master-only quiet wiring.
231
- env: { ...process.env, BOT_LOBBY_SUBAGENT: "1" },
447
+ // Marks subagent processes so bot-lobby skips master-only wiring, and
448
+ // skips pi's startup network work (update checks, package refresh).
449
+ env: { ...process.env, BOT_LOBBY_SUBAGENT: "1", PI_SKIP_VERSION_CHECK: "1", PI_OFFLINE: "1", ...options.env },
232
450
  shell: false,
233
- stdio: ["ignore", "pipe", "pipe"],
451
+ detached: POSIX,
452
+ windowsHide: true,
453
+ stdio: ["pipe", "pipe", "pipe"],
234
454
  });
235
- const buffers = { stdout: "", stderr: "" };
236
- const state = { killed: false, timedOut: false };
237
- const flush = wireOutput(proc, buffers, options.onEvent);
455
+ const state: RunState = { killed: false, timedOut: false, stalled: false, wrappedUp: false, settled: false };
456
+ const input = rpcInput(proc);
457
+ const started = Date.now();
458
+ let lastOutput = started;
459
+ let quietGraceUntil = 0;
460
+ let toolsInFlight = 0;
461
+ let stderr = "";
462
+ let exitCode: number | undefined;
463
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
464
+ let done = false;
238
465
 
239
- const finish = (exitCode: number) => {
240
- clearTimeout(timer);
466
+ const onEvent = (event: PiStreamEvent) => {
467
+ if (event.type === "tool_execution_start") toolsInFlight += 1;
468
+ if (event.type === "tool_execution_end") toolsInFlight = Math.max(0, toolsInFlight - 1);
469
+ if (event.type === "retry") quietGraceUntil = Date.now() + event.delayMs;
470
+ options.onEvent?.(event);
471
+ };
472
+ const onControl = (event: ControlEvent) => {
473
+ if (event.type === "response" && event.command === "prompt" && event.success === false) {
474
+ state.protocolError = event.error ?? "the prompt was rejected";
475
+ input.close();
476
+ } else if (event.type === "extension_ui_request" && event.method && DIALOG_METHODS.has(event.method)) {
477
+ // Nobody can answer a dialog inside a subagent; cancel it instead of blocking forever.
478
+ input.send({ type: "extension_ui_response", id: event.id, cancelled: true });
479
+ } else if (event.type === "agent_settled") {
480
+ state.settled = true;
481
+ input.close();
482
+ } else if (event.type === "agent_end" && event.willRetry !== true && !settleTimer) {
483
+ settleTimer = setTimeout(() => input.close(), SETTLE_FALLBACK_MS);
484
+ }
485
+ };
486
+ const collector = createStreamCollector(onEvent, onControl);
487
+ proc.stdout?.setEncoding("utf8");
488
+ proc.stderr?.setEncoding("utf8");
489
+ proc.stdout?.on("data", (chunk: string) => {
490
+ lastOutput = Date.now();
491
+ collector.push(chunk);
492
+ });
493
+ proc.stderr?.on("data", (chunk: string) => {
494
+ // Keep stderr head-bounded: it only feeds exit-code error messages.
495
+ if (stderr.length < MAX_STREAM_CHARS) stderr += chunk;
496
+ });
497
+
498
+ const finish = (code: number) => {
499
+ if (done) return;
500
+ done = true;
501
+ clearTimeout(deadline);
502
+ clearTimeout(settleTimer);
503
+ clearInterval(watch);
241
504
  options.signal?.removeEventListener("abort", onAbort);
242
- flush();
243
- resolve({ exitCode, ...buffers, ...state });
505
+ input.close();
506
+ // Reap anything the agent left running in its group.
507
+ if (POSIX) signalGroup(proc, "SIGTERM");
508
+ const { killed, timedOut, stalled, wrappedUp, protocolError } = state;
509
+ resolve({ exitCode: code, stdout: collector.finish(), stderr, killed, timedOut, stalled, wrappedUp, protocolError });
244
510
  };
245
511
  const onAbort = () => {
246
512
  state.killed = true;
247
- terminate(proc);
513
+ input.send({ type: "abort" });
514
+ input.close();
515
+ terminate(proc, 3000);
248
516
  };
249
- const timer = setTimeout(() => {
517
+ const deadline = setTimeout(() => {
250
518
  state.killed = true;
251
519
  state.timedOut = true;
252
- terminate(proc);
520
+ input.send({ type: "abort" });
521
+ input.close();
522
+ setTimeout(() => terminate(proc), options.graceMs ?? DEADLINE_GRACE_MS).unref();
253
523
  }, options.timeoutMs);
524
+ const watch = setInterval(() => {
525
+ if (state.killed || done) return;
526
+ const now = Date.now();
527
+ if (!state.wrappedUp && !state.settled && options.wrapUpAtMs && options.wrapUpAtMs > 0 && now - started >= options.wrapUpAtMs) {
528
+ state.wrappedUp = true;
529
+ input.send({ type: "steer", message: options.wrapUpMessage ?? WRAP_UP_MESSAGE });
530
+ options.onEvent?.({ type: "wrap_up" });
531
+ }
532
+ const limit = toolsInFlight > 0 ? options.toolStallTimeoutMs : options.stallTimeoutMs;
533
+ if (limit && limit > 0 && now - Math.max(lastOutput, quietGraceUntil) >= limit) {
534
+ state.killed = true;
535
+ state.stalled = true;
536
+ input.close();
537
+ terminate(proc, 3000);
538
+ }
539
+ }, watchInterval(options.stallTimeoutMs, options.toolStallTimeoutMs, options.wrapUpAtMs));
540
+ watch.unref();
254
541
 
255
- proc.on("close", (code) => finish(code ?? 0));
542
+ proc.on("exit", (code, signal) => {
543
+ exitCode = code ?? (signal ? 1 : 0);
544
+ // A grandchild may hold stdout open forever; give the pipe a moment to drain, then move on.
545
+ setTimeout(() => finish(exitCode ?? 0), EXIT_DRAIN_MS);
546
+ });
547
+ proc.on("close", (code) => finish(exitCode ?? code ?? 0));
256
548
  proc.on("error", (error: Error) => {
257
- buffers.stderr += error.message;
549
+ stderr += error.message;
258
550
  finish(1);
259
551
  });
552
+ if (options.prompt !== undefined) input.send({ id: "task", type: "prompt", message: options.prompt });
553
+ options.onStart?.({ steer: (text) => input.send({ type: "steer", message: text }) });
260
554
  if (options.signal?.aborted) onAbort();
261
555
  else options.signal?.addEventListener("abort", onAbort, { once: true });
262
556
  });
263
557
  }
264
558
 
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" };
559
+ function toResult(parsed: ParsedStream, outcome: ProcessOutcome, aborted: boolean, options: PiRunOptions): PiRunResult {
560
+ const base = { output: parsed.text, usage: parsed.usage, model: parsed.model, wrappedUp: outcome.wrappedUp === true };
561
+ if (outcome.stalled) {
562
+ const quiet = shortDuration(options.stallTimeoutMs ?? 0);
563
+ return { ...base, status: "timeout", stalled: true, error: `stalled: no output for ${quiet}` };
564
+ }
565
+ if (outcome.timedOut) {
566
+ const note = outcome.wrappedUp ? " after a wrap-up request" : "";
567
+ return { ...base, status: "timeout", error: `hit the ${shortDuration(options.timeoutMs)} time limit${note}` };
568
+ }
268
569
  if (outcome.killed || aborted || parsed.stopReason === "aborted") {
269
570
  return { ...base, status: "cancelled", error: "agent was cancelled" };
270
571
  }
572
+ if (outcome.protocolError) return { ...base, status: "failed", error: outcome.protocolError };
271
573
  if (outcome.exitCode !== 0) {
272
574
  const detail = parsed.errorMessage ?? outcome.stderr.trim();
273
575
  return { ...base, status: "failed", error: detail || `pi exited with code ${outcome.exitCode}` };
@@ -295,9 +597,18 @@ export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spa
295
597
  cwd: options.cwd,
296
598
  signal: options.signal,
297
599
  timeoutMs: options.timeoutMs,
298
- onEvent: (event) => options.onActivity?.(activityWord(event.toolName)),
600
+ prompt: `Task: ${options.task}`,
601
+ env: options.env,
602
+ stallTimeoutMs: options.stallTimeoutMs,
603
+ toolStallTimeoutMs: options.toolStallTimeoutMs,
604
+ wrapUpAtMs: options.wrapUpAtMs,
605
+ onStart: options.onStart,
606
+ onEvent: (event) => {
607
+ if (event.type === "tool_execution_start") options.onActivity?.(activityWord(event.toolName));
608
+ options.onEvent?.(event);
609
+ },
299
610
  });
300
- return toResult(parsePiStream(outcome.stdout), outcome, options.signal?.aborted === true);
611
+ return toResult(parsePiStream(outcome.stdout), outcome, options.signal?.aborted === true, options);
301
612
  } finally {
302
613
  prompt?.cleanup();
303
614
  }
package/src/index.ts CHANGED
@@ -4,10 +4,17 @@ 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";
9
+ import { registerLobbyEvents } from "./lobby/runtime.ts";
7
10
 
8
11
  export default function (pi: ExtensionAPI): void {
9
12
  registerLifecycle(pi, CONFIG_DIR_NAME);
13
+ // After the lifecycle, so the lobby opens over a task the widget state already knows.
14
+ registerLobbyEvents(pi, CONFIG_DIR_NAME);
10
15
  onTransition((task) => ping(task.state, task.title));
11
16
  registerCommands(pi, CONFIG_DIR_NAME);
12
17
  registerOrchestrateTool(pi, CONFIG_DIR_NAME);
18
+ // Parallel workers share files through the master's file desk.
19
+ if (isSubagentProcess()) registerDeskClient(pi);
13
20
  }