@a-t-h-i/bot-lobby 0.2.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.
- package/README.md +138 -32
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +58 -1
- package/prompts/qa.md +35 -2
- package/prompts/researcher.md +6 -0
- package/prompts/reviewer.md +16 -0
- package/prompts/scout.md +11 -2
- package/prompts/worker.md +35 -2
- package/src/desk/client-extension.ts +101 -0
- package/src/desk/desk.ts +249 -0
- package/src/desk/ipc.ts +178 -0
- package/src/desk/session.ts +214 -0
- package/src/execution/agent-runner.ts +160 -14
- package/src/execution/pi-runner.ts +351 -65
- package/src/index.ts +4 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +45 -0
- package/src/pi/commands.ts +28 -3
- package/src/pi/expressions.ts +84 -16
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +125 -35
- package/src/pi/model-support.ts +101 -0
- package/src/pi/run-summary.ts +170 -0
- package/src/pi/settings-ui.ts +158 -60
- package/src/pi/tools.ts +50 -7
- package/src/pi/ui.ts +82 -13
- package/src/pi/zen-large.ts +158 -20
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +42 -14
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +112 -18
- package/src/schemas/findings.ts +20 -0
- package/src/schemas/task.ts +26 -1
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +127 -10
|
@@ -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
|
|
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
|
-
/**
|
|
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-
|
|
73
|
-
* forwarding
|
|
74
|
-
* `
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
94
|
-
if (event.type === "tool_execution_start"
|
|
95
|
-
|
|
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,
|
|
121
|
-
export function buildPiArgs(options: PiRunOptions & { systemPromptFile?: string }): string[] {
|
|
122
|
-
const args = ["--mode", "
|
|
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-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
|
|
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 = (
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
488
|
+
input.send({ type: "abort" });
|
|
489
|
+
input.close();
|
|
490
|
+
terminate(proc, 3000);
|
|
248
491
|
};
|
|
249
|
-
const
|
|
492
|
+
const deadline = setTimeout(() => {
|
|
250
493
|
state.killed = true;
|
|
251
494
|
state.timedOut = true;
|
|
252
|
-
|
|
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("
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/master/master.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type
|
|
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
|
-
/**
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
return 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|