@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.
- package/README.md +266 -35
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +70 -1
- package/prompts/panel.md +39 -0
- package/prompts/planner.md +64 -0
- package/prompts/qa.md +35 -2
- package/prompts/quickfix.md +41 -0
- 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 +165 -14
- package/src/execution/pi-runner.ts +376 -65
- package/src/index.ts +7 -0
- package/src/lobby/feed.ts +253 -0
- package/src/lobby/issues.ts +227 -0
- package/src/lobby/layout.ts +174 -0
- package/src/lobby/planner.ts +474 -0
- package/src/lobby/quickfix.ts +227 -0
- package/src/lobby/runtime.ts +440 -0
- package/src/lobby/tabs/home.ts +164 -0
- package/src/lobby/tabs/issues.ts +72 -0
- package/src/lobby/tabs/metrics.ts +162 -0
- package/src/lobby/tabs/plan.ts +160 -0
- package/src/lobby/tabs/quickfix.ts +101 -0
- package/src/lobby/tabs/tasks.ts +209 -0
- package/src/lobby/view.ts +855 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +165 -0
- package/src/pi/commands.ts +46 -59
- package/src/pi/events.ts +5 -2
- package/src/pi/expressions.ts +43 -12
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +5 -15
- package/src/pi/model-support.ts +135 -0
- package/src/pi/run-summary.ts +172 -0
- package/src/pi/settings-ui.ts +162 -60
- package/src/pi/start-task.ts +63 -0
- package/src/pi/tools.ts +51 -8
- package/src/pi/ui.ts +151 -49
- package/src/pi/zen-large.ts +41 -4
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +29 -8
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +177 -18
- package/src/schemas/findings.ts +26 -0
- package/src/schemas/task.ts +27 -1
- package/src/state/backlog.ts +106 -0
- package/src/state/comments.ts +136 -0
- package/src/state/metrics.ts +305 -0
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- 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
|
|
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
|
-
/**
|
|
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-
|
|
73
|
-
* forwarding
|
|
74
|
-
* `
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
94
|
-
if (event.type === "tool_execution_start"
|
|
95
|
-
|
|
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
|
-
/**
|
|
121
|
-
export function
|
|
122
|
-
|
|
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-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
|
|
451
|
+
detached: POSIX,
|
|
452
|
+
windowsHide: true,
|
|
453
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
234
454
|
});
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
const
|
|
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
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
513
|
+
input.send({ type: "abort" });
|
|
514
|
+
input.close();
|
|
515
|
+
terminate(proc, 3000);
|
|
248
516
|
};
|
|
249
|
-
const
|
|
517
|
+
const deadline = setTimeout(() => {
|
|
250
518
|
state.killed = true;
|
|
251
519
|
state.timedOut = true;
|
|
252
|
-
|
|
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("
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|