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/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 CLI selection, process invocation,
9
- // progress/outcome normalization, continuation, and single-attempt cleanup. Feature code
10
- // keeps retries, UI, source/session capture and settings.
11
- // For Guided Review (`feature: "review"`), Claude and Muse enforce strict file-only inspection;
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 Muse is recommended for untrusted PRs.`;
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 Muse in \`/bro config\`.` };
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()) {