diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index fc66b25846..2977bda80a 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -342,6 +342,11 @@ environment pull-request show `. Diff commands require an explicit target (alias `--host`) or `--environment ` to inspect the machine where work will run; the selectors cannot be combined. With neither selector they intentionally inspect the primary machine. +- Pi's native cycling preference is available through `bb pi models list`, + `bb pi models set `, and `bb pi models enable-all`. Each accepts + optional `--machine ` and `--json`; without a machine it targets + the primary host. These commands update Pi's global `enabledModels` and do + not accept a cwd. - Known ACP agents can appear automatically when their CLI is installed on the host; for example `opencode`, `omp`, Grok Build's `grok` CLI, or Hermes' `hermes` CLI on PATH appears as provider `acp-opencode`, `acp-omp`, diff --git a/docs/configuration.md b/docs/configuration.md index 82443aa16d..c1f1b452fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -384,6 +384,35 @@ so a configured agent shows the generic tool glyph, and bb drops the field when it reads the old array. A setting entry wins over a config entry with the same `id`. +## Pi enabled models + +Settings → Plugins → Pi provider → Models edits Pi's global `enabledModels` on +the primary machine. The editor lists authenticated models, supports search +and per-model switches, and updates BB's picker and model cycling immediately +after Save. **Enable all** removes the native preference; **Reset** restores the +last saved selection. + +For CLI use: + +```sh +bb pi models list [--machine ] [--json] +bb pi models set [--machine ] [--json] +bb pi models enable-all [--machine ] [--json] +``` + +Without `--machine`, the command targets the primary machine. SDK clients call +the Pi plugin through `sdk.plugins.callRpc` with plugin id `provider-pi` and +method `readModelSettings` or `writeModelSettings`; each input carries +`hostId`, writes also carry `enabledModelIds` (`string[]` or `null`), and the +call supplies an `outputSchema` for the snapshot as required by +`sdk.plugins.callRpc`. Neither CLI nor SDK accepts a cwd because this editor +changes Pi's global +preference, not a workspace settings file. + +This preference controls ordinary picker/cycling visibility, not execution +authorization. A model already selected by a thread remains available as a +selected-only model. + ## Custom Models Register extra picker models by editing top-level `customModels` in diff --git a/docs/model-preferences-policy.md b/docs/model-preferences-policy.md new file mode 100644 index 0000000000..7a5632031c --- /dev/null +++ b/docs/model-preferences-policy.md @@ -0,0 +1,102 @@ +# Provider-native model preferences + +Status: accepted and implemented for Pi + +## Decision + +Pi's global `enabledModels` setting remains the authority for Pi's preferred +model set. BB exposes a product editor for that native preference; it does not +create a second BB model-policy document. + +The editor lists authenticated models on the host selected by Settings. It +shows model switches, search, enabled count, **Enable all**, **Reset**, and +**Save**. Host routing is supplied by the Settings host context. The editor +never asks for a machine, cwd, file path, pattern, revision, or conflict +resolution. + +The CLI offers the same global operation through `bb pi models`; its optional +`--machine` flag follows BB's remote-targeting convention. SDK clients use the +Pi plugin's `readModelSettings` and `writeModelSettings` RPC methods through +`sdk.plugins.callRpc`. Neither surface accepts cwd. + +## Invariants + +### Preferences are not authorization + +`enabledModels` controls Pi's picker and model cycling. It does not authorize +execution. A selected thread model outside the preferred set remains in BB's +selected-only catalog and can continue to run. + +A future execution restriction would be a separate BB-owned policy with an +explicit denial result. It must not reuse this preference. + +### Pi owns matching + +Pi model identifiers and `enabledModels` patterns are opaque outside the Pi +bridge. The bridge uses Pi's native resolver, including ordered patterns, +globs, bare IDs, aggregator IDs containing slashes or colons, and thinking +suffixes. Core model contracts carry only ordinary model IDs and never gain Pi +pattern fields. + +The product editor intentionally writes exact authenticated model IDs. Existing +native patterns are resolved internally to switch state but are never exposed +or rewritten until the user saves a changed selection. + +### Empty resolution is unrestricted + +Pi treats absent or empty `enabledModels` as unrestricted. It also treats a +configured pattern set that resolves no models as unrestricted rather than +deny-all. BB preserves that behavior: the picker cannot become empty because a +stale native pattern stopped matching. + +**Enable all** removes the global `enabledModels` field. The editor prevents a +user from disabling the final authenticated model. + +### Selected-only models remain available + +Model discovery partitions the authenticated catalog: + +- models selected by Pi's native preference appear in `models`, in Pi's + resolved order; +- authenticated models outside that preference appear in + `selectedOnlyModels`; +- duplicate IDs are not introduced. + +The distinction preserves an existing thread's selection without presenting +that model during ordinary cycling. + +### Hosts are isolated + +The selected host determines credentials, authenticated models, and the global +Pi settings file. A host with no authenticated models reports that state; it +must never show another host's cached catalog. + +Saving clears the server's model-catalog memo and broadcasts a model change so +web and mobile picker queries refetch immediately. The Pi bridge reloads native +settings before every model-list resolution, so a save does not require a BB +restart. + +### Writes preserve the settings document + +The Pi bridge updates only global `enabledModels`. It takes the same +host-local lock Pi uses (`proper-lockfile` on the settings path), preserves +unrelated JSON fields and file mode, writes a temporary file beside the real +file (through a symlinked `settings.json`, not over the link), and atomically +renames it over the settings file. Invalid settings or write failures fail the +save rather than replacing unknown content; a settings file pi could not load +is reported and read as empty for listings, so the picker keeps working. + +The bridge reads only the global file. A project's `.pi/settings.json` +`enabledModels` applies in pi only once the project is trusted, a decision +the bridge cannot see, and a repository must not be able to steer the picker. + +## Ownership + +| Concern | Owner | +| -------------------------------------------- | -------------------------------------------- | +| Authenticate and enumerate Pi models | Pi model runtime in the Pi bridge | +| Resolve native `enabledModels` semantics | Pi native resolver in the Pi bridge | +| Atomically update global Pi settings | Pi bridge on the selected host | +| Route the selected host | BB Settings context / CLI machine resolution | +| Cache invalidation and client notification | BB server provider plugin API | +| Search, draft, reset, save, and empty states | Pi provider plugin app | diff --git a/packages/templates/src/templates/bb-guide-providers.md b/packages/templates/src/templates/bb-guide-providers.md index 710fb45bad..1c2c10a044 100644 --- a/packages/templates/src/templates/bb-guide-providers.md +++ b/packages/templates/src/templates/bb-guide-providers.md @@ -13,6 +13,12 @@ Providers are agent backends (e.g., codex, claude-code). Each supports different List available providers bb provider models [providerId] [--machine | --environment ] List models for a provider + bb pi models list [--machine ] [--json] + List authenticated Pi models and enabled state + bb pi models set [--machine ] [--json] + Replace Pi's global enabled models + bb pi models enable-all [--machine ] [--json] + Enable every authenticated Pi model Use these before spawning threads if you are unsure which provider or model to use. `--host` is an alias for `--machine`. Machine and environment selectors are diff --git a/plugins/provider-pi/app.css b/plugins/provider-pi/app.css new file mode 100644 index 0000000000..281efe551a --- /dev/null +++ b/plugins/provider-pi/app.css @@ -0,0 +1,44 @@ +.pi-extension-state { + display: grid; + gap: 0.5rem; + min-width: 0; + color: var(--foreground); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); +} + +.pi-extension-state__metadata, +.pi-extension-state__widget { + display: grid; + gap: 0.375rem; + min-width: 0; + padding: 0.625rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.625rem; + background: var(--surface-recessed); +} + +.pi-extension-state__title { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pi-extension-state__status { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + overflow: hidden; + color: var(--muted-foreground); + text-overflow: ellipsis; + white-space: nowrap; +} + +.pi-extension-state__widget { + overflow-x: auto; + font-family: var(--font-mono); + line-height: 1.45; + white-space: pre; +} diff --git a/plugins/provider-pi/app.test.tsx b/plugins/provider-pi/app.test.tsx new file mode 100644 index 0000000000..88b2477edc --- /dev/null +++ b/plugins/provider-pi/app.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExperimentalProviderExtensionStateProps } from "@get-bb/plugin-sdk/app"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; + +const toastMocks = vi.hoisted(() => ({ + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: toastMocks })); +import type { PiModelSettingsSnapshot } from "./src/model-settings-contract.js"; + +const snapshot: PiModelSettingsSnapshot = { + models: [ + { + id: "anthropic/claude-sonnet-5", + displayName: "Claude Sonnet 5", + provider: "anthropic", + reasoning: true, + }, + { + id: "openai/gpt-5.1", + displayName: "GPT-5.1", + provider: "openai", + reasoning: true, + }, + { + id: "google/gemini-3-pro", + displayName: "Gemini 3 Pro", + provider: "google", + reasoning: true, + }, + ], + enabledModelIds: ["anthropic/claude-sonnet-5"], +}; + +afterEach(cleanup); + +describe("Pi model settings editor", () => { + it("tracks unsaved changes, resets, saves, and enables all", async () => { + const write = vi.fn((input: unknown) => ({ + ...snapshot, + enabledModelIds: (input as { enabledModelIds: string[] | null }).enabledModelIds, + })); + const app = await loadPluginApp(() => import("./app.js")); + const slot = renderSlot( + app.settingsSections.find(({ id }) => id === "models")!, + { experimental_hostId: "host-1" }, + { + rpc: { + readModelSettings: () => snapshot, + writeModelSettings: write, + }, + }, + ); + + const gpt = await slot.findByRole("switch", { + name: "Enable openai/gpt-5.1", + }); + const search = slot.getByRole("textbox", { name: "Search Pi models" }); + fireEvent.change(search, { target: { value: "openai" } }); + expect(slot.queryByText("Gemini 3 Pro")).toBeNull(); + fireEvent.change(search, { target: { value: "" } }); + + fireEvent.click(gpt); + expect(slot.getByText("Unsaved changes")).toBeTruthy(); + expect( + slot.getByText("2 of 3 models enabled for Pi cycling."), + ).toBeTruthy(); + + fireEvent.click(slot.getByRole("button", { name: "Reset" })); + expect(slot.queryByText("Unsaved changes")).toBeNull(); + expect((gpt as HTMLButtonElement).getAttribute("data-state")).toBe( + "unchecked", + ); + + fireEvent.click(gpt); + fireEvent.click(slot.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(write).toHaveBeenCalledWith({ + hostId: "host-1", + enabledModelIds: ["anthropic/claude-sonnet-5", "openai/gpt-5.1"], + }), + ); + await waitFor(() => expect(slot.queryByText("Unsaved changes")).toBeNull()); + + fireEvent.click( + slot.getByRole("switch", { name: "Enable openai/gpt-5.1" }), + ); + fireEvent.click(slot.getByRole("button", { name: "Enable all" })); + fireEvent.click(slot.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(write).toHaveBeenLastCalledWith({ + hostId: "host-1", + enabledModelIds: null, + }), + ); + }); + + it("shows the selected host's no-model authentication state", async () => { + const app = await loadPluginApp(() => import("./app.js")); + const slot = renderSlot( + app.settingsSections.find(({ id }) => id === "models")!, + { experimental_hostId: "host-empty" }, + { + rpc: { + readModelSettings: () => ({ models: [], enabledModelIds: null }), + }, + }, + ); + + expect( + await slot.findByText( + "No authenticated Pi models are available on this host. Run `pi` there to sign in.", + ), + ).toBeTruthy(); + expect(slot.inspection.rpcCalls[0]).toMatchObject({ + method: "readModelSettings", + input: { hostId: "host-empty" }, + }); + }); +}); + +describe("Pi extension state renderer", () => { + beforeEach(() => { + toastMocks.info.mockClear(); + toastMocks.warning.mockClear(); + toastMocks.error.mockClear(); + }); + + let threadSerial = 0; + function stateProps( + threadId: string, + payload: unknown, + sourceSeq: number, + ): ExperimentalProviderExtensionStateProps { + return { + threadId, + providerId: "pi", + kind: "provider-pi/extension-ui", + payload: payload as ExperimentalProviderExtensionStateProps["payload"], + sourceSeq, + placement: "aboveEditor", + experimental_dispatchAction: async () => ({ applied: false }), + }; + } + function snapshot(overrides: Record) { + return { statuses: [], widgets: [], notifications: [], title: null, editor: null, ...overrides }; + } + + it("toasts a notification once when it arrives, never the ones a persisted snapshot already held", async () => { + const app = await loadPluginApp(() => import("./app.js")); + const registration = app.providerExtensionStates.find(({ name }) => name === "extension-ui")!; + const threadId = `thr_toast_${(threadSerial += 1)}`; + const persisted = snapshot({ + notifications: [{ id: 1, message: "old news", level: "info" }], + }); + const slot = renderSlot(registration, stateProps(threadId, persisted, 10)); + expect(toastMocks.info).not.toHaveBeenCalled(); + + const arrived = snapshot({ + notifications: [ + { id: 1, message: "old news", level: "info" }, + { id: 2, message: "heads up", level: "warning" }, + ], + }); + slot.rerender(); + expect(toastMocks.warning).toHaveBeenCalledOnce(); + expect(toastMocks.warning).toHaveBeenCalledWith( + "heads up", + expect.objectContaining({ closeButton: true }), + ); + // The same snapshot again (a refetch, a re-render) is not news. + slot.rerender(); + expect(toastMocks.warning).toHaveBeenCalledOnce(); + + // A replaced session counts from 1 again: the null snapshot in between + // resets the mark, so its first notification shows. + slot.rerender(); + slot.rerender( + , + ); + expect(toastMocks.error).toHaveBeenCalledWith("fresh", expect.anything()); + expect(toastMocks.info).not.toHaveBeenCalled(); + }); + + it("applies an editor request once, not again on every render or for a persisted one", async () => { + const app = await loadPluginApp(() => import("./app.js")); + const registration = app.providerExtensionStates.find(({ name }) => name === "extension-ui")!; + const threadId = `thr_editor_${(threadSerial += 1)}`; + const persisted = snapshot({ editor: { revision: 3, text: "from before" } }); + const slot = renderSlot(registration, stateProps(threadId, persisted, 20), { + composer: { text: "my draft" }, + }); + // A persisted request predates this mount: the draft is the user's. + expect(slot.inspection.composer.text).toBe("my draft"); + + const requested = snapshot({ editor: { revision: 4, text: " exact\n text " } }); + slot.rerender(); + expect(slot.inspection.composer.text).toBe(" exact\n text "); + + await slot.behavior.setComposerText("edited by hand"); + slot.rerender(); + expect(slot.inspection.composer.text).toBe("edited by hand"); + }); +}); diff --git a/plugins/provider-pi/app.tsx b/plugins/provider-pi/app.tsx new file mode 100644 index 0000000000..903b8db6b1 --- /dev/null +++ b/plugins/provider-pi/app.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { + definePluginApp, + useComposer, + type ExperimentalProviderExtensionStateProps, +} from "@get-bb/plugin-sdk/app"; +import { + PI_EXTENSION_UI_STATE_NAME, + piExtensionUIStateSchema, + type PiExtensionUIState, +} from "./src/extension-state.js"; +import { PiModelSettingsEditor } from "./src/model-settings-editor.js"; +import "./app.css"; + +type PiNotification = PiExtensionUIState["notifications"][number]; + +/** How long a notification stays up: errors linger, the rest pass. */ +function notificationDurationMs(level: PiNotification["level"]): number { + return level === "error" ? 10_000 : 5_000; +} + +function showNotification(notification: PiNotification): void { + const show = + notification.level === "error" + ? toast.error + : notification.level === "warning" + ? toast.warning + : toast.info; + show(notification.message, { + id: `pi-extension-notification-${notification.id}`, + closeButton: true, + duration: notificationDurationMs(notification.level), + }); +} + +function parseState(payload: unknown): PiExtensionUIState | null { + const parsed = piExtensionUIStateSchema.safeParse(payload); + return parsed.success ? parsed.data : null; +} + +/** + * What each thread's renderer has already acted on, kept outside the + * component: a remount (navigating away and back, a second pane, a kind + * entry flickering) must not re-apply an editor request or re-toast a + * notification, and one thread's marks must not bleed into another's. A null + * snapshot is the session ending; the next session's counters restart, so + * the marks restart with them. + */ +interface ThreadMarks { + editorRevision: number | null; + notificationId: number; +} +const threadMarks = new Map(); + +/** + * The marks for a thread, seeded from the snapshot present when the thread + * is first seen: what a persisted snapshot already holds is history (the + * TUI showed it at the time, the composer draft may have moved on), not + * news to act on. + */ +function marksFor(threadId: string, state: PiExtensionUIState | null): ThreadMarks { + let marks = threadMarks.get(threadId); + if (marks === undefined) { + marks = { + editorRevision: state?.editor?.revision ?? null, + notificationId: Math.max(0, ...(state?.notifications ?? []).map(({ id }) => id)), + }; + threadMarks.set(threadId, marks); + } + return marks; +} + +/** + * What Pi's extensions put beside the composer. Statuses, widgets and the + * title render in place; a notification is transient, like pi's own, so it + * goes to the app's toaster instead — once, when it arrives. + */ +function PiExtensionState({ payload, placement, threadId }: ExperimentalProviderExtensionStateProps) { + const composer = useComposer(); + const state = parseState(payload); + const editor = state?.editor ?? null; + const notifications = state?.notifications ?? null; + const marks = marksFor(threadId, state); + + useEffect(() => { + if (placement !== "aboveEditor") return; + if (state === null) { + // The session ended: its successor counts from 1 again. + marks.editorRevision = null; + marks.notificationId = 0; + return; + } + if (editor !== null && editor.revision !== marks.editorRevision) { + marks.editorRevision = editor.revision; + composer.setText(editor.text); + } + for (const notification of notifications ?? []) { + if (notification.id <= marks.notificationId) continue; + marks.notificationId = notification.id; + showNotification(notification); + } + }, [composer, editor, marks, notifications, placement, state]); + + if (state === null) return null; + const widgets = state.widgets.filter((widget) => widget.placement === placement); + const showMetadata = + placement === "aboveEditor" && (state.statuses.length > 0 || state.title !== null); + if (!showMetadata && widgets.length === 0) return null; + + return ( +
+ {showMetadata ? ( +
+ {state.title !== null ? ( +
{state.title}
+ ) : null} + {state.statuses.map((status) => ( +
+ {status.text} +
+ ))} +
+ ) : null} + {widgets.map((widget) => ( +
+ {widget.lines.map((line, index) => ( +
{line || " "}
+ ))} +
+ ))} +
+ ); +} + +export default definePluginApp((app) => { + app.slots.settingsSection({ + id: "models", + title: "Models", + description: "Choose the authenticated models available in Pi's picker and model cycling.", + component: PiModelSettingsEditor, + }); + app.slots.experimental_providerExtensionState({ + name: PI_EXTENSION_UI_STATE_NAME, + component: PiExtensionState, + }); +}); diff --git a/plugins/provider-pi/package.json b/plugins/provider-pi/package.json index 68d64aecfd..241fefbaba 100644 --- a/plugins/provider-pi/package.json +++ b/plugins/provider-pi/package.json @@ -14,7 +14,8 @@ "icon": "./icons/pi.svg" }, "server": "./server.ts", - "host": "./src/host.ts" + "host": "./src/host.ts", + "app": "./app.tsx" }, "keywords": [ "bb-plugin" @@ -28,14 +29,24 @@ "@bb/provider-bridge-protocol": "workspace:*", "@earendil-works/pi-ai": "0.84.0", "@earendil-works/pi-coding-agent": "0.84.0", + "@testing-library/react": "^16.3.2", "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^29.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "sonner": "^1.7.4", "typescript": "npm:@typescript/typescript6@^6.0.2", "typebox": "1.3.7", "typescript-7": "npm:typescript@^7.0.2", "vitest": "^4.1.1" }, "dependencies": { + "@bb/shared-ui": "workspace:*", "@get-bb/plugin-sdk": "workspace:*", + "minimatch": "^10.2.5", + "proper-lockfile": "^4.1.2", "zod": "^4.3.6" } } diff --git a/plugins/provider-pi/server.test.ts b/plugins/provider-pi/server.test.ts index 5fe63e17f2..3f30d5f150 100644 --- a/plugins/provider-pi/server.test.ts +++ b/plugins/provider-pi/server.test.ts @@ -1,11 +1,14 @@ /** - * The plugin's registration: the environment it passes through and the - * skill roots it declares. The per-host roots are the host entry's answer - * (`src/native-roots.test.ts`). + * The plugin's registration: the environment it passes through, the skill + * roots it declares, and the extension state it renders. The per-host roots + * are the host entry's answer (`src/native-roots.test.ts`); the model + * settings backend routes every read and write to the selected host. */ import { describe, expect, it } from "vitest"; import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; import piPlugin from "./server.js"; +import { PI_EXTENSION_UI_STATE_NAME } from "./src/extension-state.js"; +import type { PiModelSettingsSnapshot } from "./src/model-settings-contract.js"; function registeredDeclaration() { const host = createFakePluginHost({ pluginId: "provider-pi" }); @@ -41,3 +44,109 @@ describe("the pi plugin's skill roots", () => { expect(declaration.experimental_resolvesNativeRoots).toBe(true); }); }); + +describe("the pi plugin's extension state", () => { + it("declares the extension-ui state kind the app bundle renders, with no action schema", () => { + const kinds = registeredDeclaration().extensionKinds; + expect(kinds?.[PI_EXTENSION_UI_STATE_NAME]?.state).toBeDefined(); + expect(kinds?.[PI_EXTENSION_UI_STATE_NAME]?.item).toBeUndefined(); + expect(kinds?.[PI_EXTENSION_UI_STATE_NAME]?.experimental_action).toBeUndefined(); + }); +}); + +const workstation: PiModelSettingsSnapshot = { + models: [ + { + id: "anthropic/claude-sonnet-5", + displayName: "Claude Sonnet 5", + provider: "anthropic", + reasoning: true, + }, + ], + enabledModelIds: null, +}; +const empty: PiModelSettingsSnapshot = { models: [], enabledModelIds: null }; + +describe("Pi provider settings backend", () => { + it("routes reads to the requested host without leaking another host's models", async () => { + const { bb, harness } = createFakePluginHost({ + pluginId: "provider-pi", + experimental_callProviderBridgeRpc: ({ hostId }) => + hostId === "host-workstation" ? workstation : empty, + }); + piPlugin(bb); + + await expect( + harness.behavior.callRpc("readModelSettings", { hostId: "host-empty" }), + ).resolves.toEqual(empty); + await expect( + harness.behavior.callRpc("readModelSettings", { hostId: "host-workstation" }), + ).resolves.toEqual(workstation); + expect( + harness.inspection.experimental_providerBridgeRpcCalls.map(({ providerId, hostId }) => ({ + providerId, + hostId, + })), + ).toEqual([ + { providerId: "pi", hostId: "host-empty" }, + { providerId: "pi", hostId: "host-workstation" }, + ]); + }); + + it("offers natural CLI host targeting without a cwd selector", async () => { + const { bb, harness } = createFakePluginHost({ + pluginId: "provider-pi", + sdk: { + hosts: { + list: async () => [ + { id: "host-primary", name: "Workstation" }, + { id: "host-laptop", name: "Laptop" }, + ], + }, + system: { + config: async () => ({ primaryHostId: "host-primary" }), + }, + }, + experimental_callProviderBridgeRpc: () => workstation, + }); + piPlugin(bb); + + const result = await harness.behavior.runCli([ + "models", + "set", + "anthropic/claude-sonnet-5", + "--machine", + "Laptop", + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(workstation); + expect(harness.inspection.experimental_providerBridgeRpcCalls.at(-1)).toMatchObject({ + providerId: "pi", + hostId: "host-laptop", + method: "model-settings/write", + input: { enabledModelIds: ["anthropic/claude-sonnet-5"] }, + }); + }); + + it("writes through the bridge and invalidates provider model caches", async () => { + const written = { ...workstation, enabledModelIds: ["anthropic/claude-sonnet-5"] }; + const { bb, harness } = createFakePluginHost({ + pluginId: "provider-pi", + experimental_callProviderBridgeRpc: ({ method }) => + method === "model-settings/write" ? written : workstation, + }); + piPlugin(bb); + + await expect( + harness.behavior.callRpc("writeModelSettings", { + hostId: "host-workstation", + enabledModelIds: ["anthropic/claude-sonnet-5"], + }), + ).resolves.toEqual(written); + expect(harness.inspection.experimental_providerModelChanges).toEqual([ + { providerId: "pi", hostId: "host-workstation" }, + ]); + }); +}); diff --git a/plugins/provider-pi/server.ts b/plugins/provider-pi/server.ts index 98b52b233a..8ed0df2f12 100644 --- a/plugins/provider-pi/server.ts +++ b/plugins/provider-pi/server.ts @@ -1,5 +1,93 @@ import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { piProviderDeclaration } from "./src/declaration.js"; +import { + piModelSettingsBridgeContract, + piModelSettingsRpcContract, + type PiModelSettingsSnapshot, +} from "./src/model-settings-contract.js"; + +interface ParsedCliArgs { + command: "list" | "set" | "enable-all"; + machine: string | null; + json: boolean; + modelIds: string[]; +} + +function parseCliArgs(argv: string[]): ParsedCliArgs { + const args = [...argv]; + if (args.shift() !== "models") { + throw new Error( + "Usage: bb pi models [list|set |enable-all] [--machine ] [--json]", + ); + } + const commandToken = args[0]; + let command: ParsedCliArgs["command"] = "list"; + if (commandToken === "set" || commandToken === "enable-all" || commandToken === "list") { + command = commandToken; + args.shift(); + } + let machine: string | null = null; + let json = false; + const modelIds: string[] = []; + while (args.length > 0) { + const arg = args.shift()!; + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--machine") { + const value = args.shift(); + if (!value) throw new Error("--machine requires an id or name"); + machine = value; + continue; + } + if (arg.startsWith("--")) throw new Error(`Unknown option ${arg}`); + modelIds.push(arg); + } + if (command === "set" && modelIds.length === 0) { + throw new Error("bb pi models set requires at least one model id"); + } + if (command !== "set" && modelIds.length > 0) { + throw new Error(`bb pi models ${command} does not accept model ids`); + } + return { command, machine, json, modelIds }; +} + +async function resolveHostId(bb: BbPluginApi, machine: string | null): Promise { + const hosts = await bb.sdk.hosts.list(); + if (machine !== null) { + const normalized = machine.toLowerCase(); + const matches = hosts.filter( + (host) => host.id === machine || host.name.toLowerCase() === normalized, + ); + if (matches.length !== 1) { + throw new Error( + matches.length === 0 + ? `No machine named ${JSON.stringify(machine)} was found` + : `Machine name ${JSON.stringify(machine)} is ambiguous; use its id`, + ); + } + return matches[0]!.id; + } + const config = await bb.sdk.system.config(); + const primary = hosts.find((host) => host.id === config.primaryHostId) ?? hosts[0]; + if (primary === undefined) throw new Error("No machine is available"); + return primary.id; +} + +function formatSnapshot(snapshot: PiModelSettingsSnapshot): string { + if (snapshot.models.length === 0) { + return "No authenticated Pi models are available on this machine.\n"; + } + const enabled = + snapshot.enabledModelIds === null ? null : new Set(snapshot.enabledModelIds); + return `${snapshot.models + .map( + (model) => + `${enabled === null || enabled.has(model.id) ? "on " : "off"}\t${model.id}\t${model.displayName}`, + ) + .join("\n")}\n`; +} /** * First-party Pi provider plugin. The declaration is the only source of this @@ -8,10 +96,78 @@ import { piProviderDeclaration } from "./src/declaration.js"; * and the ones a host's pi `settings.json` names are resolved on that host * by the plugin's `bb.host` entry (`src/native-roots.ts`) when bb lists * skills there. + * + * Pi's enabled-model preference is host-local too (its `settings.json` + * `enabledModels`): the plugin edits it through its own bridge RPC on the + * selected host, from the settings section (`app.tsx`) and `bb pi models`. */ export default function plugin(bb: BbPluginApi): void { const registered = bb.providers.register(piProviderDeclaration()); bb.onDispose(() => { registered.dispose(); }); + + const bridge = bb.providers.experimental_client({ + providerId: "pi", + contract: piModelSettingsBridgeContract, + }); + const read = (hostId: string, signal?: AbortSignal) => + bridge.call("model-settings/read", null, { + hostId, + ...(signal === undefined ? {} : { signal }), + }); + const write = async ( + hostId: string, + enabledModelIds: string[] | null, + signal?: AbortSignal, + ) => { + const snapshot = await bridge.call( + "model-settings/write", + { enabledModelIds }, + { hostId, ...(signal === undefined ? {} : { signal }) }, + ); + bb.providers.experimental_modelsChanged({ providerId: "pi", hostId }); + return snapshot; + }; + + bb.rpc.register(piModelSettingsRpcContract, { + readModelSettings: ({ hostId }) => read(hostId), + writeModelSettings: ({ hostId, enabledModelIds }) => write(hostId, enabledModelIds), + }); + + bb.cli.register({ + name: "pi", + summary: "Inspect and configure Pi", + commands: [ + { + name: "models", + summary: "List or replace Pi's enabled models", + usage: + "bb pi models [list|set |enable-all] [--machine ] [--json]", + }, + ], + async run(argv, context) { + try { + const parsed = parseCliArgs(argv); + const hostId = await resolveHostId(bb, parsed.machine); + const snapshot = + parsed.command === "list" + ? await read(hostId, context.signal) + : await write( + hostId, + parsed.command === "enable-all" ? null : parsed.modelIds, + context.signal, + ); + return { + exitCode: 0, + stdout: parsed.json ? `${JSON.stringify(snapshot, null, 2)}\n` : formatSnapshot(snapshot), + }; + } catch (error) { + return { + exitCode: 1, + stderr: `${error instanceof Error ? error.message : String(error)}\n`, + }; + } + }, + }); } diff --git a/plugins/provider-pi/src/bridge/bridge.command-list.test.ts b/plugins/provider-pi/src/bridge/bridge.command-list.test.ts new file mode 100644 index 0000000000..0320e2ef12 --- /dev/null +++ b/plugins/provider-pi/src/bridge/bridge.command-list.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; +import { type FakePiBridgeHarness, startFakePiBridge } from "./test-support.js"; + +/** + * Sessionless command discovery: `command/list` asks the cwd's catalog child + * for pi's `get_commands` and lists the extension commands and prompt + * templates with their origin; skills are the daemon's static scan, not + * the bridge's to repeat. Another provider id is not this bridge's. + */ + +let harness: FakePiBridgeHarness; +let nextId = 4000; + +beforeEach(async () => { + harness = await startFakePiBridge({ prefix: "bb-pi-command-list-", initialize: true }); +}, 90_000); + +afterEach(async () => { + await harness.teardown(); +}, 90_000); + +it("lists pi's extension commands and prompt templates by origin, without skills", async () => { + const response = await harness.request((nextId += 1), "command/list", { + providerId: "pi", + cwd: harness.workspaceDir, + }); + expect(response.result).toEqual({ + supported: true, + diagnostics: [], + commands: [ + { + name: "project-smoke", + source: "command", + origin: "project", + description: "Project smoke command", + argumentHint: null, + }, + { + name: "global-smoke", + source: "command", + origin: "user", + description: "Global smoke command", + argumentHint: null, + }, + { + name: "ext", + source: "command", + origin: "user", + description: "Fake extension command: runs one ctx.ui call, or nothing", + argumentHint: null, + }, + { name: "review", source: "command", origin: "user", description: "Review the diff", argumentHint: null }, + ], + }); +}, 90_000); + +it("answers unsupported for a provider this bridge does not serve", async () => { + const response = await harness.request((nextId += 1), "command/list", { + providerId: "acp-other", + cwd: harness.workspaceDir, + }); + expect(response.result).toEqual({ supported: false }); +}, 90_000); diff --git a/plugins/provider-pi/src/bridge/bridge.extension-ui.test.ts b/plugins/provider-pi/src/bridge/bridge.extension-ui.test.ts new file mode 100644 index 0000000000..b9a235879b --- /dev/null +++ b/plugins/provider-pi/src/bridge/bridge.extension-ui.test.ts @@ -0,0 +1,394 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; +import { handleLine } from "./bridge.js"; +import { PI_EXTENSION_UI_STATE_KIND } from "./extension-state.js"; +import { FULL_PERMISSION_OPTIONS, type FakePiBridgeHarness, startFakePiBridge } from "./test-support.js"; + +/** + * Pi's extension UI over RPC: the `extension_ui_request` lines an extension + * raises through `ctx.ui` become bb's provider state (statuses, widgets, + * title, editor text) and bb's thread-scoped user questions (select, + * confirm, input, editor), each answered back to pi as the matching + * `extension_ui_response`. The fake's `/ui` and `/ask` prompts raise them. + */ + +let harness: FakePiBridgeHarness; +let nextId = 2000; + +beforeEach(async () => { + harness = await startFakePiBridge({ prefix: "bb-pi-extension-ui-", initialize: true }); +}, 90_000); + +afterEach(async () => { + await harness.teardown(); +}, 90_000); + +/** `creq_` plus ten characters of the id alphabet (no 0, 1, l, o). */ +const CLIENT_REQUEST_ID_ALPHABET = "23456789abcdefghijkmnpqrstuvwxyz"; +function clientRequestId(serial: number): string { + let suffix = ""; + let value = serial; + for (let index = 0; index < 10; index += 1) { + suffix = CLIENT_REQUEST_ID_ALPHABET[value % 32] + suffix; + value = Math.floor(value / 32); + } + return `creq_${suffix}`; +} + +/** Start a turn and wait for pi to accept it (a rejected start opens no turn). */ +async function turnStart(threadId: string, text: string): Promise { + const id = (nextId += 1); + const response = await harness.request(id, "turn/start", { + threadId, + providerThreadId: threadId, + clientRequestId: clientRequestId(id), + input: [{ type: "text", text, mentions: [] }], + options: FULL_PERMISSION_OPTIONS, + }); + expect(response.error).toBeUndefined(); +} + +function extensionStates(threadId: string): unknown[] { + return harness + .deltasOf(threadId) + .filter( + (delta) => + delta.kind === "extension.state" && delta.extensionKind === PI_EXTENSION_UI_STATE_KIND, + ) + .map((delta) => delta.payload); +} + +function assistantTexts(threadId: string): string[] { + return harness + .deltasOf(threadId) + .filter((delta) => delta.kind === "item.textDelta") + .map((delta) => String(delta.text ?? "")); +} + +async function ui(threadId: string, request: Record): Promise { + const since = harness.deltasOf(threadId).length; + await turnStart(threadId, `/ui ${JSON.stringify(request)}`); + await harness.waitForTurnBoundary(threadId, since); +} + +it("projects state methods into one bounded snapshot beside the composer", async () => { + const threadId = "thr_ext_state"; + expect((await harness.startThread(threadId)).result).toMatchObject({ providerThreadId: threadId }); + // Identity announced with no state yet: the reset carries a null snapshot. + expect(extensionStates(threadId)).toEqual([null]); + + await ui(threadId, { method: "setStatus", statusKey: "owner", statusText: "reviewing" }); + await ui(threadId, { method: "setTitle", title: "bb ⟶ pi" }); + await ui(threadId, { + method: "setWidget", + widgetKey: "plan", + widgetLines: ["step 1", "step 2"], + widgetPlacement: "belowEditor", + }); + await ui(threadId, { method: "notify", message: "heads up", notifyType: "warning" }); + await ui(threadId, { method: "set_editor_text", text: " draft\n\nkept verbatim " }); + await ui(threadId, { method: "setStatus", statusKey: "owner" }); + + expect(extensionStates(threadId).at(-1)).toEqual({ + statuses: [], + widgets: [{ key: "plan", lines: ["step 1", "step 2"], placement: "belowEditor" }], + notifications: [{ id: 1, message: "heads up", level: "warning" }], + title: "bb ⟶ pi", + editor: { revision: expect.any(Number), text: " draft\n\nkept verbatim " }, + }); + // The status was there before it was cleared. + expect(extensionStates(threadId)).toContainEqual( + expect.objectContaining({ statuses: [{ key: "owner", text: "reviewing" }] }), + ); + + // Stopping the session clears its state before the child goes. + const stop = await harness.request((nextId += 1), "thread/stop", { + threadId, + providerThreadId: threadId, + activeTurnId: null, + intent: "release", + }); + expect(stop.result).toMatchObject({ ok: true }); + expect(extensionStates(threadId).at(-1)).toBeNull(); +}, 90_000); + +it("asks a select as a thread-scoped question and hands pi the chosen option verbatim", async () => { + const threadId = "thr_ext_select"; + await harness.startThread(threadId); + await turnStart(threadId, '/ask {"method":"select","title":"Pick one","options":["alpha"," beta "]}'); + + const request = await harness.waitForMessage( + (message) => message.method === "interaction/request", + "the extension's question", + ); + expect(request.params).toMatchObject({ + threadId, + providerThreadId: threadId, + turnId: null, + experimental_scope: "thread", + payload: { + kind: "user_question", + questions: [ + { + prompt: "Pick one", + multiSelect: false, + allowFreeText: false, + options: [ + { value: "option-0", label: "alpha" }, + { value: "option-1", label: " beta " }, + ], + }, + ], + }, + }); + const question = ( + request.params as { payload: { questions: { id: string }[] } } + ).payload.questions[0]!; + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: { kind: "user_answer", answers: { [question.id]: { selected: ["option-1"] } } }, + }), + ); + await harness.waitForTurnBoundary(threadId); + expect(assistantTexts(threadId).join("")).toBe(JSON.stringify({ value: " beta " })); +}, 90_000); + +it("answers confirm with a boolean and input with the verbatim text", async () => { + const threadId = "thr_ext_confirm_input"; + await harness.startThread(threadId); + + await turnStart(threadId, '/ask {"method":"confirm","title":"Proceed?","message":"It is destructive."}'); + const confirm = await harness.waitForMessage( + (message) => message.method === "interaction/request", + "the confirm question", + ); + expect(confirm.params).toMatchObject({ + payload: { + questions: [ + { + prompt: "Proceed?\n\nIt is destructive.", + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + }, + ], + }, + }); + const confirmQuestion = ( + confirm.params as { payload: { questions: { id: string }[] } } + ).payload.questions[0]!; + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: confirm.id, + result: { kind: "user_answer", answers: { [confirmQuestion.id]: { selected: ["no"] } } }, + }), + ); + const afterConfirm = await harness.waitForTurnBoundary(threadId); + expect(assistantTexts(threadId).join("")).toBe(JSON.stringify({ confirmed: false })); + + await turnStart(threadId, '/ask {"method":"input","title":"Name it","placeholder":"feature/x"}'); + const input = await harness.waitForMessage( + (message) => + message.method === "interaction/request" && message.id !== confirm.id, + "the input question", + ); + expect(input.params).toMatchObject({ + payload: { + questions: [ + { + prompt: "Name it", + allowFreeText: true, + experimental_responseMode: "verbatim", + experimental_placeholder: "feature/x", + }, + ], + }, + }); + const inputQuestion = ( + input.params as { payload: { questions: { id: string }[] } } + ).payload.questions[0]!; + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: input.id, + result: { + kind: "user_answer", + answers: { + [inputQuestion.id]: { selected: [], experimental_verbatimText: " two\nlines " }, + }, + }, + }), + ); + await harness.waitForTurnBoundary(threadId, afterConfirm); + expect(assistantTexts(threadId).join("")).toContain(JSON.stringify({ value: " two\nlines " })); +}, 90_000); + +it("asks an editor with its prefill and returns the edited text byte-for-byte", async () => { + const threadId = "thr_ext_editor"; + await harness.startThread(threadId); + await turnStart(threadId, '/ask {"method":"editor","title":"Edit the body","prefill":" line one\\n\\nline two "}'); + const request = await harness.waitForMessage( + (message) => message.method === "interaction/request", + "the editor question", + ); + expect(request.params).toMatchObject({ + payload: { + questions: [ + { + prompt: "Edit the body", + allowFreeText: true, + experimental_responseMode: "verbatim", + experimental_prefill: " line one\n\nline two ", + }, + ], + }, + }); + const question = ( + request.params as { payload: { questions: { id: string }[] } } + ).payload.questions[0]!; + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: { + kind: "user_answer", + answers: { [question.id]: { selected: [], experimental_verbatimText: " line one\n\nline 2 " } }, + }, + }), + ); + await harness.waitForTurnBoundary(threadId); + expect(assistantTexts(threadId).join("")).toBe(JSON.stringify({ value: " line one\n\nline 2 " })); +}, 90_000); + +it("cancels an editor whose prefill exceeds bb's cap instead of shortening it", async () => { + const threadId = "thr_ext_editor_long"; + await harness.startThread(threadId); + const prefill = "x".repeat(4097); + await turnStart(threadId, `/ask {"method":"editor","title":"Too long","prefill":"${prefill}"}`); + // No question reaches bb: pi is answered "cancelled" straight away. + await harness.waitForTurnBoundary(threadId); + expect(harness.messages.some((message) => message.method === "interaction/request")).toBe(false); + expect(assistantTexts(threadId).join("")).toBe(JSON.stringify({ cancelled: true })); +}, 90_000); + +it("withdraws the question when the session stops, and pi sees a cancelled dialog", async () => { + const threadId = "thr_ext_cancel"; + await harness.startThread(threadId); + await turnStart(threadId, '/ask {"method":"input","title":"Still there?"}'); + const request = await harness.waitForMessage( + (message) => message.method === "interaction/request", + "the question", + ); + + const stop = await harness.request((nextId += 1), "thread/stop", { + threadId, + providerThreadId: threadId, + activeTurnId: null, + intent: "release", + }); + expect(stop.result).toMatchObject({ ok: true }); + const cancel = await harness.waitForMessage( + (message) => message.method === "interaction/cancel", + "the question's withdrawal", + ); + expect(cancel.params).toMatchObject({ + requestId: request.id, + threadId, + providerThreadId: threadId, + reason: expect.stringContaining("stopped"), + }); + // A late answer for the old session is not an answer to anything. + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: { kind: "user_answer", answers: {} }, + }), + ); + expect((await harness.startThread("thr_ext_cancel_next")).result).toMatchObject({ + providerThreadId: "thr_ext_cancel_next", + }); +}, 90_000); + +it("withdraws a dialog bb cannot answer in time on pi's own timeout", async () => { + const threadId = "thr_ext_timeout"; + await harness.startThread(threadId); + await turnStart(threadId, '/ask {"method":"confirm","title":"Quick?","message":"","timeout":300}'); + const request = await harness.waitForMessage( + (message) => message.method === "interaction/request", + "the question", + ); + const cancel = await harness.waitForMessage( + (message) => message.method === "interaction/cancel", + "the timeout withdrawal", + ); + expect(cancel.params).toMatchObject({ requestId: request.id, reason: expect.stringContaining("timed out") }); + await harness.waitForTurnBoundary(threadId); + expect(assistantTexts(threadId).join("")).toMatch(/timedOut|cancelled/u); +}, 90_000); + +it("answers turn/start before an extension command's dialog is answered, then ends the turn on pi's answer", async () => { + const threadId = "thr_ext_command_dialog"; + await harness.startThread(threadId); + // Pi answers `prompt` only after the handler returns, and the handler is + // waiting on this dialog: a turn/start that waited on pi would wait on the + // answer it blocks. + await turnStart(threadId, '/ext {"method":"confirm","title":"Continue?","message":"Go on?"}'); + + const request = await harness.waitForMessage( + (message) => message.method === "interaction/request", + "the command's question", + ); + expect(request.params).toMatchObject({ threadId, turnId: null, experimental_scope: "thread" }); + expect(harness.deltasOf(threadId).some((delta) => delta.kind === "turn.boundary")).toBe(false); + + const question = ( + request.params as { payload: { questions: { id: string }[] } } + ).payload.questions[0]!; + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: { kind: "user_answer", answers: { [question.id]: { selected: ["yes"] } } }, + }), + ); + await harness.waitForTurnBoundary(threadId); + // No agent run: nothing was said, and the turn still closed cleanly. + expect(assistantTexts(threadId)).toEqual([]); + expect(harness.deltasOf(threadId).find((delta) => delta.kind === "turn.boundary")).toMatchObject({ + status: "completed", + }); +}, 90_000); + +it("ends the turn of an extension command that asks nothing and starts no run", async () => { + const threadId = "thr_ext_command_plain"; + await harness.startThread(threadId); + await turnStart(threadId, '/ext {"method":"notify","message":"done","notifyType":"info"}'); + await harness.waitForTurnBoundary(threadId); + expect(assistantTexts(threadId)).toEqual([]); + expect(harness.messages.some((message) => message.method === "interaction/request")).toBe(false); + + // The next prompt is an ordinary run again. + const since = harness.deltasOf(threadId).length; + await turnStart(threadId, "hello"); + await harness.waitForTurnBoundary(threadId, since); + expect(assistantTexts(threadId).join("")).toBe("Response to: hello"); +}, 90_000); + +it("ends an extension command's turn on the run its handler started, not on pi's answer", async () => { + const threadId = "thr_ext_command_run"; + await harness.startThread(threadId); + await turnStart(threadId, '/ext {"run":true}'); + await harness.waitForTurnBoundary(threadId); + // Pi answered the prompt before the run; the turn waited for the run: + // every boundary comes after the run's text. + const deltas = harness.deltasOf(threadId); + expect(assistantTexts(threadId).join("")).toBe("Response to: ext run"); + const lastText = deltas.map((delta) => delta.kind).lastIndexOf("item.textDelta"); + const firstBoundary = deltas.findIndex((delta) => delta.kind === "turn.boundary"); + expect(firstBoundary).toBeGreaterThan(lastText); + expect(deltas[firstBoundary]).toMatchObject({ status: "completed" }); +}, 90_000); diff --git a/plugins/provider-pi/src/bridge/bridge.model-settings.test.ts b/plugins/provider-pi/src/bridge/bridge.model-settings.test.ts new file mode 100644 index 0000000000..9f31ee07b2 --- /dev/null +++ b/plugins/provider-pi/src/bridge/bridge.model-settings.test.ts @@ -0,0 +1,129 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { JsonValue } from "@get-bb/plugin-sdk/provider-bridge"; +import { type FakePiBridgeHarness, startFakePiBridge } from "./test-support.js"; + +/** + * Pi's enabled-model preference through the plugin's bridge RPC + * (`provider/custom` `model-settings/*`) and its effect on `model/list`: the + * global `settings.json` in the agent dir (`PI_CODING_AGENT_DIR`) is read + * and written as a file, and the picker leads with the enabled models in + * pi's cycling order. + */ + +let harness: FakePiBridgeHarness; +let agentDir: string; +let nextId = 3000; + +beforeEach(async () => { + harness = await startFakePiBridge({ prefix: "bb-pi-model-settings-", initialize: true }); + agentDir = join(harness.workspaceDir, "agent"); + mkdirSync(agentDir, { recursive: true }); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); +}, 90_000); + +afterEach(async () => { + await harness.teardown(); +}, 90_000); + +function customCall(method: string, input: JsonValue) { + return harness.request((nextId += 1), "provider/custom", { method, input }); +} + +it("reads the catalog with no scope, writes exact ids, and scopes the picker", async () => { + const read = await customCall("model-settings/read", null); + expect(read.result).toEqual({ + result: { + models: [ + { id: "fake-provider/fake-model", displayName: "Fake Model", provider: "fake-provider", reasoning: true }, + { id: "fake-provider/fake-mini", displayName: "Fake Mini", provider: "fake-provider", reasoning: false }, + ], + enabledModelIds: null, + }, + }); + expect(existsSync(join(agentDir, "settings.json"))).toBe(false); + + const written = await customCall("model-settings/write", { + enabledModelIds: ["fake-provider/fake-mini"], + }); + expect(written.result).toMatchObject({ result: { enabledModelIds: ["fake-provider/fake-mini"] } }); + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"))).toEqual({ + enabledModels: ["fake-provider/fake-mini"], + }); + + const listed = await harness.request((nextId += 1), "model/list", { cwd: harness.workspaceDir }); + const result = listed.result as { + models: { id: string; isDefault: boolean }[]; + selectedOnlyModels: { id: string; isDefault: boolean }[]; + }; + expect(result.models.map((model) => model.id)).toEqual(["fake-provider/fake-mini"]); + expect(result.models[0]?.isDefault).toBe(true); + expect(result.selectedOnlyModels.map((model) => model.id)).toEqual(["fake-provider/fake-model"]); + expect(result.selectedOnlyModels[0]?.isDefault).toBe(false); + + // Enable all: the key is removed, the rest of the file is kept. + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ theme: "dark", enabledModels: ["fake-provider/fake-mini"] }), + ); + const reset = await customCall("model-settings/write", { enabledModelIds: null }); + expect(reset.result).toMatchObject({ result: { enabledModelIds: null } }); + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"))).toEqual({ theme: "dark" }); +}, 90_000); + +it("refuses an empty selection or a model this host does not serve", async () => { + const empty = await customCall("model-settings/write", { enabledModelIds: [] }); + expect(empty.error).toMatchObject({ message: expect.stringContaining("At least one") }); + const unknown = await customCall("model-settings/write", { + enabledModelIds: ["fake-provider/nope"], + }); + expect(unknown.error).toMatchObject({ message: expect.stringContaining("not available") }); + const method = await customCall("model-settings/nope", null); + expect(method.error).toMatchObject({ message: expect.stringContaining("Unknown") }); +}, 90_000); + +it("honors pi's own patterns from the global file and ignores a project file", async () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ enabledModels: ["fake-provider/*mini*"] })); + const fromGlobal = await harness.request((nextId += 1), "model/list", { cwd: harness.workspaceDir }); + expect((fromGlobal.result as { models: { id: string }[] }).models.map((m) => m.id)).toEqual([ + "fake-provider/fake-mini", + ]); + + // A repository's .pi/settings.json only applies in pi once the project is + // trusted, which the bridge cannot see: it must not steer the picker. + mkdirSync(join(harness.workspaceDir, ".pi"), { recursive: true }); + writeFileSync( + join(harness.workspaceDir, ".pi", "settings.json"), + JSON.stringify({ enabledModels: ["fake-model"] }), + ); + const withProject = await harness.request((nextId += 1), "model/list", { cwd: harness.workspaceDir }); + expect((withProject.result as { models: { id: string }[] }).models.map((m) => m.id)).toEqual([ + "fake-provider/fake-mini", + ]); +}, 90_000); + +it("keeps listing models when the settings file is not valid JSON, and refuses to write over it", async () => { + writeFileSync(join(agentDir, "settings.json"), "{not json"); + const listed = await harness.request((nextId += 1), "model/list", { cwd: harness.workspaceDir }); + expect((listed.result as { models: { id: string }[] }).models.map((m) => m.id)).toEqual([ + "fake-provider/fake-model", + "fake-provider/fake-mini", + ]); + const written = await customCall("model-settings/write", { enabledModelIds: ["fake-provider/fake-mini"] }); + expect(written.error).toMatchObject({ message: expect.stringContaining("Failed to load Pi settings") }); + expect(readFileSync(join(agentDir, "settings.json"), "utf8")).toBe("{not json"); +}, 90_000); + +it("writes through a symlinked settings.json instead of replacing the link", async () => { + const real = join(harness.workspaceDir, "dotfiles-settings.json"); + writeFileSync(real, JSON.stringify({ theme: "dark" })); + symlinkSync(real, join(agentDir, "settings.json")); + const written = await customCall("model-settings/write", { enabledModelIds: ["fake-provider/fake-mini"] }); + expect(written.result).toMatchObject({ result: { enabledModelIds: ["fake-provider/fake-mini"] } }); + expect(lstatSync(join(agentDir, "settings.json")).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(real, "utf8"))).toEqual({ + theme: "dark", + enabledModels: ["fake-provider/fake-mini"], + }); +}, 90_000); diff --git a/plugins/provider-pi/src/bridge/bridge.ts b/plugins/provider-pi/src/bridge/bridge.ts index 742a538c50..8032f13cda 100644 --- a/plugins/provider-pi/src/bridge/bridge.ts +++ b/plugins/provider-pi/src/bridge/bridge.ts @@ -29,7 +29,10 @@ import { createPendingToolCallTracker, decodeBridgeJsonRpcResponse, experimental_defineProviderBridge, + experimental_providerCommandListParamsSchema, + experimental_providerCustomCallParamsSchema, initializeParamsSchema, + USER_QUESTION_MAX_FREE_TEXT_LENGTH, providerInstallationRunParamsSchema, providerInstallationStatusParamsSchema, providerMaintenanceParamsSchema, @@ -58,6 +61,28 @@ import { type PiSessionParams, } from "../session-params.js"; import { BB_PI_EXTENSION_SOURCE } from "./bb-pi-extension.js"; +import { + buildPiExtensionDialog, + isPiExtensionDialogRequest, + piExtensionUiRequestSchema, + type PiExtensionDialogRequest, + type PiExtensionUiResponse, +} from "./extension-dialogs.js"; +import { + PI_EXTENSION_UI_STATE_KIND, + createPiExtensionStateController, + type PiExtensionStateController, + type PiExtensionUIState, +} from "./extension-state.js"; +import { readPiModelSettings, writePiEnabledModels } from "./model-settings.js"; +import { buildScopedPiAvailableModels, resolvePiEnabledModelIds } from "./model-scope.js"; +import { + createPiPendingInteractionTracker, + type PiBridgeInteractionCancelNotification, + type PiBridgeInteractionRequest, + type PiPendingInteractionTracker, +} from "./pending-interaction-tracker.js"; +import { readPiEnabledModelPatterns } from "./settings-storage.js"; import { getPiInstallGate, getPiProviderInstallationRun, @@ -91,6 +116,14 @@ const piCommandSchema = z.discriminatedUnion("method", [ params: initializeParamsSchema, }), z.object({ method: z.literal("model/list"), params: modelListParamsSchema }), + z.object({ + method: z.literal("provider/custom"), + params: experimental_providerCustomCallParamsSchema, + }), + z.object({ + method: z.literal("command/list"), + params: experimental_providerCommandListParamsSchema, + }), z.object({ method: z.literal("provider/health"), params: providerMaintenanceParamsSchema, @@ -182,6 +215,20 @@ interface CurrentThreadSessionArgs { interface ThreadSession { session: PiRpcSession; + /** The questions this session's extensions have open in bb. */ + interactions: PiPendingInteractionTracker; + /** What this session's extensions put beside the composer. */ + extensionState: PiExtensionStateController; + /** The last snapshot, replayed once identity is announced. */ + lastExtensionState: PiExtensionUIState | null; + /** + * Dialogs raised before identity was announced (an extension asking in + * its session_start handler): the runtime cannot attach a question to a + * thread it has not been told about yet, so they wait for the announce. + */ + pendingDialogs: PiExtensionDialogRequest[]; + /** Identity announced: `thread/delta` may carry this session's state. */ + announced: boolean; sessionSerial: number; closing: boolean; providerThreadId: string; @@ -195,9 +242,16 @@ interface ThreadSession { construction: PiSessionParams; /** The model the child spawned on, resolved against pi's catalog. */ constructionModel: { provider: string; id: string } | undefined; + /** + * The commands this session's extensions registered, read from pi on the + * first slash input. Extensions load once per session: a reload is a new + * session. + */ + extensionCommandNames: Set | null; } let sessionSerialCounter = 0; +let interactionRequestCounter = 0; /** * The whole stop exchange (abort, in-process leaf read, EOF) fits here, well * under the runtime's 30 s request budget. @@ -205,7 +259,10 @@ let sessionSerialCounter = 0; const THREAD_STOP_CLOSE_TIMEOUT_MS = 8_000; const { send, sendResult, sendError } = createBridgeIo< - BridgeEventNotification | BridgeToolCallRequest + | BridgeEventNotification + | BridgeToolCallRequest + | PiBridgeInteractionRequest + | PiBridgeInteractionCancelNotification >(); const sessions = new Map(); @@ -282,6 +339,14 @@ async function closeThreadSession(args: { if (!threadSession) { return; } + // The state belongs to this session: the null snapshot goes out while + // this generation is still current, so it reaches persistence; anything + // the child still emits after `closing` flips is fenced off below. + threadSession.extensionState.clear(); + // Withdraw the open questions while this session is still current: each + // one answers pi "cancelled" on its way out, which unblocks the tool that + // was waiting on the dialog so pi's abort does not sit on it. + cancelPendingDialogs(threadSession, args.message); threadSession.closing = true; resolvePendingToolCalls(threadSession, args.message); const closePromise = Promise.resolve() @@ -319,6 +384,141 @@ function sendThreadDeltas(threadId: string, deltas: readonly ThreadDelta[]): voi }); } +function sendPiExtensionState(threadId: string, state: PiExtensionUIState | null): void { + sendThreadDeltas(threadId, [ + { kind: "extension.state", extensionKind: PI_EXTENSION_UI_STATE_KIND, payload: state }, + ]); +} + +function updatePiExtensionState( + args: CurrentThreadSessionArgs, + state: PiExtensionUIState | null, +): void { + const threadSession = sessions.get(args.threadId); + // A clear runs while the session closes; later callbacks are fenced. + if (!threadSession || threadSession.sessionSerial !== args.sessionSerial) { + return; + } + threadSession.lastExtensionState = state; + if (threadSession.announced) { + sendPiExtensionState(args.threadId, state); + } +} + +/** + * A dialog an extension raised: asked in bb as one thread-scoped user + * question, answered back to pi as the matching `extension_ui_response`. + * Pi's own timeout (`request.timeout`) settles the dialog on its side with + * the default answer, so bb's question is withdrawn at the same moment + * rather than left open for an answer nobody will read. + */ +function requestExtensionDialog( + args: CurrentThreadSessionArgs, + threadSession: ThreadSession, + request: Parameters[0], +): void { + interactionRequestCounter += 1; + const dialog = buildPiExtensionDialog(request, `pi-extension-ui-${interactionRequestCounter}`); + const pending = threadSession.interactions.request(dialog.payload); + const timeout = + "timeout" in request && typeof request.timeout === "number" && request.timeout > 0 + ? setTimeout( + () => pending.cancel("Pi extension dialog timed out"), + request.timeout, + ) + : null; + timeout?.unref?.(); + const answer = (response: PiExtensionUiResponse): void => { + if (timeout !== null) clearTimeout(timeout); + // The session may be closing (stop withdraws its dialogs): pi's child is + // still there until the close completes, and the cancelled answer is + // what lets its waiting tool return. A replaced session gets nothing. + if (sessions.get(args.threadId) !== threadSession) { + return; + } + threadSession.session.respondToExtensionUi(response); + }; + void pending.response.then( + (resolution) => answer(dialog.toResponse(resolution)), + (error: unknown) => { + // bb could not ask (a question the runtime rejected, the session + // stopping, pi's own timeout): pi sees "cancelled" either way, and the + // reason goes where the runtime logs bridge stderr. + process.stderr.write( + `pi bridge: extension ${request.method} dialog "${request.title}" cancelled: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + answer({ id: request.id, cancelled: true }); + }, + ); +} + +/** Withdraw every open dialog of a session, queued or asked; pi sees each as cancelled. */ +function cancelPendingDialogs(threadSession: ThreadSession, message: string): void { + for (const request of threadSession.pendingDialogs.splice(0)) { + threadSession.session.respondToExtensionUi({ id: request.id, cancelled: true }); + } + threadSession.interactions.cancel(message); +} + +function handleExtensionUiRequest(args: CurrentThreadSessionArgs, raw: unknown): void { + const threadSession = getCurrentThreadSession(args); + if (!threadSession) { + return; + } + const parsed = piExtensionUiRequestSchema.safeParse(raw); + if (!parsed.success) { + // A dialog bb does not know how to ask is cancelled, never left hanging. + const id = typeof (raw as { id?: unknown }).id === "string" ? (raw as { id: string }).id : null; + if (id !== null) { + threadSession.session.respondToExtensionUi({ id, cancelled: true }); + } + return; + } + const request = parsed.data; + if (isPiExtensionDialogRequest(request)) { + if ( + request.method === "editor" && + request.prefill !== undefined && + request.prefill.length > USER_QUESTION_MAX_FREE_TEXT_LENGTH + ) { + // bb's verbatim editor caps the prefill and the answer alike; a + // shortened document must never round-trip back to the extension as + // if the user had edited it that way, so this is pi's "cancelled". + process.stderr.write( + `pi bridge: extension editor "${request.title}" prefill is ${request.prefill.length} characters; bb supports ${USER_QUESTION_MAX_FREE_TEXT_LENGTH}. Cancelled.\n`, + ); + threadSession.session.respondToExtensionUi({ id: request.id, cancelled: true }); + return; + } + if (!threadSession.announced) { + threadSession.pendingDialogs.push(request); + return; + } + requestExtensionDialog(args, threadSession, request); + return; + } + const ui = threadSession.extensionState.ui; + switch (request.method) { + case "notify": + ui.notify(request.message, request.notifyType); + return; + case "setStatus": + ui.setStatus(request.statusKey, request.statusText); + return; + case "setWidget": + ui.setWidget(request.widgetKey, request.widgetLines, request.widgetPlacement); + return; + case "setTitle": + ui.setTitle(request.title); + return; + case "set_editor_text": + ui.setEditorText(request.text); + return; + } +} + function emitForSession( threadId: string, method: string, @@ -512,6 +712,12 @@ async function handleRequest(request: PiCommand & { id: string | number }): Prom case "model/list": await handleModelList(request.id, request.params); break; + case "provider/custom": + await handleCustomCall(request.id, request.params); + break; + case "command/list": + await handleCommandList(request.id, request.params); + break; case "provider/health": await handleProviderHealth(request.id, request.params); break; @@ -597,24 +803,110 @@ async function handleModelList(id: string | number, params: { cwd?: string }): P if (!gate.ok) { // The same wording the other first-party bridges use for a missing CLI, // so callers that tolerate an uninstalled provider recognize it. - sendError( + sendError(id, -32000, piNotInstalledMessage(gate)); + return; + } + try { + const cwd = params.cwd ?? process.cwd(); + const catalog = await getPiCatalog(cwd, requireExtensionPath()); + contextWindows.learn(await catalog.rawModels()); + // Pi's own `enabledModels` scope: the picker leads with what pi cycles + // through, in pi's order; the rest stay selectable but out of the picker. + const models = await catalog.catalogModels(); + sendResult( id, - -32000, - gate.status === "not_installed" - ? "Could not find the pi CLI on this host. Install @earendil-works/pi-coding-agent and retry." - : (gate.statusMessage ?? "Pi is not supported on this host."), + buildScopedPiAvailableModels({ + models, + enabledModelIds: resolvePiEnabledModelIds(readPiEnabledModelPatterns(), models), + }), ); + } catch (error) { + sendError(id, -32000, error instanceof Error ? error.message : String(error)); + } +} + +/** + * The plugin's own bridge RPC (`bb.providers.experimental_client`): Pi's + * host-local enabled-model preference, read and written on this host. The + * catalog is the process cwd's: the global settings file is what is edited, + * and the authenticated models do not depend on a workspace. + */ +async function handleCustomCall( + id: string | number, + params: z.infer, +): Promise { + const gate = await getPiInstallGate(); + if (!gate.ok) { + sendError(id, -32000, piNotInstalledMessage(gate)); return; } try { - const catalog = await getPiCatalog(params.cwd ?? process.cwd(), requireExtensionPath()); - contextWindows.learn(await catalog.rawModels()); - sendResult(id, await catalog.listModels()); + const catalog = await getPiCatalog(process.cwd(), requireExtensionPath()); + const models = await catalog.catalogModels(); + if (params.method === "model-settings/read") { + if (params.input !== null) { + throw new Error("model-settings/read input must be null"); + } + sendResult(id, { result: readPiModelSettings(models) }); + return; + } + if (params.method === "model-settings/write") { + const input = z + .object({ enabledModelIds: z.array(z.string().min(1)).nullable() }) + .strict() + .parse(params.input); + sendResult(id, { result: writePiEnabledModels(models, input.enabledModelIds) }); + return; + } + sendError(id, BRIDGE_JSON_RPC_ERRORS.METHOD_NOT_FOUND, "Unknown Pi custom method"); } catch (error) { sendError(id, -32000, error instanceof Error ? error.message : String(error)); } } +/** + * Sessionless command discovery: the commands pi's resources register for + * the workspace, listed by the cwd's catalog child (`get_commands`). Partial + * answers are pi's: an extension that fails to load is reported on pi's + * stderr (the runtime logs it) and the healthy ones are listed. + */ +async function handleCommandList( + id: string | number, + params: z.infer, +): Promise { + if (params.providerId !== "pi") { + sendResult(id, { supported: false }); + return; + } + // A host where pi cannot answer still has its static skills: the listing + // is supported, empty, and says why — never an error that would hide the + // daemon's own scan. + const gate = await getPiInstallGate(); + if (!gate.ok) { + sendResult(id, { supported: true, commands: [], diagnostics: [piNotInstalledMessage(gate)] }); + return; + } + try { + const catalog = await getPiCatalog(params.cwd, requireExtensionPath()); + sendResult(id, { supported: true, commands: await catalog.listCommands(), diagnostics: [] }); + } catch (error) { + sendResult(id, { + supported: true, + commands: [], + diagnostics: [ + `Pi could not list its commands: ${error instanceof Error ? error.message : String(error)}`, + ], + }); + } +} + +/** The wording the other first-party bridges use for a missing or unusable CLI. */ +function piNotInstalledMessage(gate: Awaited> & { ok: false }): string { + return gate.status === "not_installed" + ? "Could not find the pi CLI on this host. Install @earendil-works/pi-coding-agent and retry." + : (gate.statusMessage ?? "Pi is not supported on this host."); +} + async function handleProviderHealth( id: string | number, params: { cwd?: string }, @@ -745,14 +1037,35 @@ async function constructPiThreadSession( providerThreadId, threadId, }); + const sessionArgs: CurrentThreadSessionArgs = { sessionSerial, threadId }; const session = new PiRpcSession( - sessionOptions, + { + ...sessionOptions, + onExtensionUiRequest: (request) => handleExtensionUiRequest(sessionArgs, request), + }, createForwardToolCall(() => threadId), - createOnPiEvent({ sessionSerial, threadId }), - createOnSessionDone({ sessionSerial, threadId }), + createOnPiEvent(sessionArgs), + createOnSessionDone(sessionArgs), ); const threadSession: ThreadSession = { session, + interactions: createPiPendingInteractionTracker({ + nextRequestId: () => { + interactionRequestCounter += 1; + return `pi-interaction-${interactionRequestCounter}`; + }, + providerThreadId, + send, + sendCancel: send, + threadId, + }), + extensionState: createPiExtensionStateController((state) => + updatePiExtensionState(sessionArgs, state), + ), + lastExtensionState: null, + pendingDialogs: [], + extensionCommandNames: null, + announced: false, sessionSerial, closing: false, providerThreadId, @@ -774,6 +1087,9 @@ async function constructPiThreadSession( } return threadSession; } catch (error) { + // A dialog an extension raised during startup has no session to answer + // it now: pi sees it cancelled, and nothing stays pending on bb's side. + cancelPendingDialogs(threadSession, "Pi session failed to start"); if (sessions.get(threadId) === threadSession) { sessions.delete(threadId); } @@ -873,6 +1189,19 @@ function sendThreadSessionResult( ): void { sendThreadIdentity(threadId, providerThreadId); sendSessionResetBoundary(threadId); + const threadSession = sessions.get(threadId); + if (threadSession) { + threadSession.announced = true; + // One snapshot after the reset: it clears what an older session left + // and replays what this one's extensions set while identity was pending. + sendPiExtensionState(threadId, threadSession.lastExtensionState); + // The questions its extensions asked while identity was pending go out + // now, in the order pi raised them. + const sessionArgs = { sessionSerial: threadSession.sessionSerial, threadId }; + for (const request of threadSession.pendingDialogs.splice(0)) { + requestExtensionDialog(sessionArgs, threadSession, request); + } + } sendResult(id, { providerThreadId, sessionRestorable: true }); } @@ -969,6 +1298,66 @@ function startPiPrompt( return dispatch.consumed; } +/** + * Pi hands `/name args` to an extension's handler when `name` is a command an + * extension registered (`_tryExecuteExtensionCommand`: the text up to the + * first space, suffix and all). The session itself says which names those + * are. + */ +async function isPiExtensionCommand(threadSession: ThreadSession, text: string): Promise { + if (!text.startsWith("/")) { + return false; + } + const spaceIndex = text.indexOf(" "); + const name = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + if (name.length === 0) { + return false; + } + try { + threadSession.extensionCommandNames ??= await threadSession.session.extensionCommandNames(); + return threadSession.extensionCommandNames.has(name); + } catch (error) { + // Unknown is "a prompt": pi itself decides, and the turn waits on its + // answer as every prompt does. + process.stderr.write( + `pi bridge: could not list the session's extension commands: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + return false; + } +} + +/** + * An extension command is not a prompt to wait on. Pi answers `prompt` only + * after the handler returns, and a handler that opens a dialog returns only + * when bb answers it — an answer the runtime delivers on the same thread + * lane `turn/start` is holding. Answering `turn/start` first breaks the + * cycle; pi's answer (or the run the handler started) then ends the turn, + * which otherwise never closes: no agent run means no `agent_end`. + */ +function startPiExtensionCommand( + threadSession: ThreadSession, + threadId: string, + text: string, + images: ImageContent[], +): void { + const dispatch = threadSession.session.promptExtensionCommand( + text, + images.length > 0 ? images : undefined, + ); + void dispatch.settled.then((outcome) => { + if (outcome === null) { + return; + } + reportPromptSettled({ + ...(outcome.error !== undefined ? { error: outcome.error } : {}), + sessionSerial: threadSession.sessionSerial, + threadId, + }); + }); +} + function startPiCompaction(threadSession: ThreadSession, threadId: string): void { void threadSession.session.compact().then( () => reportPromptSettled({ sessionSerial: threadSession.sessionSerial, threadId }), @@ -1091,6 +1480,16 @@ async function handleTurnStart(id: string | number, params: TurnStartParams): Pr sendError(id, BRIDGE_JSON_RPC_ERRORS.INVALID_PARAMS, "Missing input text"); return; } + if (await isPiExtensionCommand(threadSession, text)) { + if (threadSession.closing || sessions.get(params.threadId) !== threadSession) { + sendError(id, -32000, "No active pi session"); + return; + } + startPiExtensionCommand(threadSession, params.threadId, text, images); + recordAcceptedTurnInput(params); + sendResult(id, { threadId: params.threadId }); + return; + } try { await startPiPrompt(threadSession, params.threadId, text, images); recordAcceptedTurnInput(params); @@ -1181,8 +1580,15 @@ function extractInput(input: TurnStartParams["input"]): ExtractedInput { function handleParsedMessage(parsed: unknown): void { const response = decodeBridgeJsonRpcResponse(parsed); - if (response && handleToolCallResponse(response)) { - return; + if (response) { + if (handleToolCallResponse(response)) { + return; + } + for (const threadSession of sessions.values()) { + if (threadSession.interactions.handleResponse(response)) { + return; + } + } } const decoded = decodePiJsonRpcRequest(parsed); if (decoded.kind === "ignored") { diff --git a/plugins/provider-pi/src/bridge/catalog.ts b/plugins/provider-pi/src/bridge/catalog.ts index 46d9d9468f..9194b72cd6 100644 --- a/plugins/provider-pi/src/bridge/catalog.ts +++ b/plugins/provider-pi/src/bridge/catalog.ts @@ -1,10 +1,14 @@ -import type { AvailableModel } from "@get-bb/plugin-sdk/provider-bridge"; +import type { + AvailableModel, + ExperimentalProviderCommand, +} from "@get-bb/plugin-sdk/provider-bridge"; import { resolve } from "node:path"; import { createPiModelContextWindowResolverFrom, type PiModelContextWindowResolver, } from "../delta-translation.js"; import { buildPiAvailableModels, type PiCatalogModel } from "../model-list.js"; +import { toPiExtensionCommands } from "./command-list.js"; import { PiRpcChild, buildPiChildEnv } from "./rpc-child.js"; /** @@ -85,6 +89,10 @@ export interface PiCatalog { }>; /** Raw models, for the context-window resolver. */ rawModels(): Promise; + /** The catalog's models in bb's shape, before any enabled-model scope. */ + catalogModels(): Promise; + /** The commands pi's resources register for this cwd (`get_commands`). */ + listCommands(): Promise; /** The `get_state` smoke probe: pi booted, loaded the extension, answers. */ probe(): Promise>; close(): Promise; @@ -114,6 +122,7 @@ async function spawnCatalog( ], onEvent: () => {}, onChannelMessage: () => {}, + onExtensionUiRequest: null, onExit: () => {}, recordThreadId: null, }); @@ -138,23 +147,34 @@ async function spawnCatalog( }; // Readiness: every pi child the bridge spawns opens with `get_state`. await probe(); + const catalogModels = async (): Promise => { + const raw = await fetchRaw(); + const models: PiCatalogModel[] = []; + for (const model of raw) { + const catalogModel = toCatalogModel(model); + if (catalogModel) { + models.push(catalogModel); + } else { + process.stderr.write( + `pi bridge: skipped an incomplete model from provider "${String(model.provider)}"\n`, + ); + } + } + return models; + }; return { async listModels() { - const raw = await fetchRaw(); - const models: PiCatalogModel[] = []; - for (const model of raw) { - const catalogModel = toCatalogModel(model); - if (catalogModel) { - models.push(catalogModel); - } else { - process.stderr.write( - `pi bridge: skipped an incomplete model from provider "${String(model.provider)}"\n`, - ); - } - } - return buildPiAvailableModels({ models }); + return buildPiAvailableModels({ models: await catalogModels() }); }, rawModels: fetchRaw, + catalogModels, + async listCommands() { + const data = (await spawnChild().requestOk({ type: "get_commands" })) as + | { commands?: unknown } + | undefined; + touch(); + return toPiExtensionCommands(data?.commands); + }, probe, async close() { const activeChild = child; diff --git a/plugins/provider-pi/src/bridge/command-list.ts b/plugins/provider-pi/src/bridge/command-list.ts new file mode 100644 index 0000000000..e37e254d0f --- /dev/null +++ b/plugins/provider-pi/src/bridge/command-list.ts @@ -0,0 +1,66 @@ +import type { ExperimentalProviderCommand } from "@get-bb/plugin-sdk/provider-bridge"; +import { z } from "zod"; + +/** + * Pi's `get_commands` answer: every slash command the session would accept. + * `sourceInfo.scope` is where the owning resource lives (`project` for the + * workspace's `.pi/`, anything else the user's own). + */ +const piRpcCommandSchema = z + .object({ + name: z.string().min(1), + description: z.string().optional(), + source: z.enum(["extension", "prompt", "skill"]), + sourceInfo: z.object({ scope: z.string().optional() }).passthrough().optional(), + }) + .passthrough(); + +/** + * The commands pi's resources register for a cwd that bb cannot see + * statically: extension commands (registered by executing the trusted, + * cwd-bound extension) and prompt templates. Skills are left out — the + * daemon scans pi's skill roots itself and would list each one twice. + * Unknown entries are dropped, not guessed at. + */ +export function toPiExtensionCommands(raw: unknown): ExperimentalProviderCommand[] { + const entries = z.array(z.unknown()).safeParse(raw); + if (!entries.success) { + return []; + } + const commands: ExperimentalProviderCommand[] = []; + for (const entry of entries.data) { + const parsed = piRpcCommandSchema.safeParse(entry); + if (!parsed.success || parsed.data.source === "skill") { + continue; + } + commands.push({ + name: parsed.data.name, + source: "command", + origin: parsed.data.sourceInfo?.scope === "project" ? "project" : "user", + description: parsed.data.description ?? null, + argumentHint: null, + }); + } + return commands; +} + +/** + * The names pi would hand to an extension's command handler (`source: + * "extension"`, load-order suffixes included): the inputs pi executes to + * completion before it answers `prompt`, on no input queue and with no agent + * run of their own. + */ +export function toPiExtensionCommandNames(raw: unknown): Set { + const names = new Set(); + const entries = z.array(z.unknown()).safeParse(raw); + if (!entries.success) { + return names; + } + for (const entry of entries.data) { + const parsed = piRpcCommandSchema.safeParse(entry); + if (parsed.success && parsed.data.source === "extension") { + names.add(parsed.data.name); + } + } + return names; +} diff --git a/plugins/provider-pi/src/bridge/extension-dialogs.ts b/plugins/provider-pi/src/bridge/extension-dialogs.ts new file mode 100644 index 0000000000..f0c5de9792 --- /dev/null +++ b/plugins/provider-pi/src/bridge/extension-dialogs.ts @@ -0,0 +1,250 @@ +import { + isUserQuestionPendingInteractionResolution, + type PendingInteractionPayload, + type PendingInteractionResolution, +} from "@get-bb/plugin-sdk/provider-bridge"; +import { z } from "zod"; + +/** + * Pi's `extension_ui_request` lines (RPC mode): the dialogs an extension + * raises through `ctx.ui.select/confirm/input/editor`, which pi answers from + * a matching `extension_ui_response`, and the fire-and-forget state + * methods, which need no answer. Anything pi adds later is unknown here and + * is cancelled (a dialog) or ignored (state), never guessed at. + */ +export const piExtensionUiRequestSchema = z.discriminatedUnion("method", [ + z + .object({ + id: z.string().min(1), + method: z.literal("select"), + title: z.string(), + options: z.array(z.string()), + timeout: z.number().optional(), + }) + .passthrough(), + z + .object({ + id: z.string().min(1), + method: z.literal("confirm"), + title: z.string(), + message: z.string(), + timeout: z.number().optional(), + }) + .passthrough(), + z + .object({ + id: z.string().min(1), + method: z.literal("input"), + title: z.string(), + placeholder: z.string().optional(), + timeout: z.number().optional(), + }) + .passthrough(), + z + .object({ + id: z.string().min(1), + method: z.literal("editor"), + title: z.string(), + prefill: z.string().optional(), + }) + .passthrough(), + z + .object({ + id: z.string().min(1), + method: z.literal("notify"), + message: z.string(), + notifyType: z.enum(["info", "warning", "error"]).optional(), + }) + .passthrough(), + z + .object({ + id: z.string().min(1), + method: z.literal("setStatus"), + statusKey: z.string(), + statusText: z.string().optional(), + }) + .passthrough(), + z + .object({ + id: z.string().min(1), + method: z.literal("setWidget"), + widgetKey: z.string(), + widgetLines: z.array(z.unknown()).optional(), + widgetPlacement: z.enum(["aboveEditor", "belowEditor"]).optional(), + }) + .passthrough(), + z + .object({ id: z.string().min(1), method: z.literal("setTitle"), title: z.string() }) + .passthrough(), + z + .object({ + id: z.string().min(1), + method: z.literal("set_editor_text"), + text: z.string(), + }) + .passthrough(), +]); + +export type PiExtensionUiRequest = z.infer; +export type PiExtensionDialogRequest = Extract< + PiExtensionUiRequest, + { method: "select" | "confirm" | "input" | "editor" } +>; + +/** The answer pi reads for a dialog (rpc-types `RpcExtensionUIResponse`). */ +export type PiExtensionUiResponse = + | { id: string; value: string } + | { id: string; confirmed: boolean } + | { id: string; cancelled: true }; + +export interface PiExtensionDialog { + /** The canonical user question bb asks on the extension's behalf. */ + payload: PendingInteractionPayload; + /** The answer pi receives for bb's resolution. */ + toResponse(resolution: PendingInteractionResolution): PiExtensionUiResponse; +} + +function questionPrompt(title: string, message?: string): string { + const combined = message === undefined ? title : `${title}\n\n${message}`; + return combined.trim().length > 0 ? combined : "Input requested"; +} + +function answerOf( + resolution: PendingInteractionResolution, + questionId: string, +): { selected: readonly string[]; verbatim: string | undefined } | null { + if (!isUserQuestionPendingInteractionResolution(resolution)) { + return null; + } + const answer = resolution.answers[questionId]; + if (answer === undefined) { + return null; + } + return { + selected: answer.selected, + verbatim: answer.experimental_verbatimText, + }; +} + +/** + * Every dialog is one canonical user question, thread-scoped (an extension + * may ask outside any turn). Select options get stable ids and map back to + * pi's exact option string; confirm is an explicit yes/no; input and editor + * keep the submitted text verbatim, leading whitespace and newlines + * included. A dismissed question is pi's "cancelled" — the value pi's own + * TUI returns when the user escapes the dialog. + */ +export function buildPiExtensionDialog( + request: PiExtensionDialogRequest, + questionId: string, +): PiExtensionDialog { + const id = request.id; + switch (request.method) { + case "select": { + const optionByValue = new Map( + request.options.map((option, index) => [`option-${index}`, option]), + ); + return { + payload: { + kind: "user_question", + questions: [ + { + id: questionId, + prompt: questionPrompt(request.title), + multiSelect: false, + options: [...optionByValue].map(([value, option]) => ({ + value, + label: option.trim().length > 0 ? option : '""', + })), + allowFreeText: false, + }, + ], + }, + toResponse(resolution) { + const selected = answerOf(resolution, questionId)?.selected[0]; + const option = selected === undefined ? undefined : optionByValue.get(selected); + return option === undefined ? { id, cancelled: true } : { id, value: option }; + }, + }; + } + case "confirm": + return { + payload: { + kind: "user_question", + questions: [ + { + id: questionId, + prompt: questionPrompt(request.title, request.message), + multiSelect: false, + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + allowFreeText: false, + }, + ], + }, + toResponse(resolution) { + const selected = answerOf(resolution, questionId)?.selected[0]; + return selected === undefined + ? { id, cancelled: true } + : { id, confirmed: selected === "yes" }; + }, + }; + case "input": + return { + payload: { + kind: "user_question", + questions: [ + { + id: questionId, + prompt: questionPrompt(request.title), + multiSelect: false, + allowFreeText: true, + experimental_responseMode: "verbatim", + ...(request.placeholder !== undefined + ? { experimental_placeholder: request.placeholder } + : {}), + }, + ], + }, + toResponse(resolution) { + const verbatim = answerOf(resolution, questionId)?.verbatim; + return verbatim === undefined ? { id, cancelled: true } : { id, value: verbatim }; + }, + }; + case "editor": + return { + payload: { + kind: "user_question", + questions: [ + { + id: questionId, + prompt: questionPrompt(request.title), + multiSelect: false, + allowFreeText: true, + experimental_responseMode: "verbatim", + // Never shortened here: a prefill past bb's cap is cancelled + // by the bridge before it becomes a question. + ...(request.prefill === undefined ? {} : { experimental_prefill: request.prefill }), + }, + ], + }, + toResponse(resolution) { + const verbatim = answerOf(resolution, questionId)?.verbatim; + return verbatim === undefined ? { id, cancelled: true } : { id, value: verbatim }; + }, + }; + } +} + +export function isPiExtensionDialogRequest( + request: PiExtensionUiRequest, +): request is PiExtensionDialogRequest { + return ( + request.method === "select" || + request.method === "confirm" || + request.method === "input" || + request.method === "editor" + ); +} diff --git a/plugins/provider-pi/src/bridge/extension-state.test.ts b/plugins/provider-pi/src/bridge/extension-state.test.ts new file mode 100644 index 0000000000..94c4b00936 --- /dev/null +++ b/plugins/provider-pi/src/bridge/extension-state.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PI_EXTENSION_NOTIFICATION_MAX, + PI_EXTENSION_STATUS_MAX, + PI_EXTENSION_WIDGET_MAX, +} from "../extension-state.js"; +import { + PI_EXTENSION_EDITOR_TEXT_MAX_BYTES, + createPiExtensionStateController, + type PiExtensionUIState, +} from "./extension-state.js"; + +function latestState( + onChange: ReturnType< + typeof vi.fn<(state: PiExtensionUIState | null) => void> + >, +): PiExtensionUIState { + const state = onChange.mock.calls.at(-1)?.[0]; + if (state === null || state === undefined) { + throw new Error("Expected a Pi extension UI state snapshot"); + } + return state; +} + +describe("Pi extension state bounds", () => { + it("caps replayable collections, preserves accepted editor bytes, and clears atomically", () => { + const onChange = vi.fn<(state: PiExtensionUIState | null) => void>(); + const controller = createPiExtensionStateController(onChange); + + for (let index = 0; index < PI_EXTENSION_STATUS_MAX + 5; index += 1) { + controller.ui.setStatus(`status-${index}`, `value-${index}`); + } + for (let index = 0; index < PI_EXTENSION_WIDGET_MAX + 5; index += 1) { + controller.ui.setWidget(`widget-${index}`, [`line-${index}`]); + } + for (let index = 0; index < PI_EXTENSION_NOTIFICATION_MAX + 5; index += 1) { + controller.ui.notify(`notice-${index}`); + } + const editorText = " leading\n\ntrailing "; + controller.ui.setEditorText(editorText); + + const state = latestState(onChange); + expect(state.statuses).toHaveLength(PI_EXTENSION_STATUS_MAX); + expect(state.statuses[0]?.key).toBe("status-5"); + expect(state.widgets).toHaveLength(PI_EXTENSION_WIDGET_MAX); + expect(state.widgets[0]?.key).toBe("widget-5"); + expect(state.notifications).toHaveLength(PI_EXTENSION_NOTIFICATION_MAX); + expect(state.notifications[0]?.message).toBe("notice-5"); + expect(state.editor?.text).toBe(editorText); + expect(Buffer.byteLength(JSON.stringify(state), "utf8")).toBeLessThan( + 64 * 1024, + ); + + const acceptedCallCount = onChange.mock.calls.length; + controller.ui.setEditorText( + "x".repeat(PI_EXTENSION_EDITOR_TEXT_MAX_BYTES + 1), + ); + expect(onChange).toHaveBeenCalledTimes(acceptedCallCount); + + controller.clear(); + expect(onChange).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/plugins/provider-pi/src/bridge/extension-state.ts b/plugins/provider-pi/src/bridge/extension-state.ts new file mode 100644 index 0000000000..055d6acd73 --- /dev/null +++ b/plugins/provider-pi/src/bridge/extension-state.ts @@ -0,0 +1,192 @@ +import { + PI_EXTENSION_NOTIFICATION_MAX, + PI_EXTENSION_STATUS_MAX, + PI_EXTENSION_WIDGET_LINE_MAX, + PI_EXTENSION_WIDGET_MAX, + type PiExtensionUIState, +} from "../extension-state.js"; + +export type { PiExtensionUIState } from "../extension-state.js"; + +export const PI_EXTENSION_UI_STATE_KIND = "provider-pi/extension-ui"; + +export const PI_EXTENSION_KEY_MAX_BYTES = 128; +export const PI_EXTENSION_STATUS_TEXT_MAX_BYTES = 512; +export const PI_EXTENSION_NOTIFICATION_TEXT_MAX_BYTES = 1_024; +export const PI_EXTENSION_UI_TEXT_MAX_BYTES = 1_024; +export const PI_EXTENSION_WIDGET_LINE_MAX_BYTES = 1_024; +export const PI_EXTENSION_WIDGET_TEXT_MAX_BYTES = 12_288; +export const PI_EXTENSION_EDITOR_TEXT_MAX_BYTES = 16_384; +/** + * The server caps an `extension.state` payload at 64 KiB of JSON. The + * per-field byte caps above bound the raw text, not its JSON encoding + * (quotes, backslashes and control characters escape to 2–6 bytes), so the + * whole snapshot is measured as it will be sent, with headroom. + */ +export const PI_EXTENSION_SNAPSHOT_MAX_BYTES = 60 * 1024; + +/** + * Process-wide: an editor request must read as new even when its text + * equals the previous one, and a replaced session must not restart at 1. + */ +let editorRevisionCounter = 0; + +export interface PiExtensionStateController { + /** Drop every entry and publish a null snapshot (the session is over). */ + clear(): void; + ui: { + notify(message: string, level?: "info" | "warning" | "error"): void; + setEditorText(text: string): void; + setStatus(key: string, text: string | undefined): void; + setTitle(title: string): void; + setWidget( + key: string, + lines: readonly unknown[] | undefined, + placement?: "aboveEditor" | "belowEditor", + ): void; + }; +} + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +function acceptsText(value: string, maxBytes: number): boolean { + return utf8Bytes(value) <= maxBytes; +} + +function upsertBoundedByKey( + entries: readonly T[], + entry: T, + limit: number, +): T[] { + const withoutKey = entries.filter((candidate) => candidate.key !== entry.key); + return [...withoutKey, entry].slice(-limit); +} + +function removeByKey( + entries: readonly T[], + key: string, +): T[] { + return entries.filter((entry) => entry.key !== key); +} + +function widgetTextBytes(widgets: PiExtensionUIState["widgets"]): number { + return widgets.reduce( + (total, widget) => + total + + utf8Bytes(widget.key) + + widget.lines.reduce((lineTotal, line) => lineTotal + utf8Bytes(line), 0), + 0, + ); +} + +function emptyState(): PiExtensionUIState { + return { statuses: [], widgets: [], notifications: [], title: null, editor: null }; +} + +function copyState(state: PiExtensionUIState): PiExtensionUIState { + return { + statuses: state.statuses.map((status) => ({ ...status })), + widgets: state.widgets.map((widget) => ({ ...widget, lines: [...widget.lines] })), + notifications: state.notifications.map((notification) => ({ ...notification })), + title: state.title, + editor: state.editor === null ? null : { ...state.editor }, + }; +} + +/** + * Project pi's fire-and-forget extension UI methods (what RPC mode emits as + * `extension_ui_request` lines with no answer) into one bounded snapshot. + * An update that would exceed a bound is dropped whole rather than + * truncated: a truncated status or widget line would read as the + * extension's own text. + */ +export function createPiExtensionStateController( + onChange: (state: PiExtensionUIState | null) => void, +): PiExtensionStateController { + let notificationId = 0; + let state = emptyState(); + const publish = (): void => onChange(copyState(state)); + /** + * Apply one mutation if the snapshot it produces still fits the wire; a + * snapshot that would be rejected downstream is not worth more than the + * one already showing, so the update is dropped whole. + */ + const commit = (next: PiExtensionUIState): void => { + if (utf8Bytes(JSON.stringify(next)) > PI_EXTENSION_SNAPSHOT_MAX_BYTES) { + return; + } + state = next; + publish(); + }; + + return { + clear() { + state = emptyState(); + onChange(null); + }, + ui: { + notify(message, level = "info") { + if (!acceptsText(message, PI_EXTENSION_NOTIFICATION_TEXT_MAX_BYTES)) { + return; + } + notificationId += 1; + commit({ + ...state, + notifications: [ + ...state.notifications, + { id: notificationId, message, level }, + ].slice(-PI_EXTENSION_NOTIFICATION_MAX), + }); + }, + setEditorText(text) { + if (!acceptsText(text, PI_EXTENSION_EDITOR_TEXT_MAX_BYTES)) return; + editorRevisionCounter += 1; + commit({ ...state, editor: { revision: editorRevisionCounter, text } }); + }, + setStatus(key, text) { + if (!acceptsText(key, PI_EXTENSION_KEY_MAX_BYTES)) return; + if (text !== undefined && !acceptsText(text, PI_EXTENSION_STATUS_TEXT_MAX_BYTES)) { + return; + } + commit({ + ...state, + statuses: + text === undefined + ? removeByKey(state.statuses, key) + : upsertBoundedByKey(state.statuses, { key, text }, PI_EXTENSION_STATUS_MAX), + }); + }, + setTitle(title) { + if (!acceptsText(title, PI_EXTENSION_UI_TEXT_MAX_BYTES)) return; + commit({ ...state, title }); + }, + setWidget(key, content, placement) { + if (!acceptsText(key, PI_EXTENSION_KEY_MAX_BYTES)) return; + if (content === undefined) { + commit({ ...state, widgets: removeByKey(state.widgets, key) }); + return; + } + const lines = content.slice(0, PI_EXTENSION_WIDGET_LINE_MAX); + if ( + !lines.every( + (line): line is string => + typeof line === "string" && acceptsText(line, PI_EXTENSION_WIDGET_LINE_MAX_BYTES), + ) + ) { + return; + } + const widgets = upsertBoundedByKey( + state.widgets, + { key, lines, placement: placement ?? "aboveEditor" }, + PI_EXTENSION_WIDGET_MAX, + ); + if (widgetTextBytes(widgets) > PI_EXTENSION_WIDGET_TEXT_MAX_BYTES) { + return; + } + commit({ ...state, widgets }); + }, + }, + }; +} diff --git a/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs b/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs index 01eadca9f1..783e44dea3 100644 --- a/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs +++ b/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs @@ -60,6 +60,22 @@ * - `prompt` with `streamingBehavior: "steer"` during a `/hold` run is queued * (`queue_update.steering`), consumed when the run resumes, and the run's * reply names it. + * - `/ext []` is an extension command as pi runs one: the optional + * `extension_ui_request` is raised (and a dialog awaited) before the prompt + * is answered, with no agent run unless the json has `run: true` (a run on + * "ext run" follows the answer); `get_commands` lists `ext` as an + * extension command + * - `/ui ` emits one `extension_ui_request` line with the given fields + * (a fire-and-forget `ctx.ui` call: notify, setStatus, setWidget, setTitle, + * set_editor_text) and replies "ok". `/ask ` emits a dialog request + * (select, confirm, input, editor), waits for the matching + * `extension_ui_response`, and replies with that response as JSON — exactly + * what pi's RPC mode does for `ctx.ui.select` and friends. A `timeout` + * in the dialog's JSON settles it with `{"timedOut":true}` when no answer + * arrives in time, as pi's dialog promise resolves to its default. + * - `get_commands` answers FAKE_PI_COMMANDS (a JSON array of pi's + * RpcSlashCommand shape), or a fixed project extension command, global + * extension command, prompt template, and skill. */ import { randomUUID } from "node:crypto"; @@ -191,6 +207,8 @@ const followUp = []; /** Steering queue: steers that arrived while a run was live. */ const steering = []; let endedWithStreamingFlag = false; +/** Dialogs raised through `/ask`, awaiting their `extension_ui_response`. */ +const pendingDialogs = new Map(); /** A line held back to go out in one write with the next one. */ let heldLine = null; @@ -353,6 +371,26 @@ async function runPrompt(text) { // Mid-run death without an answer: the bridge's next write hits EPIPE. process.exit(0); } + let uiReplyText = null; + const uiMatch = text.match(/^\/(ui|ask) (.*)$/su); + if (uiMatch) { + const request = { type: "extension_ui_request", id: randomUUID(), ...JSON.parse(uiMatch[2]) }; + if (uiMatch[1] === "ui") { + event(request); + uiReplyText = "ok"; + } else { + const answered = new Promise((resolve) => { + pendingDialogs.set(request.id, resolve); + if (typeof request.timeout === "number" && request.timeout > 0) { + setTimeout(() => { + if (pendingDialogs.delete(request.id)) resolve({ timedOut: true }); + }, request.timeout); + } + }); + event(request); + uiReplyText = JSON.stringify(await answered); + } + } let toolResultText = ""; const toolMatch = text.match(/^\/tool (\S+) ?(.*)$/su); if (toolMatch) { @@ -369,7 +407,13 @@ async function runPrompt(text) { .join("\n"); } const failed = text === "/fail-run"; - const reply = failed ? "" : toolMatch ? `Tool said: ${toolResultText}` : `Response to: ${text}`; + const reply = failed + ? "" + : uiReplyText !== null + ? uiReplyText + : toolMatch + ? `Tool said: ${toolResultText}` + : `Response to: ${text}`; const assistant = { role: "assistant", content: [{ type: "text", text: reply }], @@ -464,6 +508,30 @@ async function handle(command) { }); return; case "prompt": { + const extMatch = command.message.match(/^\/ext(?: (.*))?$/su); + if (extMatch) { + // An extension command, as pi runs one: the handler (here, one + // `ctx.ui` call or nothing) completes before the prompt is answered, + // no queue moves, and no agent run starts unless the handler asked + // for one (`run: true`, pi.sendMessage with triggerTurn). + const { run = false, ...ui } = extMatch[1] ? JSON.parse(extMatch[1]) : {}; + if (ui.method) { + const request = { type: "extension_ui_request", id: randomUUID(), ...ui }; + const isDialog = ["select", "confirm", "input", "editor"].includes(request.method); + const answered = new Promise((resolve) => { + if (isDialog) pendingDialogs.set(request.id, resolve); + else resolve(null); + }); + event(request); + await answered; + } + respond(id, "prompt"); + if (run) { + await runPrompt("ext run"); + await drainFollowUps(); + } + return; + } if (isStreaming && command.streamingBehavior === "steer") { // A steer into a live run: pi reports the queue BEFORE it answers the // preflight (recorded order), then hands it to the run (a held run @@ -498,6 +566,54 @@ async function handle(command) { case "steer": respond(id, "steer"); return; + case "extension_ui_response": { + // No response line: pi answers a dialog's promise and nothing else. + const resolve = pendingDialogs.get(command.id); + if (resolve) { + pendingDialogs.delete(command.id); + const { type: _type, id: _id, ...answer } = command; + resolve(answer); + } + return; + } + case "get_commands": + respond(id, "get_commands", { + commands: process.env.FAKE_PI_COMMANDS + ? JSON.parse(process.env.FAKE_PI_COMMANDS) + : [ + { + name: "project-smoke", + description: "Project smoke command", + source: "extension", + sourceInfo: { path: `${process.cwd()}/.pi/extensions/smoke.ts`, source: "extension", scope: "project", origin: "local" }, + }, + { + name: "global-smoke", + description: "Global smoke command", + source: "extension", + sourceInfo: { path: "/home/user/.pi/agent/extensions/global.ts", source: "extension", scope: "global", origin: "local" }, + }, + { + name: "ext", + description: "Fake extension command: runs one ctx.ui call, or nothing", + source: "extension", + sourceInfo: { path: "/home/user/.pi/agent/extensions/ext.ts", source: "extension", scope: "global", origin: "local" }, + }, + { + name: "review", + description: "Review the diff", + source: "prompt", + sourceInfo: { path: "/home/user/.pi/agent/prompts/review.md", source: "prompt", scope: "global", origin: "local" }, + }, + { + name: "skill:existing", + description: "Existing skill", + source: "skill", + sourceInfo: { path: `${process.cwd()}/.pi/skills/existing/SKILL.md`, source: "skill", scope: "project", origin: "local" }, + }, + ], + }); + return; case "abort": if (holdAbort) { holdAbort("abort"); @@ -566,6 +682,7 @@ readLines(process.stdin, (line) => { // asynchronously so `abort` — and a steer into a held run — can reach it. if ( command.type === "abort" || + command.type === "extension_ui_response" || command.type === "get_state" || command.type === "get_session_stats" || (command.type === "prompt" && command.streamingBehavior === "steer") diff --git a/plugins/provider-pi/src/bridge/model-scope.test.ts b/plugins/provider-pi/src/bridge/model-scope.test.ts new file mode 100644 index 0000000000..2c3bf3729d --- /dev/null +++ b/plugins/provider-pi/src/bridge/model-scope.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { PiCatalogModel } from "../model-list.js"; +import { + buildScopedPiAvailableModels, + resolvePiEnabledModelIds, + resolvePiModelScope, +} from "./model-scope.js"; + +function model(provider: string, id: string, name = id): PiCatalogModel { + return { provider, id, name, input: ["text"], reasoning: true, supportedThinkingLevels: ["off", "low"] }; +} + +const catalog = [ + model("anthropic", "claude-sonnet-5", "Claude Sonnet 5"), + model("anthropic", "claude-opus-4-8", "Claude Opus 4.8"), + model("anthropic", "claude-opus-4-8-20260101", "Claude Opus 4.8 (dated)"), + model("openai", "gpt-5.5", "GPT-5.5"), + model("openrouter", "zai/glm-5.1", "GLM 5.1"), +]; + +describe("pi's enabled-model patterns", () => { + it("resolves exact references, globs, partial names, and thinking suffixes in pattern order", () => { + expect( + resolvePiModelScope( + ["OPENAI/gpt-5.5:high", "anthropic/*opus*", "sonnet", "openrouter/zai/glm-5.1", "missing"], + catalog, + ).map((entry) => `${entry.provider}/${entry.id}`), + ).toEqual([ + "openai/gpt-5.5", + "anthropic/claude-opus-4-8", + "anthropic/claude-opus-4-8-20260101", + "anthropic/claude-sonnet-5", + "openrouter/zai/glm-5.1", + ]); + }); + + it("prefers an alias over a dated version on a partial match, as pi does", () => { + expect(resolvePiModelScope(["opus"], catalog).map((entry) => entry.id)).toEqual([ + "claude-opus-4-8", + ]); + }); + + it("treats an absent, empty, or unmatched scope as every model", () => { + expect(resolvePiEnabledModelIds(undefined, catalog)).toBeNull(); + expect(resolvePiEnabledModelIds([], catalog)).toBeNull(); + expect(resolvePiEnabledModelIds(["nothing-like-this"], catalog)).toBeNull(); + }); +}); + +describe("the scoped picker", () => { + it("leads with the enabled models in cycling order and keeps the rest selectable only", () => { + const scoped = buildScopedPiAvailableModels({ + models: catalog, + enabledModelIds: ["openai/gpt-5.5", "anthropic/claude-sonnet-5"], + }); + expect(scoped.models.map((entry) => entry.id)).toEqual([ + "openai/gpt-5.5", + "anthropic/claude-sonnet-5", + ]); + expect(scoped.models.filter((entry) => entry.isDefault).map((entry) => entry.id)).toEqual([ + "openai/gpt-5.5", + ]); + expect(scoped.selectedOnlyModels.map((entry) => entry.id)).toEqual([ + "anthropic/claude-opus-4-8", + "openrouter/zai/glm-5.1", + "anthropic/claude-opus-4-8-20260101", + ]); + expect(scoped.selectedOnlyModels.some((entry) => entry.isDefault)).toBe(false); + }); + + it("keeps a picker when only dated ids are enabled, with a default", () => { + const scoped = buildScopedPiAvailableModels({ + models: catalog, + enabledModelIds: ["anthropic/claude-opus-4-8-20260101"], + }); + expect(scoped.models.map((entry) => entry.id)).toEqual(["anthropic/claude-opus-4-8-20260101"]); + expect(scoped.models[0]?.isDefault).toBe(true); + expect(scoped.selectedOnlyModels.map((entry) => entry.id)).toEqual([ + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-4-8", + "openai/gpt-5.5", + "openrouter/zai/glm-5.1", + ]); + }); + + it("is the plain catalog without a scope", () => { + const unscoped = buildScopedPiAvailableModels({ models: catalog, enabledModelIds: null }); + expect(unscoped.models.map((entry) => entry.id)).toEqual([ + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-4-8", + "openai/gpt-5.5", + "openrouter/zai/glm-5.1", + ]); + }); +}); diff --git a/plugins/provider-pi/src/bridge/model-scope.ts b/plugins/provider-pi/src/bridge/model-scope.ts new file mode 100644 index 0000000000..b1bdc289f6 --- /dev/null +++ b/plugins/provider-pi/src/bridge/model-scope.ts @@ -0,0 +1,228 @@ +import { minimatch } from "minimatch"; +import type { AvailableModel } from "@get-bb/plugin-sdk/provider-bridge"; +import { buildPiAvailableModels, type PiCatalogModel } from "../model-list.js"; + +/** + * Pi's `enabledModels` setting is a list of patterns, resolved against the + * authenticated catalog in pi's own order (the order the user cycles models + * in). This is a port of pi's `resolveModelScopeFromModels` + * (coding-agent `core/model-resolver.ts`): the bridge has no pi SDK in RPC + * mode, and the answer has to match what pi itself will cycle through. + */ + +interface ScopeModel { + id: string; + provider: string; + name?: string; +} + +/** pi's `isValidThinkingLevel` (cli/args.ts). */ +const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +const DATE_SUFFIX_PATTERN = /-\d{8}$/; + +function isAlias(id: string): boolean { + if (id.endsWith("-latest")) return true; + return !DATE_SUFFIX_PATTERN.test(id); +} + +function modelsAreEqual(a: ScopeModel, b: ScopeModel): boolean { + return a.provider === b.provider && a.id === b.id; +} + +function findExactModelReferenceMatch( + modelReference: string, + availableModels: readonly T[], +): T | undefined { + const trimmedReference = modelReference.trim(); + if (!trimmedReference) { + return undefined; + } + const normalizedReference = trimmedReference.toLowerCase(); + const canonicalMatches = availableModels.filter( + (model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference, + ); + if (canonicalMatches.length === 1) { + return canonicalMatches[0]; + } + if (canonicalMatches.length > 1) { + return undefined; + } + const slashIndex = trimmedReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = trimmedReference.substring(0, slashIndex).trim(); + const modelId = trimmedReference.substring(slashIndex + 1).trim(); + if (provider && modelId) { + const providerMatches = availableModels.filter( + (model) => + model.provider.toLowerCase() === provider.toLowerCase() && + model.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatches.length === 1) { + return providerMatches[0]; + } + if (providerMatches.length > 1) { + return undefined; + } + } + } + const idMatches = availableModels.filter( + (model) => model.id.toLowerCase() === normalizedReference, + ); + return idMatches.length === 1 ? idMatches[0] : undefined; +} + +function tryMatchModel( + modelPattern: string, + availableModels: readonly T[], +): T | undefined { + const exactMatch = findExactModelReferenceMatch(modelPattern, availableModels); + if (exactMatch) { + return exactMatch; + } + const needle = modelPattern.toLowerCase(); + const matches = availableModels.filter( + (model) => + model.id.toLowerCase().includes(needle) || + model.name?.toLowerCase().includes(needle) === true, + ); + if (matches.length === 0) { + return undefined; + } + const aliases = matches.filter((model) => isAlias(model.id)); + const datedVersions = matches.filter((model) => !isAlias(model.id)); + if (aliases.length > 0) { + aliases.sort((a, b) => b.id.localeCompare(a.id)); + return aliases[0]; + } + datedVersions.sort((a, b) => b.id.localeCompare(a.id)); + return datedVersions[0]; +} + +/** pi's `parseModelPattern`: the model, with any `:thinking` suffix peeled off. */ +function parseModelPattern( + pattern: string, + availableModels: readonly T[], +): T | undefined { + const exactMatch = tryMatchModel(pattern, availableModels); + if (exactMatch) { + return exactMatch; + } + const lastColonIndex = pattern.lastIndexOf(":"); + if (lastColonIndex === -1) { + return undefined; + } + // A valid thinking level is peeled off; an invalid suffix is too (pi's + // scope mode warns and falls back to the prefix). + return parseModelPattern(pattern.substring(0, lastColonIndex), availableModels); +} + +function hasGlob(pattern: string): boolean { + return pattern.includes("*") || pattern.includes("?") || pattern.includes("["); +} + +/** + * The models `patterns` select, in pattern order, each at most once — + * pi's cycling order. Patterns that match nothing select nothing. + */ +export function resolvePiModelScope( + patterns: readonly string[], + models: readonly T[], +): T[] { + const scoped: T[] = []; + const add = (model: T): void => { + if (!scoped.some((entry) => modelsAreEqual(entry, model))) { + scoped.push(model); + } + }; + for (const pattern of patterns) { + if (hasGlob(pattern)) { + let globPattern = pattern; + const colonIndex = pattern.lastIndexOf(":"); + if (colonIndex !== -1 && THINKING_LEVELS.has(pattern.substring(colonIndex + 1))) { + globPattern = pattern.substring(0, colonIndex); + } + const exactMatch = findExactModelReferenceMatch(globPattern, models); + if (exactMatch) { + add(exactMatch); + continue; + } + for (const model of models) { + const fullId = `${model.provider}/${model.id}`; + if ( + minimatch(fullId, globPattern, { nocase: true }) || + minimatch(model.id, globPattern, { nocase: true }) + ) { + add(model); + } + } + continue; + } + const model = parseModelPattern(pattern, models); + if (model) { + add(model); + } + } + return scoped; +} + +export function toCanonicalId(model: ScopeModel): string { + return `${model.provider}/${model.id}`; +} + +/** + * The ids pi's scope selects, or null when the setting is absent, empty, + * or matches nothing — pi treats each of those as "every model". + */ +export function resolvePiEnabledModelIds( + patterns: readonly string[] | undefined, + models: readonly PiCatalogModel[], +): string[] | null { + if (patterns === undefined || patterns.length === 0) { + return null; + } + const scoped = resolvePiModelScope(patterns, models); + return scoped.length === 0 ? null : scoped.map(toCanonicalId); +} + +/** + * The picker lists the enabled models first, in cycling order; the rest stay + * selectable (a thread already on one of them keeps its model) but out of + * the picker, and never the default. + */ +export function buildScopedPiAvailableModels(args: { + models: readonly PiCatalogModel[]; + enabledModelIds: readonly string[] | null; +}): { models: AvailableModel[]; selectedOnlyModels: AvailableModel[] } { + if (args.enabledModelIds === null) { + return buildPiAvailableModels({ models: args.models }); + } + const enabled = new Set(args.enabledModelIds); + const byId = new Map(args.models.map((model) => [toCanonicalId(model), model])); + const preferredModels = args.enabledModelIds.flatMap((id) => { + const model = byId.get(id); + return model === undefined ? [] : [model]; + }); + const otherModels = args.models.filter((model) => !enabled.has(toCanonicalId(model))); + const preferred = buildPiAvailableModels({ models: preferredModels }); + if (preferred.models.length === 0 && preferred.selectedOnlyModels.length > 0) { + // The user enabled only dated ids (`-YYYYMMDD`): those are what pi + // cycles through, so they are the picker — the alias-only rule that + // hides dated versions behind their aliases has nothing to hide here. + const [first, ...rest] = preferred.selectedOnlyModels; + preferred.models = [{ ...first!, isDefault: true }, ...rest]; + preferred.selectedOnlyModels = []; + } + if (otherModels.length === 0) { + return preferred; + } + const other = buildPiAvailableModels({ models: otherModels }); + return { + models: preferred.models, + selectedOnlyModels: [ + ...preferred.selectedOnlyModels, + ...other.models.map((model) => (model.isDefault ? { ...model, isDefault: false } : model)), + ...other.selectedOnlyModels, + ], + }; +} diff --git a/plugins/provider-pi/src/bridge/model-settings.ts b/plugins/provider-pi/src/bridge/model-settings.ts new file mode 100644 index 0000000000..bf82d86426 --- /dev/null +++ b/plugins/provider-pi/src/bridge/model-settings.ts @@ -0,0 +1,61 @@ +import type { PiCatalogModel } from "../model-list.js"; +import { resolvePiEnabledModelIds, toCanonicalId } from "./model-scope.js"; +import { + readPiEnabledModelPatterns, + resolvePiGlobalSettingsPath, + updatePiSettingsFile, +} from "./settings-storage.js"; + +/** + * The plugin's `model-settings/*` bridge calls: Pi's host-local enabled-model + * preference (the global `settings.json`'s `enabledModels`), listed against + * the authenticated catalog of this host and written back as exact + * `/` entries. + */ + +export interface PiModelSettingsModel { + id: string; + displayName: string; + provider: string; + reasoning: boolean; +} + +export interface PiModelSettingsSnapshot { + models: PiModelSettingsModel[]; + enabledModelIds: string[] | null; +} + +export function readPiModelSettings(catalog: readonly PiCatalogModel[]): PiModelSettingsSnapshot { + const patterns = readPiEnabledModelPatterns(); + return { + models: catalog.map((model) => ({ + id: toCanonicalId(model), + displayName: model.name, + provider: model.provider, + reasoning: model.reasoning, + })), + enabledModelIds: resolvePiEnabledModelIds(patterns, catalog), + }; +} + +export function writePiEnabledModels( + catalog: readonly PiCatalogModel[], + enabledModelIds: readonly string[] | null, +): PiModelSettingsSnapshot { + const availableIds = new Set(catalog.map(toCanonicalId)); + const normalized = enabledModelIds === null ? null : [...new Set(enabledModelIds)]; + if (normalized !== null) { + if (normalized.length === 0) { + throw new Error("At least one Pi model must remain enabled"); + } + const unavailable = normalized.find((id) => !availableIds.has(id)); + if (unavailable !== undefined) { + throw new Error(`Pi model "${unavailable}" is not available on this host`); + } + } + updatePiSettingsFile(resolvePiGlobalSettingsPath(), (current) => { + const { enabledModels: _previous, ...rest } = current; + return normalized === null ? rest : { ...rest, enabledModels: normalized }; + }); + return readPiModelSettings(catalog); +} diff --git a/plugins/provider-pi/src/bridge/pending-interaction-tracker.ts b/plugins/provider-pi/src/bridge/pending-interaction-tracker.ts new file mode 100644 index 0000000000..220031fab8 --- /dev/null +++ b/plugins/provider-pi/src/bridge/pending-interaction-tracker.ts @@ -0,0 +1,143 @@ +import { + pendingInteractionResolutionSchema, + type BridgeJsonRpcResponse, + type PendingInteractionPayload, + type PendingInteractionResolution, +} from "@get-bb/plugin-sdk/provider-bridge"; + +export class PiExtensionInteractionCancelledError extends Error { + constructor(message: string) { + super(message); + this.name = "PiExtensionInteractionCancelledError"; + } +} + +export interface PiBridgeInteractionCancelNotification { + jsonrpc: "2.0"; + method: "interaction/cancel"; + params: { + requestId: string | number; + providerThreadId: string; + threadId: string; + reason: string; + }; +} + +export interface PiBridgeInteractionRequest { + jsonrpc: "2.0"; + id: string | number; + method: "interaction/request"; + params: { + threadId: string; + providerThreadId: string; + turnId: null; + experimental_scope: "thread"; + payload: PendingInteractionPayload; + }; +} + +export interface PiExtensionInteractionRequest { + cancel(message: string): void; + response: Promise; +} + +interface PendingInteraction { + reject(error: Error): void; + resolve(resolution: PendingInteractionResolution): void; +} + +export interface PiPendingInteractionTracker { + cancel(message: string): void; + handleResponse(response: BridgeJsonRpcResponse): boolean; + request(payload: PendingInteractionPayload): PiExtensionInteractionRequest; +} + +/** + * One tracker belongs to one pi session. Request ids are process-global at + * the caller, while cancellation removes this session's callbacks before a + * replacement can be installed, so a late answer can never cross into a + * new session. + */ +export function createPiPendingInteractionTracker(options: { + nextRequestId(): string; + providerThreadId: string; + send(request: PiBridgeInteractionRequest): void; + sendCancel(notification: PiBridgeInteractionCancelNotification): void; + threadId: string; +}): PiPendingInteractionTracker { + const pending = new Map(); + const cancelRequest = (requestId: string | number, message: string): void => { + const entry = pending.get(requestId); + if (!entry) { + return; + } + pending.delete(requestId); + try { + options.sendCancel({ + jsonrpc: "2.0", + method: "interaction/cancel", + params: { + requestId, + providerThreadId: options.providerThreadId, + threadId: options.threadId, + reason: message, + }, + }); + } catch { + // Local cancellation remains authoritative if the transport closed. + } + entry.reject(new PiExtensionInteractionCancelledError(message)); + }; + + return { + request(payload) { + const requestId = options.nextRequestId(); + const response = new Promise((resolve, reject) => { + pending.set(requestId, { reject, resolve }); + try { + options.send({ + jsonrpc: "2.0", + id: requestId, + method: "interaction/request", + params: { + threadId: options.threadId, + providerThreadId: options.providerThreadId, + turnId: null, + experimental_scope: "thread", + payload, + }, + }); + } catch (error) { + pending.delete(requestId); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + return { + cancel: (message) => cancelRequest(requestId, message), + response, + }; + }, + handleResponse(response) { + const entry = pending.get(response.id); + if (!entry) { + return false; + } + pending.delete(response.id); + if ("error" in response) { + entry.reject(new Error(response.error.message ?? "Interaction failed")); + return true; + } + try { + entry.resolve(pendingInteractionResolutionSchema.parse(response.result)); + } catch (error) { + entry.reject(error instanceof Error ? error : new Error(String(error))); + } + return true; + }, + cancel(message) { + for (const requestId of [...pending.keys()]) { + cancelRequest(requestId, message); + } + }, + }; +} diff --git a/plugins/provider-pi/src/bridge/rpc-child.ts b/plugins/provider-pi/src/bridge/rpc-child.ts index 9895f4575a..5a4ffa7dbc 100644 --- a/plugins/provider-pi/src/bridge/rpc-child.ts +++ b/plugins/provider-pi/src/bridge/rpc-child.ts @@ -65,6 +65,12 @@ export interface SpawnPiRpcChildArgs { args: readonly string[]; onEvent: (event: Record) => void; onChannelMessage: (message: Record) => void; + /** + * Where an `extension_ui_request` line goes (a dialog or a state update an + * extension raised through `ctx.ui`). Null for a headless child: every + * dialog is cancelled, the way pi's own TUI answers an escaped one. + */ + onExtensionUiRequest: ((request: Record) => void) | null; onExit: (info: PiRpcChildExitInfo) => void; /** The bb thread this child serves, for record mode. */ recordThreadId: string | null; @@ -282,6 +288,11 @@ export class PiRpcChild { return response.data; } + /** Write one command pi answers with no response (`extension_ui_response`). */ + send(command: Record): void { + this.writeStdin(`${JSON.stringify(command)}\n`); + } + /** Write one message to the extension's channel (fd 4). */ sendChannel(message: Record): void { const writer = this.channelWriter; @@ -399,15 +410,21 @@ export class PiRpcChild { return; } if (message.type === "extension_ui_request") { - // Headless: every dialog another extension raises is cancelled, the - // way the in-process bridge's rpc mode binding answered them. - this.writeStdin( - `${JSON.stringify({ - type: "extension_ui_response", - id: message.id, - cancelled: true, - })}\n`, - ); + if (this.args.onExtensionUiRequest === null) { + // Headless: every dialog another extension raises is cancelled, the + // way pi's own TUI answers an escaped dialog; state updates have no + // answer and nowhere to go. + this.writeStdin( + `${JSON.stringify({ + type: "extension_ui_response", + id: message.id, + cancelled: true, + })} +`, + ); + return; + } + this.args.onExtensionUiRequest(message); return; } if (typeof message.type === "string") { diff --git a/plugins/provider-pi/src/bridge/rpc-session.ts b/plugins/provider-pi/src/bridge/rpc-session.ts index 79ff75a226..438900a053 100644 --- a/plugins/provider-pi/src/bridge/rpc-session.ts +++ b/plugins/provider-pi/src/bridge/rpc-session.ts @@ -2,6 +2,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { experimental_buildBridgeToolCallContent as buildBridgeToolCallContent } from "@get-bb/plugin-sdk/provider-bridge"; import type { ImageContent } from "@earendil-works/pi-ai"; +import { toPiExtensionCommandNames } from "./command-list.js"; import { NO_REQUEST_TIMEOUT, PiRpcChild, @@ -52,6 +53,11 @@ export interface PiRpcSessionOptions { scratchDir: string; extensionPath: string; recordThreadId: string; + /** + * Where pi's `extension_ui_request` lines go (dialogs and state updates an + * extension raises through `ctx.ui`); without it every dialog is cancelled. + */ + onExtensionUiRequest?: (request: Record) => void; /** * Spawn without a session file (`--no-session`): a helper that only needs * the extension's in-process SDK access (forks) and must not append to any @@ -116,6 +122,8 @@ function readinessTimeoutMs(): number { return Number.isFinite(configured) && configured > 0 ? configured : 60_000; } const CHANNEL_REQUEST_TIMEOUT_MS = 30_000; +/** `get_commands` is a memory read; well inside the runtime's 30 s `turn/start` budget. */ +const EXTENSION_COMMAND_LIST_TIMEOUT_MS = 5_000; /** How long an `agent_end` waits for the extension's in-process leaf report. */ const AGENT_END_LEAF_TIMEOUT_MS = 5_000; @@ -179,6 +187,8 @@ export class PiRpcSession { private autoRetryInProgress = false; private terminalSteerSettlement: Promise | null = null; private readonly pendingRunSettlements: PendingRunSettlement[] = []; + /** `agent_start` events seen: whether a dispatch started a run at all. */ + private agentStartCount = 0; private readonly channelReplies = new Map(); private nextChannelRequestId = 0; private lastKnownLeafId: string | null = null; @@ -222,6 +232,18 @@ export class PiRpcSession { return this.lastKnownLeafId ?? undefined; } + /** + * Answer a dialog an extension raised (`extension_ui_request`). A child + * that is gone gets nothing: pi's own timeout or exit settled the dialog. + */ + respondToExtensionUi(response: Record): void { + const child = this.child; + if (!child || child.exited) { + return; + } + child.send({ type: "extension_ui_response", ...response }); + } + /** * Spawn the child with the model and thinking level as flags, probe * readiness (`get_state` answered AND the extension's `ready`), and verify @@ -305,6 +327,12 @@ export class PiRpcSession { onChannelMessage: (message) => { if (child === this.child) this.handleChannelMessage(message); }, + onExtensionUiRequest: + this.options.onExtensionUiRequest === undefined + ? null + : (request) => { + if (child === this.child) this.options.onExtensionUiRequest?.(request); + }, onExit: (info) => { for (const file of scratchFiles) rmSync(file, { force: true }); if (child === this.child) this.handleExit(info); @@ -429,6 +457,77 @@ export class PiRpcSession { return { consumed: tracked.promise, settled }; } + /** + * The names of the commands this session's loaded extensions registered. + * Answered from memory; the short budget keeps a wedged pi from eating the + * runtime's whole `turn/start` budget before the prompt path reports it. + */ + async extensionCommandNames(): Promise> { + const data = (await this.requireChild().requestOk( + { type: "get_commands" }, + EXTENSION_COMMAND_LIST_TIMEOUT_MS, + )) as { commands?: unknown } | undefined; + return toPiExtensionCommandNames(data?.commands); + } + + /** + * A slash command an extension registered. Pi runs its handler to + * completion before it answers `prompt` — dialogs included — and that + * handler moves no input queue and starts no agent run unless it asks for + * one. So `consumed` is immediate: the caller must be free to deliver the + * dialog answers the handler is waiting on. `settled` is pi's answer, or + * the run the handler started, whichever ends the command. + */ + promptExtensionCommand(text: string, images?: ImageContent[]): PiInputDispatch { + const child = this.child; + if (!child || child.exited) { + const consumed = Promise.reject(new Error("No active Pi session")); + void consumed.catch(() => undefined); + return { consumed, settled: Promise.resolve(null) }; + } + this.isProcessing = true; + const runsBefore = this.agentStartCount; + const settlement = new Promise((resolve) => { + this.pendingRunSettlements.push({ resolve }); + }); + const settled = child + .requestOk( + { + type: "prompt", + message: text, + ...(images && images.length > 0 ? { images } : {}), + streamingBehavior: "followUp", + }, + // The handler owns the wait: a dialog stays open as long as the user + // takes, and a dead child rejects every pending request. + NO_REQUEST_TIMEOUT, + ) + .then( + async (): Promise => { + if (this.agentStartCount === runsBefore) { + // A run the handler asked for (pi.sendMessage with triggerTurn) + // may surface after pi's answer: pi's own state says whether one + // is under way, and its events arrive before that answer does. + const state = await this.getState().catch(() => null); + if (this.agentStartCount === runsBefore && state?.isStreaming !== true) { + // Handled without a run: nothing else will settle the slot. + this.dropRunSettlement(settlement); + this.isProcessing = false; + return {}; + } + } + return await settlement; + }, + (error: unknown): PiPromptRunOutcome => { + this.isProcessing = false; + this.dropRunSettlement(settlement); + this.onDone(error); + return { error }; + }, + ); + return { consumed: Promise.resolve(), settled }; + } + async steer(text: string, images?: ImageContent[]): Promise { const child = this.requireChild(); const tracked = this.trackPendingInputConsumption("steering"); @@ -872,6 +971,9 @@ export class PiRpcSession { } private trackProcessingState(event: PiRpcEvent): void { + if (event.type === "agent_start") { + this.agentStartCount += 1; + } if ( event.type === "agent_start" || (event.type === "compaction_start" && event.reason === "manual") diff --git a/plugins/provider-pi/src/bridge/settings-storage.test.ts b/plugins/provider-pi/src/bridge/settings-storage.test.ts new file mode 100644 index 0000000000..3148991dfe --- /dev/null +++ b/plugins/provider-pi/src/bridge/settings-storage.test.ts @@ -0,0 +1,100 @@ +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + readPiEnabledModelPatterns, + readPiSettingsFile, + updatePiSettingsFile, +} from "./settings-storage.js"; + +const roots: string[] = []; + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "bb-pi-settings-")); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("pi settings files", () => { + it("rewrites atomically, keeps unrelated keys and the file mode, and leaves no temp file", () => { + const root = tempRoot(); + const path = join(root, "agent", "settings.json"); + mkdirSync(join(root, "agent"), { recursive: true }); + writeFileSync(path, JSON.stringify({ theme: "dark", skills: ["~/skills"] })); + chmodSync(path, 0o644); + + updatePiSettingsFile(path, (current) => ({ ...current, enabledModels: ["a/b"] })); + + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + theme: "dark", + skills: ["~/skills"], + enabledModels: ["a/b"], + }); + expect(statSync(path).mode & 0o777).toBe(0o644); + expect(readdirSync(join(root, "agent"))).toEqual(["settings.json"]); + }); + + it("creates a private file in a missing agent dir and reads an absent one as empty", () => { + const root = tempRoot(); + const path = join(root, "fresh", "settings.json"); + expect(readPiSettingsFile(path)).toEqual({}); + updatePiSettingsFile(path, () => ({ enabledModels: ["a/b"] })); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("reports an unreadable file instead of silently starting over", () => { + const root = tempRoot(); + const path = join(root, "settings.json"); + writeFileSync(path, "{not json"); + expect(() => readPiSettingsFile(path)).toThrow(/Failed to load Pi settings/u); + expect(() => updatePiSettingsFile(path, (current) => current)).toThrow( + /Failed to load Pi settings/u, + ); + expect(readFileSync(path, "utf8")).toBe("{not json"); + }); + + it("reads the global enabledModels, and reads a broken file as empty instead of failing", () => { + const root = tempRoot(); + const agentDir = join(root, "agent"); + mkdirSync(agentDir, { recursive: true }); + const env = { PI_CODING_AGENT_DIR: agentDir }; + expect(readPiEnabledModelPatterns(env)).toBeUndefined(); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ enabledModels: ["global/*"] })); + expect(readPiEnabledModelPatterns(env)).toEqual(["global/*"]); + writeFileSync(join(agentDir, "settings.json"), "{not json"); + expect(readPiEnabledModelPatterns(env)).toBeUndefined(); + }); + + it("writes through a symlink and leaves no lock behind", () => { + const root = tempRoot(); + const real = join(root, "dotfiles", "settings.json"); + mkdirSync(join(root, "dotfiles"), { recursive: true }); + mkdirSync(join(root, "agent"), { recursive: true }); + writeFileSync(real, JSON.stringify({ theme: "dark" })); + const link = join(root, "agent", "settings.json"); + symlinkSync(real, link); + + updatePiSettingsFile(link, (current) => ({ ...current, enabledModels: ["a/b"] })); + + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(real, "utf8"))).toEqual({ theme: "dark", enabledModels: ["a/b"] }); + expect(readdirSync(join(root, "agent"))).toEqual(["settings.json"]); + expect(readdirSync(join(root, "dotfiles"))).toEqual(["settings.json"]); + }); +}); diff --git a/plugins/provider-pi/src/bridge/settings-storage.ts b/plugins/provider-pi/src/bridge/settings-storage.ts new file mode 100644 index 0000000000..8390307041 --- /dev/null +++ b/plugins/provider-pi/src/bridge/settings-storage.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; +import { z } from "zod"; +import { resolvePiAgentDir } from "../native-roots.js"; + +/** + * Pi's global settings file, read and written as a file: the bridge runs + * without pi's SDK in RPC mode, and pi itself reads the same JSON on its + * next start. Only the keys the bridge owns are interpreted; everything + * else in the file is carried through untouched. + * + * Project `.pi/settings.json` is deliberately not read: pi applies it only + * for a trusted project (its trust prompt or `trust.json`), a decision the + * bridge cannot see, and a repository must not be able to steer the picker. + */ + +const piSettingsSchema = z + .object({ enabledModels: z.array(z.string()).optional() }) + .passthrough(); + +export type PiSettings = z.infer; + +export function resolvePiGlobalSettingsPath( + env: Readonly> = process.env, +): string { + return join(resolvePiAgentDir({ homeDir: homedir(), env }), "settings.json"); +} + +function loadError(path: string, error: unknown): Error { + return new Error( + `Failed to load Pi settings at ${path}: ${error instanceof Error ? error.message : String(error)}`, + ); +} + +/** The parsed file, an empty object when it is absent; throws when unreadable. */ +export function readPiSettingsFile(path: string): PiSettings { + if (!existsSync(path)) { + return {}; + } + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { + throw loadError(path, error); + } + try { + return piSettingsSchema.parse(JSON.parse(raw)); + } catch (error) { + throw loadError(path, error); + } +} + +/** + * The file pi actually reads: `settings.json` is commonly a symlink into a + * dotfiles checkout, and a rename over the link would replace the link with + * a plain file. The real path is where the temp file lands and the rename + * happens, so the write goes through the link as pi's own does. + */ +function resolveWritePath(path: string): string { + return existsSync(path) ? realpathSync(path) : path; +} + +/** + * Rewrite the settings file through `update`, atomically and under pi's + * own lock: the new content lands in a sibling temp file renamed over the + * original, so a reader (pi starting up) sees the old file or the new one, + * never a torn write; and pi serializes its writes with the same + * proper-lockfile lock on this path, so neither side loses the other's + * update. A new file is private to the user; an existing one keeps its mode. + */ +export function updatePiSettingsFile( + path: string, + update: (current: PiSettings) => PiSettings, +): PiSettings { + mkdirSync(dirname(path), { recursive: true }); + const target = resolveWritePath(path); + const directory = dirname(target); + const exists = existsSync(target); + // pi's FileSettingsStorage: `lockSync(path, { realpath: false })` on the + // settings path itself; the lock file sits beside it. + const release = lockfile.lockSync(path, { realpath: false }); + let temporaryPath: string | null = null; + try { + const next = update(readPiSettingsFile(target)); + temporaryPath = join(directory, `.settings-${process.pid}-${randomUUID()}.tmp`); + writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + if (exists) chmodSync(temporaryPath, statSync(target).mode); + renameSync(temporaryPath, target); + temporaryPath = null; + return next; + } finally { + if (temporaryPath !== null) rmSync(temporaryPath, { force: true }); + release(); + } +} + +/** + * The global `enabledModels` patterns, or undefined when the file does not + * set them. A file pi cannot load either is reported on stderr and read as + * empty, the way pi itself keeps running on a broken settings file: a + * listing must not fail because of it (a write still refuses it). + */ +export function readPiEnabledModelPatterns( + env: Readonly> = process.env, +): string[] | undefined { + const path = resolvePiGlobalSettingsPath(env); + try { + return readPiSettingsFile(path).enabledModels; + } catch (error) { + process.stderr.write(`pi bridge: ${error instanceof Error ? error.message : String(error)}\n`); + return undefined; + } +} diff --git a/plugins/provider-pi/src/declaration.ts b/plugins/provider-pi/src/declaration.ts index 7ac6520e1b..9687fc9c9b 100644 --- a/plugins/provider-pi/src/declaration.ts +++ b/plugins/provider-pi/src/declaration.ts @@ -1,4 +1,8 @@ import type { PluginProviderDeclaration } from "@get-bb/plugin-sdk"; +import { + PI_EXTENSION_UI_STATE_NAME, + piExtensionUIStateUpdateSchema, +} from "./extension-state.js"; import { PI_NATIVE_ROOTS_DECLARATION } from "./native-roots.js"; export function piProviderDeclaration(): PluginProviderDeclaration { @@ -42,5 +46,11 @@ export function piProviderDeclaration(): PluginProviderDeclaration { // scans these beside bb's own. ...PI_NATIVE_ROOTS_DECLARATION, composerActions: [], + // What pi's extensions put beside the composer (`ctx.ui` statuses, + // widgets, notifications, title, editor text), as one current snapshot + // the bridge keeps and the app bundle renders; null clears it. + extensionKinds: { + [PI_EXTENSION_UI_STATE_NAME]: { state: piExtensionUIStateUpdateSchema }, + }, }; } diff --git a/plugins/provider-pi/src/extension-state.ts b/plugins/provider-pi/src/extension-state.ts new file mode 100644 index 0000000000..203e86cc4c --- /dev/null +++ b/plugins/provider-pi/src/extension-state.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +/** + * The provider extension-state kind this plugin declares and renders: the + * current snapshot of what Pi's extensions put beside the composer through + * `ctx.ui` (statuses, widgets, notifications, the title, editor text). The + * bridge builds it from pi's `extension_ui_request` events in RPC mode; the + * app bundle renders it. Shared here so the declaration's schema and the + * renderer's parser are one. + */ +export const PI_EXTENSION_UI_STATE_NAME = "extension-ui"; + +export const PI_EXTENSION_STATUS_MAX = 16; +export const PI_EXTENSION_WIDGET_MAX = 16; +export const PI_EXTENSION_NOTIFICATION_MAX = 8; +export const PI_EXTENSION_WIDGET_LINE_MAX = 32; + +const statusSchema = z + .object({ + key: z.string().max(128), + text: z.string().max(512), + }) + .strict(); + +const widgetSchema = z + .object({ + key: z.string().max(128), + lines: z.array(z.string().max(1_024)).max(PI_EXTENSION_WIDGET_LINE_MAX), + placement: z.enum(["aboveEditor", "belowEditor"]), + }) + .strict(); + +const notificationSchema = z + .object({ + id: z.number().int().positive(), + message: z.string().max(1_024), + level: z.enum(["info", "warning", "error"]), + }) + .strict(); + +export const piExtensionUIStateSchema = z + .object({ + statuses: z.array(statusSchema).max(PI_EXTENSION_STATUS_MAX), + widgets: z.array(widgetSchema).max(PI_EXTENSION_WIDGET_MAX), + notifications: z.array(notificationSchema).max(PI_EXTENSION_NOTIFICATION_MAX), + title: z.string().max(1_024).nullable(), + editor: z + .object({ + revision: z.number().int().positive(), + text: z.string().max(16_384), + }) + .strict() + .nullable(), + }) + .strict(); + +/** A null snapshot clears the state (the owning session ended). */ +export const piExtensionUIStateUpdateSchema = piExtensionUIStateSchema.nullable(); +export type PiExtensionUIState = z.infer; diff --git a/plugins/provider-pi/src/model-settings-contract.ts b/plugins/provider-pi/src/model-settings-contract.ts new file mode 100644 index 0000000000..d34ed38e75 --- /dev/null +++ b/plugins/provider-pi/src/model-settings-contract.ts @@ -0,0 +1,52 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +export const piModelSettingsModelSchema = z + .object({ + id: z.string().min(1), + displayName: z.string(), + provider: z.string().min(1), + reasoning: z.boolean(), + }) + .strict(); + +export const piModelSettingsSnapshotSchema = z + .object({ + models: z.array(piModelSettingsModelSchema), + enabledModelIds: z.array(z.string().min(1)).nullable(), + }) + .strict(); + +export const piModelSettingsBridgeContract = defineRpcContract({ + "model-settings/read": { + input: z.null(), + output: piModelSettingsSnapshotSchema, + }, + "model-settings/write": { + input: z + .object({ enabledModelIds: z.array(z.string().min(1)).nullable() }) + .strict(), + output: piModelSettingsSnapshotSchema, + }, +}); + +export const piModelSettingsRpcContract = defineRpcContract({ + readModelSettings: { + input: z.object({ hostId: z.string().min(1) }).strict(), + output: piModelSettingsSnapshotSchema, + }, + writeModelSettings: { + input: z + .object({ + hostId: z.string().min(1), + enabledModelIds: z.array(z.string().min(1)).nullable(), + }) + .strict(), + output: piModelSettingsSnapshotSchema, + }, +}); + +export type PiModelSettingsModel = z.infer; +export type PiModelSettingsSnapshot = z.infer< + typeof piModelSettingsSnapshotSchema +>; diff --git a/plugins/provider-pi/src/model-settings-editor.tsx b/plugins/provider-pi/src/model-settings-editor.tsx new file mode 100644 index 0000000000..9156ff2040 --- /dev/null +++ b/plugins/provider-pi/src/model-settings-editor.tsx @@ -0,0 +1,255 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@bb/shared-ui/button"; +import { Badge } from "@bb/shared-ui/badge"; +import { Input } from "@bb/shared-ui/input"; +import { Switch } from "@bb/shared-ui/switch"; +import { useRpc } from "@get-bb/plugin-sdk/app"; +import type { piModelSettingsRpcContract } from "./model-settings-contract.js"; +import type { + PiModelSettingsModel, + PiModelSettingsSnapshot, +} from "./model-settings-contract.js"; + +interface PiModelSettingsEditorProps { + experimental_hostId?: string | null; +} + +function equalSelection( + left: string[] | null, + right: string[] | null, +): boolean { + if (left === null || right === null) return left === right; + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function normalizeSelection( + ids: string[], + allIds: readonly string[], +): string[] | null { + const allowed = new Set(allIds); + const normalized = [...new Set(ids)].filter((id) => allowed.has(id)); + return normalized.length === allIds.length ? null : normalized; +} + +function searchText(model: PiModelSettingsModel): string { + return `${model.id} ${model.displayName} ${model.provider}`.toLowerCase(); +} + +export function PiModelSettingsEditor({ + experimental_hostId: hostId = null, +}: PiModelSettingsEditorProps) { + const rpc = useRpc(); + const hostIdRef = useRef(hostId); + hostIdRef.current = hostId; + const [snapshot, setSnapshot] = useState( + null, + ); + const [draft, setDraft] = useState(null); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading", + ); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + + useEffect(() => { + let active = true; + setSaving(false); + setSaveError(false); + if (hostId === null) { + setSnapshot(null); + setDraft(null); + setStatus("ready"); + return () => { + active = false; + }; + } + setStatus("loading"); + void rpc + .call("readModelSettings", { hostId }) + .then((next) => { + if (!active) return; + setSnapshot(next); + setDraft(next.enabledModelIds); + setStatus("ready"); + }) + .catch(() => { + if (active) setStatus("error"); + }); + return () => { + active = false; + }; + }, [hostId, rpc]); + + const models = snapshot?.models ?? []; + const allIds = useMemo(() => models.map(({ id }) => id), [models]); + const enabled = useMemo( + () => (draft === null ? null : new Set(draft)), + [draft], + ); + const enabledCount = draft === null ? models.length : draft.length; + const dirty = + snapshot !== null && !equalSelection(draft, snapshot.enabledModelIds); + const query = search.trim().toLowerCase(); + const visibleModels = useMemo( + () => + query.length === 0 + ? models + : models.filter((model) => searchText(model).includes(query)), + [models, query], + ); + + async function save(): Promise { + if (hostId === null || !dirty) return; + setSaving(true); + setSaveError(false); + try { + const next = await rpc.call("writeModelSettings", { + hostId, + enabledModelIds: draft, + }); + if (hostIdRef.current !== hostId) return; + setSnapshot(next); + setDraft(next.enabledModelIds); + } catch { + if (hostIdRef.current === hostId) setSaveError(true); + } finally { + if (hostIdRef.current === hostId) setSaving(false); + } + } + + if (status === "loading") { + return

Loading Pi models…

; + } + if (status === "error") { + return ( +

+ Pi model settings are unavailable on this host. +

+ ); + } + if (hostId === null) { + return ( +

+ Pi model settings are unavailable because no host is selected. +

+ ); + } + if (models.length === 0) { + return ( +

+ No authenticated Pi models are available on this host. Run `pi` there to + sign in. +

+ ); + } + + return ( +
+
+ setSearch(event.target.value)} + placeholder="Search models" + aria-label="Search Pi models" + className="h-8 flex-1" + /> +
+ + + +
+
+ +
+ + {enabledCount} of {models.length} models enabled for Pi cycling. + + {dirty ? Unsaved changes : null} +
+ + {saveError ? ( +

+ Pi model settings could not be saved. +

+ ) : null} + +
+ {visibleModels.length === 0 ? ( +
+ No models match your search. +
+ ) : ( +
+ {visibleModels.map((model) => { + const checked = enabled === null || enabled.has(model.id); + return ( +
+ { + const current = draft ?? allIds; + setDraft( + normalizeSelection( + nextChecked + ? [...current, model.id] + : current.filter((id) => id !== model.id), + allIds, + ), + ); + }} + /> +
+
+ + {model.displayName} + + {model.provider} + {model.reasoning ? ( + reasoning + ) : null} +
+

+ {model.id} +

+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/plugins/provider-pi/src/native-roots.ts b/plugins/provider-pi/src/native-roots.ts index eb1e719b62..28e9b0a637 100644 --- a/plugins/provider-pi/src/native-roots.ts +++ b/plugins/provider-pi/src/native-roots.ts @@ -60,7 +60,7 @@ export interface ResolvePiNativeRootsArgs { env: Readonly>; } -function resolvePiAgentDir(args: ResolvePiNativeRootsArgs): string { +export function resolvePiAgentDir(args: ResolvePiNativeRootsArgs): string { const configured = args.env.PI_CODING_AGENT_DIR?.trim(); return configured ? resolveStoredPath(args.homeDir, configured, args.homeDir) diff --git a/plugins/provider-pi/src/proper-lockfile.d.ts b/plugins/provider-pi/src/proper-lockfile.d.ts new file mode 100644 index 0000000000..11a327c199 --- /dev/null +++ b/plugins/provider-pi/src/proper-lockfile.d.ts @@ -0,0 +1,10 @@ +declare module "proper-lockfile" { + export interface LockOptions { + realpath?: boolean; + } + export interface ProperLockfile { + lockSync(path: string, options?: LockOptions): () => void; + } + const lockfile: ProperLockfile; + export default lockfile; +} diff --git a/plugins/provider-pi/src/styles.d.ts b/plugins/provider-pi/src/styles.d.ts new file mode 100644 index 0000000000..cbe652dbe0 --- /dev/null +++ b/plugins/provider-pi/src/styles.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/plugins/provider-pi/tsconfig.json b/plugins/provider-pi/tsconfig.json index 6acac8aade..7742760603 100644 --- a/plugins/provider-pi/tsconfig.json +++ b/plugins/provider-pi/tsconfig.json @@ -4,6 +4,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "jsx": "react-jsx", "lib": ["ES2022", "DOM"], "noEmit": true, "skipLibCheck": true, @@ -17,5 +18,5 @@ }, "types": ["node"] }, - "include": ["server.ts", "src", "vitest.config.ts"] + "include": ["app.tsx", "app.test.tsx", "server.ts", "src", "vitest.config.ts"] } diff --git a/plugins/provider-pi/vitest.config.ts b/plugins/provider-pi/vitest.config.ts index 41c19cd1f9..ce200865aa 100644 --- a/plugins/provider-pi/vitest.config.ts +++ b/plugins/provider-pi/vitest.config.ts @@ -9,7 +9,7 @@ export default defineWorkspaceTestConfig({ projects: sharedWorkerProjects({ pkgDir: __dirname, name: "bb-plugin-provider-pi", - include: ["*.test.ts", "src/**/*.test.ts"], + include: ["*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"], }), }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ca0dca6a8..43e2a93b51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3397,9 +3397,18 @@ importers: plugins/provider-pi: dependencies: + '@bb/shared-ui': + specifier: workspace:* + version: link:../../packages/shared-ui '@get-bb/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + minimatch: + specifier: ^10.2.5 + version: 10.2.5 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 zod: specifier: 4.3.6 version: 4.3.6 @@ -3416,9 +3425,30 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.84.0 version: 0.84.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.21.0)(zod@4.3.6) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^22.0.0 version: 22.19.10 + '@types/react': + specifier: ^19.0.0 + version: 19.2.13 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.13) + jsdom: + specifier: ^29.0.1 + version: 29.0.1(@noble/hashes@2.0.1) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + sonner: + specifier: ^1.7.4 + version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) typebox: specifier: 1.3.7 version: 1.3.7