pi-bro 0.14.0 → 0.15.1
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/CHANGELOG.md +17 -0
- package/README.md +75 -17
- package/backend.ts +507 -0
- package/bro.ts +133 -491
- package/package.json +7 -4
package/backend.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
|
|
7
|
+
// Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
|
|
8
|
+
// This is the Agy-only implementation of docs/plans/2026-09-22-shared-backend-design.md: it owns
|
|
9
|
+
// Agy CLI selection, process invocation, progress/outcome normalization, continuation, and
|
|
10
|
+
// single-attempt cleanup. Feature code (bro.ts) keeps retries, UI, source/session capture and
|
|
11
|
+
// settings.
|
|
12
|
+
|
|
13
|
+
export type BackendFeature = "explain" | "show" | "btw" | "advisor";
|
|
14
|
+
export type BackendAccess = "restricted" | "workspace-full";
|
|
15
|
+
export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
|
|
16
|
+
export type BackendContinuation = { id: string };
|
|
17
|
+
export type BackendProgress = { kind: "text"; text: string } | { kind: "activity"; label: string; timestamp: number };
|
|
18
|
+
export type BackendOnProgress = (progress: BackendProgress) => void;
|
|
19
|
+
|
|
20
|
+
export type BackendRequest = {
|
|
21
|
+
feature: BackendFeature;
|
|
22
|
+
prompt: string;
|
|
23
|
+
access: BackendAccess;
|
|
24
|
+
cwd?: string; // required for workspace-full
|
|
25
|
+
continuation?: BackendContinuation;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type BackendOutcome =
|
|
29
|
+
| { status: "success"; text: string; continuation?: BackendContinuation }
|
|
30
|
+
| { status: "failure" | "cancelled" | "timeout"; message: string; partialText?: string };
|
|
31
|
+
|
|
32
|
+
export type BackendExecuteOptions = {
|
|
33
|
+
// Injectable for offline tests only -- production always uses the defaults below.
|
|
34
|
+
killEscalationMs?: number;
|
|
35
|
+
deadlineMs?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function errorMessage(error: unknown): string {
|
|
39
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withDoctor(message: string): string {
|
|
43
|
+
return message.includes("/bro doctor") ? message : `${message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function agyFailureMessage(action: string, result: { code: number; killed: boolean; stderr: string }): string {
|
|
47
|
+
if (result.killed) return `Agy timed out while trying to ${action}. Run \`/bro doctor\` for setup help.`;
|
|
48
|
+
const detail = result.stderr.trim();
|
|
49
|
+
if (detail) return `Agy could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`;
|
|
50
|
+
return `Agy could not ${action}. Make sure Agy is installed and signed in, then run \`/bro doctor\`.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Strips a "-low/-medium/-high" suffix from a stored model id and separates it back into a plain
|
|
54
|
+
// Agy `--model`/`--effort` pair; a "default" effort omits --effort entirely (Agy's own default).
|
|
55
|
+
export function agySelection(pair: { model: string; effort: "default" | "low" | "medium" | "high" }): AgySelection {
|
|
56
|
+
if (pair.effort === "default") return { model: pair.model };
|
|
57
|
+
const suffix = (["low", "medium", "high"] as const).find((effort) => pair.model.endsWith(`-${effort}`));
|
|
58
|
+
return {
|
|
59
|
+
model: suffix ? pair.model.slice(0, -suffix.length - 1) : pair.model,
|
|
60
|
+
effort: pair.effort,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function processStartMessage(processError: NodeJS.ErrnoException): string {
|
|
65
|
+
return processError.code === "ENOENT"
|
|
66
|
+
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
67
|
+
: `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null): string {
|
|
71
|
+
return withDoctor(`Agy exited unexpectedly${exitSignal ? ` (signal ${exitSignal})` : ""}, not from a request Bro made.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
|
|
75
|
+
// just the immediate agy process -- child.kill() alone only ever reaches the immediate child.
|
|
76
|
+
function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
|
|
77
|
+
if (process.platform !== "win32" && typeof child.pid === "number") {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(-child.pid, signalName);
|
|
80
|
+
return;
|
|
81
|
+
} catch {
|
|
82
|
+
// Group may already be gone (e.g. the child already exited) -- fall through.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
child.kill(signalName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The three causes that stop an in-flight attempt: user cancellation, the host-imposed deadline,
|
|
89
|
+
// and a protocol failure (malformed/inconsistent Agy output). Exactly one is latched -- the first
|
|
90
|
+
// to occur -- and it is never relabeled by a later signal (e.g. a cancel arriving after a deadline
|
|
91
|
+
// already fired stays a timeout, not a cancellation).
|
|
92
|
+
type StopCause = "cancelled" | "timeout" | "protocol";
|
|
93
|
+
|
|
94
|
+
type Attempt = {
|
|
95
|
+
causeOf: () => StopCause | undefined;
|
|
96
|
+
stop: (cause: StopCause) => void;
|
|
97
|
+
closed: Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>;
|
|
98
|
+
dispose: () => void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const DEFAULT_KILL_ESCALATION_MS = 5_000;
|
|
102
|
+
|
|
103
|
+
// Begins bounded lifecycle management for one already-spawned child: on cancellation, deadline, or
|
|
104
|
+
// protocol failure it signals the whole POSIX process group (SIGTERM, then SIGKILL after
|
|
105
|
+
// `killEscalationMs` if the child or a misbehaving grandchild ignores it). Windows only ever
|
|
106
|
+
// reaches the immediate child directly -- there is no process-tree guarantee there.
|
|
107
|
+
function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
|
|
108
|
+
let cause: StopCause | undefined;
|
|
109
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
let finishClose: (value: { code: number | null; exitSignal: NodeJS.Signals | null }) => void;
|
|
111
|
+
const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
112
|
+
finishClose = resolve;
|
|
113
|
+
child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const stop = (next: StopCause) => {
|
|
117
|
+
if (cause) return; // latched: the first stop cause wins
|
|
118
|
+
cause = next;
|
|
119
|
+
killAgyGroup(child, "SIGTERM");
|
|
120
|
+
killTimer = setTimeout(() => {
|
|
121
|
+
killAgyGroup(child, "SIGKILL");
|
|
122
|
+
// Detached descendants (or Windows grandchildren) may retain inherited pipes.
|
|
123
|
+
// Stop waiting on those pipes after escalation; never promote this stop to success.
|
|
124
|
+
child.stdin?.destroy();
|
|
125
|
+
child.stdout?.destroy();
|
|
126
|
+
child.stderr?.destroy();
|
|
127
|
+
finishClose({ code: null, exitSignal: "SIGKILL" });
|
|
128
|
+
}, killEscalationMs);
|
|
129
|
+
killTimer.unref?.();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const onAbort = () => stop("cancelled");
|
|
133
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
134
|
+
if (signal.aborted) onAbort();
|
|
135
|
+
|
|
136
|
+
const deadlineTimer = setTimeout(() => stop("timeout"), deadlineMs);
|
|
137
|
+
deadlineTimer.unref?.();
|
|
138
|
+
|
|
139
|
+
const dispose = () => {
|
|
140
|
+
signal.removeEventListener("abort", onAbort);
|
|
141
|
+
clearTimeout(deadlineTimer);
|
|
142
|
+
if (killTimer) clearTimeout(killTimer);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { causeOf: () => cause, stop, closed, dispose };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type AgyEvent = {
|
|
149
|
+
event?: string;
|
|
150
|
+
conversation_id?: string;
|
|
151
|
+
step_update?: { step_type?: string; text_delta?: unknown };
|
|
152
|
+
result?: { status?: string; response?: unknown; error?: unknown; conversation_id?: string };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function parseExplainLine(line: string): { delta?: string; result?: string } {
|
|
156
|
+
let event: AgyEvent;
|
|
157
|
+
try {
|
|
158
|
+
event = JSON.parse(line) as AgyEvent;
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
161
|
+
}
|
|
162
|
+
if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
|
|
163
|
+
return { delta: event.step_update.text_delta };
|
|
164
|
+
}
|
|
165
|
+
if (event.event === "result") {
|
|
166
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
167
|
+
throw new Error("Agy did not complete the explanation successfully.");
|
|
168
|
+
}
|
|
169
|
+
return { result: event.result.response };
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function parseBtwAgyLine(line: string): { delta?: string; result?: string; conversationId?: string; error?: string } {
|
|
175
|
+
let event: AgyEvent;
|
|
176
|
+
try {
|
|
177
|
+
event = JSON.parse(line) as AgyEvent;
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
180
|
+
}
|
|
181
|
+
const conversationId = event.conversation_id ?? event.result?.conversation_id;
|
|
182
|
+
if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
|
|
183
|
+
return { delta: event.step_update.text_delta, conversationId };
|
|
184
|
+
}
|
|
185
|
+
if (event.event === "result") {
|
|
186
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
187
|
+
const detail = typeof event.result?.error === "string" ? event.result.error : "Agy did not complete the turn successfully.";
|
|
188
|
+
return { error: detail, conversationId };
|
|
189
|
+
}
|
|
190
|
+
return { result: event.result.response, conversationId };
|
|
191
|
+
}
|
|
192
|
+
return { conversationId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// explain/show and btw all print the prompt as a single --print argv value and read a stream-json
|
|
196
|
+
// result off stdout; only sandbox flag, cwd, timeout, and (btw only) --conversation differ.
|
|
197
|
+
async function executeArgvPrint(
|
|
198
|
+
request: BackendRequest,
|
|
199
|
+
selection: AgySelection,
|
|
200
|
+
signal: AbortSignal,
|
|
201
|
+
onProgress: BackendOnProgress | undefined,
|
|
202
|
+
killEscalationMs: number,
|
|
203
|
+
deadlineMsOverride: number | undefined,
|
|
204
|
+
): Promise<BackendOutcome> {
|
|
205
|
+
const isBtw = request.feature === "btw";
|
|
206
|
+
const full = isBtw && request.access === "workspace-full";
|
|
207
|
+
const deadlineMs = deadlineMsOverride ?? (isBtw ? (full ? 610_000 : 130_000) : 125_000);
|
|
208
|
+
const printTimeout = full ? "10m" : "2m";
|
|
209
|
+
const action = isBtw ? "answer the side question" : "simplify the response";
|
|
210
|
+
const timeoutVerb = isBtw ? "during the side conversation" : "while simplifying the response";
|
|
211
|
+
const emptyTextMessage = isBtw ? "Agy returned no answer for the side question." : "Agy returned no final explanation.";
|
|
212
|
+
|
|
213
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
214
|
+
|
|
215
|
+
const runDirectory = full ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
216
|
+
if (signal.aborted) {
|
|
217
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
218
|
+
return { status: "cancelled", message: "Canceled." };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const child = spawn(
|
|
223
|
+
"agy",
|
|
224
|
+
[
|
|
225
|
+
...(full ? ["--dangerously-skip-permissions"] : ["--sandbox"]),
|
|
226
|
+
"--disable-slash-commands",
|
|
227
|
+
"--output-format",
|
|
228
|
+
"stream-json",
|
|
229
|
+
"--model",
|
|
230
|
+
selection.model,
|
|
231
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
232
|
+
"--print-timeout",
|
|
233
|
+
printTimeout,
|
|
234
|
+
...(request.continuation ? ["--conversation", request.continuation.id] : []),
|
|
235
|
+
"--print",
|
|
236
|
+
request.prompt,
|
|
237
|
+
],
|
|
238
|
+
{
|
|
239
|
+
cwd: full ? request.cwd : runDirectory,
|
|
240
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
241
|
+
windowsHide: true,
|
|
242
|
+
detached: process.platform !== "win32",
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
247
|
+
let processError: Error | undefined;
|
|
248
|
+
let stderr = "";
|
|
249
|
+
let partial = "";
|
|
250
|
+
let final = "";
|
|
251
|
+
let conversationId = request.continuation?.id;
|
|
252
|
+
let parseError: string | undefined;
|
|
253
|
+
|
|
254
|
+
child.stderr?.setEncoding("utf8");
|
|
255
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
256
|
+
stderr += chunk;
|
|
257
|
+
});
|
|
258
|
+
child.once("error", (error) => {
|
|
259
|
+
processError = error;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
|
|
263
|
+
try {
|
|
264
|
+
for await (const line of lines) {
|
|
265
|
+
if (!line.trim() || attempt.causeOf()) continue;
|
|
266
|
+
try {
|
|
267
|
+
if (isBtw) {
|
|
268
|
+
const parsed = parseBtwAgyLine(line);
|
|
269
|
+
if (parsed.conversationId) conversationId = parsed.conversationId;
|
|
270
|
+
if (parsed.error) {
|
|
271
|
+
parseError = parsed.error;
|
|
272
|
+
attempt.stop("protocol");
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
if (parsed.delta) {
|
|
276
|
+
partial += parsed.delta;
|
|
277
|
+
onProgress?.({ kind: "text", text: partial });
|
|
278
|
+
}
|
|
279
|
+
if (parsed.result !== undefined) final = parsed.result;
|
|
280
|
+
} else {
|
|
281
|
+
const parsed = parseExplainLine(line);
|
|
282
|
+
if (parsed.delta) {
|
|
283
|
+
partial += parsed.delta;
|
|
284
|
+
onProgress?.({ kind: "text", text: partial });
|
|
285
|
+
}
|
|
286
|
+
if (parsed.result !== undefined) final = parsed.result;
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
parseError = errorMessage(error);
|
|
290
|
+
attempt.stop("protocol");
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
lines.close();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { code, exitSignal } = await attempt.closed;
|
|
299
|
+
attempt.dispose();
|
|
300
|
+
const cause = attempt.causeOf();
|
|
301
|
+
|
|
302
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText: partial || undefined };
|
|
303
|
+
if (cause === "timeout") {
|
|
304
|
+
return { status: "timeout", message: `Agy timed out ${timeoutVerb}. Run \`/bro doctor\` for setup help.`, partialText: partial || undefined };
|
|
305
|
+
}
|
|
306
|
+
if (parseError) return { status: "failure", message: withDoctor(parseError), partialText: partial || undefined };
|
|
307
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException) };
|
|
308
|
+
if (exitSignal || code === null) {
|
|
309
|
+
return { status: "failure", message: unexpectedSignalMessage(exitSignal), partialText: partial || undefined };
|
|
310
|
+
}
|
|
311
|
+
if (code !== 0) return { status: "failure", message: agyFailureMessage(action, { code, killed: false, stderr }) };
|
|
312
|
+
|
|
313
|
+
const text = final.trim();
|
|
314
|
+
if (!text) return { status: "failure", message: withDoctor(stderr.trim() || emptyTextMessage) };
|
|
315
|
+
return { status: "success", text, continuation: isBtw && conversationId ? { id: conversationId } : undefined };
|
|
316
|
+
} finally {
|
|
317
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Guards only against a single runaway line with no newline (a protocol break, not a real
|
|
322
|
+
// response size) -- agy's real NDJSON lines are far smaller than this.
|
|
323
|
+
const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
|
|
324
|
+
|
|
325
|
+
type AdvisorAgyEvent = {
|
|
326
|
+
event?: string;
|
|
327
|
+
step_update?: { tool_name?: unknown; step_type?: unknown; text_delta?: unknown };
|
|
328
|
+
result?: { status?: unknown; response?: unknown; error?: unknown };
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Only a tool_name or a user-facing agent_response/assistant text_delta becomes an activity label
|
|
332
|
+
// -- hidden reasoning/thinking step_types and any other shape stay unreported, never leaked into
|
|
333
|
+
// progress.
|
|
334
|
+
function advisorActivityFromEvent(event: AdvisorAgyEvent): string | undefined {
|
|
335
|
+
if (event.event !== "step_update" || !event.step_update || typeof event.step_update !== "object") return undefined;
|
|
336
|
+
const update = event.step_update;
|
|
337
|
+
if (typeof update.tool_name === "string" && update.tool_name.trim()) return update.tool_name.trim();
|
|
338
|
+
const isUserFacingText = update.step_type === "agent_response" || update.step_type === "assistant";
|
|
339
|
+
if (isUserFacingText && typeof update.text_delta === "string" && update.text_delta.trim()) {
|
|
340
|
+
return update.text_delta.split("\n").find((line) => line.trim())?.trim();
|
|
341
|
+
}
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Older agy CLIs reject --input-format with Go's flag-package usage dump and exit before running
|
|
346
|
+
// the agent at all (no terminal result event). Turn that into an actionable version hint instead
|
|
347
|
+
// of a bare "no terminal result" error.
|
|
348
|
+
export function advisorFlagErrorHint(stderr: string): string | undefined {
|
|
349
|
+
const match = /flags? provided but not defined: -([a-z0-9-]+)/i.exec(stderr);
|
|
350
|
+
if (!match) return undefined;
|
|
351
|
+
return `flag provided but not defined: -${match[1]} (installed Agy CLI is too old; the advisor needs Agy 1.1.15+ for --input-format stream-json — run \`agy update\`, then \`/bro doctor\`)`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Advisor speaks stdin NDJSON (never --print argv) and always runs fresh in the workspace with
|
|
355
|
+
// tools auto-approved; it never resumes and never passes --conversation.
|
|
356
|
+
async function executeAdvisorStdin(
|
|
357
|
+
request: BackendRequest,
|
|
358
|
+
selection: AgySelection,
|
|
359
|
+
signal: AbortSignal,
|
|
360
|
+
onProgress: BackendOnProgress | undefined,
|
|
361
|
+
killEscalationMs: number,
|
|
362
|
+
deadlineMsOverride: number | undefined,
|
|
363
|
+
): Promise<BackendOutcome> {
|
|
364
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
365
|
+
|
|
366
|
+
const deadlineMs = deadlineMsOverride ?? 610_000;
|
|
367
|
+
const child = spawn(
|
|
368
|
+
"agy",
|
|
369
|
+
[
|
|
370
|
+
"--dangerously-skip-permissions",
|
|
371
|
+
"--disable-slash-commands",
|
|
372
|
+
"--output-format",
|
|
373
|
+
"stream-json",
|
|
374
|
+
"--input-format",
|
|
375
|
+
"stream-json",
|
|
376
|
+
"--model",
|
|
377
|
+
selection.model,
|
|
378
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
379
|
+
"--print-timeout",
|
|
380
|
+
"10m",
|
|
381
|
+
],
|
|
382
|
+
{ cwd: request.cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
386
|
+
|
|
387
|
+
let processError: Error | undefined;
|
|
388
|
+
let stderr = "";
|
|
389
|
+
let final: string | undefined;
|
|
390
|
+
let terminalError: string | undefined;
|
|
391
|
+
let protocolError: string | undefined;
|
|
392
|
+
let sawTerminal = false;
|
|
393
|
+
let stdoutBuffer = "";
|
|
394
|
+
|
|
395
|
+
child.stderr?.setEncoding("utf8");
|
|
396
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
397
|
+
stderr += chunk;
|
|
398
|
+
});
|
|
399
|
+
child.once("error", (error) => {
|
|
400
|
+
processError = error;
|
|
401
|
+
});
|
|
402
|
+
child.stdin?.on("error", () => {
|
|
403
|
+
// agy exiting before it reads stdin is reported through the close/error path below.
|
|
404
|
+
});
|
|
405
|
+
child.stdin?.end(`${JSON.stringify({ event: "user", message: { content: request.prompt } })}\n`);
|
|
406
|
+
|
|
407
|
+
const handleLine = (line: string) => {
|
|
408
|
+
if (!line.trim() || sawTerminal || attempt.causeOf()) return;
|
|
409
|
+
let event: AdvisorAgyEvent;
|
|
410
|
+
try {
|
|
411
|
+
event = JSON.parse(line) as AdvisorAgyEvent;
|
|
412
|
+
} catch {
|
|
413
|
+
throw new Error("Agy emitted invalid stream-json output.");
|
|
414
|
+
}
|
|
415
|
+
const activity = advisorActivityFromEvent(event);
|
|
416
|
+
if (activity) onProgress?.({ kind: "activity", label: activity, timestamp: Date.now() });
|
|
417
|
+
if (event.event !== "result") return;
|
|
418
|
+
sawTerminal = true;
|
|
419
|
+
const result = event.result;
|
|
420
|
+
const status = typeof result?.status === "string" ? result.status.trim().toUpperCase() : undefined;
|
|
421
|
+
if (status === "SUCCESS" && typeof result?.response === "string") {
|
|
422
|
+
final = result.response;
|
|
423
|
+
} else {
|
|
424
|
+
const detail = typeof result?.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
|
|
425
|
+
terminalError = `Agy failed with status ${status ?? "(missing)"}${detail}`;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
child.stdout?.setEncoding("utf8");
|
|
430
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
431
|
+
stdoutBuffer += chunk;
|
|
432
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
433
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
434
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
435
|
+
protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
436
|
+
stdoutBuffer = "";
|
|
437
|
+
attempt.stop("protocol");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
for (const line of parts) {
|
|
441
|
+
try {
|
|
442
|
+
handleLine(line);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
protocolError ??= errorMessage(error);
|
|
445
|
+
attempt.stop("protocol");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const { code, exitSignal } = await attempt.closed;
|
|
452
|
+
attempt.dispose();
|
|
453
|
+
if (stdoutBuffer.trim() && !sawTerminal) {
|
|
454
|
+
try {
|
|
455
|
+
handleLine(stdoutBuffer);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
protocolError ??= errorMessage(error);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const cause = attempt.causeOf();
|
|
462
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled." };
|
|
463
|
+
if (cause === "timeout") {
|
|
464
|
+
return { status: "timeout", message: "Agy timed out during the advisor consultation. Run `/bro doctor` for setup help." };
|
|
465
|
+
}
|
|
466
|
+
if (protocolError) return { status: "failure", message: withDoctor(protocolError) };
|
|
467
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException) };
|
|
468
|
+
if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal) };
|
|
469
|
+
if (!sawTerminal) {
|
|
470
|
+
const hint = advisorFlagErrorHint(stderr);
|
|
471
|
+
return {
|
|
472
|
+
status: "failure",
|
|
473
|
+
message: withDoctor(hint ?? (stderr.trim() ? `Agy exited without a terminal result event: ${stderr.trim()}` : "Agy exited without a terminal result event.")),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (terminalError) return { status: "failure", message: withDoctor(terminalError) };
|
|
477
|
+
if (code !== 0) return { status: "failure", message: agyFailureMessage("complete the advisor consultation", { code, killed: false, stderr }) };
|
|
478
|
+
|
|
479
|
+
const text = final?.trim();
|
|
480
|
+
if (!text) return { status: "failure", message: withDoctor(stderr.trim() || "Agy returned no advice.") };
|
|
481
|
+
return { status: "success", text };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
|
|
485
|
+
// e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
|
|
486
|
+
// host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
|
|
487
|
+
// after a bounded grace period) before resolving. `options` is for offline tests only -- production
|
|
488
|
+
// callers never override the deadline or kill-escalation delay.
|
|
489
|
+
export async function execute(
|
|
490
|
+
request: BackendRequest,
|
|
491
|
+
selection: AgySelection,
|
|
492
|
+
signal: AbortSignal,
|
|
493
|
+
onProgress?: BackendOnProgress,
|
|
494
|
+
options?: BackendExecuteOptions,
|
|
495
|
+
): Promise<BackendOutcome> {
|
|
496
|
+
if (
|
|
497
|
+
(request.access === "workspace-full" && !request.cwd?.trim()) ||
|
|
498
|
+
(request.feature === "advisor" && request.access !== "workspace-full") ||
|
|
499
|
+
((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
|
|
500
|
+
(request.feature !== "btw" && request.continuation)
|
|
501
|
+
) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
|
|
502
|
+
const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
|
|
503
|
+
if (request.feature === "advisor") {
|
|
504
|
+
return executeAdvisorStdin(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
505
|
+
}
|
|
506
|
+
return executeArgvPrint(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
507
|
+
}
|