pi-bro 0.15.0 → 0.16.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/CHANGELOG.md +18 -0
- package/README.md +50 -28
- package/backend.ts +724 -0
- package/bro.ts +574 -554
- package/package.json +3 -2
package/backend.ts
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
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 implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
|
|
9
|
+
// Claude Code CLI (explain/show/advisor; no btw yet): it owns 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 const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
export type BackendSelection =
|
|
18
|
+
| ({ backend?: "agy" } & AgySelection)
|
|
19
|
+
| { backend: "claude"; model: string; effort?: (typeof CLAUDE_EFFORTS)[number] };
|
|
20
|
+
export type BackendContinuation = { id: string };
|
|
21
|
+
export type BackendProgress = { kind: "text"; text: string } | { kind: "activity"; label: string; timestamp: number };
|
|
22
|
+
export type BackendOnProgress = (progress: BackendProgress) => void;
|
|
23
|
+
|
|
24
|
+
export type BackendRequest = {
|
|
25
|
+
feature: BackendFeature;
|
|
26
|
+
prompt: string;
|
|
27
|
+
access: BackendAccess;
|
|
28
|
+
cwd?: string; // required for workspace-full
|
|
29
|
+
continuation?: BackendContinuation;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type BackendOutcome =
|
|
33
|
+
| { status: "success"; text: string; continuation?: BackendContinuation }
|
|
34
|
+
| { status: "failure" | "cancelled" | "timeout"; message: string; partialText?: string };
|
|
35
|
+
|
|
36
|
+
export type BackendExecuteOptions = {
|
|
37
|
+
// Injectable for offline tests only -- production always uses the defaults below.
|
|
38
|
+
killEscalationMs?: number;
|
|
39
|
+
deadlineMs?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function errorMessage(error: unknown): string {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function withDoctor(message: string): string {
|
|
47
|
+
return message.includes("/bro doctor") ? message : `${message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function agyFailureMessage(action: string, result: { code: number; killed: boolean; stderr: string }): string {
|
|
51
|
+
if (result.killed) return `Agy timed out while trying to ${action}. Run \`/bro doctor\` for setup help.`;
|
|
52
|
+
const detail = result.stderr.trim();
|
|
53
|
+
if (detail) return `Agy could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`;
|
|
54
|
+
return `Agy could not ${action}. Make sure Agy is installed and signed in, then run \`/bro doctor\`.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Strips a "-low/-medium/-high" suffix from a stored model id and separates it back into a plain
|
|
58
|
+
// Agy `--model`/`--effort` pair; a "default" effort omits --effort entirely (Agy's own default).
|
|
59
|
+
export function agySelection(pair: { model: string; effort: "default" | "low" | "medium" | "high" }): AgySelection {
|
|
60
|
+
if (pair.effort === "default") return { model: pair.model };
|
|
61
|
+
const suffix = (["low", "medium", "high"] as const).find((effort) => pair.model.endsWith(`-${effort}`));
|
|
62
|
+
return {
|
|
63
|
+
model: suffix ? pair.model.slice(0, -suffix.length - 1) : pair.model,
|
|
64
|
+
effort: pair.effort,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function processStartMessage(processError: NodeJS.ErrnoException, cli = "Agy"): string {
|
|
69
|
+
if (processError.code === "ENOENT") {
|
|
70
|
+
return cli === "Agy"
|
|
71
|
+
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
72
|
+
: "Claude Code could not start. Make sure `claude` is installed and on PATH, then run `/bro doctor`.";
|
|
73
|
+
}
|
|
74
|
+
return `${cli} could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null, cli = "Agy"): string {
|
|
78
|
+
return withDoctor(`${cli} exited unexpectedly${exitSignal ? ` (signal ${exitSignal})` : ""}, not from a request Bro made.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
|
|
82
|
+
// just the immediate agy process -- child.kill() alone only ever reaches the immediate child.
|
|
83
|
+
function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
|
|
84
|
+
if (process.platform !== "win32" && typeof child.pid === "number") {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(-child.pid, signalName);
|
|
87
|
+
return;
|
|
88
|
+
} catch {
|
|
89
|
+
// Group may already be gone (e.g. the child already exited) -- fall through.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
child.kill(signalName);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The three causes that stop an in-flight attempt: user cancellation, the host-imposed deadline,
|
|
96
|
+
// and a protocol failure (malformed/inconsistent Agy output). Exactly one is latched -- the first
|
|
97
|
+
// to occur -- and it is never relabeled by a later signal (e.g. a cancel arriving after a deadline
|
|
98
|
+
// already fired stays a timeout, not a cancellation).
|
|
99
|
+
type StopCause = "cancelled" | "timeout" | "protocol";
|
|
100
|
+
|
|
101
|
+
type Attempt = {
|
|
102
|
+
causeOf: () => StopCause | undefined;
|
|
103
|
+
stop: (cause: StopCause) => void;
|
|
104
|
+
closed: Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>;
|
|
105
|
+
dispose: () => void;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const DEFAULT_KILL_ESCALATION_MS = 5_000;
|
|
109
|
+
|
|
110
|
+
// Begins bounded lifecycle management for one already-spawned child: on cancellation, deadline, or
|
|
111
|
+
// protocol failure it signals the whole POSIX process group (SIGTERM, then SIGKILL after
|
|
112
|
+
// `killEscalationMs` if the child or a misbehaving grandchild ignores it). Windows only ever
|
|
113
|
+
// reaches the immediate child directly -- there is no process-tree guarantee there.
|
|
114
|
+
function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
|
|
115
|
+
let cause: StopCause | undefined;
|
|
116
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
+
let finishClose: (value: { code: number | null; exitSignal: NodeJS.Signals | null }) => void;
|
|
118
|
+
const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
119
|
+
finishClose = resolve;
|
|
120
|
+
child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const stop = (next: StopCause) => {
|
|
124
|
+
if (cause) return; // latched: the first stop cause wins
|
|
125
|
+
cause = next;
|
|
126
|
+
killAgyGroup(child, "SIGTERM");
|
|
127
|
+
killTimer = setTimeout(() => {
|
|
128
|
+
killAgyGroup(child, "SIGKILL");
|
|
129
|
+
// Detached descendants (or Windows grandchildren) may retain inherited pipes.
|
|
130
|
+
// Stop waiting on those pipes after escalation; never promote this stop to success.
|
|
131
|
+
child.stdin?.destroy();
|
|
132
|
+
child.stdout?.destroy();
|
|
133
|
+
child.stderr?.destroy();
|
|
134
|
+
finishClose({ code: null, exitSignal: "SIGKILL" });
|
|
135
|
+
}, killEscalationMs);
|
|
136
|
+
killTimer.unref?.();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const onAbort = () => stop("cancelled");
|
|
140
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
141
|
+
if (signal.aborted) onAbort();
|
|
142
|
+
|
|
143
|
+
const deadlineTimer = setTimeout(() => stop("timeout"), deadlineMs);
|
|
144
|
+
deadlineTimer.unref?.();
|
|
145
|
+
|
|
146
|
+
const dispose = () => {
|
|
147
|
+
signal.removeEventListener("abort", onAbort);
|
|
148
|
+
clearTimeout(deadlineTimer);
|
|
149
|
+
if (killTimer) clearTimeout(killTimer);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return { causeOf: () => cause, stop, closed, dispose };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type AgyEvent = {
|
|
156
|
+
event?: string;
|
|
157
|
+
conversation_id?: string;
|
|
158
|
+
step_update?: { step_type?: string; text_delta?: unknown };
|
|
159
|
+
result?: { status?: string; response?: unknown; error?: unknown; conversation_id?: string };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
function parseExplainLine(line: string): { delta?: string; result?: string } {
|
|
163
|
+
let event: AgyEvent;
|
|
164
|
+
try {
|
|
165
|
+
event = JSON.parse(line) as AgyEvent;
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
168
|
+
}
|
|
169
|
+
if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
|
|
170
|
+
return { delta: event.step_update.text_delta };
|
|
171
|
+
}
|
|
172
|
+
if (event.event === "result") {
|
|
173
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
174
|
+
throw new Error("Agy did not complete the explanation successfully.");
|
|
175
|
+
}
|
|
176
|
+
return { result: event.result.response };
|
|
177
|
+
}
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parseBtwAgyLine(line: string): { delta?: string; result?: string; conversationId?: string; error?: string } {
|
|
182
|
+
let event: AgyEvent;
|
|
183
|
+
try {
|
|
184
|
+
event = JSON.parse(line) as AgyEvent;
|
|
185
|
+
} catch {
|
|
186
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
187
|
+
}
|
|
188
|
+
const conversationId = event.conversation_id ?? event.result?.conversation_id;
|
|
189
|
+
if (event.event === "step_update" && event.step_update?.step_type === "agent_response" && typeof event.step_update.text_delta === "string") {
|
|
190
|
+
return { delta: event.step_update.text_delta, conversationId };
|
|
191
|
+
}
|
|
192
|
+
if (event.event === "result") {
|
|
193
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
194
|
+
const detail = typeof event.result?.error === "string" ? event.result.error : "Agy did not complete the turn successfully.";
|
|
195
|
+
return { error: detail, conversationId };
|
|
196
|
+
}
|
|
197
|
+
return { result: event.result.response, conversationId };
|
|
198
|
+
}
|
|
199
|
+
return { conversationId };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// explain/show and btw all print the prompt as a single --print argv value and read a stream-json
|
|
203
|
+
// result off stdout; only sandbox flag, cwd, timeout, and (btw only) --conversation differ.
|
|
204
|
+
async function executeArgvPrint(
|
|
205
|
+
request: BackendRequest,
|
|
206
|
+
selection: AgySelection,
|
|
207
|
+
signal: AbortSignal,
|
|
208
|
+
onProgress: BackendOnProgress | undefined,
|
|
209
|
+
killEscalationMs: number,
|
|
210
|
+
deadlineMsOverride: number | undefined,
|
|
211
|
+
): Promise<BackendOutcome> {
|
|
212
|
+
const isBtw = request.feature === "btw";
|
|
213
|
+
const full = isBtw && request.access === "workspace-full";
|
|
214
|
+
const deadlineMs = deadlineMsOverride ?? (isBtw ? (full ? 610_000 : 130_000) : 125_000);
|
|
215
|
+
const printTimeout = full ? "10m" : "2m";
|
|
216
|
+
const action = isBtw ? "answer the side question" : "simplify the response";
|
|
217
|
+
const timeoutVerb = isBtw ? "during the side conversation" : "while simplifying the response";
|
|
218
|
+
const emptyTextMessage = isBtw ? "Agy returned no answer for the side question." : "Agy returned no final explanation.";
|
|
219
|
+
|
|
220
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
221
|
+
|
|
222
|
+
const runDirectory = full ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
223
|
+
if (signal.aborted) {
|
|
224
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
225
|
+
return { status: "cancelled", message: "Canceled." };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const child = spawn(
|
|
230
|
+
"agy",
|
|
231
|
+
[
|
|
232
|
+
...(full ? ["--dangerously-skip-permissions"] : ["--sandbox"]),
|
|
233
|
+
"--disable-slash-commands",
|
|
234
|
+
"--output-format",
|
|
235
|
+
"stream-json",
|
|
236
|
+
"--model",
|
|
237
|
+
selection.model,
|
|
238
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
239
|
+
"--print-timeout",
|
|
240
|
+
printTimeout,
|
|
241
|
+
...(request.continuation ? ["--conversation", request.continuation.id] : []),
|
|
242
|
+
"--print",
|
|
243
|
+
request.prompt,
|
|
244
|
+
],
|
|
245
|
+
{
|
|
246
|
+
cwd: full ? request.cwd : runDirectory,
|
|
247
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
248
|
+
windowsHide: true,
|
|
249
|
+
detached: process.platform !== "win32",
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
254
|
+
let processError: Error | undefined;
|
|
255
|
+
let stderr = "";
|
|
256
|
+
let partial = "";
|
|
257
|
+
let final = "";
|
|
258
|
+
let conversationId = request.continuation?.id;
|
|
259
|
+
let parseError: string | undefined;
|
|
260
|
+
|
|
261
|
+
child.stderr?.setEncoding("utf8");
|
|
262
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
263
|
+
stderr += chunk;
|
|
264
|
+
});
|
|
265
|
+
child.once("error", (error) => {
|
|
266
|
+
processError = error;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
|
|
270
|
+
try {
|
|
271
|
+
for await (const line of lines) {
|
|
272
|
+
if (!line.trim() || attempt.causeOf()) continue;
|
|
273
|
+
try {
|
|
274
|
+
if (isBtw) {
|
|
275
|
+
const parsed = parseBtwAgyLine(line);
|
|
276
|
+
if (parsed.conversationId) conversationId = parsed.conversationId;
|
|
277
|
+
if (parsed.error) {
|
|
278
|
+
parseError = parsed.error;
|
|
279
|
+
attempt.stop("protocol");
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
if (parsed.delta) {
|
|
283
|
+
partial += parsed.delta;
|
|
284
|
+
onProgress?.({ kind: "text", text: partial });
|
|
285
|
+
}
|
|
286
|
+
if (parsed.result !== undefined) final = parsed.result;
|
|
287
|
+
} else {
|
|
288
|
+
const parsed = parseExplainLine(line);
|
|
289
|
+
if (parsed.delta) {
|
|
290
|
+
partial += parsed.delta;
|
|
291
|
+
onProgress?.({ kind: "text", text: partial });
|
|
292
|
+
}
|
|
293
|
+
if (parsed.result !== undefined) final = parsed.result;
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
parseError = errorMessage(error);
|
|
297
|
+
attempt.stop("protocol");
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
lines.close();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const { code, exitSignal } = await attempt.closed;
|
|
306
|
+
attempt.dispose();
|
|
307
|
+
const cause = attempt.causeOf();
|
|
308
|
+
|
|
309
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText: partial || undefined };
|
|
310
|
+
if (cause === "timeout") {
|
|
311
|
+
return { status: "timeout", message: `Agy timed out ${timeoutVerb}. Run \`/bro doctor\` for setup help.`, partialText: partial || undefined };
|
|
312
|
+
}
|
|
313
|
+
if (parseError) return { status: "failure", message: withDoctor(parseError), partialText: partial || undefined };
|
|
314
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException) };
|
|
315
|
+
if (exitSignal || code === null) {
|
|
316
|
+
return { status: "failure", message: unexpectedSignalMessage(exitSignal), partialText: partial || undefined };
|
|
317
|
+
}
|
|
318
|
+
if (code !== 0) return { status: "failure", message: agyFailureMessage(action, { code, killed: false, stderr }) };
|
|
319
|
+
|
|
320
|
+
const text = final.trim();
|
|
321
|
+
if (!text) return { status: "failure", message: withDoctor(stderr.trim() || emptyTextMessage) };
|
|
322
|
+
return { status: "success", text, continuation: isBtw && conversationId ? { id: conversationId } : undefined };
|
|
323
|
+
} finally {
|
|
324
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Guards only against a single runaway line with no newline (a protocol break, not a real
|
|
329
|
+
// response size) -- agy's real NDJSON lines are far smaller than this.
|
|
330
|
+
const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
|
|
331
|
+
|
|
332
|
+
type AdvisorAgyEvent = {
|
|
333
|
+
event?: string;
|
|
334
|
+
step_update?: { tool_name?: unknown; step_type?: unknown; text_delta?: unknown };
|
|
335
|
+
result?: { status?: unknown; response?: unknown; error?: unknown };
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Only a tool_name or a user-facing agent_response/assistant text_delta becomes an activity label
|
|
339
|
+
// -- hidden reasoning/thinking step_types and any other shape stay unreported, never leaked into
|
|
340
|
+
// progress.
|
|
341
|
+
function advisorActivityFromEvent(event: AdvisorAgyEvent): string | undefined {
|
|
342
|
+
if (event.event !== "step_update" || !event.step_update || typeof event.step_update !== "object") return undefined;
|
|
343
|
+
const update = event.step_update;
|
|
344
|
+
if (typeof update.tool_name === "string" && update.tool_name.trim()) return update.tool_name.trim();
|
|
345
|
+
const isUserFacingText = update.step_type === "agent_response" || update.step_type === "assistant";
|
|
346
|
+
if (isUserFacingText && typeof update.text_delta === "string" && update.text_delta.trim()) {
|
|
347
|
+
return update.text_delta.split("\n").find((line) => line.trim())?.trim();
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Older agy CLIs reject --input-format with Go's flag-package usage dump and exit before running
|
|
353
|
+
// the agent at all (no terminal result event). Turn that into an actionable version hint instead
|
|
354
|
+
// of a bare "no terminal result" error.
|
|
355
|
+
export function advisorFlagErrorHint(stderr: string): string | undefined {
|
|
356
|
+
const match = /flags? provided but not defined: -([a-z0-9-]+)/i.exec(stderr);
|
|
357
|
+
if (!match) return undefined;
|
|
358
|
+
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\`)`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Advisor speaks stdin NDJSON (never --print argv) and always runs fresh in the workspace with
|
|
362
|
+
// tools auto-approved; it never resumes and never passes --conversation.
|
|
363
|
+
async function executeAdvisorStdin(
|
|
364
|
+
request: BackendRequest,
|
|
365
|
+
selection: AgySelection,
|
|
366
|
+
signal: AbortSignal,
|
|
367
|
+
onProgress: BackendOnProgress | undefined,
|
|
368
|
+
killEscalationMs: number,
|
|
369
|
+
deadlineMsOverride: number | undefined,
|
|
370
|
+
): Promise<BackendOutcome> {
|
|
371
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
372
|
+
|
|
373
|
+
const deadlineMs = deadlineMsOverride ?? 610_000;
|
|
374
|
+
const child = spawn(
|
|
375
|
+
"agy",
|
|
376
|
+
[
|
|
377
|
+
"--dangerously-skip-permissions",
|
|
378
|
+
"--disable-slash-commands",
|
|
379
|
+
"--output-format",
|
|
380
|
+
"stream-json",
|
|
381
|
+
"--input-format",
|
|
382
|
+
"stream-json",
|
|
383
|
+
"--model",
|
|
384
|
+
selection.model,
|
|
385
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
386
|
+
"--print-timeout",
|
|
387
|
+
"10m",
|
|
388
|
+
],
|
|
389
|
+
{ cwd: request.cwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
393
|
+
|
|
394
|
+
let processError: Error | undefined;
|
|
395
|
+
let stderr = "";
|
|
396
|
+
let final: string | undefined;
|
|
397
|
+
let terminalError: string | undefined;
|
|
398
|
+
let protocolError: string | undefined;
|
|
399
|
+
let sawTerminal = false;
|
|
400
|
+
let stdoutBuffer = "";
|
|
401
|
+
|
|
402
|
+
child.stderr?.setEncoding("utf8");
|
|
403
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
404
|
+
stderr += chunk;
|
|
405
|
+
});
|
|
406
|
+
child.once("error", (error) => {
|
|
407
|
+
processError = error;
|
|
408
|
+
});
|
|
409
|
+
child.stdin?.on("error", () => {
|
|
410
|
+
// agy exiting before it reads stdin is reported through the close/error path below.
|
|
411
|
+
});
|
|
412
|
+
child.stdin?.end(`${JSON.stringify({ event: "user", message: { content: request.prompt } })}\n`);
|
|
413
|
+
|
|
414
|
+
const handleLine = (line: string) => {
|
|
415
|
+
if (!line.trim() || sawTerminal || attempt.causeOf()) return;
|
|
416
|
+
let event: AdvisorAgyEvent;
|
|
417
|
+
try {
|
|
418
|
+
event = JSON.parse(line) as AdvisorAgyEvent;
|
|
419
|
+
} catch {
|
|
420
|
+
throw new Error("Agy emitted invalid stream-json output.");
|
|
421
|
+
}
|
|
422
|
+
const activity = advisorActivityFromEvent(event);
|
|
423
|
+
if (activity) onProgress?.({ kind: "activity", label: activity, timestamp: Date.now() });
|
|
424
|
+
if (event.event !== "result") return;
|
|
425
|
+
sawTerminal = true;
|
|
426
|
+
const result = event.result;
|
|
427
|
+
const status = typeof result?.status === "string" ? result.status.trim().toUpperCase() : undefined;
|
|
428
|
+
if (status === "SUCCESS" && typeof result?.response === "string") {
|
|
429
|
+
final = result.response;
|
|
430
|
+
} else {
|
|
431
|
+
const detail = typeof result?.error === "string" && result.error.trim() ? `: ${result.error.trim()}` : "";
|
|
432
|
+
terminalError = `Agy failed with status ${status ?? "(missing)"}${detail}`;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
child.stdout?.setEncoding("utf8");
|
|
437
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
438
|
+
stdoutBuffer += chunk;
|
|
439
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
440
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
441
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
442
|
+
protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
443
|
+
stdoutBuffer = "";
|
|
444
|
+
attempt.stop("protocol");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
for (const line of parts) {
|
|
448
|
+
try {
|
|
449
|
+
handleLine(line);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
protocolError ??= errorMessage(error);
|
|
452
|
+
attempt.stop("protocol");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const { code, exitSignal } = await attempt.closed;
|
|
459
|
+
attempt.dispose();
|
|
460
|
+
if (stdoutBuffer.trim() && !sawTerminal) {
|
|
461
|
+
try {
|
|
462
|
+
handleLine(stdoutBuffer);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
protocolError ??= errorMessage(error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const cause = attempt.causeOf();
|
|
469
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled." };
|
|
470
|
+
if (cause === "timeout") {
|
|
471
|
+
return { status: "timeout", message: "Agy timed out during the advisor consultation. Run `/bro doctor` for setup help." };
|
|
472
|
+
}
|
|
473
|
+
if (protocolError) return { status: "failure", message: withDoctor(protocolError) };
|
|
474
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException) };
|
|
475
|
+
if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal) };
|
|
476
|
+
if (!sawTerminal) {
|
|
477
|
+
const hint = advisorFlagErrorHint(stderr);
|
|
478
|
+
return {
|
|
479
|
+
status: "failure",
|
|
480
|
+
message: withDoctor(hint ?? (stderr.trim() ? `Agy exited without a terminal result event: ${stderr.trim()}` : "Agy exited without a terminal result event.")),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (terminalError) return { status: "failure", message: withDoctor(terminalError) };
|
|
484
|
+
if (code !== 0) return { status: "failure", message: agyFailureMessage("complete the advisor consultation", { code, killed: false, stderr }) };
|
|
485
|
+
|
|
486
|
+
const text = final?.trim();
|
|
487
|
+
if (!text) return { status: "failure", message: withDoctor(stderr.trim() || "Agy returned no advice.") };
|
|
488
|
+
return { status: "success", text };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
// Claude Code supports every feature except btw (it has no continuation wiring yet).
|
|
493
|
+
export function backendSupports(backend: "agy" | "claude", feature: BackendFeature): boolean {
|
|
494
|
+
return backend === "agy" || feature !== "btw";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
type ClaudeEvent = {
|
|
498
|
+
type?: unknown;
|
|
499
|
+
subtype?: unknown;
|
|
500
|
+
is_error?: unknown;
|
|
501
|
+
result?: unknown;
|
|
502
|
+
parent_tool_use_id?: unknown;
|
|
503
|
+
stop_reason?: unknown;
|
|
504
|
+
terminal_reason?: unknown;
|
|
505
|
+
event?: { type?: unknown; delta?: { type?: unknown; text?: unknown } };
|
|
506
|
+
message?: { content?: unknown };
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Every Claude run is fresh (never resumed, nothing persisted) under --safe-mode, which disables
|
|
510
|
+
// CLAUDE.md, skills, plugins, hooks and MCP servers but keeps the user's auth (--bare would break
|
|
511
|
+
// OAuth). The prompt goes over stdin. explain/show additionally run tool-less in a scratch cwd;
|
|
512
|
+
// advisor runs in the caller's workspace with tools auto-approved. Only the terminal `result`
|
|
513
|
+
// event is authoritative; streamed text_delta events are progress only, and thinking is dropped.
|
|
514
|
+
async function executeClaude(
|
|
515
|
+
request: BackendRequest,
|
|
516
|
+
selection: { model: string; effort?: string },
|
|
517
|
+
signal: AbortSignal,
|
|
518
|
+
onProgress: BackendOnProgress | undefined,
|
|
519
|
+
killEscalationMs: number,
|
|
520
|
+
deadlineMsOverride: number | undefined,
|
|
521
|
+
): Promise<BackendOutcome> {
|
|
522
|
+
const isAdvisor = request.feature === "advisor";
|
|
523
|
+
const deadlineMs = deadlineMsOverride ?? (isAdvisor ? 610_000 : 125_000);
|
|
524
|
+
const action = isAdvisor ? "complete the advisor consultation" : "simplify the response";
|
|
525
|
+
|
|
526
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
527
|
+
const runDirectory = isAdvisor ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
528
|
+
if (signal.aborted) {
|
|
529
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
530
|
+
return { status: "cancelled", message: "Canceled." };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
const child = spawn(
|
|
535
|
+
"claude",
|
|
536
|
+
[
|
|
537
|
+
"-p",
|
|
538
|
+
"--safe-mode",
|
|
539
|
+
"--no-session-persistence",
|
|
540
|
+
"--disable-slash-commands",
|
|
541
|
+
"--output-format",
|
|
542
|
+
"stream-json",
|
|
543
|
+
"--verbose",
|
|
544
|
+
"--include-partial-messages",
|
|
545
|
+
...(isAdvisor
|
|
546
|
+
? ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--dangerously-skip-permissions"]
|
|
547
|
+
: ["--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]),
|
|
548
|
+
"--model",
|
|
549
|
+
selection.model,
|
|
550
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
551
|
+
],
|
|
552
|
+
{
|
|
553
|
+
cwd: isAdvisor ? request.cwd : runDirectory,
|
|
554
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
555
|
+
windowsHide: true,
|
|
556
|
+
detached: process.platform !== "win32",
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
561
|
+
let processError: Error | undefined;
|
|
562
|
+
let stderr = "";
|
|
563
|
+
let partial = "";
|
|
564
|
+
let final: string | undefined;
|
|
565
|
+
let terminalError: string | undefined;
|
|
566
|
+
let protocolError: string | undefined;
|
|
567
|
+
let stdoutBuffer = "";
|
|
568
|
+
|
|
569
|
+
child.stderr?.setEncoding("utf8");
|
|
570
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
571
|
+
stderr += chunk;
|
|
572
|
+
});
|
|
573
|
+
child.once("error", (error) => {
|
|
574
|
+
processError = error;
|
|
575
|
+
});
|
|
576
|
+
child.stdin?.on("error", () => {
|
|
577
|
+
// claude exiting before it reads stdin is reported through the close/error path below.
|
|
578
|
+
});
|
|
579
|
+
child.stdin?.end(request.prompt);
|
|
580
|
+
|
|
581
|
+
const handleLine = (line: string) => {
|
|
582
|
+
if (!line.trim() || attempt.causeOf()) return;
|
|
583
|
+
let event: ClaudeEvent;
|
|
584
|
+
try {
|
|
585
|
+
event = JSON.parse(line) as ClaudeEvent;
|
|
586
|
+
} catch {
|
|
587
|
+
throw new Error("Claude emitted invalid stream-json output.");
|
|
588
|
+
}
|
|
589
|
+
const topLevel = event.parent_tool_use_id === undefined || event.parent_tool_use_id === null;
|
|
590
|
+
if (!isAdvisor && topLevel && event.type === "stream_event" && event.event?.type === "content_block_delta") {
|
|
591
|
+
const delta = event.event.delta;
|
|
592
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
|
|
593
|
+
partial += delta.text;
|
|
594
|
+
onProgress?.({ kind: "text", text: partial });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (isAdvisor && topLevel && event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
598
|
+
for (const block of event.message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
|
|
599
|
+
const label =
|
|
600
|
+
block?.type === "tool_use" && typeof block.name === "string"
|
|
601
|
+
? block.name.trim()
|
|
602
|
+
: block?.type === "text" && typeof block.text === "string"
|
|
603
|
+
? block.text.split("\n").find((text) => text.trim())?.trim()
|
|
604
|
+
: undefined;
|
|
605
|
+
if (label) onProgress?.({ kind: "activity", label, timestamp: Date.now() });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (event.type !== "result" || !topLevel) return;
|
|
609
|
+
if (event.subtype === "success" && event.is_error === false && (event.stop_reason !== "end_turn" || (event.terminal_reason !== undefined && event.terminal_reason !== "completed"))) {
|
|
610
|
+
terminalError ??= `Claude did not complete the answer (stop reason: ${String(event.stop_reason)}, terminal reason: ${String(event.terminal_reason)}).`;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// A result ends a model turn, not necessarily the stream; the first failure is latched and
|
|
614
|
+
// otherwise the latest successful result wins once the process exits cleanly.
|
|
615
|
+
if (event.subtype === "success" && event.is_error === false && typeof event.result === "string") {
|
|
616
|
+
final = event.result;
|
|
617
|
+
} else {
|
|
618
|
+
const detail =
|
|
619
|
+
typeof event.subtype === "string" && event.subtype !== "success"
|
|
620
|
+
? event.subtype
|
|
621
|
+
: typeof event.result === "string" && event.result.trim()
|
|
622
|
+
? event.result.trim()
|
|
623
|
+
: "turn failed";
|
|
624
|
+
terminalError ??= `Claude failed: ${detail}`;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
child.stdout?.setEncoding("utf8");
|
|
629
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
630
|
+
stdoutBuffer += chunk;
|
|
631
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
632
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
633
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
634
|
+
protocolError ??= `Claude emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
635
|
+
stdoutBuffer = "";
|
|
636
|
+
attempt.stop("protocol");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
for (const line of parts) {
|
|
640
|
+
try {
|
|
641
|
+
handleLine(line);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
protocolError ??= errorMessage(error);
|
|
644
|
+
attempt.stop("protocol");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const { code, exitSignal } = await attempt.closed;
|
|
651
|
+
attempt.dispose();
|
|
652
|
+
if (stdoutBuffer.trim()) {
|
|
653
|
+
try {
|
|
654
|
+
handleLine(stdoutBuffer);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
protocolError ??= errorMessage(error);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const partialText = partial || undefined;
|
|
661
|
+
const cause = attempt.causeOf();
|
|
662
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText };
|
|
663
|
+
if (cause === "timeout") {
|
|
664
|
+
const during = isAdvisor ? "during the advisor consultation" : "while simplifying the response";
|
|
665
|
+
return { status: "timeout", message: `Claude timed out ${during}. Run \`/bro doctor\` for setup help.`, partialText };
|
|
666
|
+
}
|
|
667
|
+
if (protocolError) return { status: "failure", message: withDoctor(protocolError), partialText };
|
|
668
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException, "Claude") };
|
|
669
|
+
if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal, "Claude"), partialText };
|
|
670
|
+
if (terminalError) return { status: "failure", message: withDoctor(terminalError), partialText };
|
|
671
|
+
if (code !== 0) {
|
|
672
|
+
const detail = stderr.trim();
|
|
673
|
+
return {
|
|
674
|
+
status: "failure",
|
|
675
|
+
message: detail
|
|
676
|
+
? `Claude could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
677
|
+
: `Claude could not ${action}. Make sure Claude Code is installed and signed in, then run \`/bro doctor\`.`,
|
|
678
|
+
partialText,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (final === undefined) {
|
|
682
|
+
return { status: "failure", message: withDoctor(`Claude exited without a result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), partialText };
|
|
683
|
+
}
|
|
684
|
+
const text = final.trim();
|
|
685
|
+
if (!text) return { status: "failure", message: withDoctor("Claude returned no final answer."), partialText };
|
|
686
|
+
return { status: "success", text };
|
|
687
|
+
} finally {
|
|
688
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
|
|
693
|
+
// e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
|
|
694
|
+
// host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
|
|
695
|
+
// after a bounded grace period) before resolving. `options` is for offline tests only -- production
|
|
696
|
+
// callers never override the deadline or kill-escalation delay.
|
|
697
|
+
export async function execute(
|
|
698
|
+
request: BackendRequest,
|
|
699
|
+
selection: BackendSelection,
|
|
700
|
+
signal: AbortSignal,
|
|
701
|
+
onProgress?: BackendOnProgress,
|
|
702
|
+
options?: BackendExecuteOptions,
|
|
703
|
+
): Promise<BackendOutcome> {
|
|
704
|
+
if (
|
|
705
|
+
(request.access === "workspace-full" && !request.cwd?.trim()) ||
|
|
706
|
+
(request.feature === "advisor" && request.access !== "workspace-full") ||
|
|
707
|
+
((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
|
|
708
|
+
(request.feature !== "btw" && request.continuation)
|
|
709
|
+
) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
|
|
710
|
+
const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
|
|
711
|
+
if (selection.backend === "claude") {
|
|
712
|
+
if (!backendSupports("claude", request.feature)) {
|
|
713
|
+
return { status: "failure", message: "Claude does not support /bro btw yet; switch btw back to Agy in `/bro config`." };
|
|
714
|
+
}
|
|
715
|
+
if (!selection.model.trim() || (selection.effort !== undefined && !CLAUDE_EFFORTS.includes(selection.effort))) {
|
|
716
|
+
return { status: "failure", message: "Unsupported Claude selection: check the model and effort (low, medium, high, xhigh or max)." };
|
|
717
|
+
}
|
|
718
|
+
return executeClaude(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
719
|
+
}
|
|
720
|
+
if (request.feature === "advisor") {
|
|
721
|
+
return executeAdvisorStdin(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
722
|
+
}
|
|
723
|
+
return executeArgvPrint(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
724
|
+
}
|