pi-bro 0.25.1 → 0.27.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 +50 -0
- package/README.md +18 -11
- package/docs/README.md +2 -0
- package/docs/getting-started.md +2 -2
- package/docs/grill-me.md +63 -0
- package/docs/guided-review.md +4 -4
- package/docs/reference/commands.md +2 -0
- package/docs/reference/data-and-permissions.md +17 -3
- package/docs/reference/settings.md +11 -2
- package/docs/reference/troubleshooting.md +5 -0
- package/docs/remember-me.md +90 -0
- package/package.json +14 -4
- package/src/advisor.ts +4 -2
- package/src/backend.ts +511 -11
- package/src/bro.ts +37 -0
- package/src/btw.ts +55 -9
- package/src/config-ui.ts +149 -51
- package/src/doctor.ts +142 -3
- package/src/explain-modal.ts +6 -2
- package/src/grill-engine.ts +116 -0
- package/src/grill.ts +312 -0
- package/src/remember-engine.ts +141 -0
- package/src/remember-store.ts +193 -0
- package/src/remember-tool.ts +22 -0
- package/src/remember.ts +294 -0
- package/src/settings.ts +85 -6
package/src/backend.ts
CHANGED
|
@@ -2,13 +2,27 @@ import { type ChildProcess, spawn } from "node:child_process";
|
|
|
2
2
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
createAgentSession,
|
|
7
|
+
DefaultResourceLoader,
|
|
8
|
+
getAgentDir,
|
|
9
|
+
ModelRuntime,
|
|
10
|
+
SessionManager,
|
|
11
|
+
SettingsManager,
|
|
12
|
+
type ExtensionContext,
|
|
13
|
+
type SessionEntry,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
// clampThinkingLevel and the Model type are the only things Bro needs from pi-ai, which is the
|
|
16
|
+
// lower-level LLM engine pi-coding-agent is built on. Bro imports it directly, so it is declared as
|
|
17
|
+
// a peer dependency alongside pi-coding-agent and pi-tui rather than relied on transitively.
|
|
18
|
+
import { clampThinkingLevel, type Model as PiAiModel } from "@earendil-works/pi-ai";
|
|
5
19
|
|
|
6
20
|
// Shared internal execution boundary for explain, show, btw, advisor, and review.
|
|
7
21
|
// This implements docs/plans/2026-09-22-shared-backend-design.md across all supported backends
|
|
8
|
-
// (Agy, Claude Code, Grok, Codex, Muse): it owns
|
|
9
|
-
// progress/outcome normalization, continuation, and single-attempt cleanup.
|
|
10
|
-
// keeps retries, UI, source/session capture and settings.
|
|
11
|
-
// For Guided Review (`feature: "review"`), Claude and
|
|
22
|
+
// (Agy, Claude Code, Grok, Codex, Muse, and the in-process Pi SDK): it owns model selection, process
|
|
23
|
+
// (or session) invocation, progress/outcome normalization, continuation, and single-attempt cleanup.
|
|
24
|
+
// Feature code keeps retries, UI, source/session capture and settings.
|
|
25
|
+
// For Guided Review (`feature: "review"`), Claude, Muse, and Pi enforce strict file-only inspection;
|
|
12
26
|
// Agy, Grok, and Codex run inside their own sandbox with shell available, and the UI owns telling
|
|
13
27
|
// the user via `reviewBackendNotice`.
|
|
14
28
|
|
|
@@ -17,10 +31,10 @@ export type BackendAccess = "restricted" | "workspace-full";
|
|
|
17
31
|
// Per-backend truth, not one shared sentence: Codex and Grok have a read-only sandbox, Agy's
|
|
18
32
|
// sandbox only restricts terminal commands and still lets it write inside the project. Kept short
|
|
19
33
|
// enough that the whole notice fits the review window's advisory rows on a narrow terminal.
|
|
20
|
-
export function reviewBackendNotice(backend: "agy" | "claude" | "grok" | "codex" | "muse"): string | undefined {
|
|
21
|
-
if (backend === "claude" || backend === "muse") return undefined;
|
|
34
|
+
export function reviewBackendNotice(backend: "agy" | "claude" | "grok" | "codex" | "muse" | "pi"): string | undefined {
|
|
35
|
+
if (backend === "claude" || backend === "muse" || backend === "pi") return undefined;
|
|
22
36
|
const sandbox = backend === "agy" ? "sandboxed shell (project writes allowed)" : "sandboxed shell in a read-only sandbox";
|
|
23
|
-
return `Guided Review on ${backend} uses ${sandbox}, not strict file-only inspection. Claude or
|
|
37
|
+
return `Guided Review on ${backend} uses ${sandbox}, not strict file-only inspection. Claude, Muse, or Pi is recommended for untrusted PRs.`;
|
|
24
38
|
}
|
|
25
39
|
// One default deadline table for every adapter: workspace-full, Guided Review and the advisor get
|
|
26
40
|
// the long bound; btw the middle one; explain/show the short one.
|
|
@@ -35,12 +49,14 @@ export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const
|
|
|
35
49
|
export const GROK_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
|
|
36
50
|
export const CODEX_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
|
|
37
51
|
export const MUSE_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
52
|
+
export const PI_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
38
53
|
export type BackendSelection =
|
|
39
54
|
| ({ backend?: "agy" } & AgySelection)
|
|
40
55
|
| { backend: "claude"; model: string; effort?: (typeof CLAUDE_EFFORTS)[number] }
|
|
41
56
|
| { backend: "grok"; model: string; effort?: (typeof GROK_EFFORTS)[number] }
|
|
42
57
|
| { backend: "codex"; model: string; effort?: (typeof CODEX_EFFORTS)[number] }
|
|
43
|
-
| { backend: "muse"; model: string; effort?: (typeof MUSE_EFFORTS)[number] }
|
|
58
|
+
| { backend: "muse"; model: string; effort?: (typeof MUSE_EFFORTS)[number] }
|
|
59
|
+
| { backend: "pi"; model: string; effort?: (typeof PI_EFFORTS)[number] };
|
|
44
60
|
export type BackendContinuation = { id: string };
|
|
45
61
|
export type BackendProgress = { kind: "text"; text: string } | { kind: "activity"; label: string; timestamp: number };
|
|
46
62
|
export type BackendOnProgress = (progress: BackendProgress) => void;
|
|
@@ -55,12 +71,15 @@ export type BackendRequest = {
|
|
|
55
71
|
|
|
56
72
|
export type BackendOutcome =
|
|
57
73
|
| { status: "success"; text: string; continuation?: BackendContinuation }
|
|
58
|
-
| { status: "failure" | "cancelled" | "timeout"; message: string; partialText?: string };
|
|
74
|
+
| { status: "failure" | "cancelled" | "timeout"; message: string; partialText?: string; continuationExpired?: true };
|
|
59
75
|
|
|
60
76
|
export type BackendExecuteOptions = {
|
|
61
77
|
// Injectable for offline tests only -- production always uses the defaults below.
|
|
62
78
|
killEscalationMs?: number;
|
|
63
79
|
deadlineMs?: number;
|
|
80
|
+
// Injectable for offline tests only: stands in for the SDK's createAgentSession so the Pi adapter's
|
|
81
|
+
// setup, cancellation, timeout and terminal-verification paths can be exercised without a model.
|
|
82
|
+
piCreateSession?: typeof createAgentSession;
|
|
64
83
|
};
|
|
65
84
|
|
|
66
85
|
function errorMessage(error: unknown): string {
|
|
@@ -1376,6 +1395,478 @@ async function executeMuse(
|
|
|
1376
1395
|
}
|
|
1377
1396
|
}
|
|
1378
1397
|
|
|
1398
|
+
// ----------------------------------------------------------------------------
|
|
1399
|
+
// In-process Pi SDK backend ("pi")
|
|
1400
|
+
//
|
|
1401
|
+
// Runs a real AgentSession inside this process instead of spawning a CLI. It is deliberately
|
|
1402
|
+
// isolated from the host session: its own resource loader (no extensions, skills, prompt
|
|
1403
|
+
// templates, themes or context files), its own SettingsManager with auto-retry and compaction
|
|
1404
|
+
// switched off in memory, and an explicit tool allowlist per feature. The host model registry is
|
|
1405
|
+
// rehydrated into a fresh ModelRuntime so in-memory provider registrations and runtime keys
|
|
1406
|
+
// (e.g. OAuth shorthand) still resolve, exactly as a spawned subagent would.
|
|
1407
|
+
// ----------------------------------------------------------------------------
|
|
1408
|
+
|
|
1409
|
+
// Host-side dependencies the CLI adapters get for free from their process environment. Optional so
|
|
1410
|
+
// offline tests (and callers with no registry) still compile; a missing registry fails loudly at
|
|
1411
|
+
// model-resolution time rather than silently in the registry.
|
|
1412
|
+
// The host facts the in-process Pi backend needs. `scopedModels` carries a `provider/model:high`
|
|
1413
|
+
// pattern's thinking level, which createAgentSession cannot recover on its own, and
|
|
1414
|
+
// `isProjectTrusted` decides whether the workspace's `.pi/settings.json` may apply.
|
|
1415
|
+
export type PiHostContext = Pick<ExtensionContext, "modelRegistry" | "scopedModels"> & {
|
|
1416
|
+
isProjectTrusted?: () => boolean;
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
// The most recent host context Bro saw (set by /bro commands and the advisor tool). A module-level
|
|
1420
|
+
// binding, like pi-flow-external's execution context, keeps the host registry reachable from the
|
|
1421
|
+
// shared feature helpers without threading it through every call site; Bro runs one host session
|
|
1422
|
+
// at a time.
|
|
1423
|
+
let currentPiHost: PiHostContext | undefined;
|
|
1424
|
+
|
|
1425
|
+
export function setPiHostContext(host: PiHostContext | undefined): void {
|
|
1426
|
+
currentPiHost = host;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
export function getPiHostContext(): PiHostContext | undefined {
|
|
1430
|
+
return currentPiHost;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const PI_EXCLUDED_TOOLS = ["bro_advisor", "external_delegate", "external_workflow", "mcp__*"];
|
|
1434
|
+
|
|
1435
|
+
/** Tools a Pi session may use for a given feature/access pair. Empty means "no tools at all". */
|
|
1436
|
+
export function piToolAllowlist(feature: BackendFeature, access: BackendAccess): { tools: string[]; excludeTools?: string[] } {
|
|
1437
|
+
if (feature === "advisor" || access === "workspace-full") {
|
|
1438
|
+
return { tools: ["read", "bash", "edit", "write"], excludeTools: [...PI_EXCLUDED_TOOLS] };
|
|
1439
|
+
}
|
|
1440
|
+
if (feature === "review") return { tools: ["read", "grep", "find", "ls"] };
|
|
1441
|
+
// explain, show, elevate and restricted btw only ever see the prompt.
|
|
1442
|
+
return { tools: [] };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// The thinking level a scoped model pattern asked for (e.g. "provider/model:high"), if any. Only
|
|
1446
|
+
// used when the Bro effort is "default": an explicit Bro effort must win, because it is the user's
|
|
1447
|
+
// current choice. Verified against the SDK: createAgentSession does not derive thinkingLevel from
|
|
1448
|
+
// scopedModels, so a `:thinking` suffix would otherwise be silently dropped.
|
|
1449
|
+
export function piScopedThinkingLevel(
|
|
1450
|
+
scoped: readonly { model: { provider: string; id: string }; thinkingLevel?: string }[] | undefined,
|
|
1451
|
+
providerId: string,
|
|
1452
|
+
modelId: string,
|
|
1453
|
+
): string | undefined {
|
|
1454
|
+
return scoped?.find((entry) => entry.model.provider === providerId && entry.model.id === modelId)?.thinkingLevel;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** The physical model a stored `pi` selection names, or undefined when it is unknown/virtual. */
|
|
1458
|
+
export function piPhysicalModel(
|
|
1459
|
+
registry: PiHostContext["modelRegistry"] | ModelRuntime | undefined,
|
|
1460
|
+
providerId: string,
|
|
1461
|
+
modelId: string,
|
|
1462
|
+
): PiPhysicalModel | undefined {
|
|
1463
|
+
if (!registry || !providerId || !modelId) return undefined;
|
|
1464
|
+
const model = "find" in registry ? registry.find(providerId, modelId) : registry.getModel(providerId, modelId);
|
|
1465
|
+
if (!model) return undefined;
|
|
1466
|
+
// Virtual models route to another model at request time; a spawned-style child needs a physical one.
|
|
1467
|
+
if ((model as { api?: string }).api === "pi-virtual") return undefined;
|
|
1468
|
+
return model as PiPhysicalModel;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/** Splits a stored Pi model string on the FIRST "/" only, so IDs containing slashes survive. */
|
|
1472
|
+
export function splitPiModel(model: string): { providerId: string; modelId: string } | undefined {
|
|
1473
|
+
const separator = model.indexOf("/");
|
|
1474
|
+
if (separator <= 0 || separator === model.length - 1) return undefined;
|
|
1475
|
+
return { providerId: model.slice(0, separator), modelId: model.slice(separator + 1) };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// The shape both a Pi registry entry and a resolved scoped model present for effort clamping.
|
|
1479
|
+
export type PiPhysicalModel = PiAiModel<string> | { reasoning?: boolean; thinkingLevelMap?: unknown };
|
|
1480
|
+
|
|
1481
|
+
// The requested and effective Pi effort for a model. "default" is resolved by the SDK from settings
|
|
1482
|
+
// (and any scoped `:thinking` suffix), so it is reported as such rather than guessed at. Used by
|
|
1483
|
+
// `/bro doctor` so its clamp claim is computed rather than assumed.
|
|
1484
|
+
export function piEffectiveEffort(model: PiPhysicalModel | undefined, effort: string): string {
|
|
1485
|
+
if (!model) return effort;
|
|
1486
|
+
return piClampEffort(model, effort);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Why a Pi attempt stopped, latched first-wins exactly like beginAttempt's StopCause.
|
|
1490
|
+
type PiStopCause = "cancelled" | "timeout";
|
|
1491
|
+
|
|
1492
|
+
// The terminal stop reasons pi-ai can report. Anything other than "stop" means the turn did not
|
|
1493
|
+
// finish normally, so it must not be reported as a complete answer. "toolUse" only appears on an
|
|
1494
|
+
// intermediate turn, so a *final* assistant message carrying it is also incomplete.
|
|
1495
|
+
const PI_INCOMPLETE_STOP_REASONS = new Set(["length", "pending", "deferred", "toolUse"]);
|
|
1496
|
+
|
|
1497
|
+
// Our own levels (plus "default") are a superset of pi-ai's, and "default" is resolved by the SDK
|
|
1498
|
+
// from settings per model, so it cannot be clamped here.
|
|
1499
|
+
function piClampEffort(model: PiPhysicalModel | undefined, effort: string): string {
|
|
1500
|
+
if (effort === "default" || !model) return effort;
|
|
1501
|
+
try {
|
|
1502
|
+
return clampThinkingLevel(model as never, effort as never) as string;
|
|
1503
|
+
} catch {
|
|
1504
|
+
return effort;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// In-memory btw sessions, keyed by the session id handed back as a continuation id. Nothing here
|
|
1509
|
+
// ever reaches the filesystem: btw side conversations must not appear in ~/.pi/agent/sessions.
|
|
1510
|
+
const piBtwSessions = new Map<string, SessionEntry[]>();
|
|
1511
|
+
const PI_BTW_SESSIONS_MAX = 20;
|
|
1512
|
+
|
|
1513
|
+
function rememberPiBtwSession(sessionId: string, entries: SessionEntry[]): void {
|
|
1514
|
+
piBtwSessions.delete(sessionId);
|
|
1515
|
+
piBtwSessions.set(sessionId, entries);
|
|
1516
|
+
while (piBtwSessions.size > PI_BTW_SESSIONS_MAX) {
|
|
1517
|
+
const oldest = piBtwSessions.keys().next().value;
|
|
1518
|
+
if (oldest === undefined) break;
|
|
1519
|
+
piBtwSessions.delete(oldest);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function piErrorMessage(feature: BackendFeature, message: string): string {
|
|
1524
|
+
const action =
|
|
1525
|
+
feature === "review"
|
|
1526
|
+
? "complete the guided review request"
|
|
1527
|
+
: feature === "advisor"
|
|
1528
|
+
? "complete the advisor consultation"
|
|
1529
|
+
: feature === "btw"
|
|
1530
|
+
? "answer the side question"
|
|
1531
|
+
: "simplify the response";
|
|
1532
|
+
return `Pi could not ${action}: ${message}`;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function piTimeoutMessage(feature: BackendFeature, seconds: number): string {
|
|
1536
|
+
const during =
|
|
1537
|
+
feature === "review"
|
|
1538
|
+
? "during the guided review request"
|
|
1539
|
+
: feature === "advisor"
|
|
1540
|
+
? "during the advisor consultation"
|
|
1541
|
+
: feature === "btw"
|
|
1542
|
+
? "during the side conversation"
|
|
1543
|
+
: "while simplifying the response";
|
|
1544
|
+
return `Pi timed out ${during} after ${Math.round(seconds / 1000)}s. Run \`/bro doctor\` for setup help.`;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function piAssistantText(message: { content?: unknown }): string {
|
|
1548
|
+
const content = message.content;
|
|
1549
|
+
if (!Array.isArray(content)) return "";
|
|
1550
|
+
return content
|
|
1551
|
+
.map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" ? String((part as { text?: unknown }).text ?? "") : ""))
|
|
1552
|
+
.join("")
|
|
1553
|
+
.trim();
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Copies every host-registered provider and runtime key into a fresh runtime. */
|
|
1557
|
+
async function bridgePiModelRuntime(host: PiHostContext | undefined, agentDir: string): Promise<ModelRuntime> {
|
|
1558
|
+
const modelRuntime = await ModelRuntime.create({
|
|
1559
|
+
authPath: join(agentDir, "auth.json"),
|
|
1560
|
+
modelsPath: join(agentDir, "models.json"),
|
|
1561
|
+
});
|
|
1562
|
+
const registry = host?.modelRegistry;
|
|
1563
|
+
if (registry) {
|
|
1564
|
+
for (const id of registry.getRegisteredProviderIds()) {
|
|
1565
|
+
const native = registry.getRegisteredNativeProvider(id);
|
|
1566
|
+
if (native) modelRuntime.registerNativeProvider(native);
|
|
1567
|
+
const config = registry.getRegisteredProviderConfig(id);
|
|
1568
|
+
if (config) modelRuntime.registerProvider(id, config);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
return modelRuntime;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Copying only the key for the selected provider is enough for a run, but not for listing what the
|
|
1575
|
+
// host can authenticate: a catalog built without runtime keys offers the user fewer models than
|
|
1576
|
+
// execution can actually use. Copy every runtime key the host holds.
|
|
1577
|
+
//
|
|
1578
|
+
// The provider list must come from the host's model catalog, not getRegisteredProviderIds(): that
|
|
1579
|
+
// method lists only extension-registered providers, so a runtime key set on a built-in provider
|
|
1580
|
+
// (for example Pi's own `--api-key`) would be missed and its models would vanish from `/bro config`.
|
|
1581
|
+
async function copyAllRuntimeCredentials(host: PiHostContext | undefined, runtime: ModelRuntime): Promise<void> {
|
|
1582
|
+
const registry = host?.modelRegistry;
|
|
1583
|
+
if (!registry) return;
|
|
1584
|
+
const providerIds = new Set(registry.getRegisteredProviderIds());
|
|
1585
|
+
// getAll() may be absent on a partial host stub; the extension list above still applies.
|
|
1586
|
+
for (const model of registry.getAll?.() ?? []) providerIds.add(model.provider);
|
|
1587
|
+
for (const providerId of providerIds) {
|
|
1588
|
+
if (registry.getProviderAuthStatus(providerId).source !== "runtime") continue;
|
|
1589
|
+
const key = await registry.getApiKeyForProvider(providerId);
|
|
1590
|
+
if (key) await runtime.setRuntimeApiKey(providerId, key);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// The runtime a Pi request or catalog read runs against: the host's registrations plus its runtime
|
|
1595
|
+
// credentials. Both the executor and `/bro doctor`/`/bro config` go through here so they can never
|
|
1596
|
+
// disagree about which models exist.
|
|
1597
|
+
export async function bridgePiRuntimeForCatalog(host: PiHostContext | undefined, agentDir: string): Promise<ModelRuntime> {
|
|
1598
|
+
const runtime = await bridgePiModelRuntime(host, agentDir);
|
|
1599
|
+
await copyAllRuntimeCredentials(host, runtime);
|
|
1600
|
+
return runtime;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async function copyRuntimeCredentials(
|
|
1604
|
+
host: PiHostContext | undefined,
|
|
1605
|
+
runtime: ModelRuntime,
|
|
1606
|
+
providerId: string,
|
|
1607
|
+
): Promise<void> {
|
|
1608
|
+
const registry = host?.modelRegistry;
|
|
1609
|
+
if (!registry) return;
|
|
1610
|
+
if (registry.getProviderAuthStatus(providerId).source !== "runtime") return;
|
|
1611
|
+
const key = await registry.getApiKeyForProvider(providerId);
|
|
1612
|
+
if (key) await runtime.setRuntimeApiKey(providerId, key);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
async function executePi(
|
|
1616
|
+
request: BackendRequest,
|
|
1617
|
+
selection: { model: string; effort?: string },
|
|
1618
|
+
signal: AbortSignal,
|
|
1619
|
+
onProgress: BackendOnProgress | undefined,
|
|
1620
|
+
killEscalationMs: number,
|
|
1621
|
+
deadlineMsOverride: number | undefined,
|
|
1622
|
+
host: PiHostContext | undefined,
|
|
1623
|
+
createSession: typeof createAgentSession = createAgentSession,
|
|
1624
|
+
): Promise<BackendOutcome> {
|
|
1625
|
+
void killEscalationMs; // in-process: abort() is cooperative, there is no process group to kill
|
|
1626
|
+
const deadlineMs = deadlineMsOverride ?? backendDeadlineMs(request.feature, request.access);
|
|
1627
|
+
const parts = splitPiModel(selection.model);
|
|
1628
|
+
if (!parts) return { status: "failure", message: piErrorMessage(request.feature, `\`${selection.model}\` is not a \`<provider>/<id>\` model.`) };
|
|
1629
|
+
|
|
1630
|
+
const agentDir = getAgentDir();
|
|
1631
|
+
// Full-access features run in the caller's workspace; restricted explain/show have no workspace at all,
|
|
1632
|
+
// and btw runs in the workspace in both modes.
|
|
1633
|
+
const cwd = request.cwd ?? process.cwd();
|
|
1634
|
+
|
|
1635
|
+
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
1636
|
+
let unsubscribe: (() => void) | undefined;
|
|
1637
|
+
let stopCause: PiStopCause | undefined;
|
|
1638
|
+
let sawAgentEnd = false;
|
|
1639
|
+
let willRetry = false;
|
|
1640
|
+
let partial = "";
|
|
1641
|
+
|
|
1642
|
+
// Settles the moment a stop is latched. Runtime preparation, session construction and the model
|
|
1643
|
+
// turn are all raced against it, so a cancellation or deadline is reported at the deadline instead
|
|
1644
|
+
// of after an uncooperative provider finally returns.
|
|
1645
|
+
let settleStop: (() => void) | undefined;
|
|
1646
|
+
const stopped = new Promise<void>((resolve) => {
|
|
1647
|
+
settleStop = resolve;
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
const stop = (cause: PiStopCause) => {
|
|
1651
|
+
if (stopCause) return; // latched: the first cause wins
|
|
1652
|
+
stopCause = cause;
|
|
1653
|
+
settleStop?.();
|
|
1654
|
+
void session?.abort();
|
|
1655
|
+
};
|
|
1656
|
+
// The stop outcome for the active cause, or undefined when this attempt is still running.
|
|
1657
|
+
const stoppedOutcome = (): BackendOutcome | undefined => {
|
|
1658
|
+
if (stopCause === "cancelled") return { status: "cancelled", message: "Canceled.", ...(partial ? { partialText: partial } : {}) };
|
|
1659
|
+
if (stopCause === "timeout") return { status: "timeout", message: piTimeoutMessage(request.feature, deadlineMs), ...(partial ? { partialText: partial } : {}) };
|
|
1660
|
+
return undefined;
|
|
1661
|
+
};
|
|
1662
|
+
// Same, for the sites that have already established a cause was latched.
|
|
1663
|
+
const latchedOutcome = (): BackendOutcome => stoppedOutcome() ?? { status: "failure", message: piErrorMessage(request.feature, "the attempt stopped without a cause.") };
|
|
1664
|
+
const onAbort = () => stop("cancelled");
|
|
1665
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1666
|
+
// An abort signal that is already aborted never fires its listeners, so latch it explicitly.
|
|
1667
|
+
if (signal.aborted) onAbort();
|
|
1668
|
+
// The deadline covers runtime preparation too: it is real (if small) I/O, and a hanging credential
|
|
1669
|
+
// lookup must not hold a cancelled request forever.
|
|
1670
|
+
// Deliberately NOT unref'd, unlike the CLI adapters' timers: there the child process keeps the
|
|
1671
|
+
// event loop alive, but here an awaited in-process promise is not a handle, so an unref'd timer
|
|
1672
|
+
// could let the loop drain and the timeout silently never fire. It is cleared in `finally`, so it
|
|
1673
|
+
// cannot outlive the attempt.
|
|
1674
|
+
const deadlineTimer = setTimeout(() => stop("timeout"), deadlineMs);
|
|
1675
|
+
|
|
1676
|
+
try {
|
|
1677
|
+
if (stopCause) return latchedOutcome();
|
|
1678
|
+
|
|
1679
|
+
// Resolve the physical model before paying for session construction, inside the latch so a slow
|
|
1680
|
+
// or throwing host credential lookup becomes an ordinary outcome rather than a rejection.
|
|
1681
|
+
const modelRuntime = await Promise.race([bridgePiModelRuntime(host, agentDir), stopped.then(() => undefined)]);
|
|
1682
|
+
if (!modelRuntime) return latchedOutcome();
|
|
1683
|
+
// Fall back to the host registry, which may know a model the rebuilt runtime does not.
|
|
1684
|
+
const model = modelRuntime.getModel(parts.providerId, parts.modelId) ?? host?.modelRegistry?.find(parts.providerId, parts.modelId);
|
|
1685
|
+
if (!model) {
|
|
1686
|
+
return { status: "failure", message: piErrorMessage(request.feature, `model \`${selection.model}\` is not in your Pi model catalog. Run \`/bro doctor\`.`) };
|
|
1687
|
+
}
|
|
1688
|
+
if ((model as { api?: string }).api === "pi-virtual") {
|
|
1689
|
+
return { status: "failure", message: piErrorMessage(request.feature, `\`${selection.model}\` is a virtual model; choose a physical model in \`/bro config\`.`) };
|
|
1690
|
+
}
|
|
1691
|
+
await Promise.race([copyRuntimeCredentials(host, modelRuntime, parts.providerId), stopped]);
|
|
1692
|
+
if (stopCause) return latchedOutcome();
|
|
1693
|
+
|
|
1694
|
+
// Guided Review runs on an untrusted PR checkout, so its `.pi/settings.json` must never be merged.
|
|
1695
|
+
// Elsewhere the host's own trust decision decides: a repo the user declined to trust must not get
|
|
1696
|
+
// to steer thinking levels, budgets, transport or the shell the bash tool runs with.
|
|
1697
|
+
// A host that predates isProjectTrusted() (or a test stub) falls back to Pi's own default.
|
|
1698
|
+
const hostTrustsProject = (() => {
|
|
1699
|
+
try { return host?.isProjectTrusted?.() ?? true; } catch { return false; }
|
|
1700
|
+
})();
|
|
1701
|
+
// An in-memory manager seeded from the effective on-disk settings, rather than one that keeps
|
|
1702
|
+
// reading them. It gives two things a file-backed manager cannot:
|
|
1703
|
+
// - the single-attempt and no-warming overrides cannot be discarded by a later reload(), and
|
|
1704
|
+
// - nothing this child does can write the user's global settings.json.
|
|
1705
|
+
// cacheWarming must be set here: getCacheWarmingMode() reads globalSettings directly, so an
|
|
1706
|
+
// applyOverrides({cacheWarming}) would be ignored, and the warmer replays the prompt (another
|
|
1707
|
+
// paid request) once a prompt-cache TTL is close to expiring.
|
|
1708
|
+
const projectTrusted = request.feature !== "review" && hostTrustsProject;
|
|
1709
|
+
const baseSettings = SettingsManager.create(cwd, agentDir, { projectTrusted }).getSettings();
|
|
1710
|
+
const settingsManager = SettingsManager.inMemory({
|
|
1711
|
+
...baseSettings,
|
|
1712
|
+
retry: { ...baseSettings.retry, enabled: false, provider: { ...baseSettings.retry?.provider, maxRetries: 0 } },
|
|
1713
|
+
compaction: { ...baseSettings.compaction, enabled: false },
|
|
1714
|
+
cacheWarming: "off",
|
|
1715
|
+
}, { projectTrusted });
|
|
1716
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
1717
|
+
cwd,
|
|
1718
|
+
agentDir,
|
|
1719
|
+
settingsManager,
|
|
1720
|
+
noExtensions: true,
|
|
1721
|
+
disabledBuiltinExtensions: ["mcp", "codemode"],
|
|
1722
|
+
noSkills: true,
|
|
1723
|
+
noPromptTemplates: true,
|
|
1724
|
+
noThemes: true,
|
|
1725
|
+
noContextFiles: true,
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
// reload() re-reads settings from disk into the resource loader; the in-memory manager above is
|
|
1729
|
+
// unaffected, so the overrides survive it.
|
|
1730
|
+
// A deadline that fires during reload or session construction must stop the attempt here: the
|
|
1731
|
+
// timer has already latched (and abort() was a no-op with no session yet), and a timeout does
|
|
1732
|
+
// not set signal.aborted, so checking the latch is the only thing that prevents a paid model
|
|
1733
|
+
// request from starting after its deadline.
|
|
1734
|
+
await Promise.race([resourceLoader.reload(), stopped]);
|
|
1735
|
+
if (stopCause) return latchedOutcome();
|
|
1736
|
+
|
|
1737
|
+
// btw resumes the retained in-memory transcript; every other feature starts fresh. A thread whose
|
|
1738
|
+
// retained entries are gone must fail loudly rather than silently answering without its history.
|
|
1739
|
+
const continuationId = request.feature === "btw" ? request.continuation?.id : undefined;
|
|
1740
|
+
// Bro keeps exactly one side thread, so a btw turn with no continuation id means the previous
|
|
1741
|
+
// thread was reset (backend/mode/preferences change or /clear) and its retained transcript is now
|
|
1742
|
+
// unreachable. Drop it instead of holding the entries until the cap evicts them.
|
|
1743
|
+
if (request.feature === "btw" && !continuationId) piBtwSessions.clear();
|
|
1744
|
+
const retained = continuationId ? piBtwSessions.get(continuationId) : undefined;
|
|
1745
|
+
if (continuationId && !retained) {
|
|
1746
|
+
// Tagged so the caller can drop the dead id: without that, every later turn resends it and
|
|
1747
|
+
// the thread stays stuck until the user clears it.
|
|
1748
|
+
return { status: "failure", continuationExpired: true, message: piErrorMessage(request.feature, "the side conversation expired before it could be continued; ask the question again to start a fresh thread.") };
|
|
1749
|
+
}
|
|
1750
|
+
const sessionManager = retained && continuationId
|
|
1751
|
+
? SessionManager.inMemory(cwd, { id: continuationId }, retained)
|
|
1752
|
+
: SessionManager.inMemory(cwd);
|
|
1753
|
+
const allowlist = piToolAllowlist(request.feature, request.access);
|
|
1754
|
+
// An explicit Bro effort wins; with "default" the SDK resolves the level from settings, but it
|
|
1755
|
+
// cannot see a scoped pattern's `:thinking` suffix, so carry that through ourselves.
|
|
1756
|
+
const thinkingLevel = selection.effort && selection.effort !== "default"
|
|
1757
|
+
? selection.effort
|
|
1758
|
+
: piScopedThinkingLevel(host?.scopedModels, parts.providerId, parts.modelId);
|
|
1759
|
+
|
|
1760
|
+
const sessionPromise = createSession({
|
|
1761
|
+
cwd,
|
|
1762
|
+
agentDir,
|
|
1763
|
+
model,
|
|
1764
|
+
modelRuntime,
|
|
1765
|
+
settingsManager,
|
|
1766
|
+
sessionManager,
|
|
1767
|
+
resourceLoader,
|
|
1768
|
+
...(allowlist.tools.length ? { tools: allowlist.tools } : { noTools: "all" as const }),
|
|
1769
|
+
...(allowlist.excludeTools ? { excludeTools: allowlist.excludeTools } : {}),
|
|
1770
|
+
...(thinkingLevel ? { thinkingLevel: thinkingLevel as NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"] } : {}),
|
|
1771
|
+
});
|
|
1772
|
+
const created = await Promise.race([sessionPromise, stopped.then(() => undefined)]);
|
|
1773
|
+
if (!created) {
|
|
1774
|
+
// The latch won the race, but construction may still resolve afterwards. That session is
|
|
1775
|
+
// never prompted and cannot be disposed through `session` below, so tear it down here.
|
|
1776
|
+
void sessionPromise.then(
|
|
1777
|
+
(late) => { try { late.session.dispose(); } catch { /* best-effort teardown */ } },
|
|
1778
|
+
() => { /* construction failed; nothing to dispose */ },
|
|
1779
|
+
);
|
|
1780
|
+
return latchedOutcome();
|
|
1781
|
+
}
|
|
1782
|
+
session = created.session;
|
|
1783
|
+
if (stopCause) {
|
|
1784
|
+
// The attempt was stopped while the session was being built, so abort it directly; the latch
|
|
1785
|
+
// already fired its own abort before this session existed.
|
|
1786
|
+
void session.abort();
|
|
1787
|
+
return latchedOutcome();
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
unsubscribe = session.subscribe((event) => {
|
|
1791
|
+
if (stopCause) return;
|
|
1792
|
+
if (event.type === "agent_end") {
|
|
1793
|
+
sawAgentEnd = true;
|
|
1794
|
+
// The SDK reports its own retry decision here; a retrying turn must never count as settled.
|
|
1795
|
+
willRetry = (event as { willRetry?: boolean }).willRetry === true;
|
|
1796
|
+
} else if (event.type === "message_update" && event.message.role === "assistant") {
|
|
1797
|
+
const text = piAssistantText(event.message);
|
|
1798
|
+
if (text) {
|
|
1799
|
+
partial = text;
|
|
1800
|
+
onProgress?.({ kind: "text", text });
|
|
1801
|
+
}
|
|
1802
|
+
} else if (event.type === "tool_execution_start" && onProgress) {
|
|
1803
|
+
onProgress({ kind: "activity", label: event.toolName, timestamp: Date.now() });
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
// Race the turn against the stop latch so a non-cooperative provider cannot hold the caller.
|
|
1808
|
+
//
|
|
1809
|
+
// Racing alone is not enough: session.prompt() awaits an auth check before any run exists, and
|
|
1810
|
+
// abort() only latches when a run is already active (the SDK's _startRun then clears that flag).
|
|
1811
|
+
// A stop latched during that window would otherwise be forgotten and the request would still go
|
|
1812
|
+
// out. preflightResult fires once the run is about to start, so re-issuing abort there lands on
|
|
1813
|
+
// the SDK's own agent_start cancellation path and stops the first request.
|
|
1814
|
+
const prompted = Promise.resolve(
|
|
1815
|
+
session.prompt(request.prompt, {
|
|
1816
|
+
source: "extension",
|
|
1817
|
+
preflightResult: (disposition) => {
|
|
1818
|
+
if (disposition === "started" && stopCause) void session?.abort();
|
|
1819
|
+
},
|
|
1820
|
+
}),
|
|
1821
|
+
).then(() => "done" as const);
|
|
1822
|
+
await Promise.race([prompted, stopped.then(() => "stopped" as const)]);
|
|
1823
|
+
if (stopCause) return latchedOutcome();
|
|
1824
|
+
|
|
1825
|
+
// pi-ai encodes provider failures as a final assistant turn rather than throwing.
|
|
1826
|
+
const messages = session.messages;
|
|
1827
|
+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
1828
|
+
const stopReason = (lastAssistant as { stopReason?: string } | undefined)?.stopReason;
|
|
1829
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
1830
|
+
const detail = (lastAssistant as { errorMessage?: string } | undefined)?.errorMessage ?? `Pi turn did not complete (stopReason: ${stopReason}).`;
|
|
1831
|
+
return { status: "failure", message: withDoctor(piErrorMessage(request.feature, detail)), ...(partial ? { partialText: partial } : {}) };
|
|
1832
|
+
}
|
|
1833
|
+
// A truncated or unfinished turn is not a complete answer; its text is partial at best.
|
|
1834
|
+
if (stopReason !== undefined && PI_INCOMPLETE_STOP_REASONS.has(stopReason)) {
|
|
1835
|
+
return { status: "failure", message: withDoctor(piErrorMessage(request.feature, `Pi stopped before finishing (stopReason: ${stopReason}).`)), ...(partial ? { partialText: partial } : {}) };
|
|
1836
|
+
}
|
|
1837
|
+
// Success needs an observed agent_end with no pending retry: without one this code cannot
|
|
1838
|
+
// confirm the turn settled, and a retrying turn is still inside Bro's single attempt.
|
|
1839
|
+
if (!sawAgentEnd) return { status: "failure", message: withDoctor(piErrorMessage(request.feature, "the turn ended without an agent_end event.")), ...(partial ? { partialText: partial } : {}) };
|
|
1840
|
+
if (willRetry) return { status: "failure", message: withDoctor(piErrorMessage(request.feature, "the turn ended but the SDK still intended to retry.")), ...(partial ? { partialText: partial } : {}) };
|
|
1841
|
+
const text = lastAssistant ? piAssistantText(lastAssistant) : "";
|
|
1842
|
+
if (!text) return { status: "failure", message: withDoctor(piErrorMessage(request.feature, "Pi returned no final explanation.")), ...(partial ? { partialText: partial } : {}) };
|
|
1843
|
+
|
|
1844
|
+
// btw continuation uses the in-memory session id, which never touches disk.
|
|
1845
|
+
if (request.feature === "btw") {
|
|
1846
|
+
rememberPiBtwSession(session.sessionId, session.sessionManager.getEntries());
|
|
1847
|
+
return { status: "success", text, continuation: { id: session.sessionId } };
|
|
1848
|
+
}
|
|
1849
|
+
return { status: "success", text };
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
const stoppedResult = stoppedOutcome();
|
|
1852
|
+
if (stoppedResult) return stoppedResult;
|
|
1853
|
+
return { status: "failure", message: withDoctor(piErrorMessage(request.feature, errorMessage(error))), ...(partial ? { partialText: partial } : {}) };
|
|
1854
|
+
} finally {
|
|
1855
|
+
signal.removeEventListener("abort", onAbort);
|
|
1856
|
+
clearTimeout(deadlineTimer);
|
|
1857
|
+
try {
|
|
1858
|
+
unsubscribe?.();
|
|
1859
|
+
} catch {
|
|
1860
|
+
// A listener that throws during teardown must not mask the real outcome.
|
|
1861
|
+
}
|
|
1862
|
+
try {
|
|
1863
|
+
session?.dispose();
|
|
1864
|
+
} catch {
|
|
1865
|
+
// dispose() is best-effort; a failure here cannot change the already-decided outcome.
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1379
1870
|
// Single-attempt executor shared by all features. Never retries; never spawns a pre-aborted
|
|
1380
1871
|
// request; on cancellation, host deadline, or a protocol failure, stops the whole POSIX process
|
|
1381
1872
|
// group (SIGTERM, then SIGKILL after a bounded grace period) before resolving. `options` is for
|
|
@@ -1386,6 +1877,7 @@ export async function execute(
|
|
|
1386
1877
|
signal: AbortSignal,
|
|
1387
1878
|
onProgress?: BackendOnProgress,
|
|
1388
1879
|
options?: BackendExecuteOptions,
|
|
1880
|
+
host?: PiHostContext,
|
|
1389
1881
|
): Promise<BackendOutcome> {
|
|
1390
1882
|
if (
|
|
1391
1883
|
(request.access === "workspace-full" && !request.cwd?.trim()) ||
|
|
@@ -1396,10 +1888,18 @@ export async function execute(
|
|
|
1396
1888
|
) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
|
|
1397
1889
|
const backend = (selection as { backend?: unknown }).backend;
|
|
1398
1890
|
// An explicit tag guard: a stale or corrupt runtime tag must fail, never fall through to Agy.
|
|
1399
|
-
if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok" && backend !== "codex" && backend !== "muse") {
|
|
1400
|
-
return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude, Grok, Codex or
|
|
1891
|
+
if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok" && backend !== "codex" && backend !== "muse" && backend !== "pi") {
|
|
1892
|
+
return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude, Grok, Codex, Muse or Pi in \`/bro config\`.` };
|
|
1401
1893
|
}
|
|
1402
1894
|
const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
|
|
1895
|
+
if (selection.backend === "pi") {
|
|
1896
|
+
// Same shape checks every other adapter performs, so a corrupt runtime tag fails as an outcome
|
|
1897
|
+
// instead of throwing a TypeError out of execute().
|
|
1898
|
+
if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !PI_EFFORTS.includes(selection.effort as never))) {
|
|
1899
|
+
return { status: "failure", message: "Unsupported Pi selection: check the model (<provider>/<id>) and effort (minimal, low, medium, high, xhigh or max)." };
|
|
1900
|
+
}
|
|
1901
|
+
return executePi(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs, host ?? currentPiHost, options?.piCreateSession);
|
|
1902
|
+
}
|
|
1403
1903
|
if (selection.backend === "codex") {
|
|
1404
1904
|
// Codex btw always runs (and resumes) in the caller's workspace, even restricted.
|
|
1405
1905
|
if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
|